《Vuejs设计与实现》笔记

247 阅读11分钟

computed的实现原理

computed的特点:

  1. 传入一个getter函数,返回值是响应式的;
  2. 懒计算:只有读取computed的值时,computed内部才会进行计算它的值;
  3. 缓存,computed依赖的响应式数据不变,再次读取computed的时候会返回缓存值。

computed的实现依赖于Vue的响应式系统,介绍computed的实现原理前,需要先说明一下Vue的响应式系统的基本原理。

在Vue3中,把一个数据变成响应式数据,是利用Proxy来拦截数据的读写操作,读数据的时候收集副作用函数,写数据的时候把副作用函数取出来执行。

我们以一个简单的对象为例,把它变成响应式,就是new Proxy,第二个参数传入配置对象,分别定义get和set行为,在get函数中执行track收集副作用,在set函数中执行trigger执行对应的副作用。

通常我们直接执行一遍副作用函数触发收集就完事了,但是这样功能太过简单,不够灵活。

所以我们需要一个effect函数来处理副作用函数。我们把副作用函数和一些配置参数传递给effect,effect拿到副作用函数后不会立即执行它,而是包装成另外一个函数fn,它返回值是副作用函数的返回值。这个函数会return出去给用户,用户可以手动执行,从而拿到副作用函数的返回值。

这个配置很重要,如果没有这个配置设计,那么数据一变,副作用函数就会立即同步执行。有了这个设计,我们就可以配置一个调度函数,在effect内部的包装函数中,不直接执行副作用,而是执行我们的调度函数,并且把副作用作为参数传递出来,这样我们就能在依赖的响应式数据变化后,自行决定要不要执行真正的副作用。这个调度函数是实现computed懒计算和缓存的基础。

这就是Vue响应式系统的基本原理,track收集副作用,trigger触发副作用,effect包装副作用函数,实现懒执行和调度。

回到computed的实现,给computed传递计算函数的时候,我们在computed内部调用effect,把计算函数作为副作用传给它,调度函数传递一个什么也不做的函数。这样我们得到effect的返回值effectFn,只要执行effectFn,就能拿到计算属性的结果,而且由于调度函数的存在,依赖数据变化了,计算函数并不会重新执行。在computed内部还需要利用Proxy定义一个对象obj,obj作为computed的返回值。我们拦截读取obj的value属性的行为,在get函数中执行effectFn得到计算属性的值。这就是懒计算的原理,只有读取了,才会执行副作用进行计算。

还有一个缓存,在computed内部声明两个变量,一个cache缓存计算值,一个dirty来判断是否需要重新计算。依赖的数据变化的时候,调度函数会被执行,所以在调度函数中把dirty置为true。在get函数中就根据dirty的值来决定要返回缓存值cache还是重新执行effectFn,重新计算计算属性,并更新返回值。这就是缓存的实现思路。

最后还差一个,就是计算属性也应该是响应式的。所以我们在内部obj的get函数执行的时候,先手动调用track函数进行副作用收集。在调度函数中手动执行trigger函数,执行计算属性对应的副作用函数。

为什么要使用Proxy替代Object.defineProperty

Proxy比Object.defineProperty更灵活,更强大。

Object.defineProperty只能代理对象,Proxy还能代理Set和Map类型。

Object.defineProperty只能拦截对象的get和set操作,像in操作符,for...in,还有对象属性的新增和删除等等,Object.defineProperty是拦截不了的。

当我们使用各种api读取或操作对象时,引擎会调用对象的内部方法,使用Proxy能够对这些方法进行拦截,查看ES语言规范,就能知道什么操作对应的拦截函数名是什么。比如in操作符,对应的拦截函数是has,所以我们可以通过在has拦截函数来实现对in操作符的代理。

Proxy需要和Reflect一起配合使用,我们在拦截函数增加track或trigger操作后,最终还是要完成内部方法的默认行为,这时就可以通过Reflect来调用原来的内部方法。比如拦截了默认的has行为,最终需要调用Reflect.has方法。

Vue2是如何代理数组的?有什么缺陷?Vue3是如何代理数组的?

Vue2是用Object.defineProperty加上重写数组原型上的方法来实现代理的。如果初始化了一个响应式数组,通过索引来访问和修改数组元素,只要不超过数组长度,那么是可以被拦截到的。通过push,pop,splice之类的方法来修改数组也可以被拦截,因为Vue重写了这些方法。

但是有一些情况Vue2是无法代理的。比如副作用函数读取数组的length属性,直接修改length或者通过索引来改变数组长度,都是无法触发副作用函数执行的。

而Vue3使用Proxy可以根据ES语言规范来代理数组的所有行为。当通过索引来设置数组元素的时候会被set函数拦截到,如果设置的索引大于原数组长度,就需要触发和length相关的副作用。 其实Vue3代理数组的核心就是根据ES规范,查看对数组进行读写操作的时候调用的内部方法的运行逻辑,再做一些对应的拦截。数组的复杂之处就在于有很多方法会读取或者修改length属性,所以要注意避免收集不必要的副作用。例如push操作是会读取length属性的,这就需要重写push方法,在调用push的时候避免收集和length相关的副作用。

