✨「前端进阶」浅谈Vue3设计与实现

677 阅读8分钟

✨「前端进阶」浅谈Vue3设计与实现

前言

先说结论,首先讲讲Vue在3版本新增的一些新特性及针对2.x版本主要做的一些改变:

  1. 采用Composition API(紧凑式API)代替Option API (选项式API),增强了逻辑复用及编程体验
  2. 响应式系统底层采用ES6的Proxy代替ObJect.defineproperty,避免Api缺陷
  3. 采用快速Diff算法及最长递增子序列提高Patch效率
  4. 模版编译优化,添加PatchFlag等提升Diff更新效率,避免不必要对比

Vue3新特性

  1. 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>
    
  2. 基于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进行代理

  3. 快速Diff:

    image-20230917161544626.png 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节点不需要移动。

      image-20230917162335201.png

    快速Diff大体流程:

    1. 从头对比找到有相同的节点 patch ,发现不同,立即跳出。

    2. 如果第一步没有patch完,立即,从后往前开始patch ,如果发现不同立即跳出循环。

    3. 如果新的节点大于老的节点数 ,对于剩下的节点全部以新的vnode处理( 这种情况说明已经patch完相同的vnode )。

    4. 对于老的节点大于新的节点的情况 , 对于超出的节点全部卸载 ( 这种情况说明已经patch完相同的vnode )。

    5. 不确定的元素( 这种情况说明没有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
      • 没有找到与老节点对应的新节点,卸载当前老节点
      • 如果找到与老节点对应的新节点,把老节点的索引,记录在存放新节点的数组中
    • 如果发生移动:

    1. 根据 newIndexToOldIndexMap 新老节点索引列表找到最长稳定序列
    2. 对于 newIndexToOldIndexMap[i] =0 证明不存在老节点 ,从新形成新的vnode
    3. 对需要移动的节点,构建最长递增子序列进行移动处理。

    补充:移动元素算法源码

    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--
     }
     }
     }
    
    
  4. 模板编译优化:

    Vue.js 3 的编译器会将编译时得到的关键信息“附着”在它生成 的虚拟 DOM 上,这些信息会通过虚拟 DOM 传递给渲染器。最终,渲 染器会根据这些关键信息执行“快捷路径”,从而提升运行时的性能。

    1. Block 与 PatchFlags:引入块的概念收集动态节点(包含直接动态子节点和所有动态子代节点),有了 Block 这个概念之后,渲染器的更新操作将会以 Block 为 维度。也就是说,当渲染器在更新一个 Block 时,会忽略虚拟节点的 children 数组,而是直接找到该虚拟节点的 dynamicChildren 数组,并只更新该数组中的动态节点。这样,在更新时就实现了跳过静 态内容,只更新动态内容。同时,由于动态节点中存在对应的PatchFlag标志,所以在更新动态节点的时候,也能够做到靶向更新。(比如:当一个动态节点的 patchFlag 值为数字 1 时,我们知道它只存在动态的 文本节点,所以只需要更新它的文本内容即可,避免不必要的对比)同时
    2. 静态提升: Vue3会将静态节点、子树等渲染代码移到渲染函数之外,这样可以避免每次渲染时重新创建这些不会变化的对象。进一步还有预字符串化处理。
    3. 缓存内联事件处理: 组件重新渲染时(即 render 函数重新执行时),都会为 Comp 组件创建一个全新的 props 对象。同时,props 对象中事件 属性的值也会是全新的函数。这会导致渲染器对 Comp 组 件进行更新,造成额外的性能开销。为了避免这类无用的更新,我们需要对内联事件处理函数进行缓存,再render执行中直接取缓存。
  5. 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 与平台无关的运行时核心,包括 render
  • runtime-dom 浏览器环境中的运行时核心
  • runtime-test 用于自动化测试的相关配套
  • server-renderer 用于 SSR 服务端渲染的逻辑
  • shared 一些各个包之间共享的公共工具
  • size-check 一个用于测试 tree shaking 后代码大小的示例库
  • template-explorer 用于检查模板编译后的输出,主要用于开发调试
  • vue Vue 3 的主要入口,包括运行时和编译器,包括几个不同的入口(开发版本、runtime 版本、full 版本)

reactivity

整体流程

1.png

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.reactiveReactiveFlags.readonly 引用代理对象
  • 代理对象根据它是 reactivereadonly 的, 将 ReactiveFlags.isReactiveReactiveFlags.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,因此 keyfoo

本质上建立一种数据结构:

// 伪代码
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)