✨「前端进阶」浅谈Vue3设计与实现
前言
先说结论,首先讲讲Vue在3版本新增的一些新特性及针对2.x版本主要做的一些改变:
- 采用Composition API(紧凑式API)代替Option API (选项式API),增强了逻辑复用及编程体验
- 响应式系统底层采用ES6的Proxy代替ObJect.defineproperty,避免Api缺陷
- 采用快速Diff算法及最长递增子序列提高Patch效率
- 模版编译优化,添加PatchFlag等提升Diff更新效率,避免不必要对比
Vue3新特性
-
Composition API:
这个是革命性的变化,不同于Vue2的选项式API,组合式API将组件初始化时使用setup方法,包含所有数据、方法,并统一返回。这使编写的代码逻辑性更强,避免2.x版本中data、methods之间反复横跳的问题,同时Composition API在逻辑复用方面更强,hooks写法兴起了Vueuse等一系列库,这让Vue3看起来React化~
举个例子:
Vue3 composition API: <script> export default{ setup(){ const count =ref (0); const add=()=>{ count.value++; } return { count, add } } } </script> -
基于Proxy的响应式:
在Vue2中响应式数据是通过ES5的数据劫持setter/getter 来实现的,使用的是object.defineproperty,但只能对现有的对象属性进行劫持,针对的是对象上的属性,尽管做一些重写也不能完全覆盖数据变更的地方(比如动态向对象上添加Key,或通过下标的方式修改数组。后面才衍生了$set)
而在Vue3中响应式系统使用的是Proxy,针对的是整个对象,直接代理对象,修改代理对象时拦截数据的变化。
const data={} const dataProxy=new Proxy(data,{ set(target,key,val){ // 触发监听 Reflact.set() } get(target,key,val){ // 依赖收集 Reflact.get() } })Proxy.revocable()方法可以用来创建一个可撤销的代理对象。
const target = { name: 'vuejs'} const {proxy, revoke} = Proxy.revocable(target, handler) proxy.name // 正常取值输出 vuejs revoke() // 取值完成对proxy进行封闭,撤消代理 proxy.name // TypeError: Revoked需要注意的是Proxy只有在ES6的环境使用,还无法编译降级,但在现代浏览器这兼容不成问题,可能还有人会说Proxy只针对引用数据类型,那么Vue3怎么代理基本数据类型?其实Vue3中Ref这个API就是解决这个问题,把基本类型的数据用一个key为value的对象包装起来,再使用Proxy进行代理。
-
快速Diff:
Diff算法对比复用只针对节点存在Key的情况,对比tag类型及Key判断是否复用节点。
目前主流的一些Diff算法:
-
React: 遍历新节点序列在旧节点序列出现的位置,如果位置递增,则新节点不需要移动,否则节点后移。
-
Vue 2: 双端比较Diff。分别用新、旧节点序列两个双指针的start跟end相互比较,查找复用,直到指针重合,退出循环。若四次对比都没找到复用节点,只能拿新序列的节点去旧序列依次遍历对比。
-
Vue 3: 快速Diff+最长递增子序列移动。预处理用新、旧节点序列两个双指针的start跟end进行靠中遍历,预先去除那些不需要处理的节点(节点相同),然后遍历生成新节点在旧节点序列中index的source数组和source中的最长递增子序列seq数组(代表新节点序列不需要移动的index数组),从后向前遍历,若seq[j]==index 说明节点不需要移动,指针上移,否则节点插入队尾。类型下图的遍历流程,其中新节点中p-3、p-4节点不需要移动。
快速Diff大体流程:
-
从头对比找到有相同的节点 patch ,发现不同,立即跳出。
-
如果第一步没有patch完,立即,从后往前开始patch ,如果发现不同立即跳出循环。
-
如果新的节点大于老的节点数 ,对于剩下的节点全部以新的vnode处理( 这种情况说明已经patch完相同的vnode )。
-
对于老的节点大于新的节点的情况 , 对于超出的节点全部卸载 ( 这种情况说明已经patch完相同的vnode )。
-
不确定的元素( 这种情况说明没有patch完相同的vnode ) 与 3 ,4对立关系。如下情况:
// [i ... e1 + 1]: a b [c d e] f g // [i ... e2 + 1]: a b [e d c h] f g // i = 2, e1 = 4, e2 = 5
-
把没有比较过的新的vnode节点,通过map保存,记录已经patch的新节点的数量 patched,没有经过 path 新的节点的数量 toBePatched
-
建立一个数组newIndexToOldIndexMap,每个子元素都是[ 0, 0, 0, 0, 0, 0, ] 里面的数字记录老节点的索引 ,数组索引就是新节点的索引
-
遍历老节点:
- 如果 toBePatched新的节点数量为0 ,那么统一卸载老的节点
- 如果,老节点的key存在 ,通过key找到对应的index
- 如果,老节点的key不存在:遍历剩下的所有新节点,如果找到与当前老节点对应的新节点那么 ,将新节点的索引,赋值给newIndex
- 没有找到与老节点对应的新节点,卸载当前老节点
- 如果找到与老节点对应的新节点,把老节点的索引,记录在存放新节点的数组中
-
如果发生移动:
- 根据 newIndexToOldIndexMap 新老节点索引列表找到最长稳定序列
- 对于 newIndexToOldIndexMap[i] =0 证明不存在老节点 ,从新形成新的vnode
- 对需要移动的节点,构建最长递增子序列进行移动处理。
补充:移动元素算法源码
if (moved) { const seq = lis(sources) // s 指向最长递增子序列的最后一个元素 let s = seq.length - 1 // i 指向新的一组子节点的最后一个元素 let i = count - 1 // for 循环使得 i 递减,即按照图 11-24 中箭头的方向移动 for (i; i >= 0; i--) { if (source[i] === -1) { // 说明索引为 i 的节点是全新的节点,应该将其挂载 // 该节点在新 children 中的真实位置索引 const pos = i + newStart const newVNode = newChildren[pos] // 该节点的下一个节点的位置索引 const nextPos = pos + 1 // 锚点 const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null // 挂载 patch(null, newVNode, container, anchor) } else if (i !== seq[s]) { // 如果节点的索引 i 不等于 seq[s] 的值,说明该节点需要移动 } else { // 当 i === seq[s] 时,说明该位置的节点不需要移动 // 只需要让 s 指向下一个位置 s-- } } } -
-
模板编译优化:
Vue.js 3 的编译器会将编译时得到的关键信息“附着”在它生成 的虚拟 DOM 上,这些信息会通过虚拟 DOM 传递给渲染器。最终,渲 染器会根据这些关键信息执行“快捷路径”,从而提升运行时的性能。
- Block 与 PatchFlags:引入块的概念收集动态节点(包含直接动态子节点和所有动态子代节点),有了 Block 这个概念之后,渲染器的更新操作将会以 Block 为 维度。也就是说,当渲染器在更新一个 Block 时,会忽略虚拟节点的 children 数组,而是直接找到该虚拟节点的 dynamicChildren 数组,并只更新该数组中的动态节点。这样,在更新时就实现了跳过静 态内容,只更新动态内容。同时,由于动态节点中存在对应的PatchFlag标志,所以在更新动态节点的时候,也能够做到靶向更新。(比如:当一个动态节点的 patchFlag 值为数字 1 时,我们知道它只存在动态的 文本节点,所以只需要更新它的文本内容即可,避免不必要的对比)同时
- 静态提升: Vue3会将静态节点、子树等渲染代码移到渲染函数之外,这样可以避免每次渲染时重新创建这些不会变化的对象。进一步还有预字符串化处理。
- 缓存内联事件处理: 组件重新渲染时(即 render 函数重新执行时),都会为 Comp 组件创建一个全新的 props 对象。同时,props 对象中事件 属性的值也会是全新的函数。这会导致渲染器对 Comp 组 件进行更新,造成额外的性能开销。为了避免这类无用的更新,我们需要对内联事件处理函数进行缓存,再render执行中直接取缓存。
-
Tree-shaking的优化:
在使用Vue3时可以选择按需选择引入相应的模块,而不是一次性引入所有代码,这样打包时Vue3可以将没有引用的源码移除,从而减少体积。
代码结构
Vue3的代码主要分packages和scripts两个目录,script主要用于代码检查、打包等工程操作,真正的源码位于packages目录下,一共有13个包:
compiler-core模板解析核心,与具体环境无关,主要生成 AST,并根据 AST 生成render()方法compiler-dom浏览器环境中的模板解析逻辑,如处理 HTML 转义、处理 v-model 等指令compiler-sfc负责解析 Vue 单文件组件,在前面 vue-loader 的解析中有讲解过compiler-ssr服务端渲染环境中的模板解析逻辑reactivity响应式数据相关逻辑runtime-core与平台无关的运行时核心,包括 renderruntime-dom浏览器环境中的运行时核心runtime-test用于自动化测试的相关配套server-renderer用于 SSR 服务端渲染的逻辑shared一些各个包之间共享的公共工具size-check一个用于测试 tree shaking 后代码大小的示例库template-explorer用于检查模板编译后的输出,主要用于开发调试vueVue 3 的主要入口,包括运行时和编译器,包括几个不同的入口(开发版本、runtime 版本、full 版本)
reactivity
整体流程
ReactiveFlags
export const enum ReactiveFlags {
skip = '__v_skip',
isReactive = '__v_isReactive',
isReadonly = '__v_isReadonly',
raw = '__v_raw',
reactive = '__v_reactive',
readonly = '__v_readonly'
}
- 代理对象会通过
ReactiveFlags.raw引用原始对象 - 原始对象会通过
ReactiveFlags.reactive或ReactiveFlags.readonly引用代理对象 - 代理对象根据它是
reactive或readonly的, 将ReactiveFlags.isReactive或ReactiveFlags.isReadonly属性值设置为true
Track与Trigger
track() 和 trigger() 是依赖收集的核心,track() 用来跟踪收集依赖(收集 effect),trigger() 用来触发响应(执行 effect),它们需要配合 effect() 函数使用
const obj = { foo: 1 }
effect(() => {
console.log(obj.foo)
track(obj, TrackOpTypes.GET, 'foo')
})
obj.foo = 2
trigger(obj, TriggerOpTypes.SET, 'foo')
track与trigger函数接受三个参数:
- target:要跟踪的目标对象,这里就是
obj - 跟踪操作的类型:
obj.foo是读取对象的值,因此是'get' - key:要跟踪目标对象的
key,我们读取的是foo,因此key是foo
本质上建立一种数据结构:
// 伪代码
map : {
[target]: {
[key]: [effect1, effect2....]
}
}
简单的理解,effect 与对象和具体操作的 key,是以这种映射关系建立关联的:
[target]`---->`key1`---->`[effect1, effect2...]
[target]`---->`key2`---->`[effect1, effect3...]
[target2]`---->`key1`---->`[effect5, effect6...]
既然 effect 与目标对象 target 已经建立了联系,那么当然就可以想办法通过 target ----> key 进而取到 effect ,然后执行它们,而这就是 trigger() 函数做的事情,所以在调用 trigger 函数时我们要指定目标对象和相应的key值。
toRef
使用reactive 声明响应式对象有时出现对象的二次引用,造成响应丢失(对象解构返回渲染环境也会丢失响应)。
const obj = reactive({ foo: 1 }) // obj 是响应式数据
const obj2 = { foo: obj.foo }
effect(() => {
console.log(obj2.foo) // 这里读取 obj2.foo
})
obj.foo = 2 // 设置 obj.foo 显然无效
解决问题可以使用toRef() 函数把响应式对象的某个Key值转换成ref。它的实现就直接set、get返回,因为target本身就是响应式的,所以无需track和trigger。
function toRef(target, key) {
return {
isRef: true,
get value() {
return target[key]
},
set value(newVal){
target[key] = newVal
}
}
}
但是ref的值需要.value访问值才行。如果不想要.value来取值,我们可以直接再包一层reactive。
const obj = reactive({ foo: 1 })
// const obj2 = { foo: toRef(obj, 'foo') }
const obj2 = reactive({ ...toRefs(obj) }) // 让 obj2 也是 reactive
effect(() => {
console.log(obj2.foo) // 即使 obj2.foo 是 ref,我们也不需要 .value 来取值
})
obj.foo = 2 // 有效
它的实现,发现值如果是ref,则返回.value。但这对于ref组成的数组,仍然需要.value来访问。
if (isRef(res)) {
// ref unwrapping - does not apply for Array + integer key.
const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
return shouldUnwrap ? res.value : res
}
通常使用ref()函数时,我们是为了引用原始数据类型,但引用非基本类型也是可以,比如:
const refObj=ref({foo:1})
refObj.value是一个对象,这对象是响应式的,修改refObj.value.foo会触发响应。而shallowRef是浅代理,只代理ref对象本身,也就是.value是被代理的,而.value所引用的对象并没有被代理,修改refObj.value.foo不会触发响应。那如果也要触发响应呢?Vue3提供triggerRef函数,可以让我们强制触发响应。
triggerRef(refObj)