一步一步实现Vue数据绑定

2,033 阅读14分钟

嗯,直接进入正题吧。不知道大家是否有和我一样的学习习惯,就是一步一步的从无到有的去实现一个原理。就好像升级打怪,装备一点一点的升级才好玩。我把每次增加的代码,对应在git的提交中, 毕竟很多文件,直接在一篇文章中很难描述清楚。

好难写,有时候自己明白了是一回事,再写出来就是另一回事了

晒两张老照片

当时准备了大约一周多,在小组内分享,也算是记录在上家公司的高光时刻吧,可惜当时很对想法都留在了纸上,于是这次记录在掘金中吧。

0 目录

  • 1 准备工作
  • 2 vue 对象数据劫持
  • 3 vue 数组数据劫持
  • 4 模板编译
  • 5 发布订阅模式
  • 6 依赖收集
  • ...
  • 最后 我的联系方式

1 准备工作

这阶段代码git地址如下:github.com/Ace7523/vue… 因为我学习的习惯就是一步一步的从简到繁增加功能,大家看时候对应的看提交版本的,这部分代码对应的是第一次commit。

webpack新建一个vue项目,相信这个大家都知道了,我就无须赘述。我会把每个阶段的代码git地址提供出来。需要强调一点就是改了这里:

作用是 import 引用依赖时候优先在指定目录下寻找,找不到的话再去node_modules中寻找。

再顺带提一点,es6的export 和 export default 的区别是什么,因为代码中有两种写法,不要弄混哦

答: export default 一个文件只能有一个, 引用时候 import XXX from ...

export 一个文件中可以有多个,引用时候用 import { XXX } from ...

2 vue 对象数据劫持

这阶段代码git地址如下:github.com/Ace7523/vue… 对应第一次commit

先来提两个问题,1,vue是数据驱动的这句话怎么理解? 2,如何实现数据驱动?

答: 数据驱动我个人理解是,数据优先,只要数据变了,页面就跟着变化,而不用再去想着这个数据应该和页面中哪个dom位置相关,再手动去改。

答:对数据进行劫持,比如一个var a = 10 ,当读取a的值时候,知道在读取,当给a赋值操作时候,又知道在赋值。这就是对数据的劫持。

因为完整代码地址我都有提供,所以就截图来看关键的点

1 创建自己的vue实例

2 利用 Object.defineProperty 对上图中data方法返回的对象进行数据劫持,先来看一下劫持之前的数据表征:

写数据劫持的方法

这段代码也很容易理解,就是利用了一下Object.defineProperty,把对象的每个属性的get和set重新写了一遍。这么做之后,我们再打印data数据看一下:

可以看出,name age testObj,分别都拥有了get和set属性,那么这个是干嘛用的呢?再来修改一点代码,如下

运行一下下面的语句就知道了(解释一下_data, data的值挂在的vm实例的_data上)

console.log( vm._data.name )

打印结果如下:

这就表明了,在想要读取name属性的值,之前,先打印的语句,也就是,实现数据劫持。同理,设置也是一样。

3 完善上述的劫持,因为对象内部还包含对象的这种情况代码中没有实现

这次打印结果如下,testObj对象内部的对象,也拥有的set 和 get

4 把vm._data 代理到 vm 上,一边以后取值直接 vm.name 拿到的就是vm._data.name

就是通过这样一个方法实现的,大家自己看下就好

5 小结

这阶段代码不难,其实分开一步步来看的话,每一阶段都不难,难得是,后面说到依赖收集的时候,不记得这段的逻辑了,所以一定要好好消化。

3 vue 数组数据劫持

这阶段代码git地址如下:github.com/Ace7523/vue… 具体对应的是第二次commit。

为什么要对数组做一次特殊的劫持呢?因为就目前的对象劫持成程度,无法监听到数组push了一个元素,举个例子:如下图测试一下

看看打印结果:

从这个结果中可以看出,只触发了一次get,没有set。 get是在vm.arr , 这个过程触发的,因为读取arr的值就会触发get。 打印出的4是数组的push方法返回的值。(ps:数组push返回变化后的数组长度)

那这种情况肯定不是我们想要的,比如页面根据数组中元素个数来渲染按钮,但是数组中元素是接口动态返回的,虽然数组已经是劫持过的,但是新增元素时候,我们不知道这个数组新增了,那就不行了。

修改如下 在数据劫持的Observer 构造函数中 针对数组重新封装劫持方法

arrayMethods observerArray这两个方法写在了另外一个array.js文件中。稍微解释一下,就是说在进行数据劫持的时候,发现是数组的话,就先改变数组的原型指向,然后再对这个数组进行劫持。下面用代码来解释

其实更通俗一点的说,就是要重写数组的push等方法,要求既要保留原来的push方法全部,又要有所增加。(限定只是被劫持的数组元素的原型方法重新写,而不是直接改变Array.prototype) 那要怎么实现?就像下面那样实现