如何代理Map和Set?

集合类型数据的操作大都需要调用原型方法,例如get,set,delete等等。这些方法的调用分两步,先是读取方法,然后再执行。所以可以在读取方法这一步入手。在get拦截函数中返回一个重写的方法,重写的方法主要做两件事,一个是执行trigger或track,一个是正确绑定原方法内部的this值。

Map和Set还有三个迭代器方法entries, keys, values,调用它们会返回一个迭代器对象,需要注意的是集合数据本身也是一个迭代器对象。所以需要重新实现Symbol.iterator属性。

如何代理基本数据类型?为什么可以在模板直接访问一个 ref 的值,而无须通过 value 属性来访问?

Proxy无法直接拦截基本数据类型的操作,所以需要把原始值包装成一个普通对象,再用Proxy来拦截,这就是为什么在setup函数值,使用ref值需要通过value属性来操作。而setup函数的返回值会进行自动脱ref处理,也是用Proxy对返回值进行代理,在get函数中返回value。

HTML Attributes 和 DOM Properties有什么区别?

HTML Attributes是在html标签上的属性,DOM Properties是一个dom元素上的属性。很多HTML Attributes都有同名的DOM Properties,比如id。有一些是不同名的如class和className,还有一些是各自独有的属性。简单来说,HTML Attributes的作用是设置与之对应的DOM Properties的初始值。修改了DOM属性后,再用getAttribute去获取html属性,会发现html属性是不变的。

简单diff:

  1. 对于一个新节点,找到可以复用的旧节点。
  2. 移动可以复用的旧节点。

第一步,在旧节点中寻找可以复用的旧节点,其实就是找到类型和key值都相同的节点。

第二步,首先要判断这个节点是否需要移动。 Vue采取的做法是,每一次找到可以复用的旧节点时,都要用到它的索引值,把遇到的最大索引值更新到变量lastIndex中。如果索引值小于lastIndex,说明节点需要移动。 我们假设现在有新旧两个数组,先从新数组取出一个新节点A,然后去旧数组找到key值一样的A',A'的位置是5,把5记录到lastIndex中。 然后继续下一个新节点B,对应B'的索引是3。B'的索引小于5,说明B'原来是在A'前面的。但是现在按照新数组的遍历顺序,A应该在前面的。 所以我们判定B'节点需要移动。 到这一步就可以通过新的节点数组,知道这个旧节点应该移动到哪一个节点前面,利用insertBefore来完成操作。 这就是简单diff算法的核心原理,此外还要处理新增元素和删除元素的情况。在旧数组中找不到可复用的,说明是新增节点。然后反过来遍历旧节点,在新数组中找不到对应的节点,则需要删除。

双端diff:

Vue2中使用的diff算法

用四个变量来指向新旧数组的头尾节点的索引,每一轮diff过后缩小比较的范围。所以用一个while循环来实现,每次循环都要执行这几个步骤:

  1. 新旧两个数组进行头跟头比较,尾跟尾比较,然后再交替首尾比较,判断是否能找到可以复用的节点,如果有,就执行打补丁更新属性,移动真实dom节点。
  2. 如果4次比较都没有找到可复用的节点,就要取新数组第一个节点,再去遍历旧数组找可复用节点。找到了就执行更新操作,找不到说明是新增节点,执行挂载操作。 循环结束后,要判断新数组是否还有节点未被遍历到,如果有就是新增节点,需要挂载节点。 如果新数组遍历完了,旧数组还有节点未遍历到,说明这些节点需要卸载移除掉。

快速diff算法

Vue3使用的是快速diff算法。快速diff算法借鉴了文本diff中的预处理思路,先处理两个数组公共的前缀和后缀,再对剩下的节点进行diff。

diff要解决的核心问题就是判断节点是否需要移动,怎么做到移动次数最少。

首先我们看如何判断是否需要移动节点: 对于预处理过的两个vnode数组,需要构造一个source数组,数组元素是新节点在旧数组中的索引,就是表示这个新节点原来是在什么位置。 如果source数组是单调递增的,说明节点在新数组和旧数组中的相对前后位置是一样的。因为我们遍历新数组,索引是递增的,如果对应的旧位置也是递增的,那么相对位置关系肯定是一样的。

然后再看怎么使移动次数最少: 如果判断出source数组不是单调递增,那么说明有节点需要移动。为了尽可能地少移动节点,要求出这个source数组的最长递增子序列,这个序列对应的节点是不需要移动的。 有了最长递增子序列和source数组,就可以从后往前开始遍历新数组,取出每一个节点, 如果在source数组中对应的值是-1,说明旧数组没有,需要新增这个节点。 如果在source数组中的位置对应了最长递增子序列的值,说明这个节点不需要移动,只需要patch更新属性。如果不属于最长递增子序列,说明需要移动节点。