教你从源码看Vue的响应式原理

原创 前端开发者 随笔 前端 202阅读 2019-04-30 17:31:34 举报

前段时间把 vue源码抽时间看了一遍,耐心点看再结合网上各种分析文章还是比较容易看明白的,没太大问题,唯一的问题就是

看完即忘

当然了,也不是说啥都不记得了,大概流程以及架构这些东西还是能留下个印象的,对于 Vue的构建算是有了个整体认知,只是具体到代码级别的细节很难记住多少,不过也情有可原嘛,又不是背代码谁能记住那么多逻辑绕来绕去的东西?

想来想去,响应式这个东西几年前就已经被列入《三年前端,五年面试》考试大纲,那就它吧

初始化

首先找入口,vue源码的src目录下,存放的就是未打包前的代码,这个目录下又分出几个目录:

compiler跟模板编译相关,将模板编译成语法树,再将 ast编译成浏览器可识别的 js代码,用于生成 DOM

core就是 Vue的核心代码了,包括内置组件(slottransition等),内置 api的封装(nextTickset等)、生命周期、observervdom

platforms跟跨平台相关,vue目前可以运行在webweex上,这个目录里存在的文件用于抹平平台间的 api差异,赋予开发者无感知的开发体验

server存放跟服务器渲染(SSR)相关的逻辑

sfc,缩写来自于 Single File Components,即 单文件组件,用于配合 webpack解析 .vue文件,由于我们一般会将单个组件的 templatescriptstyle,以及自定义的 customBlocks写在一个单 .vue文件中,而这四个都是不同的东西,肯定需要在解析的时候分别抽离出来,交给对应的处理器处理成浏览器可执行的 js文件

share定义一些客户端和服务器端公用的工具方法以及常量,例如生命周期的名称、必须的 polyfill

//在此我向大家推荐一个前端全栈开发交流圈:582735936 突破技术瓶颈,提升思维能力

其他的就废话不多说了,直接进入主题,数据的响应式肯定是跟 data 以及 props有关,所以直接从 data以及 props的初始化开始

node_modules\vue\src\core\instance\state.js文件中的 initState方法用于对 propsdatamethods等的初始化工作,在 new vue的时候,会调用 _init方法,此方法位于 Vue的原型 Vue.prototype上,这个方法就会调用 initState

initState方法如下:

可见,在此方法中,分别调用了 initPropsinitMethodsinitDatainitComputedinitWatch方法,这些方法中对 propsmethodsdatacomputedwatch进行了初始化过程,本文只是分析响应式,所以其他抛开不谈,只看 initPropsinitData

initProps中,主要是使用了一个 for...inprops进行遍历,调用 defineReactive方法将每个 props值变成响应式的值defineReactive正是 vue响应式的核心方法,放到后面再说;

并且又调用 proxy方法把这些 props值代理到 vue上,这样做的目的是能够让直接访问 vm.xxx 得到和访问 vm._props.xxx同样的效果(也就是代理了)

上面的意思具体点就是,你定义在 props中的东西(比如:props: { a; 1 }),首先会被附加到 vm._props对象的属性上(即 vm._props.a),然后遍历 vm._props,对其上的属性进行响应式处理(对 a响应式处理),但是我们一般访问 props并没有看到过什么 this._props.a的代码,而是直接 this.a就取到了,原因就在于 vue内部已经为我们进行了一层代理

首先附加在 vm._props上的目的是方便 vue内部的处理,只要是挂载 vm._props上的数据就都是 props而不是 datawatch什么的,而代理到 vm上则是方便开发者书写

proxy方法的原理其实就是使用 Object.definePropertygetset方法代理了属性的访问

最后,这里面还有个 toggleObserving方法,这个方法是 vue内部对逻辑的一个优化,如果当前组件是根组件,那么根组件是不应该有 props的,但是呢,你给根组件加个 propsvue也不会报错,子组件的 props可以由父元素改变,但是根组件是没有父组件的,所以很显然根组件的 props肯定是不会改变的,也就没必要对这种 props进行依赖收集了

这里调用 toggleObserving就是禁止掉根组件 props的依赖收集

initData里做的事情跟 initProps差不多,首先,会把 data值取出放到 vm._data上,由于data的类型可以是一个对象也可以是一个函数,所以这里会判断下,如果是函数则调用 getData方法获取 data对象,否则直接取 data的值即可,不传 data的话,默认 data值是空对象 {}

