您好!
欢迎来到京东云开发者社区
登录
首页
博文
课程
大赛
工具
用户中心
开源
首页
博文
课程
大赛
工具
开源
更多
用户中心
开发者社区
>
博文
>
手牵手带你实现mini-vue
分享
打开微信扫码分享
点击前往QQ分享
点击前往微博分享
点击复制链接
手牵手带你实现mini-vue
自猿其说Tech
2021-12-14
IP归属:未知
36200浏览
前端
### 1 前言 随着 Vue、React、Angularjs 等框架的诞生,数据驱动视图的理念也深入人心,就 Vue 来说,它拥有着双向数据绑定、虚拟dom、组件化、视图与数据相分离等等造福程序员的优点,那 Vue 的双向数据绑定实现原理是什么样的,如果让我们自己去实现一个这样的双向数据绑定要怎么做呢,本文就与大家分享一下 Vue 的绑定原理及其简单实现 ### 2 核心技术 大家都知道 Vue2 双向绑定是基于 ES5的 Object.defineProperty 方法+发布订阅者模式实现的 那我们首先简单了解一下这两个模块都是做什么的,在 Vue 中充当了什么角色 #### 2.1 Object.defineProperty 用来在对象上定义或者修改一个属性值,实现数据劫持,为修改数据后去调用视图更新做准备 ```javascript const obj = {} let age = 18 Object.defineProperty(obj, 'age',{ get() { return age }, set(newVal) { age = newVal + 1 }, enumerable: true }) console.log(obj.age) // 18 obj.age = 20 console.log(obj.age) // 21 ``` #### 2.2 发布订阅者模式 此模式简单来讲就是分为发布和订阅两个概念,订阅意思就是我们会定义很多个订阅者,每个订阅者都会有自己的 update 方法,把需要更新的订阅者放到数组中,而发布就代表通知订阅者去依次执行其 update 方法,从而实现数据更新 ```javascript // 定义放订阅者的数组 function Dep() { this.subs = [] } // 定义存放订阅者的方法 Dep.prototype.addSub = function(sub) { this.subs.push(sub) } // 定义发布的方法 Dep.prototype.notify = function(sub) { this.subs.forEach(sub => { // 依次通知订阅者去执行update方法 sub.update() }) } ``` 写到这里我们就把发布和订阅准备好了,但是还缺少订阅者,且订阅者要保证提供一个 update 方法才行,那我们不禁想到能否去创建一个构造函数,通过这个构造函数创建的实例都会有 update 方法呢 ```javascript function Watcher(fn) { this.fn = fn } // 通过该构造函数创建的实例都会有update方法 Watcher.prototype.update = function() { this.fn() } // new实例 const watcher1 = new Watcher(() => console.log('我是watcher1')) const watcher2 = new Watcher(() => console.log('我是watcher2')) const dep = new Dep() // 把准备好的事件放入到数组中 dep.addSub(watcher1) dep.addSub(watcher2) // 进行发布 dep.notify() // 最终输出 我是watcher1 我是watcher2 ``` ### 3 具体实现 #### 3.1 初始化 一个框架都是从它的初始化开始的,Vue 也不例外 ```javascript <body> <div id="app"> <p>a value: {{a.a}}</p> <div>b value: {{b}}</div> <span>v-model: </span><input type="text" v-model="b"> </div> <script> // 模仿 Vue 的初始化和传入的参数 let vue = new Vue({ el: '#app', data: { a: { a: 'is a' }, b: 'is b', c: 'is c' } }) </script> </body> ``` #### 3.2 数据劫持 observe Vue 中在 data 中定义的属性才可以实现双向绑定,为了实现这个功能,我们定义一个 Observe 用来劫持到对象的属性 ```javascript // 给对象增加数据劫持 function Observe(data) { // 因 defineProperty 每次只能设置单个属性 所以需遍历 for (let key in data) { let val = data[key] observe(val) Object.defineProperty(data, key, { enumerable: true, get() { return val }, set(newVal) { if (newVal === val) return // 新值与旧值相等时 不做处理 val = newVal // 之所以给 val 赋值 是因为取值时取得val observe(newVal) // 当给变量值赋予一个新对象时 依然需要劫持到其属性 } }) } } function observe(data) { if (typeof data !== 'object') return return new Observe(data) } ``` #### 3.3 构造函数编写 ```javascript function Vue(options = {}) { // 模仿 Vue 把属性挂载到 $options 且可以通过this._data访问属性 this.$options = options const data = this._data = this.$options.data observe(data) // 给 data 增加数据劫持 } ``` ![](//img1.jcloudcs.com/developer.jdcloud.com/9c5ac28f-5efb-4914-9a38-fa8a50b4b4b920211214135801.png) 上图中我们模仿 Vue 对 options 和 data 增加了一些可访问方式,给 data 增加了数据劫持,也在我们的实例中看到了效果,那这时又有了新的问题,Vue 中访问数据都是 this.xxx 可直接通过实例访问,这样比我们图中的访问方式还要更方便一些,那我们也能否把属性直接挂载到实例上呢,当然是可以的 #### 3.4 数据代理 如果想要直接把属性挂载到实例上,那我们需要保证通过实例直接访问的属性值是实时无误的,且去修改该属性值还能够被劫持到,否则会影响后面的双向数据绑定,既然 data 中的数据我们已经通过 Observe(在3.2节)做了劫持,那我们在通过 this.xxx 直接修改属性时只需要去修改 data 对应中的属性就可以触发 Observe 劫持 ```javascript function Vue(options = {}) { // 模仿 Vue 把属性挂载到 $options this.$options = options const data = this._data = this.$options.data observe(data) // 将当前 this 传入方法 将属性挂载到 this 上 proxyData.call(this, data) } // this 代理 this._data function proxyData(data) { const vm = this for (let key in data) { Object.defineProperty(vm, key, { enumerable: true, get() { return vm._data[key] }, set(newVal) { // 直接修改 data 中对应属性 触发 data 中劫持 保持数据统一 vm._data[key] = newVal } }) } } ``` ![](//img1.jcloudcs.com/developer.jdcloud.com/a0597694-ba3d-4096-90d2-57101052571320211214135940.png) #### 3.5 实现 compile 先在内存中创建一个文档碎片来递归所有 dom 节点,用正则匹配 {{}} 相符的节点,获取到括号里的 key,最后在 data 中拿到对应 key 的属性值,替换到节点上(因为主要是实现双向绑定,所以我们将 dom 的操作放到文档碎片中操作来代替虚拟 dom) ```javascript function Compile(el, vm) { vm.$el = document.querySelector(el) // 建立文档碎片 将 el 下的所有元素挪进文档碎片 避免死循环 const fragment = document.createDocumentFragment() // 将 el 中的元素都移入碎片中 while (child = vm.$el.firstChild) { fragment.appendChild(child) } // 匹配节点中的{{}} 将其替换为对应的值 replace(fragment) function replace(fragment) { // 循环每一层节点 Array.from(fragment.childNodes).forEach(node => { const text = node.textContent // 定义正则表达式 const reg = /\{\{(.*)\}\}/ // 此判断为当节点是文本节点(因为变量都是文本)且被包含在{{}}中的文本节点时 // 文本节点 Node.TEXT_NODE: 3 if (node.nodeType === Node.TEXT_NODE && reg.test(text)) { // 以下三行为了获取到 key 对应的value值 页面初始化后正常将变量替换为值 const arr = RegExp.$1.split('.') // [a, a] [b] let val = vm arr.forEach(k => (val = val[k])) node.textContent = text.replace(reg, val) } if (node.childNodes) { replace(node) } }) } // 将处理好的文档碎片塞回dom中 vm.$el.appendChild(fragment) } ``` 初始化 Vue 时调用 compile ```javascript function Vue(options = {}) { // 模仿 Vue 把属性挂载到 $options this.$options = options const data = this._data = this.$options.data observe(data) // 将当前 this 传入方法 将属性挂载到 this 上 proxyData.call(this, data) new Compile(options.el, this) } ``` ![](//img1.jcloudcs.com/developer.jdcloud.com/2039dc86-4509-48f4-bbed-e5cd765ff0a520211214140028.png) #### 3.6 Model -> ViewModel -> View 目前我们已经实现的功能:数据劫持、this代理、编译模板 ,最终我们要达到修改数据、视图自动更新的效果,还需要以下工作 1)第一步我们需要创建一个订阅者,其 update 事件就是接收到我们更新后的数据值然后去更新 dom, 因为要更新 dom,所以此订阅者是在 compile 中定义的,并且大家会发现我们在编译过程中,是循环每一层节点去判断的,也就意味着我们页面有多少个符合条件的文本节点,就会新建多少个 watcher,那这时就需要把文本节点的对应 key 和 value 传入 watcher 中,用来判断更新的哪个节点值 ![](//img1.jcloudcs.com/developer.jdcloud.com/6ab87fb3-0aa3-40cb-afd8-2dfb8312145020211214140050.png) 2)既然我们的 watcher 新增了参数(vue 实例、节点变量)所以我们需要对 watcher 方法做出更改 ![](//img1.jcloudcs.com/developer.jdcloud.com/b36e5a6e-e8fe-4207-b50d-d32e4ba30be220211214140117.png) 3)当 watcher 定义好后,还需要修改下其 update 方法,因为我们的 watcher 第三个参数也就是回调函数中新增了参数,需要给其传参 ```javascript Watcher.prototype.update = function() { // this.exp 可取到 key 值 从 vm 中凭借 key 就可以取到属性值 let val = this.vm const arr = this.exp.split('.') arr.forEach(k => (val = val[k])) this.fn(val) // 传入 newVal } ``` 4)订阅者都准备好了,还需要添加订阅者到 dep 数组并且在数据改动后调用发布,这个过程需要在 observe 中实现 ![](//img1.jcloudcs.com/developer.jdcloud.com/4083cc5f-7aac-403f-97eb-41d580e1158b20211214140155.png) 5)最终效果如图所示 ![](//img1.jcloudcs.com/developer.jdcloud.com/7a540237-8352-4de2-9ae9-983c2626b02820211214140214.gif) #### 3.7 View -> ViewModel -> Model 上面我们实现了从数据到视图的更新,那视图从数据的更新呢,首先我们想到一个最常见的例子(v-model), 要想实现它,我们需要做以下两步 1)把 value 值展示到绑定 v-model 的 input 中 ![](//img1.jcloudcs.com/developer.jdcloud.com/8737ba8b-9b1d-4467-8995-42626964b7b520211214140339.png) ![](//img1.jcloudcs.com/developer.jdcloud.com/50cef92b-91d6-4075-83f2-1ad65a01f64620211214140350.png) 2)其次是每次我们更改值时都应该把值更新到界面上,所以我们还需要新建一个 watcher,在绑定 v-model 的 input 上绑定事件,当输入文案时获取到输入的值,改变 data 只对应的属性值 ![](//img1.jcloudcs.com/developer.jdcloud.com/0048b54b-aa9b-41a0-b1ec-9d1df3c0ce6020211214140409.png) 3)最终效果如图所示 ![](//img1.jcloudcs.com/developer.jdcloud.com/f06c3208-24ba-4899-9ffe-d5952d699a4a20211214140429.gif) ### 4 总结 #### 4.1 实现思路 ![](//img1.jcloudcs.com/developer.jdcloud.com/2608fee6-8f5c-4065-8ca9-26589112fd4920211214140450.png) #### 4.2 优缺点 优点:成功达到了数据和视图的双向驱动,像在操作表单时使用会更方便,省略了很多重复的 onChange 事件去处理数据的变化,也省略了给 dom 添加值的操作,代码量会更少,更方便维护 缺点:修改数据时会使得我们无法追踪数据的改变源头,且在数据劫持那步需要去循环,为一个对象的每一个属性增加劫持,无法直接在一个对象上增加所有属性的劫持(该缺点 Vue3 已规避,大家可自行学习) #### 4.3 小结 在工作中我们很多项目会用到框架 ,了解它的一些原理有助于我们更好的去使用,便于我们培养自己的‘造轮子’能力,遇到问题时能更好的解决,减少不必要的 bug,更好的去调试代码,一些很复杂的组件如果找不到开源的话,自己也能去实现不至于一头雾水 #### 4.4 参考资料 - 可运行的该源码实例: https://coding.jd.com/zhangtingting155/mini-vue.git - Vue 双向绑定:https://github.com/answershuto/learnVue/blob/master/docs/%E4%BB%8E%E6%BA%90%E7%A0%81%E8%A7%92%E5%BA%A6%E5%86%8D%E7%9C%8B%E6%95%B0%E6%8D%AE%E7%BB%91%E5%AE%9A.MarkDown - 数据劫持:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty - 很有趣的设计模式:https://refactoringguru.cn/design-patterns ### 5 思考 至此,一个双向数据绑定功能就基本实现了,本文我们的实现是基于 Vue2 的双向数据绑定原理,目前 Vue3 已经趋于稳定,我们可以思考下,如果是基于 Vue3 的原理去做,那需要怎么去实现呢。 ------------ ###### 自猿其说Tech-京东物流技术发展部 ###### 作者:张婷婷
原创文章,需联系作者,授权转载
上一篇:log4j2“核弹级”漏洞,你中招了吗
下一篇:敏捷在业务效能中的赋能
相关文章
前端十年回顾 | 漫画前端的前世今生
Taro小程序跨端开发入门实战
【技术干货】企业级扫描平台EOS关于JS扫描落地与实践!
自猿其说Tech
文章数
426
阅读量
2149964
作者其他文章
01
深入JDK中的Optional
本文将从Optional所解决的问题开始,逐层解剖,由浅入深,文中会出现Optioanl方法之间的对比,实践,误用情况分析,优缺点等。与大家一起,对这项Java8中的新特性,进行理解和深入。
01
Taro小程序跨端开发入门实战
为了让小程序开发更简单,更高效,我们采用 Taro 作为首选框架,我们将使用 Taro 的实践经验整理了出来,主要内容围绕着什么是 Taro,为什么用 Taro,以及 Taro 如何使用(正确使用的姿势),还有 Taro 背后的一些设计思想来进行展开,让大家能够对 Taro 有个完整的认识。
01
Flutter For Web实践
Flutter For Web 已经发布一年多时间,它的发布意味着我们可以真正地使用一套代码、一套资源部署整个大前端系统(包括:iOS、Android、Web)。渠道研发组经过一段时间的探索,使用Flutter For Web技术开发了移动端可视化编程平台—Flutter乐高,在这里希望和大家分享下使用Flutter For Web实践过程和踩坑实践
01
配运基础数据缓存瘦身实践
在基础数据的常规能力当中,数据的存取是最基础也是最重要的能力,为了整体提高数据的读取能力,缓存技术在基础数据的场景中得到了广泛的使用,下面会重点展示一下配运组近期针对数据缓存做的瘦身实践。
自猿其说Tech
文章数
426
阅读量
2149964
作者其他文章
01
深入JDK中的Optional
01
Taro小程序跨端开发入门实战
01
Flutter For Web实践
01
配运基础数据缓存瘦身实践
添加企业微信
获取1V1专业服务
扫码关注
京东云开发者公众号