vuejs设计与实现 7-11 渲染器
Vue渲染器
1. 渲染器
2. 挂载与更新
3. 简单diff
4. 双端diff
5. 快速diff
渲染器
- 渲染器与响应式系统关系:利用响应系统的能力,自动调用渲染器完成页面的渲染和更新。
- 渲染器:用来执行渲染任务的,把虚拟DOM渲染为特定平台上的真实元素;浏览器平台上,把虚拟DOM渲染为真实DOM元素;渲染器不仅能够渲染真实DOM元素,它还是框架跨平台能力的关键;
- 渲染器把虚拟DOM节点渲染为真实DOM节点的过程叫作挂载mount;如vue.js组件中的mounted钩子就会在挂载完成时触发,这意味着mounted钩子里可以访问真实DOM元素;
- 渲染器需要接收一个挂载点作为参数,用来指定具体的挂载位置;这里的挂载点就是一个DOM元素,渲染器会把该DOM元素作为容器元素,并把内容渲染到其中;
- 渲染器会执行挂载和打补丁操作,对于新的元素,渲染器会将它挂载到容器内,对于新旧vnode都存在的情况,渲染器则会执行打补丁操作,即对比新旧vnode,只更新变化的内容;
- 在浏览器平台上,渲染器可以利用DOM API完成DOM元素的创建、修改和删除。为了让渲染器不直接依赖浏览器平台特有的API,将用来创建、修改和删除的操作抽象成可配置的对象;用户可以在调用createRenderer函数创建渲染器的时候指定自定义的配置对象,从而实现自定义的行为;
挂载与更新
- 树型结构,虚拟DOM树;
- 当vnode.children的值是字符串时,会把它设置为元素的文本内容;一个元素除了具有文本子节点外,还可以包含其他元素子节点,且子节点可以是很多个;vnode.children是数组则循环遍历它,并调用patch函数挂载数组中的虚拟节点;
- HTML Attributes指的是定义在HTML标签上的属性;如
id='my-input' type='text' value='foo'
<input id='my-input' type='text' value='foo' />
当浏览器解析这段HTML代码后,会创建一个与之相符的DOM元素对象,可以通过JavaScript代码来读取该DOM对象const el = document.querySelector('#my-input')
,这个DOM对象包含很多属性,即DOM Properties如el.id,el.type,el.value;- HTML Attributes与DOM Properties的作用是设置与之对应的DOM Properties的初始值;
- 正确设置元素属性:检查每一个vnode.type中的属性,看看是否存在对应的DOM Properties,如果存在则优先设置DOM Properties,
el=[key]=true | el[key]=value
;如果要设置的属性没有对应的DOM Properties则使用setAttribute函数设置属性el.setAttribute(key, vnode.props[key])
- class的处理:class可能是一个字符串值,也可能是一个对象值,也可能是上面两种类型的数组,所以在设置元素的class之前将值归一化为统一的字符串形式,再把该字符串作为元素的class值去设置,因此需要封装normalizeClass函数,用它来将不同类型的class正常化为字符串;
<p class="foo bar"></p>
const vnode = {
type: 'p',
props: {
class: 'foo bar'
}
}
<p :class="cls"></p>
const cls = {foo: true, bar: false}
const vnode = {
type: 'p',
props: {
class: {foo: true, bar: false}
}
}
<p :class="arr"></p>
const arr = [
'foo bar',
{
baz: true
}
]
const vnode = {
type: 'p',
props: {
class: [
'foo bar',
{baz: true}
]
}
}
- 卸载操作:卸载操作发生在更新阶段,更新指的是在初次挂载完成之后,后续渲染会触发更新;根据vnode对象获取与其相关联的真实DOM元素,然后使用原生DOM操作方法将该DOM元素移除;当调用createElement函数创建真实DOM元素时,会把真实DOM元素赋值给vnode.el属性,在vnode与真实DOM元素之间建立了联系,可以通过vnode.el来获取该虚拟节点对应的真实DOM元素;当卸载操作发生时,只需要根据虚拟节点对象vnode.el取得真实DOM元素,再将其从父元素中移除即可;
renderer.render(vnode, document.querySelector('#app'));//初次挂载
renderer.render(newVnode, document.querySelector('#app'));// 再次挂载新vnode,触发更新
renderer.render(null,document.querySelector('#app'));//卸载
- 区分vnode类型:在执行更新操作之前,优先检查新旧vnode所描述的内容是否相同,如果不同,则直接调用unmount函数将旧vnode卸载;卸载完成后,应该将参数n1的值重置为null,这样才能保证后续挂载操作正确执行;即使新旧vnode描述的内容相同,也需要进一步确认其类型是否相同,一个vnode可以用来描述普通标签,描述组件,描述Fragment等,不同类型的vnode,需要提供不同的挂载或打补丁的处理方式;
- 事件处理:(1)如何在虚拟节点中描述事件?事件可以视作一种特殊的属性,因此可以约定,在vnode.props对象中,凡是以字符串on开头的属性都视作事件;(2)如何将事件添加到DOM元素上?只需要在patchProps中调用addEventListener函数来绑定事件即可;(3)更新事件如果处理呢?可以绑定一个伪造的事件处理函数invoker,然后把真正的事件处理函数设置为invoker.value属性的值,当有更新事件的时候,不需要调用removeEventListener函数来移除上一次绑定的事件,只需要更新invoker.vlaue的值即可;伪造的事件处理函数不仅可以提升性能,还能解决事件冒泡与事件更新之间相互影响的问题;为了解决事件覆盖的问题,重新设计el._vei的数据结构,将el._vei设置为一个对象,它的键是事件名称,值是对应的事件处理函数,这样就不会发生事件覆盖的现象了;
- 事件冒泡与更新时机问题:屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行;
- 更新子节点:(1)首先,检测新子节点的类型是否是文本节点,是则检查旧子节点的类型,旧子节点类型可能有三种情况:没有子节点、文本子节点、一组子节点;如果没有旧子节点或旧子节点类型是文本子节点,则只需要将新的文本内容设置给容器元素即可;如果旧子节点存在且不是文本子节点,则是一组子节点,循环遍历它们,并逐个调用unmount函数进行卸载;(2)其次,新子节点是一组子节点,若没有旧子节点或旧子节点内容是文本节点,只需要将容器元素清空,然后逐个将新的一组子节点挂载到容器中即可;如果旧子节点也是一组子节点,则涉及新旧两组子节点的比对,常说的diff;(3)最后,新子节点不存在,若旧子节点也不存在则什么都不做,若旧子节点是一组子节点,逐个卸载,若旧子节点是文本子节点,则清空文本内容即可;
- 文本节点、注释节点:分别为文本节点和注释节点创建了symbol类型的值,并将其作为vnode.type属性的值,这样就能用vnode来描述文本节点和注释节点了;
- Fragment街道:与文本节点注释节点类似,也为片段创建唯一标识;
简单diff
- Diff用来计算两组子节点的差异,并试图最大程度地复用DOM元素;
- 简单diff:遍历新旧两组子节点中数量较多的那一组,并逐个调用patch函数进行打补丁,然后比较新旧两组子节点的数量,如果新的一组子节点的数量较多,说明有新的子节点需要挂载,否则说明在旧的一组子节点中,有节点需要卸载;
- 虚拟节点中key属性的作用:更新时,渲染器通过key属性找到可复用的节点,然后尽可能地通过DOM移动操作来完成更新,避免过多地对DOM元素进行销毁和重建;
- 简单diff是如何寻找需要移动的节点的?简单diff核心逻辑,拿着新的一组子节点中的节点去旧的一组子节点中寻找可复用的节点,如果找到了,则记录该节点的索引,并把这个位置索引称为最大索引,在整个更新过程中,如果一个节点的索引值小于最大索引,则说明该节点对应的真实DOM元素需要移动;
- 简单diff利用虚拟节点的key属性,尽可能地复用DOM元素,并通过移动DOM方式来完成更新,从而减少不断地创建和销毁DOM元素带来的性能开销;
双端diff
- 双端比较,在新旧两组子节点的四个端点之间分别进行比较(1-旧首位vs新首位,2-旧尾位vs新尾位,3-旧首位vs新尾位,4-旧尾位vs新首位),并试图找到可复用的节点,相比简单diff,对于同样的更新场景,执行的DOM移动次数较少;若前4步都没有找到可复用的节点,则拿新的一组子节点中的头部节点去旧的一组子节点中寻找(5),判断是否需要移动及打补丁;若第5步也找不到则是新加元素;当
newStartIdx > newEndIdx
,满足更新停止的条件,若旧的一组子节点中存在未被处理的节点,则将其移除;
快速diff
- 借鉴文本diff中的预处理思路,先处理新旧两组子节点中相同的前置节点和相同的后置节点,当前置节点和后置节点全部处理完毕后,如果无法简单地通过挂载新节点或卸载已经不存在的节点来完成更新,则需要根据节点的索引关系,构造出一个最长递增子序列,最长递增子序列所指向的节点即为不需要移动的节点;
- 对于相同的前置节点和后置节点,由于它们在新旧两组子节点的相对位置不变,则无须移动它们,但需要在它们之间打补丁;
vue2 diff
- 首先,比较新旧根节点:
- 如果根节点类型不同,直接替换整个旧根节点的 DOM 元素。
- 如果类型相同,但 key 值不同,也会替换整个旧根节点的 DOM 元素。
- 对于类型相同的节点,会进一步比较它们的子节点:
- 新旧子节点都为文本节点时,直接比较文本内容,不同则更新文本。
- 新旧子节点都为数组时,会采用双指针的方式进行比较。
- 首先比较新旧节点数组的头部和尾部,尽量复用相同位置的节点。
- 对于新增或删除的节点,相应地进行 DOM 操作(添加或删除节点)。
- 在比较子节点的过程中,如果发现节点类型不同,会替换对应的 DOM 元素。
- 对于具有 key 属性的节点,通过 key 来识别节点的身份,提高节点复用的准确性和效率。
- 只比较同一层级,不跨层比较;
- 比较标签名,如果同一层级的标签名type不同,直接删除老的VNode;
- 比较key,如果标签名和key相同,代表新旧VNode是同一个节点
- Diff算法核心:patch,sameVNode,patchVnode,updateChildren
- patch源码位置(vue2):vue/blob/main/src/core/vdom/patch.ts
- vnode存在,oldVNode不存在,新增VNode节点
- vnode不存在,oldVNode存在,删除oldVNode节点
- 两个都存在,通过sameVnode函数判断是不是同一个节点,如果是,通过patchVnode进行后续对比;不是则把VNode挂载在oldVNode的父元素下,如果组件的根节点被替换,就遍历父节点删除旧节点;如果是服务端渲染就通过hydrating把oldVNode和真实DOM结合;
- patchVnode主要做了几个判断:
- 新节点是否是文本节点,如果是,则直接更新dom的文本内容为新节点的文本内容
- 新节点和旧节点如果都有子节点,则处理比较更新子节点
- 只有新节点有子节点,旧节点没有,那么不用比较了,所有节点都是全新的,所以直接全部新建就好了,新建是指创建出所有新DOM,并且添加进父节点
- 只有旧节点有子节点而新节点没有,说明更新后的页面,旧节点全部都不见了,那么要做的,就是把所有的旧节点删除,也就是直接把DOM 删除
- updateChildren
- 首先应该不是判断四种命中,而是略过已经加了undefined标记的项
- 1.旧前节点与新前节点命中
- 2.旧后与新后命中
- 3.旧前与新后命中
- 4.旧后与新前命中
- 5.四种都没有匹配到,key相同但元素不同,创建新元素
- 6.指针移动结束,旧的开始指针>旧的结束指针,代表产生了新元素
- 7.遍历结束,新的开始指针 > 新的结束指针,代表有元素需要被删除
vue3 diff
- 首先,同样会比较新旧根节点。
- 类型不同则直接替换整个根节点。
- 类型相同但 key 不同也会替换。
- 对于类型相同且具有子节点的情况:
- 如果子节点都是数组,会创建一个映射表来快速定位新旧子节点。
- 优先处理头部和尾部的节点,通过索引快速比较。
- 对于中间部分的节点,通过 key 来准确识别和复用节点。
- 对于具有插槽内容的节点,会单独处理插槽的更新。
- 在处理节点的更新时,会更加精确地判断节点的属性、事件等的变化,只更新有变化的部分。
- 对于新增或删除的节点,进行相应的 DOM 操作。
- 在 Vue 3 中,patchKeyedChildren 函数用于处理具有键值(key)的子节点的更新操作。
- 首先,获取新旧子节点数组的长度,并初始化一些指针和变量用于遍历和比较。
- 创建一个 newStartIdx 和 oldStartIdx 分别指向新旧子节点数组的起始位置,newEndIdx 和 oldEndIdx 分别指向新旧子节点数组的末尾位置。
- 进入一个循环,通过比较起始位置和末尾位置的节点的 key 值来判断节点是否相同。
- 如果起始位置的节点 key 相同,调用 patch 函数来更新节点,并将 newStartIdx 和 oldStartIdx 向前移动一位。
- 如果末尾位置的节点 key 相同,调用 patch 函数来更新节点,并将 newEndIdx 和 oldEndIdx 向后移动一位。
- 如果起始位置或末尾位置的节点 key 不同,则通过 key 来查找旧子节点数组中是否存在与新起始位置或新末尾位置节点相同的节点。
- 如果找到,则将该旧节点移动到正确的位置,并调用 patch 函数更新。
- 如果未找到,则创建新节点或删除旧节点。
- 当循环结束后,如果还有剩余的新节点,说明是新增节点,将它们逐个创建并插入到 DOM 中。
- 如果还有剩余的旧节点,说明是删除节点,将它们从 DOM 中移除。
- 总的来说,patchKeyedChildren 函数通过高效地利用节点的 key 来实现子节点的最小化更新操作,减少不必要的 DOM 操作,提高性能。
- patchKeyedChildren 函数中调用的 patch 函数主要负责对具体节点进行更新操作:
- 比较新旧节点的类型:
- 如果类型不同,则进行节点的替换操作。
- 如果类型相同,则继续下面的比较和更新。
- 比较节点的属性(props):
- 检查属性的添加、删除和修改,更新对应的 DOM 属性。
- 处理事件监听器:
- 添加、删除或更新节点上的事件监听器。
- 处理子节点:
- 如果有子节点,递归调用相关的 diff 算法或更新子节点的操作。
- 更新节点的文本内容(如果是文本节点)。
- 处理指令(如果有):
- 例如 v-if、v-for 等指令的更新和处理。
- patch 函数如何处理复杂的组件更新:
- 检查组件的类型是否发生变化:如果组件类型改变,可能需要进行完全的卸载和重新挂载操作。
- 比较组件的 props:
- 检查新的 props 值与旧的 props 值的差异。
- 如果 props 发生了变化,触发组件内部相应的响应式逻辑。
- 处理组件的插槽(slots):
- 比较新旧插槽内容的差异,并进行相应的更新。
- 触发组件的生命周期钩子:
- 在适当的时候调用组件的 beforeUpdate、updated 等生命周期钩子。
- 处理组件内部的状态(如果有):
- 如果组件具有自身的状态(通过 data 选项或 setup 函数定义),比较状态的变化,并重新渲染组件的视图。
- 重新计算组件的计算属性(computed properties):根据新的依赖值重新计算计算属性的值。
- 处理组件的子组件:递归地对组件的子组件执行 patch 操作,以确保整个组件树的正确更新。
参考&感谢各路大神
1. vue.js设计与实现-霍春阳
2. vue3源码
宝剑锋从磨砺出,梅花香自苦寒来。