highlight: vs2015
前言
原文来自 我的个人博客
再上一章中,我们完成了 renderer 的基础架构,完成了 ELEMENT 节点的挂载并且导出了可用的 render 函数。
我们知道对于 render
而言,除了有 挂载 操作之外,还存在 更新和删除 的操作。
那么在本章就让我们一起来实现一下它们吧。
1. 新旧元素相同时 ELEMENT 节点的更新操作
所谓更新操作指的是:生成一个新的虚拟 DOM
树,运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM
上去。
我们来看下面的代码:
<script>
const { h, render } = Vue
const vnode = h(
'div',
{
class: 'test'
},
'hello render'
)
// 挂载
render(vnode, document.querySelector('#app'))
// 延迟两秒,生成新的 vnode,进行更新操作
setTimeout(() => {
const vnode2 = h(
'div',
{
class: 'active'
},
'update'
)
render(vnode2, document.querySelector('#app'))
}, 2000)
</script>
以上代码执行了两遍 render
操作,第一遍是挂载操作,第二遍是更新操作。
1.1 源码阅读
我们知道每次的 render
渲染 ELEMENT
,其实都会触发 processElement
,所以我们可以直接在 processElement
中增加断点,进入 debugger
:
- 第一次触发
processElement
为 挂载 操作,可以直接 跳过 - 第二次触发
processElement
为 更新操作,我们直接进入processElement
:
此时的 n1
(旧值)和 n2
(新值)分别为:
- 我们进入
patchElement
执行更新操作:
- 执行
const el = (n2.el = n1.el!)
。使 新旧vnode
指向 同一个el
元素。继续执行patchElement
:
- 接着会执行
patchChildren(...)
方法,表示 为子节点打补丁。我们进入patchChildren
方法:
patchChildren
方法首先会对c1
、c2
进行赋值,此时c1
为 旧节点的children
,c2
为 新节点的children
。我们继续执行patchChildren
,跳过没用的if
,来到第1648
行:
- 由上图可知会触发了
hostSetElementText
。我们知道hostSetElementText
其实是一个 设置text
的方法。那么此时patchChildren
执行完成。text
内容更新完成,浏览器展示的text
会发生变化。返回patchElement
继续执行:
- 程序会执行
patchProps(....)
方法,表示 为props
打补丁,我们进入patchProps
查看代码可以发现代码执行了两次
for
循环操作:- 第一次循环执行
for in newProps
,执行hostPatchProp
方法设置新的props
- 第二次循环执行
for in oldProps
,执行hostPatchProp
,配合!(key in newProps)
判断,删除 没有被指定的旧属性 ,比如:
- 第一次循环执行
// 原属性:
{
class: 'test',
id: 'test-id'
}
// 新属性:
{
class: 'active'
}
则 删除 id
- 至此
props
更新完成 - 至此,更替更新完成
总结:
由以上代码可知:
- 无论是 挂载 还是 更新 都会触发
processElement
方法,状态根据oldValue
进行判定 Element
的更新操作有可能 会在同一个el
中完成。(注意: 仅限元素没有发生变化时,如果新旧元素不同,那么是另外的情况。)更新操作分为:
children
更新props
更新
1.2 代码实现
根据以上逻辑,我们可以直接为 processElement
方法,新增对应的 else
逻辑:
- 在
packages/runtime-core/src/renderer.ts
中,为processElement
增加新的判断:
/**
* Element 的打补丁操作
*/
const processElement = (oldVNode, newVNode, container, anchor) => {
if (oldVNode == null) {
// 挂载操作
mountElement(newVNode, container, anchor)
} else {
// 更新操作
patchElement(oldVNode, newVNode)
}
}
- 创建
patchElement
方法:
/**
* element 的更新操作
*/
const patchElement = (oldVNode, newVNode) => {
// 获取指定的 el
const el = (newVNode.el = oldVNode.el!)
// 新旧 props
const oldProps = oldVNode.props || EMPTY_OBJ
const newProps = newVNode.props || EMPTY_OBJ
// 更新子节点
patchChildren(oldVNode, newVNode, el, null)
// 更新 props
patchProps(el, newVNode, oldProps, newProps)
}
- 创建
patchChildren
方法:
/**
* 为子节点打补丁
*/
const patchChildren = (oldVNode, newVNode, container, anchor) => {
// 旧节点的 children
const c1 = oldVNode && oldVNode.children
// 旧节点的 prevShapeFlag
const prevShapeFlag = oldVNode ? oldVNode.shapeFlag : 0
// 新节点的 children
const c2 = newVNode.children
// 新节点的 shapeFlag
const { shapeFlag } = newVNode
// 新子节点为 TEXT_CHILDREN
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 旧子节点为 ARRAY_CHILDREN
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// TODO: 卸载旧子节点
}
// 新旧子节点不同
if (c2 !== c1) {
// 挂载新子节点的文本
hostSetElementText(container, c2 as string)
}
} else {
// 旧子节点为 ARRAY_CHILDREN
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 新子节点也为 ARRAY_CHILDREN
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// TODO: 这里要进行 diff 运算
}
// 新子节点不为 ARRAY_CHILDREN,则直接卸载旧子节点
else {
// TODO: 卸载
}
} else {
// 旧子节点为 TEXT_CHILDREN
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 删除旧的文本
hostSetElementText(container, '')
}
// 新子节点为 ARRAY_CHILDREN
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// TODO: 单独挂载新子节点操作
}
}
}
}
- 创建
patchProps
方法:
/**
* 为 props 打补丁
*/
const patchProps = (el: Element, vnode, oldProps, newProps) => {
// 新旧 props 不相同时才进行处理
if (oldProps !== newProps) {
// 遍历新的 props,依次触发 hostPatchProp ,赋值新属性
for (const key in newProps) {
const next = newProps[key]
const prev = oldProps[key]
if (next !== prev) {
hostPatchProp(el, key, prev, next)
}
}
// 存在旧的 props 时
if (oldProps !== EMPTY_OBJ) {
// 遍历旧的 props,依次触发 hostPatchProp ,删除不存在于新props 中的旧属性
for (const key in oldProps) {
if (!(key in newProps)) {
hostPatchProp(el, key, oldProps[key], null)
}
}
}
}
}
至此,更新操作完成。
创建新的测试实例 packages/vue/examples/runtime/render-element-update.html
:
<script>
const { h, render } = Vue
const vnode = h(
'div',
{
class: 'test'
},
'hello render'
)
// 挂载
render(vnode, document.querySelector('#app'))
// 延迟两秒,生成新的 vnode,进行更新操作
setTimeout(() => {
const vnode2 = h(
'div',
{
class: 'active'
},
'update'
)
render(vnode2, document.querySelector('#app'))
}, 2000)
</script>
测试更新成功。
2. 新旧节点不同元素时,ELEMENT 节点的更新操作
上一小节中,完成了 Element
的更新操作,但是我们之前的更新操作是针对 相同 元素的,在 不同 元素下,ELEMENT
的更新操作会产生什么样的变化呢?
2.1 源码阅读
我们从下面代码开始阅读源码:
<script>
const { h, render } = Vue
const vnode = h('div', {
class: 'test'
}, 'hello render')
// 挂载
render(vnode, document.querySelector('#app'))
// 延迟两秒,生成新的 vnode,进行更新操作
setTimeout(() => {
const vnode2 = h('h1', {
class: 'active'
}, 'update')
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
- 等待第二次进入
render
:
vnode
存在,执行patch
方法:
- 可以看到这里直接执行了
unmount
卸载方法,我们进入unmount
: - 在
unmount
方法中,虽然代码很多,但是大多数代码都没有执行。最终会执行到remove(vnode)
,表示删除vnode
:
- 进入
remove
方法,同样大多数代码没有执行,直接到performRemove()
,执行hostRemove(el!)
,进入hostRemove
,触发的是nodeOps
中的remove
方法,代码为parent.removeChild(child)
:
- 至此
el
被删除 - 然后将
n1 = null
- 此时,进入
switch
,触发processElement
- 因为
n1 === null
,所以会触发mountElement
挂载新节点 操作
总结:
由以上代码可知:
- 当节点元素不同时,更新操作执行的其实是:先删除、后挂载 的逻辑
- 删除元素的代码从
unmount 开始,虽然逻辑很多,但是最终其实是触发了
nodeOps下的
remove方法,通过
parent.removeChild(child)` 完成的删除操作。
2.2 代码实现
- 在
packages/runtime-core/src/renderer.ts
的patch
方法中增加type
判断:
/**
* 判断是否为相同类型节点
*/
if (oldVNode && !isSameVNodeType(oldVNode, newVNode)) {
unmount(oldVNode)
oldVNode = null
}
- 在
packages/runtime-core/src/vnode.ts
中,创建isSameVNodeType
方法:
/**
* VNode
*/
export interface VNode {
key: any
...
}
/**
* 根据 key || type 判断是否为相同类型节点
*/
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
return n1.type === n2.type && n1.key === n2.key
}
- 在
packages/runtime-core/src/renderer.ts
实现unmount
方法:
export interface RendererOptions {
/**
* 卸载指定dom
*/
remove(el): void
}
/**
* 解构 options,获取所有的兼容性方法
*/
const { ...remove } = options
const unmount = vnode => {
hostRemove(vnode.el!)
}
- 在
packages/runtime-dom/src/nodeOps.ts
中,实现remove
方法:
/**
* 删除指定元素
*/
remove: child => {
const parent = child.parentNode
if (parent) {
parent.removeChild(child)
}
}
此时代码完成。
创建对应测试实例 packages/vue/examples/runtime/render-element-update-2.html
:
<script>
const { h, render } = Vue
const vnode = h(
'div',
{
class: 'test'
},
'hello render'
)
// 挂载
render(vnode, document.querySelector('#app'))
// 延迟两秒,生成新的 vnode,进行更新操作
setTimeout(() => {
const vnode2 = h(
'h1',
{
class: 'active'
},
'update'
)
render(vnode2, document.querySelector('#app'))
}, 2000)
</script>
测试成功
3. 删除元素,ELEMENT 节点的卸载操作
此时我们已经有了 unmount
函数,我们知道触发 unmount
函数,即可卸载元素。
那么接下来我们就可以基于这样的函数来去实现 卸载 操作了。
这块代码比较简单,我们直接实现即可:
- 在
packages/runtime-core/src/renderer.ts
中为render
函数补充卸载逻辑:
const render = (vnode, container) => {
if (vnode == null) {
// TODO: 卸载
if (container._vnode) {
unmount(container._vnode)
}
} else {
// 打补丁(包括了挂载和更新)
patch(container._vnode || null, vnode, container)
}
container._vnode = vnode
}
- 创建如下测试实例
packages/vue/examples/imooc/runtime/render-element-remove.html
:
<script>
const { h, render } = Vue
const vnode = h(
'div',
{
class: 'test'
},
'hello render'
)
// 挂载
render(vnode, document.querySelector('#app'))
// 延迟两秒,执行卸载操作
setTimeout(() => {
render(null, document.querySelector('#app'))
}, 2000)
</script>
测试实例,卸载成功。