这个 getData其实就是执行了传入的 function类型的data,得到的值就是对象类型的 data

另外,initData并没有直接对 data进行遍历以将 data中的值都变成是响应式的,而是另外调用 observe方法来做这件事,observe最终也调用了 defineReactive,但是在调用之前,还进行了额外的处理,这里暂时不说太多,放到后面和 defineReactive一起说;除此之外,initData也调用了 proxy进行数据代理,作用和 props调用 proxy差不多,只不过其是对 data数据进行代理

构建 Observe

现在回到上面没说的 observedefineReactive,由于 observe最终还是会调用 defineReactive,所以就直接从 observe说起

observe,字面意思就是观察、观测,其主要功能就是用于检测数据的变化,由于其属于响应式,算是 vue的一个关键核心,所以其专门有一个文件夹,用于存放相关逻辑文件

observe方法中,主要是这一句 ob = new Observer(value),这个 Observer是一个 class

在其 constructor中,做了一些事情,这里的 new Dep()Dep也是跟响应式相关的一个东西,后面再说,然后调用了 def,这个方法很简单,就是调用 Object.defineProperty将当前实例(this)添加到value__ob__属性上:

vue里很多地方都用到了 Object.defineProperty,可以看出这个东西对于 vue来说还是很重要的,少了它会很麻烦,而 IE8却不支持 Object.defineProperty,所以 Vue不兼容 IE8也是有道理的

在前面的 observe方法中,也出现过 __ob__这个东西:

可以看到,__ob__在这里用于做重复校验,如果当前数据对戏 value上已经有了 __ob__属性并且此属性是由 Observer构造而来,则直接返回这个值,避免重复创建

回到 Observer类,接下里会判断 value是不是数组,如果是数组,再判断 hasProto是否为 truth值,这个 hasProto就是用于检测当前浏览器是否支持使用 __proto__的:

如果是就调用 protoAugment,否则调用 copyAugment,后者可以看做是前者兼容 __proto__的一个 polyfill,这两个方法的目的是一样的,都是用于改写 Array.prototype上的数组方法,以便让数组类型的数据也具备响应式的能力

换句话说,数组为什么对数组的修改,也能触发响应式呢?原因就在于 vue内部对一些常用的数组方法进行了一层代理,对这些数组方法进行了修改,关键点在于,在调用这些数组方法的时候,会同时调用 notify方法:

ob就是 __ob__,即数据对象上挂载的自身的观察者,notify就是观察者的通知事件,这个后面放到 defineReactive一起说,这里调用 notify告诉 vue数据发生变化,就触发了页面的重渲染,也就相当于是数组也有了响应式的能力

完了之后,继续调用 observeArray进行深层便利,以保证所有嵌套数据都是响应式的

接上面,如果是对象的话就无需那么麻烦,直接调用 this.walk方法:

walk方法会对传入的对象进行遍历,然后对每一个遍历到的数据调用 defineReactive方法,终于到这个方法了,无论是 props的初始化还是 data的初始化最后都会调用这个方法,前面那些都是一些差异性的分别处理

大概看一眼 defineReactive这个方法,最后调用的 Object.defineProperty很显眼,原来是在这个函数中修改了属性的 get 以及 set,这两个方法很重要,分别对应所谓的 依赖收集派发更新

先上个上述所有流程的简要示意图,有个大体印象,不然说得太多容易忘

依赖收集

先看 get

首先,如果当前属性以及显式定义了 get方法,则执行这个 get获取到值,接着判断 Dep.target

这里又出现了一个新的东西: Dep,这是一个 class类,比较关键,是整个依赖收集的核心

进入 Dep的定义,此类的静态属性 target初始化的值是 null,但是可以通过两个暴露出去的方法来修改这个值

另外,在 Dep.target = null的上面还有一段注释,主要是说由于同一时间只能有一个 watcher被执行(当前执行完了再进行下一个),而这个 Dep.target的指向就是这个正在执行的 watcher,所以 Dep.target就应该是全局唯一的,这也正是为什么 target是个静态属性的原因

那么现在由于 Dep.targetnull,不符合 if(Dep.target){},所以这个值肯定在什么地方被修改了,而且应该是通过 pushTargetpopTarget来修改的