配合上边图的修改,首先,在劫持对象为数组的时候,数组的原型指向已经修改了

data.__proto__= arrayMethods

而这个arrayMethods是继承了原生数组的原型,所以,该有的方法都会有,此时我们只需把需要改变的方法修改即可。图中的7个方法会改变原数组,所以,要对这7个方法重新加工一下。就上图来解释,其实上图对于那7个方法,可以还一点都没有修改,但是留了位置,可以在那图中17行位置添加一些操作。 其实这也就是所谓的切片编程,即保留原有功能的基础之上,添加新的功能。

再啰嗦几句,因为怕这里会有朋友还不理解。 从劫持那里说起,当判断到要劫持的数据类型是数组的时候,就先改变了这个被劫持的数组的原型,arr为例,当劫持后,再次执行arr.push的时候,这个push方法不是在原生数组的原型链中获取的,而是在我们写的这个array.js中获取的,因为此时是这样执行的

arr.push() 就是 arrayMethods.push() 
arrayMethods.push = function(...args){
    let r = oldArrayProtoMethods[push].apply(this,args);
    //
    // 这里就是要增加的功能部分
    //
    return r
}

估计这么写一下就明朗多了吧,apply不明白的话,建议补一补js基础。 然后我们接着完善这个方法,注意一下apply的第二个参数是数组。看代码

也许一些js基础不牢固的小伙伴又要问了,...args是什么,apply中的args又是什么。 答:... 用在函数参数中,就是剩余运算符,用在不知道参数具体有几个的时候,如

fun(...args){
    console.log(args)
}
fun(1,2,3,4)

// 打印结果是 [1,2,3,4]

所以还是以push为例子,push(4),那么此时 inserted = [4], 就表示对原来的那个[1,2,3]的数组arr,改变arr,让arr变成了[1,2,3,4] , 那么我们肯定要对这个新增加的元素 进行 劫持,这才能够是数组一直都是响应式的。也就是增加如下代码

因为inserted是数组,所以对他进行遍历,要对新增加的每一项都要重新进行劫持。 好了,来增加一些代码来测试一下本部分增加的功能点吧。

运行结果如下

小结 这一部分是增加了对于初始化vue实例中data参数中的数组进行劫持。 其实也不难,但是却很难用文章的形式把这部分讲出来,不过我的这部分代码地址也贴出来了,大家down下来自己看看就会清楚其中的原理。

这里提一下 vue 的数据劫持是有缺点的 1 不能对数组的索引进行监控 2 arr.length = 0 这种情况也没有监控到。 反正有个印象就行。

4 模板编译

这小节的代码对应为第三次提交。

截止到目前为止,都是在岁new MyVue实例的数据做一些操作,并没有把数据渲染到页面中,所以这一节主要完成把data数据渲染到页面中,编译不是这边文章的重点,后面会单独一篇来专门写编译。

1 编译是什么,下面的图就可以很直观的表明了

说白了就是把data中的数据和模板结合一下。如果不做一些特殊处理的话,{{name}} 是不会被翻译的。

第一步,添加模板

第二步,修改初始化逻辑,有模板的话,执行渲染逻辑

第三步,vm._update()

解释一下这部分代码,先创建文档碎片,然后在文档碎片中执行编译过程,最后再把文档碎片塞回dom中。 这么绕一圈的意义,不直接操作dom,那很奢侈。

第四步,编译。 也就是compiler(node,vm)的具体实现。

解释代码: 根据节点类型,做不同处理。 如果是元素节点,则继续递归的编译此节点。 如果是文本节点,调用文本节点的编译方法。 (编译过程远比这复杂,只是本文重点不在这)

第五步,替换文本,也就是util.compilerText() 的具体实现。

解释代码,虽然看似比较绕,不过这里也先不展开说明了,后面讲到依赖收集的时候,这里还会继续修改。 总之,这部分代码,大家知道,就是利用正则,匹配到 {{name}} 然后在vm实例中读取vm.name的值,赋值即可。 必须要值得注意的一点就是,这里是name的第一次读取。 再联系一下上面讲到的数据劫持,,,这个第一次读取,肯定是个很大的伏笔

5 发布订阅模式

到了这一部分要讲发布订阅模式,先单独讲一下发布订阅模式是什么,然后再对之前代码做修改。对应代码为 第四次commit。

1 什么是发布订阅模式?

其实这几行代码就实现了简单发布订阅,Dep构造函数,他的实例上拥有subs属性和id,id不用解释。 subs用来存放watcher的,同时dep实例上还有一个notify方法,该方法执行,就会让所有的watcher执行他自己的update方法。

