前言
众所周知,Vue最大的特点之一就是数据驱动视图,即数据变化,视图更新,可以抽象成:
上述公式中:状态 state 是输入,页面UI输出,状态输入一旦变化了,页面输出也随之而变化。我们把这种特性称之为数据驱动视图。
那么问题来了:
1、Vue是如何知道 state 变化了呢?
2、 render 函数是如何得来的?
3、render 函数根据输入数据渲染页面的细节是什么?
4、执行 render 函数渲染和直接操作DOM有什么区别?
5、......
希望下面的内容能够解答你的问题!
变化监测
数据劫持
JavaScript 中想要知道一个数据变化了,可以采用以下几种方式:
1、手动调用:数据集中管理,每次需要改变数据时,使用特定的函数进行改变,比如:react 中的 setState ,小程序中的 setData
2、数据代理:JavaScript 中提供 Object.defineProperty (ES5) 和 Proxy (ES6)方式,可以拦截数据的读写操作,可以利用这个特性来重写数据的 getter 和 setter 方法,加入数据变更的处理操作,实现细粒度的了解数据变化。
Vue 中正是采用了第二种方式检测数据的方式:
-
Vue2.x => Object.defineProperty
-
Vue3.x => Proxy
注意:Object.defineProperty 由于自身缺陷,无法检测到对象属性的增减和数组的变化,所以 ES6 出了 Proxy 来解决这些问题。而 Vue 也应对这种情况出了 this.$set 、 Vue.set 、重写数组方法 等方式来实现手动触发更新。
依赖收集
在上一章中,我们迈出了第一步:让数据变的可观测。变的可观测以后,我们就能知道数据什么时候发生了变化,那么当数据发生变化时,我们去通知视图更新就好了。
那么问题又来了:
视图那么大,我们到底该通知谁去变化?总不能一个数据变化了,把整个视图全部更新一遍吧,这样显然是不合理的。
Vue 使用的方法是 每个数据使用一个收集器来记录当前数据被谁使用了,当数据变化时,通知收集器中的全部使用者更新。
用相对专业的术语来说就是:依赖收集 + 发布订阅模式
那问题又来了:
1、依赖是什么?
2、什么时候收集依赖?
3、什么时候通知依赖更新?
其实在 Vue 中,依赖即 Watcher 类的实例,在整个 Vue 的生命周期中,存在三种 Watcher 实例,分别为:
1、渲染 watcher:根据数据渲染UI界面,数据变化时,高效更新视图(render函数生成新虚拟DOM, patch过程比对新旧虚拟DOM,更新视图)
2、计算属性 watcher:执行数据变化的复杂逻辑运算
3、普通 watcher:用户手动编写的 watch 方法
Watcher 类以及依赖收集的伪代码如下:
1、当实例化Watcher类时,会先执行其构造函数;
2、在构造函数中调用了this.get()实例方法;
3、在get()方法中,首先通过window.target = this把实例自身赋给了全局的一个唯一对象window.target上
4、通过 let value = this.getter.call(vm, vm) 获取一下被依赖的数据,获取被依赖数据的目的是触发该数据上面的getter,
5、在getter里会调用 dep.depend() 收集依赖,在dep.depend()中取到挂载在 window.target 上的值并将其存入依赖数组dep中,在get()方法最后将window.target释放掉。
6、而当数据变化时,会触发数据的setter,在setter中调用了dep.notify()方法,在dep.notify()方法中,遍历所有依赖(即watcher实例),执行依赖的update()方法,也就是Watcher类中的update()实例方法,在update()方法中调用数据变化的更新回调函数,从而更新视图。
生命周期
综述
从图中我们可以看到,Vue实例的生命周期大致可分为4个阶段:
1、初始化阶段:为Vue实例上初始化一些属性,事件以及响应式数据;
2、模板编译阶段:将模板编译成渲染函数;
3、挂载阶段:将实例挂载到指定的DOM上,即将模板渲染到真实DOM中;
4、销毁阶段:将实例自身从父组件中删除,并取消依赖追踪及事件监听器;
初始阶段
1、执行 new Vue() 其实就是执行 Vue 原型链上的 _init 方法
2、initLifeCycle:给实例挂在 $root 、$parent 、$children 等属性,用于访问各种层级的组件实例
3、initEvents:初始化父组件在模板中使用v-on或@注册的监听子组件内触发的事件。
4、initState:初始化 props、methods、data、computed、watch等数据,在这里面实现了数据劫持和依赖收集,同时也将这几个属性内的属性代理到 实例上,实现可以通过 this.xx 直接访问属性内的数据
5、callHook:执行用户传入的对应生命周期函数
模板编译阶段
编译阶段其实就是将模板编译为 render 函数,毕竟手写 render 函数是有一定难度的,也是很容易出错的,通过模板的方式增强 render 函数的表现力,同时也大大简化了模板的编写难度。
挂载阶段
挂载阶段其实就是将Vue实例替换掉 el 节点,将模板内容输出到对应的挂载点下,同时此时开启Vue的数据监控,监听数据变化,当数据变化时,触发视图更新。
销毁阶段
销毁阶段的主要工作是将当前的Vue实例从其父级实例中删除,取消当前实例上的所有依赖追踪并且移除实例上的所有事件监听器。
虚拟DOM
什么是虚拟DOM
虚拟DOM其实就是用一个 JS 对象来描述一个 DOM 节点,如下所示:
Vue中的虚拟DOM
Vue中的虚拟DOM的作用
我们在视图渲染之前,把写好的template模板先编译成VNode并缓存下来,
等到数据发生变化页面需要重新渲染的时候,我们把数据发生变化后生成的VNode与前一次缓存下来的VNode进行对比,找出差异(JS对象比对和真实DOM比对来说,肯定是JS对象比对的速度更快),然后有差异的VNode对应的真实DOM节点就是需要重新渲染的节点,
最后根据有差异的VNode创建出真实的DOM节点再插入到视图中,最终完成一次视图更新。
模板编译
流程图
模板编译到视图更新的过程:
1、模板解析阶段:将一堆模板字符串用正则等方式解析成抽象语法树AST;
2、优化阶段:遍历AST,找出其中的静态节点,并打上标记,静态节点因为在视图更新中不会更新,所以直接跳过即可,从而减小 patch 函数比对的范围,提高效率;
3、代码生成阶段:将 AST 转换成渲染函数,用于生成表示模板的虚拟DOM;
render 函数是什么
render 函数是构建虚拟DOM的工具,执行 render 函数就可以生成表示当前DOM结构的虚拟DOM对象
模板解析为AST
抽象语法树(AbstractSyntaxTree,AST)是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。如下是Vue解析模板生成的AST对象。
模板解析的思路:
模板其实本质就是字符串,设置一个游标表示当前正在解析的字符位置,通过用正则匹配对应字符串的方式,不断查找出当前字符位置对应的含义(例如:标签、属性等),将各种含义的字符添加到对应的属性中,并向后移动当前游标
如此往复,直到完成全部字符串遍历,最终得到表示当前模板信息的AST对象。
优化阶段
为了提升 Vue 编译模板的效率,Vue引入了标记静态节点功能,例如:
此段模板中,span 标签包裹的动态部分会实时更新,但是 ul 及 li 标签的内容是固定的,不会随着数据的更新发生任何变化,
所以在模板编译过程中,可以将 ul 及 li 节点标记为静态节点,这样在 Vue 就
- 不需要在每次重新渲染时为其创建节点
- 也可以在虚拟DOM比对过程中完全跳过这部分
代码生成阶段
代码生成阶段其实就是将 AST 转化为 render 函数的过程,看如下代码生成的 render 函数:
其中的 *c 、* v、_m ...... 其实就是用来生成虚拟DOM的方法
视图更新
流程
1、第一次挂载,利用 render 函数生成的虚拟DOM,直接生成对应的真实DOM,并添加在挂载节点
2、数据改变,触发数据 setter 函数,当前数据管理器 Dep 通知其依赖更新
3、其中渲染依赖触发 render 函数根据最新数据重新生成新的虚拟DOM,通过 patch 函数比对前后虚拟DOM的差异,有差异的地方,将差异以真实DOM补丁的方式插入到页面中,实现页面更新
DIFF算法
VNode最大的用途就是在数据变化前后生成真实DOM对应的虚拟DOM节点,然后就可以对比新旧两份VNode,找出差异所在,然后更新有差异的DOM节点,最终达到以最少操作真实DOM更新视图的目的。
而对比新旧两份VNode并找出差异的过程就是所谓的DOM-Diff过程。
虚拟DOM节点在进行比对时,首先会去比对当前两个Vnode节点的key值是否相同,如果满足条件才会进行节点比对和复用,这也就是为什么在 v-for 中需要指定唯一key值,防止错误复用就节点,导致更新出错的问题。如下是判断是否是相同节点的方法:
更新子节点 updateChildren 有四种情况:
1、创建子节点
如果newChildren里面的某个子节点在oldChildren里找不到与之相同的子节点,那么说明newChildren里面的这个子节点是之前没有的,是需要此次新增的节点,那么就创建子节点。
2、删除子节点
如果把newChildren里面的每一个子节点都循环完毕后,发现在oldChildren还有未处理的子节点,那就说明这些未处理的子节点是需要被废弃的,那么就将这些节点删除。
3、移动子节点
如果newChildren里面的某个子节点在oldChildren里找到了与之相同的子节点,但是所处的位置不同,这说明此次变化需要调整该子节点的位置,那就以newChildren里子节点的位置为基准,调整oldChildren里该节点的位置,使之与在newChildren里的位置相同。
4、更新节点
如果newChildren里面的某个子节点在oldChildren里找到了与之相同的子节点,并且所处的位置也相同,那么就更新oldChildren里该节点,使之与newChildren里的该节点相同。
子节点的对比最容易想到的方法其实就是两层循环,查找相同节点进行比对,如下伪代码:
但是这种操作复杂度会随着节点数量的增加成倍的增加,为了优化这一点,Vue 做了如下循环之前的特殊处理:
1、把newChildren数组里的所有未处理子节点的第一个子节点和oldChildren数组里所有未处理子节点的第一个子节点做比对,如果相同,那好极了,直接进入之前文章中说的更新节点的操作并且由于新前与旧前两个节点的位置也相同,无需进行节点移动操作;如果不同,没关系,再尝试后面三种情况。
2、把newChildren数组里所有未处理子节点的最后一个子节点和oldChildren数组里所有未处理子节点的最后一个子节点做比对,如果相同,那就直接进入更新节点的操作并且由于新后与旧后两个节点的位置也相同,无需进行节点移动操作;如果不同,继续往后尝试。
3、把newChildren数组里所有未处理子节点的最后一个子节点和oldChildren数组里所有未处理子节点的第一个子节点做比对,如果相同,那就直接进入更新节点的操作,更新完后再将oldChildren数组里的该节点移动到与newChildren数组里节点相同的位置;
4、把newChildren数组里所有未处理子节点的第一个子节点和oldChildren数组里所有未处理子节点的最后一个子节点做比对,如果相同,那就直接进入更新节点的操作,更新完后再将oldChildren数组里的该节点移动到与newChildren数组里节点相同的位置;
如果这四种优化都没有命中则再执行循环比对操作。
总结
参考文档:
vue源码系列: vue-js.com/learn-vue/ 墙裂推荐,本文很多部分借鉴了该系列文章
vue官方文档: cn.vuejs.org/v2/guide/