所以什么地方会调用这两个方法?

这又得回到 get了,什么时候会调用 get?访问这个属性,也就是数据的时候就会调用这个数据的 get(如果有的话),什么时候会访问数据呢?当然是在渲染页面的时候,肯定需要拿到数据来填充模板

那么这就是生命周期的事了,这个过程应该发生在 beforeMountmount中间

主要是 new Watcher这句代码,as we all konwvue 使用观察者模式实现响应式逻辑,前面的 Observe是监听器,那么这里的 Watcher就是观察者,数据的变化会被通知给 Watcher,由 Watcher进行视图更新等操作

进入 Watcher方法

其构造函数 constructor的最后:

this.lazy是传入的修饰符,暂时不用管,这里可以认为直接调用 this.get()

可以看到,在 Watcherget方法中,上来就调用了 pushTarget方法,所以就把当前这个 watcher pushtargetStack(位于 Dep的定义文件中)数组中去了,并且把 Dep.target的值置为这个 watcher

所以,从这里可以看出 targetStack数组的作用就是类似于一个栈,栈内的项就是 watcher

try...catch...finallyfinally语句中,首先根据 this.deep来决定是否触发当前数据子属性的 getter,这里暂时不看,然后就是调用 popTarget,这个方法就是将当前 watcher出栈,并将 Dep.target指向上一个 watcher

然后 this.cleanupDeps()其实就是依赖清空,因为已经实现了对当前 watcher的依赖收集,Dep.target已经指向了其他的 watcher,所以当前 watcher的订阅就可以取消了,腾出空间给其他的依赖收集过程使用

接着执行 value = this.getter.call(vm, vm),这里的 this.getter就是:

_update_render都是挂载在 Vue.prototype上的方法,跟组件更新相关,vm._render方法返回一个 vnode,所以肯定涉及到数据的访问,不然怎么构建 vnode,既然访问数据,那么就会调用数据的 get方法(如果有的话)

那么就又回到前面了:

经过上面 Watcher的构建过程,可以知道这个时候 Dep.target其实的指向已经已经被更正为当前的 watcher了,也就是 trueth值,可以进入条件语句

首先执行 dep.depend()dep是在 defineReactive方法中 new Dep的实例,那么看下 Depdepend方法

Dep.target此时条件成立,所以继续调用 Dep.target上的 addDep方法,Dep.target指向 Watcher,所以看 WatcheraddDep方法

首先通过 id避免重复添加同一数据,最后又调用了 dep.addSub将当前 Watcher添加到 Dep中去

这里出现了几个变量,newDepIdsnewDepsdepIdsdeps,这几个变量其实就是在 Dep添加 watcher之前的一次校验,以及方便后续移除订阅,提升 vue的性能,算是 vue内部一种优化策略,这里不用理会

最终,在 Dep中,会把 watcher pushDepsubs数组属性中

即,最终 propsdata的响应式数据的 watcher都将放到 Depsubs中,这就完成了一次依赖收集的过程

继续回到 defineReactive,在调用了 dep.depend()之后,还有几行代码:

递归调用 observe,保证子属性也是响应式的,如果当前值是数组,那么保证这个数组也是响应式的

这个依赖收集过程,简要示意图如下:

派发更新

依赖收集的目的就是将所有响应式数据通过 watcher收集起来统一管理,当数据发生变化的时候,就通知视图进行更新,这个更新的过程就是派发更新

继续看 defineReactiveset方法,这个方法实现派发更新的主要逻辑

首先是一系列的验证判断,可以不用管,然后设置数据的值为传入的值,这是一般 set函数都会执行的方法

然后到 childOb = !shallow && observe(newVal),一般情况下,shallow都是 trueth值,所以会调用 observe,经过上面的分析,我们知道这个 observe就是依赖收集相关的东西,这里的意思就是对新设置的值也进行依赖收集,加入到响应式系统中来

接下来这行代码才是关键:

看下 Dep

notify方法中,遍历了 subs,对每个项调用 update方法,经过前面的分析我们知道,subs的每个项其实都是依赖收集起来的 watcher,这里也就是调用了 watcherupdate方法,通过 update来触发对应的 watcher实现页面更新

所以,Dep其实就是一个 watcher管理模块,当数据变化时,会被 Observer监测到,然后由 Dep通知到 watcher

