渲染器是用来执行渲染任务的。在浏览器平台上,用它来渲染其中的真实 DOM 元素。
渲染器与渲染是不同的。渲染器是更加宽泛的概念,它包含渲染。
挂载mountElementfunction mountElement ( vnode , container ) {
// 创建 DOM 元素
const el = document . createElement ( vnode . type )
// 处理子节点,如果子节点是字符串,代表元素具有文本节点
if ( typeof vnode . children === 'string' ) {
// 因此只需要设置元素的 textContent 属性即可
el . textContent = vnode . children
}
// 将元素添加到容器中
container . appendChild ( el )
}
上面这u但代码存在的问题,我们的目标是设计一个不依赖于浏览器平台的通用渲染器,mountElement 函数内调用了大量依赖于浏览器的 API,例如 document.createElement 、 el.textContent 以及 appendChild 等。 想要设计通用渲染器,第一步要做的就是将这些浏览器特有的 API 抽离 。
// 用于创建元素
function createElemnt ( tag ) {
return document . createElement ( tag )
}
// 用于设置元素的文本节点
function setElementText ( el , text ) {
el . textContent = text
}
// 用于在给定的 parent 下添加指定元素
function insert ( el , parent , anchor = null ) {
parent . insertBefore ( el , anchor )
}
function mountElement ( vnode , container ) {
// 调用 createElement 函数创建元素
const el = createElement ( vnode . type )
if ( typeof vnode . children === 'string' ) {
// 调用 setElementText 设置元素的文本节点
setElementText ( el , vnode . children )
}
// 调用 insert 函数将元素插入到容器内
insert ( el , container )
}
挂载与更新 当 vnode.children 的值是字符串类型时,会把它设置为元素的文本内容。一个元素除了具有文本子节点外,还可以包含其他元素子节点,并且子节点可以是很多个。为了完成子节点的渲染,我们需要修改 mountElement 函数:
function mountElement ( vnode , container ) {
const el = createElement ( vnode . type )
if ( typeof vnode . children === 'string' ) {
setElementText ( el , vnode . children )
} else if ( Array . isArray ( vnode . children )) {
// 如果 children 是数组,则遍历每一个子节点,并调用 patch 函数挂载它们
vnode . children . forEach ( child => {
patch ( null , child , el )
})
}
insert ( el , container )
}
需要注意两点:
传递给 patch 函数的第一个参数是 null 。因为是挂载阶段,没有旧 vnode ,所以只需要传递 null 即可。这样,当 patch 函数执行时,就会递归地调用 mountElement 函数完成挂载。 传递给 patch 函数的第三个参数是挂载点。由于我们正在挂载的子元素是 div 标签的子节点,所以需要把刚刚创建的 div 元素作为挂载点,这样才能保证这些子节点挂载到正确位置。
为元素设置属性为了描述元素的属性,我们需要为虚拟 DOM 定义新的 vnode.props 字段,如下面的代码所示:
const vnode = {
type : 'div' ,
// 使用 props 描述一个元素的属性
props : {
id : 'foo'
},
children : [
{
type : 'p' ,
children : 'hello'
}
]
}
vnode.props 是一个对象,它的键代表元素的属性名称,它的值代表对应属性的值。这样,我们就可以通过遍历 props 对象的方式,把这些属性渲染到对应的元素上,如下面的代码所示:
function mountElement ( vnode , container ) {
const el = createElement ( vnode . type )
// 省略 children 的处理
// 如果 vnode.props 存在才处理它
if ( vnode . props ) {
// 遍历 vnode.props
for ( const key in vnode . props ) {
// 调用 setAttribute 将属性设置到元素上
el . setAttribute ( key , vnode . props [ key ])
}
}
insert ( el , container )
}
除了用 setAttribute 函数,还可以直接将属性设置在 DOM 对象上,即 el[key] = vnode.props[key] 。但是,实际上,为元素设置属性比想象中要复杂得多,需要搞清楚 HTML Attributes 和 DOM Properties 。
HTML Attributes 与 DOM Properties例如:
<input id = "my-input" type = "text" value = "foo" />
HTML Attributes 指的就是定义在 HTML 标签上的属性,这里指的就是 id=”my-input” 、 type=”text” 和 value=”foo” 。
很多 HTML Attributes 在 DOM 对象上有与之同名的 DOM Properties 。 但 DOM Properties 与 HTML Attributes 的名字不总是一模一样的。 另外,并不是所有 HTML Attributes 都有与之对应的 DOM Properties。 类似地,也不是所有 DOM Properties 都有与之对应的 HTML Attributes。 我们把 HTML Attributes 与 DOM Properties 具有相同名称的属性看作直接映射。但并不是所有 HTML Attributes 与 DOM Properties 之间都是直接映射的关系
HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。 一旦值改变,那么 DOM Properties 始终存储着当前值,而通过 getAttribute 函数得到的仍然是初始值。 这说明一个 HTML Attributes 可能关联多个 DOM Properties。
正确地设置元素属性优先设置元素的 DOM Properties,但当值为空字符串时,要手动将值矫正为 true。
function mountElement ( vnode , container ) {
const el = createElement ( vnode . type )
// 省略 children 的处理
if ( vnode . props ) {
for ( const key in vnode . props ) {
// 用 in 操作符判断 key 是否存在对应的 DOM Properties
if ( key in el ) {
// 获取该 DOM Properties 的类型
const type = typeof el [ key ]
const value = vnode . props [ key ]
// 如果是布尔类型,并且 value 是空字符串,则将值矫正为 true
if ( type === 'boolean' && value === '' ) {
el [ key ] = true
} else {
el [ key ] = value
}
} else {
// 如果要设置的属性没有对应的 DOM Properties ,则使用 setAttributes 函数设置属性
el . setAttributes ( key , vnode . props [ key ])
}
}
}
insert ( el , container )
}
有一些 DOM Properties 是只读的,因此只能通过 setAttributes 函数来设置,这就需要修改现有的逻辑:
function shouldSetAsProps ( el , key , value ) {
// 特殊处理
if ( key === 'form' && el . tagName === 'INPUT' ) return false
// 兜底
return key in el
}
function mountElement ( vnode , container ) {
const el = createElement ( vnode . type )
// 省略 children 的处理
if ( vnode . props ) {
for ( const key in vnode . props ) {
const value = vnode . props [ key ]
// 使用 shouldSetAsProps 函数判断是否应该作为 DOM Properties 设置
if ( shouldSetAsProps ( el , key , value )) {
const type = typeof el [ key ]
if ( type === 'boolean' && value === '' ) {
el [ key ] = true
} else {
el [ key ] = value
}
} else {
el . setAttribute ( key , value )
}
}
}
inser ( el , container )
}
注意:不会将所有特殊情况一一列举,因为掌握处理问题的思路更加重要。不要惧怕写出不完美的代码,只要在后续迭代过程中“见招拆招”,代码就会变得越来越完善,框架也会变得越来越健壮。
最后,我们需要把属性的设置也变成与平台无关,因此需要把属性设置相关操作也提取到渲染器选项中,如下面的代码所示。
const renderer = createRenderer ({
createElement ( tag ) {
return document . createElement ( tag )
},
setElementText ( el , text ) {
el . textContent = text
},
insert ( el , parent , anchor = null ) {
parent . insertBefore ( el , anchor )
},
// 将属性设置相关操作封装到 patchProps 函数中,并作为渲染器选项传递
patchProps ( el , key , prevValue , nextValue ) {
if ( shouldSetAsProps ( el , key , nextValue )) {
const type = typeof el [ key ]
if ( type === 'boolean' && nextValue === '' ) {
el [ key ] = true
} else {
el [ key ] = nextValue
}
} else {
el . setAttribute ( key , nextValue )
}
}
})
而在 mountElement 函数中,只需要调用 patchProps 函数,并为其传递相关参数即可:
function mountElement ( vnode , container ) {
const el = createElement ( vnode . type )
if ( typeof vnode . children === 'string' ) {
setElementText ( el , vnode , children )
} else if ( Array . isArray ( vnode . children )) {
vnode . children . forEach ( child => {
patch ( null , child , el )
})
}
if ( vnode . props ) {
for ( const key in vnode . props ) {
// 调用 patchProps 函数即可
patchProps ( el , key , null , vnode . props [ key ])
}
}
insert ( el , container )
}
class的处理vue.js 中为元素设置类名有以下几种方式:
指定 class 为一个字符串值
<p class = "foo bar" ></p>
这段模板对应的 vnode 是:
const vnode = {
type : 'p' ,
props : {
class : 'foo bar'
}
}
指定 class 为一个对象值。
<p : class = "cls" ></p>
假设对象 cls 的内容如下:
const cls = { foo : true , bar : false }
那么,这段模板对应的 vnode 是:
const vnode = {
type : 'p' ,
props : {
class : { foo : true , bar : false }
}
}
class 是包含上述两种类型的数组
<p : class = "arr" ></p>
这个数组可以是字符串值与对象值的组合:
const arr = [
// 字符串
'foo bar' ,
// 对象
{
baz : true
}
]
那么,这段模板对应的 vnode 是:
const vnode = {
type : 'p' ,
props : {
class : [
'foo bar' ,
{ baz : true }
]
}
}
因为 class 的值可以是多种类型,所以需要封装 normalizeClass 函数,将不同类型的 class 值正常化为字符串。
由于 class 属性对应的 DOM Properties 是 el.className ,所以表达式 ‘class’ in el 的值将会是 false ,因此, patchProps 函数会使用 setAttribute 函数来完成 class 的设置。但是在浏览器中为一个元素设置 class 有三种方式,setAttribute 、el.className 或 el.classList 。el.className 的性能最优,因此:
const renderer = createRenderer ({
// 省略其他选项
patchProps ( el , key , prevValue , nextValue ) {
// 对 class 进行特殊处理
if ( key === 'class' ) {
el . className = nextValue || ''
} else if ( shouldSetProps ( el , key , nextValue )) {
const type = typeof el [ key ]
if ( type === 'boolean' && nextValue === '' ) {
el [ key ] = true
} else {
el [ key ] = nextValue
}
} else {
el . setAttribute ( key , nextValue )
}
}
})
除了 class 属性以外, Vue.js 对 style 属性也做了增强,所以也需要对 style 做类似的处理。
卸载操作卸载操作发生在更新阶段,更新指的是,在初次挂载完成之后,后续渲染会触发更新。
在前面,卸载之前渲染的内容,是通过 container.innerHTML = ‘’ 的方式,但是不太严谨,原因是:
容器的内容可能是由某个或多个组件渲染的,当卸载操作发生时,应该正确地调用这些组件的 beforeUnmount 、 unmounted 等生命周期函数。 即使内容不是由组件渲染的,有的元素存在自定义指令,我们应该在卸载操作发生时正确执行对应的指令钩子函数。 使用 innerHTML 清空容器元素内容的另一个缺陷是,它不会移除绑定在 DOM 元素上的事件处理函数。
正确的卸载方式是,根据 vnode 对象获取与其相关联的真实 DOM 元素,然后使用原生的 DOM 操作方法将该 DOM 元素移除。为此,我们需要在 vnode 与真实 DOM 元素之间建立联系,修改 mountElement 函数。
function mountElement ( vnode , container ) {
// 让 vnode.el 引用真实 DOM 元素
const el = vnode . el = createElement ( vnode . type )
if ( typeof vnode . children === 'string' ) {
setElementText ( el , vnode . children )
} else if ( Array . isArray ( vnode . children )) {
vnode . children . forEach ( child => {
patch ( null , child , el )
})
}
if ( vnode . props ) {
for ( const key in vnode . props ) {
patchProps ( el , key , null , vnode . props [ key ])
}
}
insert ( el , container )
}
当卸载操作发生时,只需要根据虚拟节点对象 vnode.el 取得真实 DOM 元素,再将其从父元素中移除即可:
function render ( vnode , container ) {
if ( vnode ) {
patch ( container . _vnode , vnode , container )
} else {
if ( container . _vnode ) {
// 根据 vnode 获取要卸载的真实 DOM 元素
const el = container . _vnode . el
// 获取 el 的父元素
const parent = el . parentNode
// 调用 removeChild 移除元素
if ( parent ) parent . removeChild ( el )
}
}
container . _vnode = vnode
}
如上面的代码所示,其中 container._vnode 代表旧 vnode ,即要被卸载的 vnode 。然后通过 container._vnode.el 取得真实 DOM 元素,并调用 removeChild 函数将其从父元素中移除即可。
将卸载操作封装到 unmount 函数中。
function unmount ( vnode ) {
const parent = vnode . el . parentNode
if ( parent ) {
parent . removeChild ( vnode . el )
}
}
unmount 函数接收一个虚拟节点作为参数,并将该虚拟节点对应的真实 DOM 元素从父元素中移除。
function render ( vnode , container ) {
if ( vnode ) {
patch ( container . _vnode , vnode , container )
} else {
if ( container . _vnode ) {
// 调用 unmount 函数卸载 vnode
unmount ( container . _vnode )
}
}
container . _vnode = vnode
}
最后,将卸载操作封装到 unmount 中,还能够带来两点额外的好处。
在 unmount 函数内,我们有机会调用绑定在 DOM 元素上的指令钩子函数,例如 beforeUnmount 、unmounted 等。 当 unmount 函数执行时,我们有机会检测虚拟节点 vnode 的类型。如果该虚拟节点描述的是组件,则我们有机会调用组件相关的生命周期函数。
区分 vnode 的类型 假设初次渲染的 vnode 是一个 p 元素:const vnode = {
type : 'p' ,
}
renderer . render ( vnode , document . querySelector ( '#app' ))
后续又渲染了一个 input 元素:const vnode = {
type : 'input'
}
renderer . render ( vnode , document . querySelector ( '#app' ))
这种情况下,更新的正确操作是,先将 p 元素卸载,再将 input 元素挂载到容器中。因此我们需要调整 patch 函数的代码:function patch ( n1 , n2 , container ) {
// 如果 n1 存在,则对比 n1 和 n2 的类型
if ( n1 && n1 . type !== n2 . type ) {
// 如果新旧 vnode 的类型不同,则直接将旧 vnode 卸载
unmount ( n1 )
n1 = null
}
if (! n1 ) {
mountElement ( n2 , container )
} else {
// 更新
}
}
事件的处理 如何在虚拟节点中描述事件,如何把事件添加到 DOM 元素上,以及如何更新事件。
如何在虚拟节点中描述事件? 事件可以视作一种特殊的属性,因此我们可以约定,在 vnode.props 对象中,凡是以字符串 on 开头的属性都视作事件。例如:
const vnode = {
type : 'p' ,
props : {
// 使用 onXxx 描述事件
onClick : () => {
alert ( 'clicked' )
}
},
children : 'text'
}
如何将事件添加到 DOM 元素上? 在 patchProps 中调用 addEventListener 函数来绑定事件。
patchProps ( el , key , prevValue , nextValue ) {
// 匹配以 on 开头的属性,视其为事件
if ( /^on/ . test ( key )) {
// 根据属性名称得到对应的事件名称,例如 onClick ---> click
const naem = key . slice ( 2 ). toLowerCase ()
// 绑定事件, nextValue 为事件处理函数
el . addEventListener ( name , nextValue )
} else if ( key === 'class' ) {
// 省略部分代码
} else if ( shouldSetAsProps ( el , key , nextValue )) {
// 省略部分代码
} else {
// 省略部分代码
}
}
如何更新事件? 按照一般的思路,需要先移除之前添加的事件处理函数,然后再将新的事件处理函数绑定到 DOM 元素上。
patchProps ( el , key , prevValue , nextValue ) {
if ( /^on/ . test ( key )) {
const name = key . slice ( 2 ). toLowerCase ()
// 移除上一次绑定的事件处理函数
prevValue && el . removeEventListenre ( name , prevValue )
// 绑定新的事件处理函数
el . addEventListener ( name , nextValue )
} else if ( key === 'class' ) {
// 省略部分代码
} else if ( shouldSetAsProps ( el , key , nextValue )) {
// 省略部分代码
} else {
// 省略部分代码
}
}
这么做代码能够按照预期工作。但还有一种性能更优的方式来完成事件更新。在绑定事件时,我们可以绑定一个伪造的事件处理函数 invoker ,然后把真正的事件处理函数设置为 invoker.value 属性的值。这样当更新事件的时候,我们将不再需要调用 removeEventListener 函数来移除上一次绑定的事件,只需要更新 invoker.value 的值即可 ,如下面的代码所示:
patchProps ( el , key , prevValue , nextValue ) {
if ( /^on/ . test ( key )) {
// 获取为该元素伪造的事件处理函数 invoker
let invoker = el . _vei
const name = key . slice ( 2 ). toLowerCase ()
if ( nextValue ) {
if (! invoker ) {
// 如果没有 invoker ,则将一个伪造的 invoker 缓存到 el._vei 中
// vei 是 vue event invoker 的首字母缩写
invoker = el . _vei = ( e ) => {
// 当伪造的事件处理函数执行时,会执行真正的事件处理函数
invoker . value ( e )
}
// 将真正的事件处理函数赋值给 invoker.value
invoker . value = nextValue
// 绑定 invoker 作为事件处理函数
el . addEventListener ( name , invoker )
} else {
// 如果 invoker 存在,意味着更新,并且只需要更新 invoker.value 的值即可
invoker . value = nextValue
}
} else if ( invoker ) {
// 新的事件绑定函数不存在,且之前绑定的 invoker 存在,则移除绑定
el . removeEventListener ( name , invoker )
}
} else if ( key === 'class' ) {
// 省略部分代码
} else if ( shouldSetAsProps ( el , key , nextValue )) {
// 省略部分代码
} else {
// 省略部分代码
}
}
上面的代码,事件绑定主要分为两个步骤。
先从 el._vei 中读取对应的 invoker ,如果 invoker 不存在,则将伪造的 invoker 作为事件处理函数,并将它缓存到 el._vei 属性中。 把真正的事件处理函数赋值给 invoker.value 属性,然后把伪造的 invoker 函数作为事件处理函数绑定到元素上。可以看到,当事件触发时,实际上执行的是伪造的事件处理函数,在其内部间接执行了真正的事件处理函数 invoker.value(e)。
当更新事件时,由于 el._vei 已经存在了,所以我们只需要将 invoker.value 的值修改为新的事件处理函数即可。这样,在更新事件时可以避免一次 removeEventListener 函数的调用,从而提升了性能。实际上,伪造的事件处理函数的作用不止于此,它还能解决事件冒泡与事件更新之间相互影响的问题。
但是还有问题,在同一时刻只能缓存一个事件处理函数。这意味着,如果一个元素同时绑定了多种事件,将会出现事件覆盖的现象。例如同时给元素绑定 click 和 contextmenu 事件:
const vnode = {
type : 'p' ,
props : {
onClick : () => {
alert ( 'clicked' )
},
onContextmenu : () => {
alert ( 'contextmenu' )
}
},
children : 'text'
}
renderer . render ( vnode , document . querySelector ( '#app' ))
当渲染器尝试渲染这上面代码中给出的 vnode 时,会先绑定 click 事件,然后再绑定 contextmenu 事件。后绑定的 contextmenu 事件的处理函数将覆盖先绑定的 click 事件的处理函数。为了解决事件覆盖的问题,我们需要重新设计 el._vei 的数据结构。我们应该将 el._vei 设计为一个对象,它的键是事件名称,它的值则是对应的事件处理函数,这样就不会发生事件覆盖的现象了,如下面的代码所示:
patchProps ( el , key , prevValue , nextValue ) {
if ( /^on/ . test ( key )) {
// 定义 el._vei 为一个对象,存在事件名称到事件处理函数的映射
const invokers = el . _vei || ( el . _vei = {})
// 根据事件名称获取 invoker
let invoker = invokers [ key ]
const name = key . slice ( 2 ). toLowerCase ()
if ( nextValue ) {
if (! invoker ) {
// 将事件处理函数缓存到 el._vei[key] 下,避免覆盖
invoker = el . _vei [ key ] = ( e ) => {
invoker . value ( e )
}
invoker . value = nextValue
el . addEventListener ( name , invoker )
} else {
invoker . value = nextValue
}
} else if ( invoker ) {
el . removeEventListener ( name , invoker )
}
} else if ( key === 'class' ) {
// 省略部分代码
} else if ( shouldSetAsProps ( el , key , nextValue )) {
// 省略部分代码
} else {
// 省略部分代码
}
}
另外,一个元素不仅可以绑定多种类型的事件,对于同一类型的事件而言,还可以绑定多个事件处理函数。我们知道,在原生 DOM 编程中,当多次调用 addEventListener 函数为元素绑定同一类型的事件时,多个事件处理函数可以共存,例如:
el . addEventListener ( 'click' , fn1 )
el . addEventListener ( 'click' , fn2 )
当点击元素时,事件处理函数 fn1 和 fn2 都会执行。因此,为了描述同一事件的多个事件处理函数,我们需要调整 vnode.props 对象中事件的数据结构,如下面的代码所示:
const vnode = {
type : 'p' ,
props : {
onClick : [
// 第一个事件处理函数
() => {
alert ( 'clicked 1' )
},
// 第二个事件处理函数
() => {
alert ( 'clicked 2' )
}
]
},
children : 'text'
}
renderer . render ( vnode , document . querySelector ( '#app' ))
在上面这段代码中,我们使用一个数组来描述事件,数组中的每个元素都是一个独立的事件处理函数,并且这些事件处理函数都能够正确地绑定到对应元素上。为了实现此功能,我们需要修改 patchProps 函数中事件处理相关的代码,如下面的代码所示:
patchProps ( el , key , prevValue , nextValue ) {
if ( /^on/ . test ( key )) {
const invokers = el . _vei || ( el . _vei = {})
let invoker = invokers [ key ]
const name = key . slice ( 2 ). toLowerCase ()
if ( nextValue ) {
if (! invoker ) {
invoker = el . _vei [ key ] = ( e ) => {
// 如果 invoker.value 是数组,则遍历它并逐个调用事件处理函数
if ( Array . isArray ( invoker . value )) {
invoker . value . forEach ( fn => fn ( e ))
} else {
// 否则直接作为函数调用
invoker . value ( e )
}
}
invoker . value = nextValue
el . addEventListener ( name , invoker )
} else {
invoker . value = nextValue
}
} else if ( invoker ) {
el . removeEventListener ( name , invoker )
}
} else if ( key === 'class' ) {
// 省略部分代码
} else if ( shouldSetAsProps ( el , key , nextValue )) {
// 省略部分代码
} else {
// 省略部分代码
}
}
在这段代码中,我们检查了 invoker 函数的实现。当 invoker 函数执行时,在调用真正的事件处理函数之前,要先检查 invoker.value 的数据结构是否是数组,如果是数组则遍历它,并逐个调用定义在数组中的事件处理函数。
事件冒泡与更新时机问题来看一个例子:
const { effect , ref } = VueReactivity
const bol = ref ( false )
effect (() => {
// 创建 vnode
const vnode = {
type : 'div' ,
props : bol . value ? {
onClick : () => {
alert ( '父元素 clicked' )
}
} : {},
children : [
{
type : 'p' ,
props : {
onClick : () => {
bol . value = true
}
},
children : 'text'
}
]
}
// 渲染 vnode
renderer . render ( vnode , document . querySelector ( '#app' ))
})
这个例子比较复杂。在上面这段代码中,我们创建一个响应式数据 bol ,它是一个 ref ,初始值为 false 。接着,创建了一个 effect ,并在副作用函数内调用 renderer.render 函数来渲染 vnode 。这里的重点在于该 vnode ,它描述了一个 div 元素,并且该 div 元素具有一个 p 元素作为子元素。我们再来详细看看 div 元素以及 p 元素的特点。
div 元素
它的 props 对象的值是由一个三元表达式决定的。在首次渲染时,由于 bol.value 的值为 false ,所以它的 props 的值是一个空对象。
p 元素
它具有 click 点击事件,并且当点击它时,事件处理函数会将 bol.value 的值设置为 true 。
结合上述特点,我们来思考一个问题:当首次渲染完成后,用鼠标点击 p 元素,会触发父级 div 元素的 click 事件的事件处理函数执行吗?
答案其实很明显,在首次渲染完成之后,由于 bol.value 的值为 false ,所以渲染器并不会为 div 元素绑定点击事件。当用鼠标点击 p 元素时,即使 click 事件可以从 p 元素冒泡的父级 div 元素,但由于 div 元素没有绑定 click 事件的事件处理函数,所以什么都不会发生。但事实是,当你尝试运行上面这段代码并点击 p 元素时,会发现父级 div 元素的 click 事件的事件处理函数竟然执行了。为什么会发生如此奇怪的现象呢?这其实与更新机制有关,我们来分析一下当点击 p 元素时,到底发生了什么。
当点击 p 元素时,绑定到它身上的 click 事件处理函数会执行,于是 bol.value 的值被改为 true 。接下来的一步非常关键,由于 bol 是一个响应式数据,所以当它的值发生变化时,会触发副作用函数重新执行。由于此时的 bol.value 已经变成了 true ,所以在更新阶段,渲染器会为父级 div 元素绑定 click 事件处理函数。当更新完成之后,点击事件才从 p 元素冒泡的父级 div 元素。由于此时 div 元素已经绑定了 click 事件的处理函数,因此就发生了上述奇怪的现象。
之所以会出现上述奇怪的现象,是因为更新操作发生在事件冒泡之前,即为 div 元素绑定事件处理函数发生在事件冒泡之前 。
我们可以发现:事件触发的时间要早于事件处理函数被绑定的时间。这意味着当一个时间触发时,目标元素上还没有绑定相关的事件处理函数,我们可以根据这个特点来解决问题:屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行 。
patchProps ( el , key , prevValue , nextValue ) {
if ( /^on/ . test ( key )) {
const invokers = el . _vei || ( el . _vei = {})
let invoker = invokers [ key ]
const name = key . slice ( 2 ). toLowerCase ()
if ( nextValue ) {
if (! invoker ) {
invoker = el . _vei [ key ] = ( e ) => {
// e.timeStamp 是事件发生的时间
// 如果事件发生的时间早于事件处理函数绑定的时间,则不执行事件处理函数
if ( e . timeStamp < invoker . attached ) return
if ( Array . isArray ( invoker . value )) {
invoker . value . forEach ( fn => fn ( e ))
} else {
invoker . value ( e )
}
}
invoker . value = nextValue
// 添加 invoker.attached 属性,存储事件处理函数被绑定的时间
invoker . attached = performance . now ()
el . addEventListener ( name , invoker )
} else {
invoker . value = nextValue
}
} else if ( invoker ) {
el . removeEventListener ( name , invoker )
}
} else if ( key === 'class' ) {
// 省略部分代码
} else if ( shouldSetAsProps ( el , key , nextValue )) {
// 省略部分代码
} else {
// 省略部分代码
}
}
如上面的代码所示,我们在原来的基础上只添加了两行代码。首先,我们为伪造的事件处理函数添加了 invoker.attached 属性,用来存储事件处理函数被绑定的时间。然后,在 invoker 执行的时候,通过事件对象的 e.timeStamp 获取事件发生的时间。最后,比较两者,如果事件处理函数被绑定的时间晚于事件发生的时间,则不执行该事件处理函数。
更新子节点回顾一下元素的子节点是如何被挂载的,如下面 mountElement 函数的代码所示:
function mountElement ( vnode , container ) {
const el = vnode . el = createElement ( vnode . type )
// 挂载子节点,首先判断 children 的类型
// 如果是字符串类型,说明是文本子节点
if ( typeof vnode . children === 'string' ) {
setElementText ( el , vnode . children )
} else if ( Array . isArray ( vnode . children )) {
// 如果是数组,说明是多个子节点
vnode . children . forEach ( child => {
patch ( null , child , el )
})
}
if ( vnode . props ) {
for ( const key in vnode . props ) {
patchProps ( el , key , null , vnode . props [ key ])
}
}
insert ( el , container )
}
对于一个元素来说,它的子节点无非有以下三种情况。
没有子节点,此时 vnode.children 的值为 null 。 具有文本子节点,此时 vnode.children 的值为字符串,代表文本的内容。 其他情况,无论是单个元素子节点,还是多个子节点(可能是文本和元素的混合),都可以用数组来表示。
如下面的代码所示:
// 没有子节点
vnode = {
type : 'div' ,
children : null
}
// 文本子节点
vnode = {
type : 'div' ,
children : 'some text'
}
// 其他情况,子节点使用数组表示
vnode = {
type : 'div' ,
children : [
{ type : 'p' },
'some text'
]
}
现在,我们已经规范化了 vnode.children 的类型。既然一个 vnode 的子节点可能有三种情况,那么当渲染器执行更新时,新旧子节点都分别时三种情况之一。
更新元素
function patchElement ( n1 , n2 ) {
const el = n2 . el = n1 . el
const oldProps = n1 . props
const newProps = n2 . props
// 第一步: 更新 props
for ( const key in newProps ) {
if ( newProps [ key ] !== oldProps [ key ]) {
patchProps ( el , key , oldProps [ key ], newProps [ key ])
}
}
for ( const key in oldProps ) {
if (!( key in newProps )) {
patchProps ( el , key , oldProps [ key ], null )
}
}
// 第二步: 更新 children
patchChildren ( n1 , n2 , el )
}
patchChildren 函数的实现如下:
function patchChildren ( n1 , n2 , container ) {
// 判断子节点的类型是否是文本节点
if ( typeof n2 . children === 'string' ) {
// 旧子节点的类型有三种可能: 没有子节点、文本子节点以及一组子节点
// 只有当旧子节点为一组子节点时,才需要逐个卸载,其他情况下什么都不需要做
if ( Array . isArray ( n1 . children )) {
n1 . children . forEach (( c ) => unmount ( c ))
}
// 最后将新的文本节点内容设置给容器元素
setElementText ( container , n2 . children )
} else if ( Array . isArray ( n2 . children )) {
// 说明新子节点是一组子节点
// 判断旧子节点是否也是一组子节点
if ( Array . isArray ( n1 . children )) {
// 代码运行到这里,则说明新旧子节点都是一组子节点,这里涉及核心的 Diff 算法
} else {
// 此时:
// 旧子节点要么是文本子节点,要么不存在
// 但无论是哪种情况,我们都只需要将容器清空,然后将新的一组子节点逐个挂载
setElementText ( container , '' )
n2 . children . forEach ( c => patch ( null , c , container ))
}
} else {
// 代码运行到这里,说明新子节点不存在
// 旧子节点是一组子节点,只需逐个卸载即可
if ( Array . isArray ( n1 . children )) {
n1 . children . forEach ( c => unmount ( c ))
} else if ( typeof n1 . children === 'string' ) {
// 旧子节点是文本子节点,清空内容即可
setElementText ( container , '' )
}
// 如果也没有旧子节点,那么什么都不需要做
}
}
文本节点和注释节点vnode.type 属性能够代表一个 vnode 的类型。如果 vnode.type 的值是字符串类型,则代表它描述的是普通标签,并且该值就代表标签的名称。但注释节点与文本节点不同于普通标签节点,它们不具有标签名称,所以我们需要人为创造一些唯一的标识,并将其作为注释节点和文本节点的 type 属性值,如下面的代码所示:
// 文本节点的 type 标识
const Text = Symbol ()
const newVnode = {
// 描述文本节点
type : Text ,
children : '我是文本内容'
}
// 注释节点的 type 标识
const Comment = Symbol ()
const newVnode = {
// 描述注释节点
type : Comment ,
children : '我是注释内容'
}
有了用于描述文本节点和注释节点的 vnode 对象后,我们就可以使用渲染器来渲染它们了,如下面的代码所示:
function patch ( n1 , n2 , container ) {
if ( n1 && n1 . type !== n2 . type ) {
unmount ( n1 )
n1 = null
}
const { type } = n2
if ( typeof type === 'string' ) {
if (! n1 ) {
mountElement ( n2 , container )
} else {
patchElement ( n1 , n2 )
}
} else if ( type === Text ) { // 如果新 vnode 的类型是 Text ,则说明该 vnode 描述的是文本节点
// 如果没有旧节点,则进行挂载
if (! n1 ) {
// 使用 createTextNode 创建文本节点
const el = n2 . el = document . createTextNode ( n2 . children )
// 将文本节点插入到容器中
insert ( el , container )
} else {
// 如果旧 vnode 存在,只需要使用新文本节点的文本内容更新旧文本节点即可
const el = n2 . el = n1 . el
if ( n2 . children !== n1 . children ) {
el . nodeValue = n2 . children
}
}
}
}
从上面的代码中我们还能注意到, patch 函数依赖浏览器平台特有的 API ,即 createTextNode 和 el.nodeValue 。为了保证渲染器核心的跨平台能力,我们需要将这两个操作 DOM 的 API 封装到渲染器的选项中,如下面的代码所示:
const renderer = createRenderer ({
createElement ( tag ) {
// 省略部分代码
},
setElementText ( el , text ) {
// 省略部分代码
},
insert ( el , parent , anchor = null ) {
// 省略部分代码
},
createText ( text ) {
return document . createTextNode ( text )
},
setText ( el , text ) {
el . nodeValue = text
},
patchProps ( el , key , prevValue , nextValue ) {
// 省略部分代码
}
})
注释节点的处理方式与文本节点的处理方式类似。不同的是,我们需要使用 document.createComment 函数创建注释节点元素。
FragmentFragment(片断)是 Vue.js3 中新增的一个 vnode 类型。在具体讨论 Fragment 的实现之前,我们有必要先了解为什么需要 Fragment 。请思考这样的场景,假设我们要封装一组列表组件:
<List>
<Items />
</List>
整体由两个组件构成,即 组件和 组件。其中 组件会渲染一个
<!-- List.vue -->
<template>
<ul>
<slot />
</ul>
</template>
而 组件负责渲染一组
列表:
<!-- Items.vue -->
<template>
<li> 1 </li>
<li> 2 </li>
<li> 3 </li>
</template>
这在 Vue.js2 中是无法实现的。 在 Vue.js2 中,组件的模板不允许存在多个根节点。这意味着,一个 组件最多只能渲染一个
标签:
<!-- Item.vue -->
<template>
<li> 1 </li>
</template>
因此在 Vue.js2 中,我们通常需要配合 v-for 指令来达到目的:
<List>
<Items v-for = "item in list" />
</List>
类似的组合还有 标签与 标签。
而 Vue.js3 支持多根节点模板,所以不存在上述问题。那么, Vue.js3 是如何用 vnode 来描述多根节点模板的呢?答案是,使用 Fragment ,如下面的代码所示:
const Fragment = Symbol()
const vnode = {
type: Fragment,
children: [
{ type: 'li', children: 'text 1' },
{ type: 'li', children: 'text 2' },
{ type: 'li', children: 'text 3' }
]
}
与文本节点和注释节点类似,片段也没有所谓的标签名称,因此我们也需要为片段创建唯一标识,即 Fragment 。对于 Fragment 类型的 vnode 的来说,它的 children 存储的内容就是模板中所有根节点。有了 Fragment 后,我们就可以用它来描述 Items.vue 组件的模板了:
<!-- Items.vue -->
<template>
<li>1</li>
<li>2</li>
<li>3</li>
</template>
这段模板对应的虚拟节点是:
const vnode = {
type: Fragment,
children: [
{ type: 'li', children: '1' },
{ type: 'li', children: '2' },
{ type: 'li', children: '3' }
]
}
类似地,对于如下模板:
<List>
<Items />
</List>
当渲染器渲染 Fragment 类型的虚拟节点时,由于 Fragment 本身并不会渲染任何内容,所以渲染器只会渲染 Fragment 的子节点,如下面的代码所示:
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
// 省略部分代码
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) { // 处理 Fragment 类型的 vnode
if (!n1) {
// 如果旧 vnode 不存在,则只需要将 Fragment 的 children 逐个挂载即可
n2.children.forEach(c => patch(null, c, container))
} else {
// 如果旧 vnode 存在,则只需要更新 Fragment 的 children 即可
patchChildren(n1, n2, container)
}
}
}
需要注意一点, unmount 函数也需要支持 Fragment 类型的虚拟节点的卸载,如下面的 unmount 函数的代码所示:
function unmount(vnode) {
// 在卸载时,如果卸载的 vnode 类型为 Fragment ,则需要卸载其 children
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c))
return
}
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}
简单 Diff 算法当新旧 vnode 的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫作 Diff 算法。我们知道, 操作 DOM 的性能开销通常比较大,而渲染器的核心 Diff 算法就是为了解决这个问题而诞生的。
减少 DOM 操作的性能开销核心 Diff 只关心新旧虚拟节点都存在一组子节点的情况。在上一章中,我们针对两组子节点的更新,采用了一种简单直接的手段,即卸载全部旧子节点,再挂载全部新子节点。这么做的确可以完成更新,但由于没有复用任何 DOM 元素,所以会产生极大的性能开销。
以下面的新旧虚拟节点为例:
// 旧 vnode
const oldVNode = {
type: 'div',
children: [
{ type: 'p', children: '1' },
{ type: 'p', children: '2' },
{ type: 'p', children: '3' }
]
}
// 新 vnode
const newVNode = {
type: 'div',
children: [
{ type: 'p', children: '4' },
{ type: 'p', children: '5' },
{ type: 'p', children: '6' }
]
}
按照之前的做法,当更新子节点时,我们需要执行 6 次 DOM 操作:
卸载所有旧子节点,需要 3 次 DOM 删除操作;挂载所有新子节点,需要 3 次 DOM 添加操作。
但是,通过观察上面新旧 vnode 的子节点,:
更新前后的所有子节点都是 p 标签,即标签元素不变;只有 p 标签的子节点(文本节点)会发生变化。
所以,理想的更新方式是,直接更新这个 p 标签的文本节点的内容。这样只需要一次 DOM 操作,即可完成一个 p 标签更新。新旧虚拟节点都有 3 个 p 标签作为子节点,所以一共只需要 3 次 DOM 操作就可以完成全部节点的更新。相比原来需要执行 6 次 DOM 操作才能完成更新的方式,其性能提升了一倍。
按照这个思路,我们可以重新实现两组子节点的更新逻辑,如下面 patchChildren 函数的代码所示:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
// 重新实现两组子节点的更新方式
// 新旧 children
const oldChildren = n1.children
const newChildren = n2.children
// 遍历旧的 children
for (let i = 0; i < oldChildren.length; i++) {
// 调用 patch 函数逐个更新子节点
patch(oldChildren[i], newChildren[i])
}
} else {
// 省略部分代码
}
}
上面的代码暴露的问题是,在进行新旧两组子节点的更新时,不应该总是遍历旧的一组子节点或遍历新的一组子节点,而是应该遍历其中长度较短的那一组。这样,我们才能够尽可能多地调用 patch 函数进行更新。接着,再对比新旧两组子节点的长度,如果新的一组子节点更长,则说明有新子节点需要挂载,否则说明有旧子节点需要卸载。最终实现如下:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
// 旧的一组子节点的长度
const oldLen = oldChildren.length
// 新的一组子节点的长度
const newLen = newChildren.length
// 两组子节点的公共长度,即两者中较短的那一组子节点的长度
const commonLength = Math.min(oldLen, newLen)
// 遍历 commonLength 次
for (let i = 0; i < commonLength; i++) {
patch(oldChildren[i], newChildren[i], container)
}
// 如果 newLen > oldLen,说明有新子节点需要挂载
if (newLen > oldLen) {
for (let i = commonLength; i < newLen; i++) {
patch(null, newChildren[i], container)
}
} else if (oldLen > newLen) {
// 如果 oldLen > newLen,说明有旧子节点需要卸载
for (let i = commonLength; i < oldLen; i++) {
unmount(oldChildren[i])
}
}
} else {
// 省略部分代码
}
}
DOM 复用与 key 的作用在上一节中,我们通过减少 DOM 操作的次数,提升了更新性能。但这种方式仍然存在可优化的空间。举个例子,假设新旧两组子节点的内容如下:
// oldChildren
[
{ type: 'p' },
{ type: 'div' },
{ type: 'span' }
]
// newChildren
[
{ type: 'span' },
{ type: 'p' },
{ type: 'div' }
]
如果使用上一节介绍的算法来完成上述两组子节点的更新,则需要 6 次 DOM 操作。
调用 patch 函数在旧子节点 { type: ‘p’ } 与新子节点 { type: ‘span’ } 之间打补丁,由于两者是不同的标签,所以 patch 函数会卸载 { type: ‘p’ } ,然后再挂载 { type: ‘span’ } ,这需要执行 2 次 DOM 操作。与第 1 步类似,卸载旧子节点 { type: ‘div’ } ,然后再挂载新子节点 { type: ‘p’ } ,这也需要执行 2 次 DOM 操作。与第 1 步类似,卸载旧子节点 { type: ‘span’ } ,然后再挂载新子节点 { type: ‘div’ } ,这同样需要执行 2 次 DOM 操作。
因此,一共进行 6 次 DOM 操作才能完成上述案例的更新。但是,观察新旧两组子节点,很容易发现,二者只是顺序不同。所以最优的处理方式是,通过 DOM 的移动来完成子节点的更新,这要比不断地执行子节点的卸载和挂载性能更好。但是,想要通过 DOM 的移动来完成更新,必须要保证一个前提:新旧两组子节点中的确存在可复用的节点。这个很好理解,如果新的子节点没有在旧的一组子节点中出现,就无法通过移动节点的方式完成更新。所以现在问题变成了:应该如何确定新的子节点是否出现在旧的一组子节点中呢?这时,我们就需要引入额外的 key 来作为 vnode 的标识。key 属性就像虚拟节点的“身份证”号,只要两个虚拟节点的 type 属性值和 key 属性值都相同,那么我们就认为它们是相同的,即可以进行 DOM 的复用。
有必要强调的一点是,DOM 可复用并不意味着不需要更新,如下面的两个虚拟节点所示:
const oldVNode = { type: 'p', key: 1, children: 'text 1' }
const newVnode = { type: 'p', key: 1, children: 'text 2' }
这两个虚拟节点拥有相同的 key 值和 vnode.type 属性值。这意味着,在更新时可以复用 DOM 元素,即只需要通过移动操作来完成更新。但仍需要对这两个虚拟节点进行打补丁操作,因为新的虚拟节点(newVNode)的文本子节点的内容已经改变了(由 ‘text 1’ 变成 ‘text 2’)。因此,在讨论如何移动 DOM 之前,我们需要先完成打补丁操作,如下面的 patchChildren 函数的代码所示:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
// 遍历新的 children
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
// 遍历旧的 children
for (let j = 0; j < oldChildren.length; j++) {
const oldVnode = oldChildren[j]
// 如果找到了具有相同 key 值的两个节点,说明可以复用,但仍然需要调用 patch 函数更新
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container)
break // 这里需要 break
}
}
}
} else {
// 省略部分代码
}
}
在上面这段代码中,我们重新实现了新旧两组子节点的更新逻辑。可以看到,我们使用了两层 for 循环,外层循环用于遍历新的一组子节点,内层循环则遍历旧的一组子节点。在内层循环中,我们逐个对比新旧子节点的 key 值,试图在旧的子节点中找到可复用的节点。一旦找到,则调用 patch 函数进行打补丁。经过这一步操作之后,我们能够保证所有可复用的节点本身都已经更新完毕了。以下面的新旧两组子节点为例:
const oldVNode = {
type: 'div',
children: [
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 },
{ type: 'p', children: 'hello', key: 3 }
]
}
const newVNode = {
type: 'div',
children: [
{ type: 'p', children: 'world', key: 3 },
{ type: 'p', children: '1', key: 1 },
{ type: 'p', children: '2', key: 2 }
]
}
// 首次挂载
renderer.render(oldVNode, document.querySelector('#app'))
setTimeout(() => {
// 1 秒钟后更新
renderer.render(newVNode, document.querySelector('#app'))
}, 1000)
运行上面这段代码,1 秒钟后,key 值为 3 的子节点对应的真实 DOM 的文本内容会由字符串 ‘hello’ 更新为字符串 ‘world’ 。但真实 DOM 仍然保持旧的一组子节点的顺序,即 key 值为 3 的节点对应的真实 DOM 仍然是最后一个子节点。由于在新的一组子节点中, key 值为 3 的节点已经变为第一个子节点了,因此我们还需要通过移动节点来完成真实 DOM 顺序的更新。
找到需要移动的元素如何判断一个节点是否移动,以及如何移动。对于第一个问题,我们可以采用逆向思维的方式,先想一想在什么情况下节点不需要移动?答案很简单,当新旧两组子节点的节点顺序不变时,就不需要额外的移动操作。在图 9-5 中,新旧两组子节点的顺序没有发生变化,图中也给出了旧的一组子节点中各个节点的索引:
key 值为 1 的节点在旧 children 数组中的索引为 0 ;key 值为 2 的节点在旧 children 数组中的索引为 1 ;key 值为 3 的节点在旧 children 数组中的索引为 2 .
接着,我们对新旧两组子节点采用上一节介绍的更新算法,看看当新旧两组子节点的顺序没有发生变化时,更新算法具有怎样的特点。
第一步:取新的一组子节点中的第一个节点 p-1 ,它的 key 为 1 。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点,发现能够找到,并且该节点在旧的一组子节点中的索引为 0 。第二步:取新的一组子节点中的第二个节点 p-2 ,它的 key 为 2 。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点,发现能够找到,并且该节点在旧的一组子节点中的索引为 1 。第三步:取新的一组子节点中的第三个节点 p-3 ,它的 key 为 3 。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点,发现能够找到,并且该节点在旧的一组子节点中的索引为 2 。
在这个过程中,每一次寻找可复用的节点时,都会记录该可复用节点在旧的一组子节点中的位置索引。如果把这些位置索引值按照先后顺序排列,则可以得到一个序列:0、1、2。这是一个递增的序列,在这种情况下不需要移动任何节点。
我们再来看看另外一个例子,如图 9-6 所示。同样,我们根据 9-6 中给出的例子再次执行更新算法,看看这一次会有什么不同。
第一步:取新的一组子节点中的第一个节点 p-3 ,它的 key 为 3 。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点,发现能够找到,并且该节点在旧的一组子节点中的索引为 2 。第二步:取新的一组子节点中的第二个节点 p-1 ,它的 key 为 1 。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点,发现能够找到,并且该节点在旧的一组子节点中的索引为 0 。
到了这一步我们发现,索引值递增的顺序被打破了,节点 p-1 在旧 children 中的索引是 0 ,它小于节点 p-3 在旧 children 中的索引 2 。这说明节点 p-1 在旧 children 中排在节点 p-3 前面,但在新 children 中,它排在节点 p-3 后面。因此,我们能够得出一个结论:节点 p-1 对应的真实 DOM 需要移动。
第三步:取新的一组子节点中的第三个节点 p-2 ,它的 key 为2 。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点,发现能够找到,并且该节点在旧的一组子节点中的索引为 1 。
到了这一步我们发现,节点 p-2 在旧 children 中的索引 1 要小于节点 p-3 在旧 children 中的索引 2 。这说明,节点 p-2 在旧 children 中排在节点 p-3 前面,但在新的 children 中,它排在节点 p-3 后面。因此,节点 p-2 对应的真实 DOM 也需要移动。
以上就是 Diff 算法在执行更新的过程中,判断节点是否需要移动的方式。在上面的例子中,我们得出了节点 p-1 和节点 p-2 需要移动的结论。这是因为它们在旧 children 中的索引要小于节点 p-3 在旧 children 中的索引。如果我们按照先后顺序记录在寻找节点过程中所遇到的位置索引,将会得到序列:2、0、1 。可以发现,这个序列不具有递增的趋势。
其实我们可以将节点 p-3 在旧 children 中的索引定义为:在旧 children 中寻找具有相同 key 值节点的过程中,遇到的最大索引值。如果在后续寻找的过程中,存在索引值比当前遇到的最大索引值还要小的节点,则意味着该节点需要移动。
我们可以用 lastIndex 变量存储整个寻找过程中遇到的最大索引值,如下面的代码所示:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'children') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
// 用来存储寻找过程中遇到的最大索引值
let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
cont newVNode = newChildren[i]
for (let j = 0; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container)
if (j < lastIndex) {
// 如果当前找到的节点在旧 children 中的索引小于最大索引值 lastIndex,
// 说明该节点对应的真实 DOM 需要移动
} else {
// 如果当前找到的节点在旧 children 中的索引不小于最大索引值,
// 则更新 lastIndex 的值
lastIndex = j
}
break // 这里需要 break
}
}
}
} else {
// 省略部分代码
}
}
如何移动元素移动节点指的是,移动一个虚拟节点所对应的真实 DOM 节点,并不是移动虚拟节点本身。既然移动的是真实 DOM 节点,那么就需要取得对它的引用才行。我们知道,当一个虚拟节点被挂载后,其对应的真实 DOM 节点会存储在它的 vnode.el 属性中。
function parchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
let j = 0
for(j; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
if (newVNode.key === oldVNode.key) {
patch(oldVNode, newVNode, container)
if (j < lastIndex) {
// 代码运行到这里,说明 newVnode 对应的真实 DOM 需要移动
// 先获取 newVNode 的前一个 vnode ,即 prevVNode
const prevVNode = newChildren[i - 1]
// 如果 prevVNode 不存在,则说明当前 newVNode 是第一个节点,它不需要移动
if (prevVNode) {
// 由于我们要将 newVNode 对应的真实 DOM 移动到 prevVNode 所对应真实 DOM 后面,
// 所以我们需要获取 prevVNode 所对应真实 DOM 的下一兄弟节点,并将其作为锚点
const anchor = prevVNode.el.nextSibling
// 调用 insert 方法将 newVNode 对应的真实 DOM 插入到锚点元素前面,
// 也就是 prevVNode 对应真实 DOM 的后面
insert(newVNode.el, container, anchor)
}
} else {
lastIndex = j
}
break
}
}
}
} else {
// 省略部分代码
}
}
insert 函数依赖浏览器原生的 insertBefore 函数,如下面的代码所示:
const renderer = createRenderer({
// 省略部分代码
insert(el, parent, anchor = null) {
// insertBefore 需要锚点元素 anchor
parent.insertBefore(el, anchor)
}
// 省略部分代码
})
添加新元素对于新增节点,在更新时我们应该正确地将它挂载,这主要分为两步:
想办法找到新增节点;将新增节点挂载到正确位置。
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
const newVNode = newChildren[i]
let j = 0
// 在第一层循环中定义变量 find ,代表是否在旧的一组子节点中找到可复用的节点,
// 初始值为 false ,代表没找到
let find = false
for (j; j < oldChildren.length; j++) {
const oldVNode = oldChildren[j]
if (newVNode.key === oldVNode.key) {
// 一旦找到可复用的节点,则将变量 find 的值设为 true
find = true
patch(oldVNode, newVNode, container)
if (j < lastIndex) {
const prevVNode = newChildren[i - 1]
if (prevVNode) {
cosnt anchor = prevVNode.el.nextSibling
insert(newVNode.el, container, anchor)
}
} else {
lastIndex = j
}
break
}
}
// 如果代码运行到这里, find 仍然为 false,
// 说明当前 newVNode 没有在旧的一组子节点中找到可复用的节点
// 也就是说,当前 newVNode 是新增节点,需要挂载
if (!find) {
// 为了将节点挂载到正确位置,我们需要先获取锚点元素
// 首先获取当前 newVNode 的前一个 vnode 节点
const prevVNode = newChildren[i - 1]
let anchor = null
if (prevVNode) {
// 如果有前一个 vnode 节点,则使用它的下一个兄弟节点作为锚点元素
anchore = prevVNode.el.nextSibling
} else {
// 如果没有前一个 vnode 节点,说明即将挂载的新节点是第一个子节点
// 这时我们使用容器元素的 firstChild 作为锚点
anchor = container.firstChild
}
// 挂载 newVNode
patch(null, newVNode, container, anchor)
}
}
} else {
// 省略部分代码
}
}
由于目前实现的 patch 函数还不支持传递第四个参数,所以我们需要调整 patch 函数的代码,如下所示:
```javascript
// patch 函数需要接收第四个参数,即锚点元素
function patch(n1, n2, container, anchor) {
// 省略部分代码
if (typeof type === ‘string’) {
if (!n1) {
// 挂载时将锚点元素作为第三个参数传递给 mountElement 函数
mountElement(n2, container, anchor)
} else {
patchElement(n1, n2)
}
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) {
// 省略部分代码
}
}
// mountElement 函数需要增加第三个参数,即锚点元素
function mountElement(vnode, container, anchor) {
// 省略部分代码
// 在插入节点时,将锚点元素透传给 insert 函数
insert(el, container, anchor)
}
<a name="QoFaS"></a>
### 移除不存在的元素
当基本的更新结束时,我们需要遍历旧的一组子节点,然后去新的一组子节点中寻找具有相同 key 值的节点。如果找不到,则说明应该删除该节点。
```javascript
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
const oldChildren = n1.children
const newChildren = n2.children
let lastIndex = 0
for (let i = 0; i < newChildren.length; i++) {
// 省略部分代码
}
// 上一步的更新操作完成后
// 遍历旧的一组子节点
for (let i = 0; i < oldChildren.length; i++) {
const oldVNode = oldChildren[i]
// 拿旧子节点 oldVNode 去新的一组子节点中寻找具有相同 key 值得节点
const has = newChildren.find(
vnode => vnode.key === oldVNode.key
)
if (!has) {
// 如果没有找到具有相同 key 值得节点,则说明需要删除该节点
// 调用 unmount 函数将其卸载
unmount(oldVNode)
}
}
} else {
// 省略部分代码
}
}
双端 Diff 算法
双端比较的原理简单 Diff 算法的问题在于,它对 DOM 的移动操作并不是最优的。双端 Diff 算法是一种同时对新旧两组子节点的两个端点进行比较的算法。因此,我们需要四个索引值,分别指向新旧两组子节点的端点,如图 10-4 所示。用代码来表达四个端点,如下面 patchChildren 和 patchKeyedChildren 函数的代码所示:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
// 封装 patchKeyedChildren 函数处理两组子节点
patchKeyedChildren(n1, n2, container)
} else {
// 省略部分代码
}
}
function patchKeyedChildren(n1, n2, container) {
const oldChildren = n1.children
const newChildren = n2.children
// 四个索引值
let oldStartIdx = 0
let oldEndIdx = oldChildren.length - 1
let newStartIdx = 0
let newEndIdx = newChildren.length - 1
有了索引后,就可以找到它所指向的虚拟节点了。
function patchKeyedChildren(n1, n2, container) {
const oldChildren = n1.children
const newChildren = n2.children
// 四个索引值
let oldStartIdx = 0
let oldEndIdx = oldChildren.length - 1
let newStartIdx = 0
let newEndIdx = newChildren.length - 1
// 四个索引指向的 vnode 节点
let oldStartVNode = oldChildren[oldStartIdx]
let oldEndVNode = oldChildren[oldEndIdx]
let newStartVNode = newChildren[newStartIdx]
let newEndVNode = newChildren[newEndIdx]
怎么比较呢?如图 10-5 所示。在双端比较中,每一轮比较都分为四个步骤,如图 10-5 中的连线所示。
第一步:比较旧的一组子节点中的第一个子节点 p-1 与新的一组子节点中的第一个子节点 p-4 ,看看它们是否相同。由于两者的 key 值不同,因此不相同,不可复用,于是什么都不做。第二步:比较旧的一组子节点中的最后一个子节点 p-4 与新的一组子节点中的最后一个子节点 p-3 ,看看它们是否相同。由于两者的 key 值不同,因此不相同,不可复用,于是什么都不做。第三步:比较旧的一组子节点中的第一个子节点 p-1 与新的一组子节点中的最后一个子节点 p-3 ,看看它们是否相同。由于两者的 key 值不同,因此不相同,不可复用,于是什么都不做。第四步:比较旧的一组子节点中的最后一个子节点 p-4 与新的一组子节点中的第一个子节点 p-4 。由于它们的 key 值相同,因此可以进行 DOM 复用。
对于可复用的 DOM 节点,我们只需要通过 DOM 移动操作完成更新即可。那么应该如何移动 DOM 元素呢?在第四步中:节点 p-4 原本是最后一个子节点,但在新的顺序中,它变成了第一个子节点。也就是说:节点 p-4 在更新之后应该是第一个子节点。对应到程序的逻辑:将索引 oldEndIdx 指向的虚拟节点所对应的真实 DOM 移动到索引 oldStartIdx 指向的虚拟节点所对应的真实 DOM 前面。如下面代码所示:
function patchKeyedChildren(n1, n2, container) {
const oldChildren = n1.children
const newChildren = n2.children
// 四个索引值
let oldStartIdx = 0
let oldEndIdx = oldChildren.length - 1
let newStartIdx = 0
let newEndIdx = newChildren.length - 1
// 四个索引指向的 vnode 节点
let oldStartVNode = oldChildren[oldStartIdx]
let oldEndVNode = oldChildren[oldEndIdx]
let newStartVNode = newChildren[newStartIdx]
let newEndVNode = newChildren[newEndIdx]
if (oldStartVNode.key === newStartVNode.key) {
// 第一步:oldStartVNode 和 newStartVNode 比较
} else if (oldEndVNode.key === newEndVNode.key) {
// 第二步: oldEndVNode 和 newEndVNode 比较
} else if (oldStartVNode.key === newEndVNode.key) {
// 第三步: oldStartVNode 和 newEndVNode 比较
} else if (oldEndVNode.key === newStartVNode.key) {
// 第四步: oldEndVNode 和 newStartVNode 比较
// 仍然需要调用 patch 函数进行打补丁
patch(oldEndVNode, newStartVNode, container)
// 移动 DOM 操作
// oldEndVNode.el 移动到 oldStartVNode.el 前面
insert(oldEndVNode.el, container, oldStartVNode.el)
// 移动 DOM 完成后,更新索引值,并指向下一个位置
oldEndVnode = oldChildren[--oldEndIdx]
newStartVnode = newChildren[++newStartIdx]
}
}
第一步 DOM 移动操作完成之后,新旧两组子节点以及真实 DOM 节点的状态。此时,真实 DOM 节点顺序为 p-4 、p-1 、 p-2 、 p-3 ,这与新的一组子节点顺序不一致。这是因为 Diff 算法还没有结束,还需要进行下一轮更新。因此,我们需要将更新逻辑封装到一个 while 循环中,如下面的代码所示:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode.key === newStartVNode.key) {
// 第一步:oldStartVNode 和 newStartVNode 比较
} else if (oldEndVNode.key === newEndVNode.key) {
// 第二步: oldEndVNode 和 newEndVNode 比较
} else if (oldStartVNode.key === newEndVNode.key) {
// 第三步: oldStartVNode 和 newEndVNode 比较
} else if (oldEndVNode.key === newStartVNode.key) {
// 第四步: oldEndVNode 和 newStartVNode 比较
// 仍然需要调用 patch 函数进行打补丁
patch(oldEndVNode, newStartVNode, container)
// 移动 DOM 操作
// oldEndVNode.el 移动到 oldStartVNode.el 前面
insert(oldEndVNode.el, container, oldStartVNode.el)
// 移动 DOM 完成后,更新索引值,并指向下一个位置
oldEndVnode = oldChildren[--oldEndIdx]
newStartVnode = newChildren[++newStartIdx]
}
}
由于在每一轮更新完成之后,紧接着都会更新四个索引中与当前更新轮次相关联的索引,所以整个 while 循环执行的条件是:头部索引值要小于等于尾部索引值。
在第一轮更新结束后循环条件仍然成立,因此需要进行下一轮的比较。
第一步:比较旧的一组子节点中的头部节点 p-1 与新的一组子节点中的头部节点 p-2 ,看看它们是否相同。由于两者的 key 值不同,不可复用,所以什么都不做。
这里,我们使用了新的名词:头部节点。它指的是头部索引 oldStartIdx 和 newStartIdx 所指向的节点。
第二步:比较旧的一组子节点中的尾部节点 p-3 与新的一组子节点中的尾部节点 p-3 ,两者的 key 值相同,可以复用。另外,由于两者都处于尾部,因此不需要对真实 DOM 进行移动操作,只需要打补丁即可,如下面的代码所示:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode.key === newStartVNode.key) {
// 步骤一:oldStartVNode 和 newStartVNode 比较
} else if (oldEndVNode.key === newEndVNode.key) {
// 步骤二: oldEndVNode 和 newEndVNode 比较
// 节点在新的顺序中仍然处于尾部,不需要移动,但仍需打补丁
patch(oldEndVNode, newEndVNode, container)
// 更新索引和尾部节点变量
oldEndVNode = oldChildren[--oldEndIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (oldStartVNode.key === newEndVNode.key) {
// 步骤三: oldStartVNode 和 newEndVNode 比较
} else if (oldEndVNode.key === newStartVNode.key) {
// 步骤四: oldEndVNode 和 newStartVNode 比较
patch(oldEndVNode, newStartVNode, container)
insert(oldEndVnode.el, container, oldStartVNode.el)
oldEndVNode = oldChildren[--oldEndIdx]
newStartVNode = newChildren[++newStartIdx]
}
}
在这一轮更新完成之后,新旧两组子节点与真实 DOM 节点的状态如图 10-8 所示。接下来,我们再根据图 10-8 所示的状态执行下一轮的比较。
第一步:比较旧的一组子节点中的头部节点 p-1 与新的一组子节点中的头部节点 p-2 ,看看它们是否相同。由于两者的 key 值不同,不可复用,因此什么都不做。
第二步:比较旧的一组子节点中的尾部节点 p-2 与新的一组子节点中的尾部节点 p-1 ,看看它们是否相同,由于两者的 key 值不同,不可复用,因此什么都不做。第三步:比较旧的一组子节点中的头部节点 p-1 与新的一组子节点中的尾部节点 p-1 。两者的 key 值相同,可以复用。
在第三步的比较中,我们找到了相同的节点,这说明:节点 p-1 原本是头部节点,但在新的顺序中,它变成了尾部节点。因此,我们需要将节点 p-1 对应的真实 DOM 移动到旧的一组子节点 p-2 所对应的真实 DOM 后面,同时还需要更新相应的索引到下一个位置,如图 10-9 所示。这一步的代码实现如下:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode.key === newStartVNode.key) {
} else if (oldEndVNode.key === newEndVNode.key) {
patch(oldEndVNode, newEndVNode, container)
oldEndVNode = oldChildren[--oldEndIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (oldStartVNode.key === newEndVNode.key) {
// 调用 patch 函数在 oldStartVNode 和 newEndVNode 之间打补丁
patch(oldStartVNode, newEndVNode, container)
// 将旧的一组子节点的头部节点对应的真实 DOM 节点 oldStartVNode.el 移动到
// 旧的一组子节点的尾部节点对应的真实 DOM 节点后面
insert(oldStartVNode.el, container, oldEndVnode.el.nextSibling)
// 更新相关索引到下一个位置
oldStartVNode = oldChildren[++oldStartIdx]
newEndVnode = newChildren[--newEndIdx]
} else if (oldEndVNode.key === newStartVNode.key) {
patch(oldEndVNode, newStartVNode, container)
insert(oldEndVnode.el, container, oldStartVNode.el)
oldEndVNode = oldChildren[--oldEndIdx]
newStartVNode = newChildren[++newStartIdx]
}
}
通过图 10-9 可知,此时,新旧两组子节点的头部索引和尾部索引发生重合,但仍然满足循环的条件,所以还会进行下一轮的更新。而在接下来的这一轮的更新中,更新步骤也发生了重合。
第一步:比较旧的一组子节点中的头部节点 p-2 与新的一组子节点中的头部节点 p-2 。发现两者 key 值相同,可以复用。但两者在新旧两组子节点中都是头部节点,因此不需要移动,只需要调用 patch 函数进行打补丁即可。
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode.key === newStartVNode.key) {
// 调用 patch 函数在 oldStartVNode 和 newStartVNode 之间打补丁
patch(oldStartVNode, newStartVNode, container)
// 更新相关索引到下一个位置
oldStartVNode = oldChildren[++oldStartIdx]
newStartVNode = newChildren[++newStartIdx]
} else if (oldEndVNode.key === newEndVNode.key) {
patch(oldEndVNode, newEndVNode, container)
oldEndVNode = oldChildren[--oldEndIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (oldStartVNode.key === newEndVNode.key) {
patch(oldStartVNode, newEndVNode, container)
insert(oldStartVNode.el, container, oldEndVnode.el.nextSibling)
oldStartVNode = oldChildren[++oldStartIdx]
newEndVnode = newChildren[--newEndIdx]
} else if (oldEndVNode.key === newStartVNode.key) {
patch(oldEndVNode, newStartVNode, container)
insert(oldEndVnode.el, container, oldStartVNode.el)
oldEndVNode = oldChildren[--oldEndIdx]
newStartVNode = newChildren[++newStartIdx]
}
}
在这一轮更新后,新旧两组子节点与真实 DOM 节点的状态如图 10-10 所示。此时,真实 DOM 节点的顺序与新的一组子节点的顺序相同了: p-4 、p-2 、 p-1 、 p-3 。另外,在这一轮更新完成之后,索引 newStartIdx 和索引 oldStartIdx 的值都小于 newEndIdx 和 oldEndIdx ,所以循环终止,双端 Diff 算法执行完毕。
双端比较的优势对于同样的例子,采用简单 Diff 算法需要两次 DOM 移动操作才能完成更新,而使用双端 Diff 算法只需要一次 DOM 移动操作即可完成更新。
非理想状态的处理方式当我们尝试按照双端 Diff 算法的思路进行第一轮比较,在四个步骤的比较过程中,都无法找到可复用的节点。这时,我们只能通过增加额外的处理步骤来处理这种非理想情况。既然两个头部和两个尾部的四个节点中都没有可复用的节点,那么我们就尝试看看非头部、非尾部的节点能否复用。具体做法是,拿新的一组子节点中的头部节点去旧的一组子节点中寻找,如下面的代码所示:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode.key === newStartVNode.key) {
// 省略部分代码
} else if (oldEndVNode.key === newEndVNode.key) {
// 省略部分代码
} else if (oldStartVNode.key === newEndVNode.key) {
// 省略部分代码
} else if (oldEndVNode.key === newStartVNode.key) {
// 省略部分代码
} else {
// 遍历旧的一组子节点,试图寻找与 newStartVNode 拥有相同 key 值的节点
// idxInOld 就是新的一组子节点的头部节点在旧的一组子节点中的索引
const idxInOld = oldChildren.findIndex(
node => node.key === newStartVNode.key
)
}
}
在旧的一组子节点中,找到与新的一组子节点的头部节点具有相同 key 值的节点意味着什么?意味着,如果在旧的子节点中找到可复用的节点,这个节点原本不是头部节点,但在更新之后,它应该变成头部几点。所以我们需要将找到的可复用节点对应的真实 DOM 节点移动到当前旧的一组子节点的头部节点所对应的真实 DOM 节点之前。具体实现如下:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode.key === newStartVNode.key) {
// 省略部分代码
} else if (oldEndVNode.key === newEndVNode.key) {
// 省略部分代码
} else if (oldStartVNode.key === newEndVNode.key) {
// 省略部分代码
} else if (oldEndVNode.key === newStartVNode.key) {
// 省略部分代码
} else {
// 遍历旧的一组子节点,试图寻找与 newStartVNode 拥有相同 key 值的节点
const idxInOld = oldChildren.findIndex(
node => node.key === newStartVNode.key
)
// idxInOld 大于 0,说明找到了可复用的节点,并且需要将其对应的真实 DOM 移动到头部
if (idxInOld > 0) {
// idxInOld 位置对应的 vnode 就是需要移动的节点
const vnodeToMove = oldChildren[idxInOld]
// 不要忘记除移动操作外还应该打补丁
patch(vnodeToMove, newStartVNode, container)
// 将 vnodeToMove.el 移动到头部节点 oldStartVNode.el 之前,因此使用后者作为锚点
insert(vnodeToMove.el, container, oldStartVNode.el)
// 由于位置 idxInOld 处的节点所对应的真实 DOM 已经移动到了别处,因此将其设置为 undefined
oldChildren[idxInOld] = undefined
// 最后更新 newStartIdx 到下一个位置
newStartVNode = newChildren[++newStartIdx]
}
}
}
当判断到旧子节点为 undefined 时,说明该节点已经被处理过了,因此不需要再处理它了,直接跳过即可。
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 增加两个判断分支,如果头尾部节点为 undefined ,则说明该节点已经被处理过了,直接跳到下一个位置
if (!oldStartVNode) {
oldStartVNode = oldChildren[++oldStartIdx]
} else if (!oldEndVNode) {
oldEndVNode = oldChildren[--oldEndIdx]
} else if (oldStartVNode.key === newStartVNode.key) {
// 省略部分代码
} else if (oldEndVNode.key === newEndVNode.key) {
// 省略部分代码
} else if (oldStartVNode.key === newEndVNode.key) {
// 省略部分代码
} else if (oldEndVNode.key === newStartVNode.key) {
// 省略部分代码
} else {
const idxInOld = oldChildren.findIndex(
node => node.key === newStartVNode.key
)
if (idxInOld > 0) {
const vnodeToMove = oldChildren[idxInOld]
patch(vnodeToMove, newStartVNode, container)
insert(vnodeToMove.el, container, oldStartVNode.el)
oldChildren[idxInOld] = undefined
newStartVNode = newChildren[++newStartIdx]
}
}
}
添加新元素在上一节中,我们讲解了非理想情况的处理,即在一轮比较过程中,不会命中四个步骤中的任何一步。这时,我们会拿新的一组子节点中的头部节点去旧的一组子节点中寻找可复用的节点,然而并非总能找得到。这说明这个节点是一个新增节点,我们应该将它挂载到正确的位置。那么应该挂载到哪里呢?很简单,因为节点是新的一组子节点中的头部节点,所以只需要将它挂载到当前头部节点之前即可。“当前”头部节点指的是,旧的一组子节点中的头部节点所对应的真实 DOM 节点。下面是用来完成挂载操作的代码:
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 增加两个判断分支,如果头尾部节点为 undefined ,则说明该节点已经被处理过了,直接跳到下一个位置
if (!oldStartVNode) {
oldStartVNode = oldChildren[++oldStartIdx]
} else if (!oldEndVNode) {
oldEndVNode = oldChildren[--oldEndIdx]
} else if (oldStartVNode.key === newStartVNode.key) {
// 省略部分代码
} else if (oldEndVNode.key === newEndVNode.key) {
// 省略部分代码
} else if (oldStartVNode.key === newEndVNode.key) {
// 省略部分代码
} else if (oldEndVNode.key === newStartVNode.key) {
// 省略部分代码
} else {
const idxInOld = oldChildren.findIndex(
node => node.key === newStartVNode.key
)
if (idxInOld > 0) {
const vnodeToMove = oldChildren[idxInOld]
patch(vnodeToMove, newStartVNode, container)
insert(vnodeToMove.el, container, oldStartVNode.el)
oldChildren[idxInOld] = undefined
} else {
// 将 newStartVNode 作为新节点挂载到头部,使用当前头部节点 oldStartVNode.el 作为锚点
patch(null, newStartVNode, container, oldStartVNode.el)
}
newStartVNode = newChildren[++newStartIdx]
}
}
上面的代码存在缺陷,当旧节点更新完后,存在新节点被遗漏的情况。为了弥补这个缺陷,需要添加额外的处理代码
while (oldSartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 省略部分代码
}
// 循环结束后检查索引值的情况
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
// 如果满足条件,则说明有新的节点遗留,需要挂载它们
for (let i = newStartIdx; i <= newEndIdx; i++) {
const anchor = newChildren[newEndIdx + 1] ? newChildren[newEndIdx + 1].el : null
patch(null, newChildren[i], container, anchor)
}
}
挂载时的锚点仍让使用当前的头部节点 oldStartVNode.el ,这样就完成了对新增元素的处理。
移除不存在的元素while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 省略部分代码
}
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
// 添加新节点
// 省略部分代码
} else if (newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx) {
// 移除操作
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
unmount(oldChildren[i])
}
}
快速 Diff 算法
相同的前置元素和后置元素不同于简单 Diff 算法和双端 Diff 算法,快速 Diff 算法包含预处理步骤,这其实是借鉴了纯文本 Diff 算法的思路。在纯文本 Diff 算法中,存在对两段文本进行预处理的过程。例如,在对两段文本进行 Diff 之前,可以先对它们进行全等比较:
if (text1 === text2) return
这也称为快捷路径。如果两段文本全等,那么就无须进入核心 Diff 算法的步骤了。除此之外,预处理过程还会处理两段文本相同的前缀和后缀。假设有如下两段文本:
TEXT1: I use vue for app development
TEXT2: I use react for app development
通过肉眼可以很容易发现,这两段文本的头部和尾部分别有一段相同的内容。对于 TEXT1 和 TEXT2 来说,真正需要进行 Diff 操作的部分是:
TEXT1: vue
TEXT2: react
这实际上是简化问题的一种方式。这么做的好处是,在特定情况下我们能够轻松地判断文本的插入和删除,例如:
TEXT1: I like you
TEXT2: I like you too
经过预处理,去掉这两段文本中相同的前缀内容和后缀内容之后,它将变成:
TEXT1:
TEXT2: too
可以看到,经过预处理后, TEXT1 的内容为空。这说明 TEXT2 在 TEXT1 的基础上增加了字符串 too 。相反,我们还可以将这两段文本的位置互换:
TEXT1: I like you too
TEXT2: I like you
这两段文本经过预处理后将变成:
TEXT1: too
TEXT2:
由此可知, TEXT2 是在 TEXT1 的基础上删除了字符串 too 。
快速 Diff 算法借鉴了纯文本 Diff 算法中预处理的步骤。以图 11-3 给出的两组子节点为例。这两组子节点的顺序如下:
旧的一组子节点:p-1、p-2、p-3。新的一组子节点:p-1、p-4、p-2、p-3。
通过观察可以发现,两组子节点具有相同的前置节点 p-1 ,以及相同的后置节点 p-2 和 p-3 ,如图 11-4 所示。对于相同的前置节点和后置节点,由于它们在新旧两组子节点中的相对位置不变,所以我们无须移动它们,但仍然需要在它们之间打补丁。对于前置节点,我们可以建立索引 j ,其初始值为 0 ,用来指向两组子节点的开头,如图 11-5 所示。然后开启一个 while 循环,让索引 j 递增,直到遇到不相同的节点为止,如下面 patchKeyedChildren 函数的代码所示:
function patchKeyedChildren (n1, n2, container) {
const newChildren = n2.children
const oldChildren = n1.children
// 处理相同的前置节点
// 索引 j 指向新旧两组子节点的开头
let j = 0
let oldVNode = oldChildren[j]
let newVNode = newChildren[j]
// while 循环向后遍历,直到遇到拥有不同 key 值的节点为止
while (oldVNode.key === newVNode.key) {
// 调用 patch 函数进行更新
patch(oldVNode, newVNode, container)
// 更新索引 j ,让其递增
j++
oldVNode = oldChildren[j]
newVNode = newChildren[j]
}
}
在上面这段代码中,我们使用 while 循环查找所有相同的前置节点,并调用 patch 函数进行打补丁,直到遇到 key 值不同的节点为止。这样,我们就完成了对前置节点的更新。在这一步更新操作过后,新旧两组子节点的状态如图 11-6 所示。这里需要注意的是,当 while 循环终止时,索引 j 的值为 1 。接下来,我们需要处理相同的后置节点。由于新旧两组子节点的数量可能不同,所以我们需要两个索引 newEnd 和 oldEnd ,分别指向新旧两组子节点中的最后一个节点,如图 11-7 所示。然后,再开启一个 while 循环,并从后向前遍历这两组子节点,直到遇到 key 值不同的节点为止,如下面的代码所示:
function patchKeyedChildren(n1, n2, container) {
const newChildren = n2.children
const oldChildren = n1.children
// 更新相同的前置节点
let j = 0
let oldVNode = oldChildren[i]
let newVNode = newChildren[j]
while (oldVNode.key === newVNode.key) {
patch(oldVNode, newVNode, container)
j++
oldVNode = oldChildren[j]
newVNode = newChildren[j]
}
// 更新相同的后置节点
// 索引 oldEnd 指向旧的一组子节点的最后一个节点
let oldEnd = oldChildren.length - 1
// 索引 newEnd 指向新的一组子节点的最后一个节点
let newEnd = newChildren.length - 1
oldVNode = oldChildren[oldEnd]
newVNode = newChildren[newEnd]
// while 循环从后向前遍历,直到遇到拥有不同 key 值的节点为止
while (oldVNode.key === newVNode.key) {
// 调用 patch 函数进行更新
patch(oldVNode, newVNode, container)
// 递减 oldEnd 和 nextEnd
oldEnd--
newEnd--
oldVNode = oldChildren[oldEnd]
newVNode = newChildren[newEnd]
}
}
与处理相同的前置节点一样,在 while 循环内,需要调用 patch 函数进行打补丁,然后递减两个索引 oldEnd 、newEnd 的值。在这一步更新操作过后,新旧两组子节点的状态如图 11-8 所示。由图 11-8 可知,当相同的前置节点和后置节点被处理完毕后,旧的一组子节点已经全部被处理了,而在新的一组子节点中,还遗留了一个未被处理的节点 p-4 。其实不难发现,节点 p-4 是一个新增节点。那么,如何用程序得出 “节点 p-4 是新增节点” 这个结论呢?这需要我们观察三个索引 j 、newEnd 和 oldEnd 之间的关系。
条件一 oldEnd < j 成立:说明在预处理过程中,所有旧子节点都处理完毕了。条件二 newEnd >= j 成立:说明在预处理过后,在新的一组子节点中,仍然有未被处理的节点,而这些遗留的节点将被视作新增节点。
如果条件一和条件二同时成立,说明在新的一组子节点中,存在遗留节点,且这些节点都是新增节点。因此我们需要将它们挂载到正确的位置。
在新的一组子节点中,索引值处于 j 和 newEnd 之间的任何节点都需要作为新的子节点进行挂载。那么,应该怎样将这些节点挂载到正确位置呢?这就要求我们必须找到正确的锚点元素。
function patchKeyedChildren(n1, n2, container) {
const newChildren = n2.children
const oldChildren = n1.children
// 更新相同的前置节点
// 省略部分代码
// 更新相同的后置节点
// 省略部分代码
// 预处理完毕后,如果满足如下条件,则说明从 j --> newEnd 之间的节点应作为新节点插入
if (j > oldEnd && j <= newEnd) {
// 锚点的索引
const anchorIndex = newEnd + 1
// 锚点元素
const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
// 采用 while 循环,调用 patch 函数逐个挂载新增节点
while (j <= newEnd) {
patch(null, newChildren[j++], container, anchor)
}
}
}
在上面这段代码中,首先计算锚点的索引值(即 anchorIndex)为 newEnd + 1 。如果小于新的一组子节点的数量,则说明锚点元素在新的一组子节点中,所以直接使用 newChildren[anchorIndex].el 作为锚点元素;否则说明索引 newEnd 对应的节点已经是尾部节点了,这时无须提供锚点元素。有了锚点元素之后,我们开启了一个 while 循环,用来遍历索引 j 和索引 newEnd 之间的节点,并调用 patch 函数挂载它们。
再来看看删除节点的情况。
function patchKeyedChildren(n1, n2, container) {
const newChildren = n2.children
const oldChildren = n1.children
// 更新相同的前置节点
// 省略部分代码
// 更新相同的后置节点
// 省略部分代码
if (j > oldEnd && j <= newEnd) {
// 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
// j -> oldEnd 之间的节点应该被卸载
while (j <= oldEnd) {
unmount(oldChildren[j++])
}
}
}
判断是否需要进行 DOM 移动操作上一节给出的例子比较理想化,当处理完相同的前置节点或后置节点后,新旧两组子节点中总会有一组子节点全部被处理完毕。在这种情况下,只需要简单地挂载、卸载节点即可。但有时情况会比较复杂,如图 11-15 中给出的例子。可以看到,与旧的一组子节点相比,新的一组子节点多出了一个新节点 p-7 ,少了一个节点 p-6 。这个例子并不像上一节给出的例子那样理想化,我们无法简单地通过预处理过程完成更新。在这个例子中,相同的前置节点只有 p-1 ,而相同的后置节点只有 p-5 。
经过预处理后,无论是新的一组子节点,还是旧的一组子节点,都有部分节点未经处理。这时就需要我们进一步处理。怎么处理呢?其实无论是简单 Diff 算法,还是双端 Diff 算法,抑或本章介绍的快速 Diff 算法,它们都遵循同样的处理规则:
判断是否有节点需要移动,以及应该如何移动;找出那些需要被添加或移除的节点。
所以接下来我们的任务就是,判断哪些节点需要移动,以及应该如何移动。在这种非理想的情况下,当相同的前置节点和后置节点被处理完毕后,索引 j 、newEnd 和 oldEnd 不满足下面两个条件中的任何一个:
j > oldEnd && j <= newEndj > newEnd && j <= oldEnd
因此我们需要增加新的 else 分支来处理这种情况,如下面的代码所示:
function patchKeyedChildren(n1, n2, container) {
const newChildren = n2.children
const oldChildren = n1.children
// 更新相同的前置节点
// 省略部分代码
// 更新相同的后置节点
// 省略部分代码
if (j > oldEnd && j <= newEnd) {
// 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
// 省略部分代码
} else {
// 增加 else 分支来处理非理想情况
}
}
接下来我们讲解具体的处理思路。首先,我们需要构造一个数组 source ,它的长度等于新的一组子节点在经过预处理之后剩余未处理节点的数量,并且 source 中每个元素的初始值都是 -1 。我们可以通过下面的代码完成 source 数组的构造:
if (j > oldEnd && j <= newEnd) {
// 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
// 省略部分代码
} else {
// 构造 source 数组
// 新的一组子节点中剩余未处理节点的数量
const count = newEnd - j + 1
const source = new Array(count)
source.fill(-1)
}
数组 source 中的每一个元素分别于新的一组子节点中剩余未处理节点对应。实际上, source 数组将用来存储新的一组子节点中的节点在旧的一组子节点中的位置索引,后面将会使用它计算出一个最长递增子序列,并用于辅助完成 DOM 移动的操作。
我们可以通过两层 for 循环来完成 source 数组的填充工作,外层循环用于遍历旧的一组子节点,内层循环用于遍历新的一组子节点:
if (j > oldEnd && j <= newEnd) {
// 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
// 省略部分代码
} else {
const count = newEnd - j + 1
const source = new Array(count)
source.fill(-1)
// oldStart 和 newStart 分别为起始索引,即 j
const oldStart = j
const newStart = j
// 遍历旧的一组子节点
for (let i = oldStart; i <= oldEnd; i++) {
const oldVNode = oldChildren[i]
// 遍历新的一组子节点
for (let k = newStart; k <= newEnd; k++) {
const newVNode = newChildren[k]
// 找到拥有相同 key 值得可复用节点
if (oldVNode.key === newVNode.key) {
// 调用 patch 进行更新
patch(oldVNode, newVNode, container)
// 最后填充 source 数组
source[k - newStart] = i
}
}
}
}
这段代码中我们采用了两层嵌套的循环,其时间复杂度为 O(n1 * n2) ,其中 n1 和 n2 为新旧两组子节点的数量,我们也可以使用 O(n^2) 来表示。当新旧两组子节点的数量较多时,两层嵌套的循环会带来性能问题。出于优化的目的,我们可以为新的一组子节点构建一张索引表,用来存储节点的 key 和节点位置索引之间的映射。有了索引表,我们就可以利用它快速地填充 source 数组,如下面的代码所示:
if (j > oldEnd && j <= newEnd) {
// 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
// 省略部分代码
} else {
const count = newEnd - j + 1
const source = new Array(count)
source.fill(-1)
// oldStart 和 newStart 分别为起始索引,即 j
const oldStart = j
const newStart = j
// 构建索引表
const keyIndex = {}
for (let i = newStart; i <= newEnd; i++) {
keyIndex[newChildren[i].key] = i
}
// 遍历旧的一组子节点中剩余未处理的节点
for (let i = oldStart; i <= oldEnd; i++) {
oldVNode = oldChildren[i]
// 通过索引表快速找到新的一组子节点中具有相同 key 值得节点位置
const k = keyIndex[oldVNode.key]
if (typeof k !== 'undefined') {
newVNode = newChildren[k]
// 调用 patch 函数完成更新
patch(oldVNode, newVNode, container)
// 填充 source 数组
source[k - newStart] = i
} else {
// 没找到
unmount(oldVNode)
}
}
}
接下来我们应该思考的是,如何判断节点是否需要移动。实际上,快速 DIff 算法判断节点是否需要移动的方法与简单 Diff 算法类似,如下面的代码所示:
if (j > oldEnd && j <= newEnd) {
// 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
// 省略部分代码
} else {
// 构造 source 数组
const count = newEnd - j + 1 // 新的一组子节点中剩余未处理节点的数量
const source = new Array(count)
source.fill(-1)
const oldStart = j
const newStart = j
// 新增两个变量, moved 和 pos
let moved = false
let pos = 0
const keyIndex = {}
for (let i = newStart; i <= newEnd; i++) {
keyIndex[newChildren[i].key] = i
}
for (let i = oldStart; i <= oldEnd; i++) {
oldVNode = oldChildren[i]
const k = keyIndex[oldVNode.key]
if (typeof k !== 'undefined') {
newVNode = newChildren[k]
patch(oldVNode, newVNode, container)
source[k - newStart] = i
// 判断节点是否需要移动
if (k < pos) {
moved = true
} else {
pos = k
}
} else {
unmount(oldVNode)
}
}
}
在上面的这段代码中,我们新增了两个变量 moved 和 pos 。前者的初始值为 false ,代表是否需要移动节点,后者的初始值为 0 ,代表遍历旧的一组子节点的过程中遇到的最大索引值 k 。我们在讲解简单 Diff 算法时曾提到,如果在遍历过程中遇到的索引值呈现递增趋势,则说明不需要移动节点,反之则需要。所以在第二个 for 循环,我们通过比较变量 k 与变量 pos 的值来判断是否需要移动节点。除此之外,我们还需要一个数量标识,代表已经更新过的节点数量。我们知道,已经更新过的节点数量应该小于新的节点数量应该小于新的一组子节点中需要更新的节点数量。一旦前者超过后者,则说明有多余的节点,我们应该将它们卸载,如下面的代码所示:
if (j > oldEnd && j <= newEnd) {
// 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
// 省略部分代码
} else {
// 构造 source 数组
const count = newEnd - j + 1
const source = new Array(count)
source.fill(-1)
const oldStart = j
const newStart = j
let moved = false
let pos = 0
const keyIndex = {}
for (let i = newStart; i <= newEnd; i++) {
keyIndex[newChildren[i].key] = i
}
// 新增 patched 变量,代表更新过的节点数量
let patched = 0
for (let i = oldStart; i <= oldEnd; i++) {
oldVNode = oldChildren[i]
// 如果更新过的节点数量小于等于需要更新的节点数量,则执行更新
if (patched <= count) {
const k = keyIndex[oldVNode.key]
if (typeof k !== 'undefined') {
newVNode = newChildren[k]
patch(oldVNode, neVNode, container)
// 每更新一个节点,都将 patched 变量 + 1
patched++
source[k - newStart] = i
if (k < pos) {
moved = true
} else {
pos = k
}
} else {
// 没找到
unmount(oldVNode)
}
} else {
// 如果更新过的节点数量大于需要更新的节点数量,则卸载多余的节点
unmount(oldVNode)
}
}
}
在上面这段代码中,我们增加了 patched 变量,其初始值为 0 ,代表更新过的节点数量。接着,在第二个 for 循环中增加了判断 patched <= count,如果此条件成立,则正常执行更新,并且每次更新后都让变量 patched 自增;否则说明剩余的节点都是多余的,于是调用 unmount 函数将它们卸载。
如何移动元素if (j > oldEnd && j <= newEnd) {
// 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
// 省略部分代码
} else {
// 省略部分代码
for (let i = oldStart; i <= oldEnd; i++) {
// 省略部分代码
}
if (moved) {
// 如果 moved 为真,则需要进行 DOM 移动操作
}
}
在上面这段代码中,我们在 for 循环后增加了一个 if 判断分支。如果变量 moved 的值为 true ,则说明需要进行 DOM 移动操作,所以用于 DOM 移动操作的逻辑将编写在该 if 语句块内。
为了计算 DOM 移动操作,我们首先要根据 source 数组计算出它的最长递增子序列。首先,我们要搞清楚什么是一个序列的递增子序列。简单来说,给定一个数值序列,找到它的一个子序列,并且该子序列中的值是递增的,子序列中的元素在原序列中不一定连续。一个序列可能有很多个