换个通俗一点的解释, 比如我有个微信公众号,那么假如有10个朋友关注了我,后面某一天我发了文章,我告诉了所有订阅我的朋友,让他们去回复个1,回复后给他们发红包。 这就是发布订阅嘛,有订阅者,被订阅改变后的通知订阅者。

new个实例来演示上述代码吧

代码运行结果如下

也,不难理解吧,就不多啰嗦了

2 发布订阅模式的使用

先不上代码,要先理清一下这个发布订阅模式怎么使用。 上面的代码理解了之后,我再来理一下他们之间的关系。

  • 1 dep是什么? dep是一个new出来实例, 这个实例可以存放watcher。

  • 2 watcher是什么? watcher就是一个对象吧,这个对象上拥有一个update方法。

  • 3 watcher是观察者没错,那谁是被观察者? 答,上个例子中其实没有被观察者,因为dep只是一个用来存放watcher的实例,只有当一个对象和dep绑定了关系后,那么这个对象才是被观察者。 是这个道理吧,因为假如有一个对象 { num: 9} , 我们想做的是,当对象中的num数值发生变化时候,就做一些操作,所以我们需要一个中间介质来存放所有的观察者,如下

    { num:9, dep: { subs: [watcher1, watcher2, ...], notify: ()={...} } }

  • 4 如何响应? 因为一些场景,我们改变了对象的num的值情况的话,再去触发这个对象的dep属性上的notify方法,就可以做到这个对象的所有观察者给予响应。

3 vue 中是如何使用的?

答: vue是数据驱动的,实例中的data属性,上边挂在了很多数据,如本例子中name, age等,他们分别都是被观察者,name拥有一个自己的dep,age也拥有一个dep,两个他们是不同的实例。也就是说,当age的值改变后,就会让age拥有的dep上的所有watcher触发update,想必大家肯定知道这个update方法内部要做什么了吧,没错,就是更新视图。

6 依赖收集

即将到达最难理解的地方,也越发觉得这些很难用文字来描述了。 这部分对应的是代码的第5次提交。

如果看到这里,我还是希望上面的内容已经全都好好吸收了,否则接下来的依赖收集是很容易晕的!!!

1 创建watcher.js

watcher.js 中 主要就是Watcher构造函数,在哪里用这个构造函数呢?修改如下:

也就是在页面第一次渲染的时候,new Watcher,注意上一版代码,这里是直接让updateComponent() 这个方法执行。

回到Watcher构造函数中,这里我们先记住,第二个参数,是个函数,这个函数,就是编译模板的那个函数,把data中的数据去到,替换{{name}}中的值的那个函数。 那这个函数只要一执行,就会触发到vm.name 的 get 拦截器,对吧。这里很重要。

然后接着看, Watcher构造函数中, get() 方法内,有一个pushTarget(this), 这行代码的意思是 把 this 赋值 给Dep.target。 this是什么? this是构造函数中的this,也就值new Watcher 这个实例。

接着看, 回到最开始的数据劫持那里 ,做如下修改

这段代码,就是对data中属性做数据劫持的代码。 就是把data中的 name age 等,变成响应式的代码。 这段代码第一次被执行是什么时候? 答 首次编译的时候。 也就是刚才的watcher中 get() 方法第一次执行的时候。 在把name属性变成响应式时, new 了一个 Dep, 同理 给age属性变成响应式时,也new 了一个Dep 。

重点看这里

if (Dep.target) {
    dep.addSub(Dep.target)
}

Dep.target是什么 ? 就是那个new 的 watcher 吧 。 这个watcher被存在了dep中,这个dep又是和name属性绑定的。当name的值发生变化后,也就是走到了这个name的set拦截方法中时,执行这个dep的notify,也就是让那个watcher的update执行了,update中存放的是那个渲染模板的方法,所以,完成了页面的更新。

这里,,,很复杂,只看文字描述应该很晦涩,但是我又不知道怎么来画图描述这一段流程。代码对应的是第五次提交,大家直接看代码吧。

我再 小结一下这部分的流程

  • 1 创建vue实例,实例中有data属性,如 data.name。 同时又有模板,
    {{name}}
    , 这毋庸置疑吧。
  • 2 vue会把data.name 和
    {{name}}
    进行编译, 变成
    Ace7523
  • 3 那么在编译的过程中,就会对data.name进行值的读取。
  • 4 data.name 在获取值之前,已经写好了数据拦截,并new了一个dep实例,这个实例用来存放watcher。
  • 5 watcher中有重新渲染页面的方法
  • 6 data.name 只要被重新赋值,就会走到set拦截器中, 执行dep.notify()
  • 7 notify就会让watcher中的重新渲染页面中的方法执行,也就完成了页面的更新。

最后 我的联系方式

文字功底有限,有些地方感觉很难直接描述清晰,大家看的有不理解的地方,可以直接加我问我,我们一起探讨,加我时注明是掘金就好。