this.lazycomputed相关,computed是惰性求值的,所以这里只是把 this.dirty设为 true,并没有做什么更新的操作;

this.syncwatch相关,如果 watch设置了这个值为 true,则是显式要求 watch更新需要在当前 Tick 一并执行,不必放到下一个 Tick

这两个暂时不看,不扩充太多避免逻辑太乱,正常流程会执行 queueWatcher(this)

queueWatcher首先会根据 has[id]来避免同一 watcher的重复添加,接下来引入了队列的概念,vue并不会在每次数据改变的时候就立即执行 watcher重渲染页面,而是把这些 watcher 先推送到一个队列里,然后在nextTick 里调用 flushSchedulerQueue批量执行这些 watcher,更新 DOM

这里在 nextTick里执行 flushSchedulerQueue的目的就是为了要等到当前 Tick中所有的 watcher都加入到 queue中,再在下一 Tick中执行队列中的 watcher

看下这个 flushSchedulerQueue方法,首先对队列中的 watcher根据其 id进行排序,将 id小的 watcher放在前面(父组件 watcherid小于子组件的), 排序的目的也已经在注释中解释地很清楚了:

大概意思就是,在清空队列之前对队列进行排序,主要是为了以下 3

  • 组件的更新是由父到子的(因为父组件的创建在子组件之前),所以 watcher的创建也应该是先父后子,执行顺序也应该保持先父后子
  • 用户自定义 watcher应该在 渲染 watcher之前执行(因为用户自定义 watcher的创建在 渲染watcher之前)
  • 如果一个组件在父组件的 watcher 执行期间被销毁,那么这个子组件的 watcher 都可以被跳过

排完序之后,使用了一个 for循环遍历队列,执行每个 watcherrun方法,那么就来看下这个 run方法

首先判断 this.active,这个 this.active的初始值是 true,那么什么时候会变成 false呢?当 watcher从所有 Dep中移除的时候,也就是这个 watcher移除掉了,所以也就没有什么派发更新的事情了

接着执行 const value = this.get()获取到当前值,调用 watcherget方法的时候会执行 watchergetter方法:

而这个 getter前面已经说了,其实就是:

也就是执行了 DOM更新的操作

回到 flushSchedulerQueue,在执行完 watcher.run()之后,还有些收尾工作,主要是执行了 resetSchedulerState方法

这个方法主要是用于重置队列状态,比如最后将 waitingflushing置为 false,这样一来,当下次调用 queueWatcher的时候,就又可以往 queue队列里堆 watcher

回到 queueWatcher这个方法

flushSchedulerQueue执行,进行批量处理 watcher的时候,flushing将被置为 true,这个时候如果再次添加新的 user watcher进来,那么就会立即添加到 queue中去

这里采取改变 queue的方式是原数组修改,也就是说添加进去的 watcher会立即加入到 flushSchedulerQueue批处理的进程中,因而在 flushSchedulerQueue中对 queue的循环处理中,for循环是实时获取 queue的长度的

另外,新加入的 watcher加到 queue的位置也是根据id进行排序的,契合上面所说的 watch执行先父后子的理念

大体流程示意图如下:

总结

vue的代码相比于 react的其实还是挺适合阅读的,我本来还打算打断点慢慢看,没想到根本没用到,这也表明了vue的轻量级确实是有原因的

少了各种模式和各种系统的堆砌,但同时又能满足一般业务的开发需要,代码体积小意味着会有更多的人有兴趣将其接入移动端,概念少意味着小白也能快速上手,俗话说得小白者得天下,vue能与 react这种顶级大厂团伙化规模维护的框架库分庭抗礼也不是没有道理的

结语

感谢您的观看,如有不足之处,欢迎批评指正。
获取资料👈👈👈
本次给大家推荐一个免费的学习群,里面概括移动应用网站开发,css,html,webpack,vue node angular以及面试资源等。
对web开发技术感兴趣的同学,欢迎加入Q群:👉👉👉582735936 👈👈👈,不管你是小白还是大牛我都欢迎,还有大牛整理的一套高效率学习路线和教程与您免费分享,同时每天更新视频资料。
最后,祝大家早日学有所成,拿到满意offer,快速升职加薪,走上人生巅峰。

评论 ( 0 )
最新评论
暂无评论

赶紧努力消灭 0 回复