三、渲染器Renderer
文章来源:《Vue.js设计与实现》—霍春阳
渲染器Renderer
Vue.js中包含了两大核心模块:编译器、渲染器。
渲染器是框架性能的核心,它的实现直接影响框架的性能。Vue.js 3 的渲染器不仅包含传统的Diff算法,还独创快捷路径的更新方式,能够充分利用编译器提供的信息,大大提升了更新性能。
graph LR A[Vue.js] R[Renderer] C[Compiler] R1((Diff)) R2((快捷路径)) A-->R A-->C R-->R1 R-->R2
渲染器是用来渲染真是DOM元素的,除此之外为了支持框架跨平台的能力,还应该考虑其支持自定义的能力。
1 | function renderer(domString, container) { |
与响应式系统的结合
1 | <script src="https://unpkg.com/@vue/reactivity@3.0.5/dist/reactivity.global.js"></script> |
基本概念
名词:
- render - 渲染
- renderer - 渲染器
- vdom - virtual DOM 虚拟DOM
- vnode - virtual node,树状的虚拟DOM
- mount - 挂载,渲染器把虚拟DOM渲染为真实DOM的过程
- container - 容器,挂载到该DOM节点
1 | function createRenderer() { |
mount & patch
根据render的执行阶段,第一次渲染叫做挂载(mount);第二次渲染叫做打补丁(patch)。
根据传入null的 vnode 还可以执行卸载操作(unmount)
1 | function createRenderer() { |
patch函数是整个渲染器的核心入口,它承载了最重要的渲染逻辑。
1 | function patch(oldVnode, newVnode, container) { |
自定义渲染器
1 | function createRenderer(options) { |
自定义渲染器,可以保证浏览器和Node.js环境可以根据平台切换使用。
HTML标签属性 & DOM对象属性
HTML标签属性:
1 | <input class="cls" id="my-input" type="text" value="foo" /> |
HTML标签属性 | DOM对象属性 |
---|---|
class | className |
id | id |
type | type |
aria-valuenow | |
value | value |
... | ... |
1 | el.getAttribute('value') // foo |
一个HTML标签属性可能关联多个DOM对象属性。
HTML标签属性的作用是设置与之对应的DOM对象属性的初始值。
1 | patchProps(el, key, prevValue, nextValue) { |
class的设置
浏览器为一个元素设置class有三种方式:
- className
- setAttribute
- classList
其中el.className性能最优。
Vue.js允许对象类型的值作为class是为了方便开发者,在底层实现上,必然需要对值进行正常化后再使用。而正常化是有代价的,大量正常化操作会消耗更多的性能。
卸载unmount
container.innerHTML = null
这样暴力卸载有几个缺点:
- 不会触发钩子函数,包括组件和指令的
- 不会移除元素绑定的事件
所以需要封装一个unmount函数。
区分 vnode 的类型
事件的处理
伪造一个invoker,invoker.value保存上次绑定的事件,这样在更新事件时就不用先调用removeEventListener了。
事件冒泡
通过invoker.attached属性,用来存储事件处理函数被绑定的时间(高精时间performance.now),通过invoker.attached 与 e.timeStamp(事件触发的时间)比较来屏蔽所有绑定时间晚于事件触发时间的回调函数的执行。
更新子节点
子节点的数据结构有3种情况:
- null
- 文本
- 数组(一个或多个子节点)
文本节点和注释节点
这两种节点不具有标签名称,所以我们需要人为创造一些唯一标识,并将其作为注释节点和文本节点的type属性值:
1 | // 文本节点的 type 标识 |
Fragment
在Vue.js 2 中不允许组件模板存在多个根节点,比如:
1 | <template> |
在 Vue.js 3 中通过 Fragment,可以使用多根节点模板:
1 | const Fragment = Symbol() |
与文本和注释节点类似,Fragment也没有所谓的标签名称,因此我们也需要创建唯一标识。
Fragment本身不渲染任何内容,只会渲染Fragment的子节点。
简单的Diff算法
我们知道,操作DOM的性能开销通常比较大,而渲染器的核心Diff算法就是为了解决这个问题诞生的。
减小DOM操作性能开销
原始流程:卸载旧的子节点,再挂载新的子节点。
改进流程:先取新旧两组子节点长度较短的一组,对比更新修改,再判断删除或新增子节点。
DOM复用 & key 的作用
1 | // old children |
上面的情况仍然需要6次更新。但是经过判断,我们发现只要移动节点位置,就可以减小性能开销。
为了精确的确认新旧两组子节点中,存在相同的节点,我们采用给每个节点定义key的方式,key 属性就像虚拟节点的“身份证”号一样。
双端 Diff 算法
将新旧两组的头和尾进行比较:
1 | while (oldStartIdx <= oldEndIdx && newStartIdx <=newEndIdx) { |
使用双端Diff算法,可以进一步减少DOM操作的次数。
快速 Diff 算法
快捷路径:如果两段文本全等,那么就无须进入核心Diff算法了。
1 | if (text1 === text2) return |
文本的前缀和后缀
1 | I use vue for app development |
快速 Diff 算法借鉴了纯文本 Diff 算法中预处理的步骤。
graph LR A[p-1] B[p-1] A --> B B --> A A1[p-4] B1[p-2] A1 --> B1 A2[p-2] B2[p-3] A2 --> B2 A3[p-3]
步骤一:对比前置节点
从新旧组第一项开始,直到遇到不同的节点为止。
1 | j = 0 |
步骤二:对比后置节点
由于新旧两组length不同,所以需要两个索引oldEnd、newEnd。
1 | oldEnd = o_list.legnth - 1 |
步骤三:找出新增节点
case 1:oldEnd < j
case 2:newEnd >= j
那么 newEnd >= x >= j,这之间的就是新增节点。
以新子节点组的最后一个相同的元素(n_list[newEnd + 1]
)为锚点,挂载 j 到 newEnd间的新增节点
1 | if (j > oldEnd && j <= newEnd) { |
步骤四:找出删除节点
case 1:j > newEnd
case 2:j <= oldEnd
1 | if (j > oldEnd && j <= newEnd) { |
最后一步:处理其他情况
1 | if (j > oldEnd && j <= newEnd) { |
关于剩余情况的处理,值得注意的是,
简单 Diff、双端 Diff、快速 Diff 都遵循同样的处理规则:
- 优先移动节点
- 添加 或 删除 节点
TODO