2023-01-05
Vue
00

目录

前言
1. 新旧元素相同时 ELEMENT 节点的更新操作
1.1 源码阅读
1.2 代码实现
2. 新旧节点不同元素时,ELEMENT 节点的更新操作
2.1 源码阅读
2.2 代码实现
3. 删除元素,ELEMENT 节点的卸载操作

前言

再上一章中,我们完成了 renderer 的基础架构,完成了 ELEMENT 节点的挂载并且导出了可用的 render 函数。

我们知道对于 render 而言,除了有 挂载 操作之外,还存在 更新和删除 的操作。

那么在本章就让我们一起来实现一下它们吧。

1. 新旧元素相同时 ELEMENT 节点的更新操作

所谓更新操作指的是:生成一个新的虚拟 DOM 树,运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM 上去

我们来看下面的代码:

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>

以上代码执行了两遍 `render 操作,第一遍是挂载操作,第二遍是更新操作。

1.1 源码阅读

我们知道每次的 render 渲染 ELEMENT,其实都会触发 processElement,所以我们可以直接在 processElement 中增加断点,进入 debugger

  1. 第一次触发 processElement挂载 操作,可以直接 跳过
  2. 第二次触发 processElement更新操作,我们直接进入 processElement

image.png

此时的 n1(旧值)和 n2(新值)分别为:

image.png

  1. 我们进入 patchElement 执行更新操作:

image.png

  1. 执行 const el = (n2.el = n1.el!)。使 新旧 vnode 指向 同一个 el 元素。继续执行 patchElement

image.png

  1. 接着会执行 patchChildren(...) 方法,表示 为子节点打补丁。我们进入 patchChildren 方法:

image.png

  1. patchChildren 方法首先会对 c1c2 进行赋值,此时 c1 为 旧节点的 childrenc2新节点的 children。我们继续执行 patchChildren,跳过没用的 if,来到第 1648 行:

image.png

  1. 由上图可知会触发了 hostSetElementText。我们知道 hostSetElementText 其实是一个 设置 text 的方法。那么此时 patchChildren 执行完成。 text 内容更新完成,浏览器展示的 text 会发生变化。返回 patchElement 继续执行:

image.png

  1. 程序会执行 patchProps(....) 方法,表示 props 打补丁,我们进入 patchProps

image.png

  1. 查看代码可以发现代码执行了两次 for 循环操作:

    1. 第一次循环执行 for in newProps,执行 hostPatchProp 方法设置新的 props

    2. 第二次循环执行 for in oldProps,执行 hostPatchProp,配合 !(key in newProps) 判断,删除 没有被指定的旧属性 ,比如:

// 原属性: { class: 'test', id: 'test-id' } // 新属性: { class: 'active' }

删除 id

至此 props 更新完成

  1. 至此,更替更新完成

总结:

由以上代码可知:

  1. 无论是 挂载 还是 更新 都会触发 processElement 方法,状态根据 oldValue 进行判定
  2. Element 的更新操作有可能 会在同一个 el 中完成。(注意: 仅限元素没有发生变化时,如果新旧元素不同,那么是另外的情况。)
  3. 更新操作分为:
    1. children 更新
    2. props 更新

1.2 代码实现

根据以上逻辑,我们可以直接为 processElement 方法,新增对应的 else 逻辑:

  1. packages/runtime-core/src/renderer.ts 中,为 processElement 增加新的判断:
ts
/** * Element 的打补丁操作 */ const processElement = (oldVNode, newVNode, container, anchor) => { if (oldVNode == null) { // 挂载操作 mountElement(newVNode, container, anchor) } else { // 更新操作 patchElement(oldVNode, newVNode) } }
  1. 创建 patchElement 方法:
ts
/** * 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) }
  1. 创建 patchChildren 方法:
ts
/** * 为子节点打补丁 */ 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: 单独挂载新子节点操作 } } } }
  1. 创建 patchProps 方法:
html
/** * 为 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 源码阅读

我们从下面代码开始阅读源码:

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>
  1. 等待第二次进入 render:

  1. vnode 存在,执行 patch 方法:

image.png

  1. 可以看到这里直接执行了 unmount 卸载方法,我们进入 unmount

  2. unmount 方法中,虽然代码很多,但是大多数代码都没有执行。最终会执行到 remove(vnode) ,表示删除 vnode

image.png

  1. 进入 remove 方法,同样大多数代码没有执行,直接到 performRemove(),执行 hostRemove(el!),进入 hostRemove,触发的是 nodeOps 中的 remove 方法,代码为 parent.removeChild(child):

image.png

  1. 至此 el 被删除

  2. 然后将 n1 = null

image.png

  1. 此时,进入 switch,触发 processElement

image.png

  1. 因为 n1 === null,所以会触发 mountElement 挂载新节点 操作

image.png

总结: 由以上代码可知:

  1. 当节点元素不同时,更新操作执行的其实是:先删除、后挂载 的逻辑
  2. 删除元素的代码从 unmount 开始,虽然逻辑很多,但是最终其实是触发了 nodeOps下的remove方法,通过parent.removeChild(child)` 完成的删除操作。

2.2 代码实现

  1. packages/runtime-core/src/renderer.tspatch 方法中增加 type 判断:
ts
/** * 判断是否为相同类型节点 */ if (oldVNode && !isSameVNodeType(oldVNode, newVNode)) { unmount(oldVNode) oldVNode = null }
  1. packages/runtime-core/src/vnode.ts 中,创建 isSameVNodeType 方法:
ts
/** * 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 }
  1. packages/runtime-core/src/renderer.ts 实现 unmount 方法:
export interface RendererOptions { /** * 卸载指定dom */ remove(el): void } /** * 解构 options,获取所有的兼容性方法 */ const { ...remove } = options const unmount = vnode => { hostRemove(vnode.el!) }
  1. packages/runtime-dom/src/nodeOps.ts 中,实现 remove 方法:
ts
/** * 删除指定元素 */ 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 函数,即可卸载元素。

那么接下来我们就可以基于这样的函数来去实现 卸载 操作了。

这块代码比较简单,我们直接实现即可:

  1. packages/runtime-core/src/renderer.ts 中为 render 函数补充卸载逻辑:
ts
const render = (vnode, container) => { if (vnode == null) { // TODO: 卸载 if (container._vnode) { unmount(container._vnode) } } else { // 打补丁(包括了挂载和更新) patch(container._vnode || null, vnode, container) } container._vnode = vnode }
  1. 创建如下测试实例 packages/vue/examples/runtime/render-element-remove.html
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>

测试实例,卸载成功。

如果对你有用的话,可以打赏哦
打赏
ali pay
wechat pay

本文作者:叶继伟

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!