Vue源码笔记:响应式原理与Diff算法详解
Vue源码笔记:响应式原理与Diff算法详解
本文是关于Vue框架源码的笔记,主要记录了Vue2和Vue3在响应式原理和Diff算法上的实现细节。文章内容较为深入,包含了具体的技术细节和代码实现逻辑,适合有一定前端开发基础的读者阅读。
响应式原理
Vue最大的特点之一就是数据驱动视图,简单来说就是数据变化引起视图变化,那么第一步就是先要知道数据什么时候发生变化,也就是说对数据的变化要进行侦测,实现数据的响应式。
Vue2响应式原理
vue2中,使用JS内置对象方法Object.defineProperty
,它可以直接在一个对象上定义一个新属性,或修改其现有属性,数据的响应式首先就是监听数据的变化,借助defineProperty
这个方法vue给数据添加了get
和set
方法,当数据被读和写分别使用get()
和set()
进行拦截自动执行一些逻辑,使得数据变得可观测,也就是响应式对象。
当然这只是最基本的,在平常开发中,组件与组件都不是毫无关系单一存在的,数据也不可能只有一个,它们之间是存在依赖关系。因此在上面提到的拦截处执行一些逻辑,这里的逻辑就是处理这些依赖关系的。
理所应当的每一个数据都会有一个管理自己依赖的依赖管理器(Dep
类实例),这是一个依赖数组:
在get
方法中可以监听到读取的操作,当读取时,把是谁读取的通过创建一个Watcher
实例,然后保存在属性的依赖管理器中,也就是依赖收集。
在set
方法中可以获取到get
方法中收集在依赖管理器中的依赖,调用每一个Watcher
的更新方法,从而进行依赖的更新。
其代码对应流程一些细节:
- 数据通过实例为
observer
对象,其构造函数会执行defineReactive
方法,在该方法内会实例一个自己的依赖管理器,在get
函数中通过dep.depend
做依赖收集,在set
函数中通过dep.notify
做依赖更新。 - 当是谁读取数据时,会实例化
Watcher
,构造函数会执行pushTarget(this)
将自身保存,在由这个Watcher
实例触发原本数据的get
方法,在该方法中触发dep.depend()
方法,从而将Watcher
添加数据的依赖管理器中。 - 当数据发生了变化时,会触发
setter
,触发dep.notify()
方法,在该方法中遍历依赖数组,调用其更新方法更新数据。
以上,是针对于对象的处理方式,由于Object.defineProperty
是对象上的方法,有一些局限性,对于数组的处理,它无法像上面一样在set
方法中进行进行依赖更新。
vue2中,为了解决这个问题,它重写了对数组原型链中操作数组的一些方法,包括:push
、pop
、shift
、unshift
、splice
、sort
、reverse
,这样在对数组进行操作时,就能监听到了,从而进行依赖的更新
不足之处:不管是对象还是数组,都有一些不足的地方,当向对象数据里添加一对新的key/value
或删除一对已有的key/value
时,当用数组的下标来操作数据时,都是无法监测到的,也就无法进行依赖更新。(为了解决这个问题,vue2提供Vue.set
和Vue.delete
全局api)
Vue3响应式原理
vue3中,使用的是ES6新增的Proxy
对象,它用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。
Proxy
劫持的是整个对象,不需要做特殊处理,解决了在vue2中的一些不足之处。数据的响应式大体思路还是不变,将数据实例为proxy
对象,在实例读取和更新时触发自定义的拦截器,在对应的拦截器实现依赖更新和依赖收集操作。反正思路是这样,我也偷懒只了解了差别比较大的地方。
Diff算法
Diff算法针对的是虚拟Dom,而虚拟Dom就是用一个JS对象来描述一个Dom节点,我们知道一个页面渲染离不开Dom节点树,在节点树上有非常多的Dom节点,每一个节点上的数据量都是非常多的,而虚拟Dom只包含一些节点的必要信息,当数据发生变化时,我们对比变化前后的虚拟Dom节点,通过Diff算法计算出需要更新的地方,然后再去更新需要更新的视图,这样大大节省了操作真实Dom带来的性能。
vue中通过VNode
类可以实例化不同类型的虚拟Dom节点,其中包含tag
表示节点的标签名,text
表示节点中包含的文本,children
表示该节点包含的子节点等。通过属性之间不同的搭配,就可以描述出各种类型的真实Dom节点,类型包含如下:
- 注释节点
- 文本节点
- 元素节点
- 组件节点
- 函数式组件节点
- 克隆节点
Vue2的Diff算法
Diff算法核心的地方是新旧都有节点,需要更新时,这里是采用了双端对比的算法,进行对比更新。
核心代码大致实现逻辑:
- 需要创建新节点时,会先判断节点类型,依次判断是否是元素节点,注释节点和文本节点,只有这三种节点才能被创建并插入到dom中。
- 需要删除节点时,先获取父节点,再删除该节点。
- 更新节点时,稍微比较复杂一点,会先进行节点类型判断,有以下逻辑:
- 新旧节点类型都是静态节点,则直接跳过
- 新节点是文本节点时,如果旧节点也是文本节点,对比文本内容,如果不同则修改为相同的,如果旧节点不是文本节点,则直接调用
setTextNode
方法创建一个相同的文本节点替换掉。 - 新节点是元素节点时,这个时候又需要判断是否含有子节点:
- 当新节点有子节点,旧节点没有子节点,则可以说明是空节点或者是文本节点,空节点的话会直接把新节点的子节点创建一份插入到空节点下面,而是文本节点会先清空文本内容,再插入。
- 当新节点没有子节点时,不管旧节点是什么,直接清空。
- 当新节点和旧节点都有子节点时,则需要递归对比更新。通过新旧节点两端逐步迭代两个子节点数组,判断更新子节点(双端对比),分为了四种情况:
- 创建子节点:如果
newchildren
的某个子节点在oldchildren
没有,就创建该节点插入到所有未处理节点之前。 - 删除子节点:如果把
newchildren
都循环完了,oldchildren
还存在未处理的节点,就删除。 - 更新子节点:如果
newchildren
的某个子节点在oldchildren
找到了,并且位置也相同,就重复上述操作再次进行对比更新。 - 移动子节点:如果
newchildren
的某个子节点在oldchildren
找到了,但是位置不相同,则需要移动旧的节点到所有未处理节点之前。
虽然情况分的蛮多的,但是总的来说就是对比新旧两份vnode,使旧的vnode和新的一样。
概括来讲其实就干了三件事:
- 新的vnode有的节点,而旧的没有就创建节点
- 新的vnode没有的节点,而旧的有就删除节点
- 新的vnode和旧的vnode都有的节点,就以新的节点为准,更新旧的节点
Vue3的Diff算法
vue3中,也是一样没有看全部,大致了解了核心Diff的一些变化,也就是上述当新旧vnode节点都有子节点需要更新时,类似vue2的算法,首先进行双向的比较,再加上最长子序列算法来减少节点的移动操作,提高了Diff的效率。
核心代码大致逻辑:
- 从新旧
children
数组前面开始迭代比对,记录下标为i
,如果相同则将下标往后移,如果不同或者一个数组遍历完成则跳出循环。 - 从新旧
children
数组后面开始迭代比对,记录下标分别为newEndIndex
、oldEndIndex
,如果相同则将下标往前移,如果不同或者一个数组遍历完成则跳出循环。 - 经过双向的循环对比之后,根据记录的下标判断,又分为一下情况:
- 下标
i
已经大于了oldEndIndex
但是小于newEndIndex
说明oldchildren
已经遍历完了,但是newchildren
还没有,则将还未遍历的元素,新增到oldchildren
中 - 下标
i
大于newEndIndex
,说明未遍历完oldChildren
,则需要将未遍历到的元素进行删除 - 最后是新旧都有剩余元素,首先生成一个
newchildren
中未处理节点一样长度的数组source
,用-1
填充,然后需要获取新旧剩余元素,都要遍历一遍,newchildren
中剩余元素遍历是为了获取一个未处理的每个元素为key,其对应原newchildren
中的下标为value的映射表,然后遍历oldChildren
中为未处理的元素,尝试在映射表中查找,如果未找到,说明需要移除该元素,如果找到了就将source
数组中把当前迭代的索引下标替换掉对应的位置中的-1
在经过上步骤处理,我们得到一个关于source
新的一组子节点在老的组中位置的数组,再使用最长增长子序列算法可以得到一个最长索引递增的数组,再配合迭代未处理的newChildren
,可以使移动最少次数就更新完成。(说实话有点不好懂,我刚想了半天这块儿都不理解,意思是这个意思,可能需要实际上手写代码操作好理解一点
可用下面代码尝试理解
// 求最长递归子序列的下标
function getSequence(arr) {
const p = arr.slice();
const result = [0];
let i, j, u, v, c;
const len = arr.length;
for (i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI !== 0) {
j = result[result.length - 1];
if (arr[j] < arrI) {
p[i] = j;
result.push(i);
continue;
}
u = 0;
v = result.length - 1;
while (u < v) {
c = (u + v) >> 1;
if (arr[result[c]] < arrI) {
u = c + 1;
} else {
v = c;
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1];
}
result[u] = i;
}
}
}
u = result.length;
v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}
getSequence([102, 103, -1, 105, 106, -1, 107, 109, -1]);
// 打印出来是这样的一个数组 [0, 1, 3, 4, 6, 7]
[102, 103, -1, 105, 106, -1, 107, 109, -1]
可以看作是source数组,通过求得最长递增子序列,这些位置上的元素不进行更新,就能使的旧节点与新节点同步。
大概有这些吧......