Skip to content

第九章:runtime 运行时 - 构建 renderer 渲染器

01:前言

从本章开始我们将要在 VNode 的基础上构建 renderer 渲染器。

根据上一章中的描述我们知道,在 packages/runtime-core/src/renderer.ts 中存放渲染器相关的内容。

Vue 提供了一个 baseCreateRenderer 的函数,该函数会返回一个对象,我们把返回的这个对象叫做 renderer 渲染器

对于该对象而言,提供了三个方法:

  1. render:渲染函数
  2. hydrate:服务端渲染相关
  3. createApp:初始化方法

查看 baseCreateRenderer 的代码,我们也可以发现整个 baseCreateRenderer 中包含了 2000 行代码,可见整个渲染器是非常复杂的。

所以说,对于我们的实现而言,还是和之前一样,我们将谨遵 没有使用就当做不存在最少代码的实现逻辑 这两个核心思想,来构建整个 render 的过程。

在实现 render 的过程中,我们也会和学习 h 函数时一样,利用 h 函数时的测试实例,配合上 render 函数来分类型进行依次处理。

那么明确好了这些内容之后,我们下面就来进入 渲染器的世界吧。

02:源码阅读:初见 render 函数,ELEMENT 节点的挂载操作

在上一小节,我们实现过一个这样的测试案例 packages/vue/examples/imooc/runtime/h-element.html

js
<script>
  const { h } = Vue

  const vnode = h('div', {
    class: 'test'
  }, 'hello render')


  console.log(vnode);
</script>

这样我们可以得到一个对应的 vnode,我们可以使用 render 函数来去渲染它。

js
render(vnode, document.querySelector('#app'))

我们可以在 packages/runtime-core/src/renderer.ts 的第 2327 行,增加 debugger

  1. 进入 render 函数

  2. render 函数接收三个参数:

    1. vnode:虚拟节点
    2. container:容器
    3. isSVG:是否是 SVG
  3. 执行 patch(container._vnode || null, vnode, container, null, null, null, isSVG)

    1. 根据我们之前所说,我们知道 patch 表示 更新 节点。这里传递的参数我们主要关注 前三个

    2. container._vnode 表示 旧节点(n1vnode 表示 新节点(n2container 表示 容器

    3. 执行 switchcaseif (shapeFlag & ShapeFlags.ELEMENT)

      1. 我们知道此时 shapeFlag 的值是 9,转为为二进制:

        00000000 00000000 00000000 00001001
      2. ShapeFlags.ELEMENT 的值是 1,转为二进制:

        00000000 00000000 00000000 00000001
      3. 两者执行 按位与 (&) ,得到的二进制结果为:

        00000000 00000000 00000000 00000001 // 十进制 1
      4. if (shapeFlag & ShapeFlags.ELEMENT) === if (1),等同于 if(true)

      5. 所以会进入 if

    4. 触发 processElement 方法:

      1. 进入 processElement 方法

      2. 因为当前为 挂载操作,所以 没有旧节点,即:n1 === null

      3. 触发 mountElement 方法,即 挂载方法

        1. 进入 mountElement 方法

        2. 执行 el = vnode.el = hostCreateElement(....),该方法为创建 Element 的方法

          1. 进入该方法,可以发现该方法指向 packages/runtime-dom/src/nodeOps.ts 中的 createElement 方法
          2. 不知道大家还记不记得,之前我们说过:vue 为了保持兼容性,把所有和浏览器相关的 API 封装到了 runtime-dom
          3. createElement 中的代码非常简单就是通过 document.createElement 方法创建 dom,并返回
        3. 此时 elvnode.el 的值为 createElement 生成的 div 实例

        4. 接下来处理:子节点

        5. 执行 if (shapeFlag & ShapeFlags.TEXT_CHILDREN),同样的 按位与 (&),大家可以自己进行下二进制的转换

        6. 触发 hostSetElementText 方法

          1. 进入该方法,同样指向 packages/runtime-dom/src/nodeOps.ts 文件下的 setElementText 方法
          2. 里面的代码非常简单只有一行 el.textContent = text
        7. 那么至此 **div 已经生成,并且 textContent 存在值 **,如果此时触发 divouterHTML 方法,得到 <div>hello render</div>

        8. 那么此时,我们就 只缺少 class 属性 了,所以接下来将进入 props 的处理

        9. 执行 for 循环,进入 hostPatchProp(...) 方法,此时 key = classprops = {class: 'test'}

          1. 进入 hostPatchProp(...) 方法,

          2. 该方法位于 /packages/runtime-dom/src/patchProp.ts 下的 patchProp 方法

          3. 此时 key === class,所以将触发 patchClass

            1. 进入 patchClass,我们可以看到它内部的代码也比较简单,主要分成了三种情况进行处理:

              js
              //  value 此时的值为 test(即:类名)
              if (value == null) {
                el.removeAttribute('class')
              } else if (isSVG) {
                el.setAttribute('class', value)
              } else {
                el.className = value
              }
            2. 完成 class 设定

        10. 当执行完成 hostPatchProp 之后, 如果此时触发 divouterHTML 方法,得到 <div class="test">hello render</div>

        11. 现在 dom 已经构建好了,最后就只剩下 挂载 操作了

        12. 继续执行代码将进入 hostInsert(el, container, anchor) 方法:

          1. 进入 hostInsert 方法
          2. 该方法位于 packages/runtime-dom/src/modulesinsert 方法
          3. 内部同样只有一行代码:parent.insertBefore(child, anchor || null)
          4. 我们知道 insertBefore 方法可以插入到 dom 到指定区域
        13. 那么到这里,我们已经成功的把 div 插入到了 dom 树中,执行完成 hostInsert 方法之后,浏览器会出现对应的 div

      4. 至此,整个 patchElement 执行完成

  4. 执行 container._vnode = vnode,为 旧节点赋值

由以上代码可知:

  1. 整个挂载 Element | Text_Children 的过程分为以下步骤
    1. 触发 patch 方法
    2. 根据 shapeFlag 的值,判定触发 processElement 方法
    3. processElement 中,根据 是否存在 旧VNode 来判定触发 挂载 还是 更新 的操作
      1. 挂载中分成了4大步:
        1. 生成 div
        2. 处理 textContent
        3. 处理 propx
        4. 挂载 dom
    4. 通过 container._vnode = vnode 赋值 旧 VNode

03:框架实现:构建 renderer 基本架构

在上一小节中,我们明确了 render 渲染 Element | Text_Children 的场景,那么接下来我们就可以根据阅读的源码来实现对应的框架渲染器了。

实现渲染器的过程我们将分为两部分:

  1. 搭建出 renderer 的基本架构:我们知道对于 renderer 而言,它内部分为 coredom 两部分,那么这两部分怎么交互,我们都会在基本架构这里处理
  2. 处理具体的 processElement 方法逻辑

那么这一小节,我们就先做第一部分:搭建出 renderer 的基本架构:

整个 基本架构 应该分为 三部分 进行处理:

  1. renderer 渲染器本身,我们需要构建出 baseCreateRenderer 方法
  2. 我们知道所有和 dom 的操作都是与 core 分离的,而和 dom 的操作包含了 两部分
    1. Element 操作:比如 insertcreateElement 等,这些将被放入到 runtime-dom
    2. props 操作:比如 设置类名,这些也将被放入到 runtime-dom

renderer 渲染器本身

  1. 创建 packages/runtime-core/src/renderer.ts 文件:

    js
     import { ShapeFlags } from 'packages/shared/src/shapeFlags'
       import { Fragment } from './vnode'
       
       /**
        * 渲染器配置对象
        */
       export interface RendererOptions {
       	/**
       	 * 为指定 element 的 prop 打补丁
       	 */
       	patchProp(el: Element, key: string, prevValue: any, nextValue: any): void
       	/**
       	 * 为指定的 Element 设置 text
       	 */
       	setElementText(node: Element, text: string): void
       	/**
       	 * 插入指定的 el 到 parent 中,anchor 表示插入的位置,即:锚点
       	 */
       	insert(el, parent: Element, anchor?): void
       	/**
       	 * 创建指定的 Element
       	 */
       	createElement(type: string)
       }
       
       /**
        * 对外暴露的创建渲染器的方法
        */
       export function createRenderer(options: RendererOptions) {
       	return baseCreateRenderer(options)
       }
       
       /**
        * 生成 renderer 渲染器
        * @param options 兼容性操作配置对象
        * @returns
        */
       function baseCreateRenderer(options: RendererOptions): any {
       	/**
       	 * 解构 options,获取所有的兼容性方法
       	 */
       	const {
       		insert: hostInsert,
       		patchProp: hostPatchProp,
       		createElement: hostCreateElement,
       		setElementText: hostSetElementText
       	} = options
       
       	const patch = (oldVNode, newVNode, container, anchor = null) => {
       		if (oldVNode === newVNode) {
       			return
       		}
       
       		const { type, shapeFlag } = newVNode
       		switch (type) {
       			case Text:
       				// TODO: Text
       				break
       			case Comment:
       				// TODO: Comment
       				break
       			case Fragment:
       				// TODO: Fragment
       				break
       			default:
       				if (shapeFlag & ShapeFlags.ELEMENT) {
       					// TODO: Element
       				} else if (shapeFlag & ShapeFlags.COMPONENT) {
       					// TODO: 组件
       				}
       		}
       	}
       
       	/**
       	 * 渲染函数
       	 */
       	const render = (vnode, container) => {
       		if (vnode == null) {
       			// TODO: 卸载
       		} else {
       			// 打补丁(包括了挂载和更新)
       			patch(container._vnode || null, vnode, container)
       		}
       		container._vnode = vnode
       	}
       	return {
       		render
       	}
       }

    这样我们就构建出了渲染器框架本身。

封装 Element 操作

  1. 创建 packages/runtime-dom/src/nodeOps.ts 模块,对外暴露 nodeOps 对象:

    js
       const doc = document
       
       export const nodeOps = {
       	/**
       	 * 插入指定元素到指定位置
       	 */
       	insert: (child, parent, anchor) => {
       		parent.insertBefore(child, anchor || null)
       	},
       
       	/**
       	 * 创建指定 Element
       	 */
       	createElement: (tag): Element => {
       		const el = doc.createElement(tag)
       
       		return el
       	},
       
       	/**
       	 * 为指定的 element 设置 textContent
       	 */
       	setElementText: (el, text) => {
       		el.textContent = text
       	}
       }

封装 props 操作

  1. 创建 packages/runtime-dom/src/patchProp.ts 模块,暴露 patchProp 方法:

    js
       import { isOn } from '@vue/shared'
       import { patchClass } from './modules/class'
       
       /**
        * 为 prop 进行打补丁操作
        */
       export const patchProp = (el, key, prevValue, nextValue) => {
       	if (key === 'class') {
       		patchClass(el, nextValue)
       	} else if (key === 'style') {
       		// TODO: style
       	} else if (isOn(key)) {
       		// TODO: 事件
       	} else {
       		// TODO: 其他属性
       	}
       }
  2. 创建 packages/runtime-dom/src/modules/class.ts 模块,暴露 patchClass 方法:

    js
       /**
        * 为 class 打补丁
        */
       export function patchClass(el: Element, value: string | null) {
       	if (value == null) {
       		el.removeAttribute('class')
       	} else {
       		el.className = value
       	}
       }
  3. packages/shared/src/index.ts 中,写入 isOn 方法:

    js
       const onRE = /^on[^a-z]/
       /**
        * 是否 on 开头
        */
       export const isOn = (key: string) => onRE.test(key)

    三大块 全部完成,标记着整个 renderer 架构设计完成。

04:框架实现:基于 renderer 完成 ELEMENT 节点挂载

根据源码我们知道 Element 的挂载主要依赖于 processElement 方法,所以我们可以直接构建该方法。

  1. packages/runtime-core/src/renderer.ts 中,创建 processElement 方法:

    js
       	/**
       	 * Element 的打补丁操作
       	 */
       	const processElement = (oldVNode, newVNode, container, anchor) => {
       		if (oldVNode == null) {
       			// 挂载操作
       			mountElement(newVNode, container, anchor)
       		} else {
       			// TODO: 更新操作
       		}
       	}
       
       	/**
       	 * element 的挂载操作
       	 */
       	const mountElement = (vnode, container, anchor) => {
       		const { type, props, shapeFlag } = vnode
       
       		// 创建 element
       		const el = (vnode.el = hostCreateElement(type))
       
       		if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
       			// 设置 文本子节点
       			hostSetElementText(el, vnode.children as string)
       		} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
       			// TODO: 设置 Array 子节点
       		}
       
       		// 处理 props
       		if (props) {
       			// 遍历 props 对象
       			for (const key in props) {
       				hostPatchProp(el, key, null, props[key])
       			}
       		}
       
       		// 插入 el 到指定的位置
       		hostInsert(el, container, anchor)
       	}
       
       	const patch = (oldVNode, newVNode, container, anchor = null) => {
       		if (oldVNode === newVNode) {
       			return
       		}
       
       		const { type, shapeFlag } = newVNode
       		switch (type) {
       			case Text:
       				// TODO: Text
       				break
       			case Comment:
       				// TODO: Comment
       				break
       			case Fragment:
       				// TODO: Fragment
       				break
       			default:
       				if (shapeFlag & ShapeFlags.ELEMENT) {
       					processElement(oldVNode, newVNode, container, anchor)
       				} else if (shapeFlag & ShapeFlags.COMPONENT) {
       					// TODO: 组件
       				}
       		}
       	}

根据源码的逻辑,我们在这里主要做了五件事情:

  1. 区分挂载、更新
  2. 创建 Element
  3. 设置 text
  4. 设置 class
  5. 插入 DOM

05:框架实现:合并渲染架构,得到可用的 render 函数

现在三大模块中,该完成的业务代码我们都已经完成了。但是还存在一个问题,那就是我们应该 如何使用 render 函数呢?

我们知道,在源码中,我们可以直接:

js
const { render } = Vue

render(vnode, document.querySelector('#app'))

但是我们来看咱们现在的代码,发现是 不可以 直接这样导出并使用的。

那么 vue 是怎么做的呢?

源代码

查看源代码,我们可以发现在 packages/runtime-dom/src/index.ts 中,存在这样的一段代码:

js
export const render = ((...args) => {
  ensureRenderer().render(...args)
}) as RootRenderFunction<Element | ShadowRoot>

而这里导出的 render 就是我们 解构出来的 render

在这段代码中涉及到一个方法 ensureRenderer,我们查看它的实现,可以发现:

js
function ensureRenderer() {
  return (
    renderer ||
    (renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
  )
}

它触发了我们导出的 createRenderer,并且传递了一个 rendererOptions 参数,查看 rendererOptions

js
const rendererOptions = /*#__PURE__*/ extend({ patchProp }, nodeOps)

可以发现 rendererOptions 就是一个合并了 propnodeOps 的对象。

这样:我们就成功的把 options 参数传递到了 createRenderer 函数中,得到了 renderer 渲染器,从而最终拿到 render 函数了。

那么明确好了源代码的实现之后,下面我们就尝试自己实现一下。

实现

  1. 创建 packages/runtime-dom/src/index.ts

    js
     import { createRenderer } from '@vue/runtime-core'
     import { extend } from '@vue/shared'
     import { nodeOps } from './nodeOps'
     import { patchProp } from './patchProp'
    
     const rendererOptions = extend({ patchProp }, nodeOps)
    
     let renderer
    
     function ensureRenderer() {
      return renderer || (renderer = createRenderer(rendererOptions))
     }
    
     export const render = (...args) => {
      ensureRenderer().render(...args)
     }
  2. packages/runtime-core/src/index.ts 中导出 createRenderer

  3. packages/vue/src/index.ts 中导出 render

  4. 在测试实例 packages/vue/examples/runtime/render-element.html

html
   <script>
     const { h, render } = Vue
   
     const vnode = h('div', {
       class: 'test'
     }, 'hello render')
   
     console.log(vnode);
   
     render(vnode, document.querySelector('#app'))
   </script>

vnode 可以正常渲染。

06:源码阅读:渲染更新,ELEMENT 节点的更新操作

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

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

所以我们可以创建如下测试实例 packages/vue/examples/imooc/runtime/render-element-update.html

js
<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>

两秒之后,我们可以发现 DOM 发生了更新。

那么在这样的一个更新操作中,render 又是如何操作的呢?

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

  1. 第一次触发 processElement挂载 操作,可以直接 跳过

  2. 第二次触发 processElement更新操作

    1. 此时 n1(旧的) 存在值为

      image-20230812231313027

    2. n2(新的) 存在值为:

      image-20230812231354845

    3. 代码执行 patchElement,即:更新操作

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

      2. 执行 patchChildren(...) 方法,表示 为子节点打补丁

        1. 进入 patchChildren 方法
        2. 执行 c1 = xxc2 = xx ,为 c1、c2 赋值,此时 c1旧节点的 childrenc2新节点的 children
        3. 执行 if (shapeFlag & ShapeFlags.TEXT_CHILDREN),我们知道此时 子 childrenshapeFlag = 9 ,是 可以 & ShapeFlags.TEXT_CHILDREN
        4. prevShapeFlag = 9,是 不可以 & ShapeFlags.ARRAY_CHILDREN
        5. 所以会触发 hostSetElementText。我们知道 hostSetElementText 其实是一个 设置 text 的方法
        6. 那么此时 text 内容更新完成浏览器展示的 text 会发生变化
      3. 代码继续执行

      4. 执行 patchProps(....) 方法,表示 为 props 打补丁

        1. 进入 patchProps 方法,此时新旧 props 为:

          image-20230812231543928

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

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

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

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

            删除 id

        3. 至此 props 更新完成

    4. 至此,更替更新完成

由以上代码可知:

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

07:框架实现:渲染更新,ELEMENT 节点的更新实现

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

  1. packages/runtime-core/src/renderer.ts 中,为 processElement 增加新的判断:

    js
       /**
        * Element 的打补丁操作
        */
       const processElement = (oldVNode, newVNode, container, anchor) => {
         if (oldVNode == null) {
           // 挂载操作
           mountElement(newVNode, container, anchor)
         } else {
           // 更新操作
           patchElement(oldVNode, newVNode)
         }
       }
  2. 创建 patchElement 方法:

    js
       /**
        * 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)
       }
  3. 创建 patchChildren 方法:

    js
     /**
        * 为子节点打补丁
        */
       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: 单独挂载新子节点操作
             }
           }
         }
       }
  4. 创建 patchProps 方法:

    js
     /**
        * 为 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

    js
    <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>

    测试更新成功。

08:源码阅读:新旧节点不同元素时,ELEMENT 节点的更新操作

在上一小节中,我们完成了 Element 的更新操作,但是我们之前的更新操作是针对 相同 元素的,那么在 不同 元素下,ELEMENT 的更新操作会产生什么样的变化呢?

创建对应的测试实例 packages/vue/examples/imooc/runtime/render-element-update-2.html

js
<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 函数开始 debugger

  1. 等待第二次进入 render

  2. vnode 存在,执行 patch 方法

    1. 进入 patch 方法

    2. 此时所有参数分别为(重点关注 n1 、n2):

      image-20230812232411070

    3. 代码执行 if (n1 && !isSameVNodeType(n1, n2))

      1. 此时 n1 肯定存在

      2. 那么 isSameVNodeType 方法又是干什么的呢?我们来看一下

        1. 进入 isSameVNodeType 方法

        2. 可以发现该方法非常简单,只有一句话

          js
          return n1.type === n2.type && n1.key === n2.key
        3. 由以上代码可知,isSameVNodeType 的作用主要就是判断 n1n2 是否为:

          1. 相同类型的元素。比如都是 div
          2. 同一个元素。key 相同 表示为同一个元素
    4. 那么此时则执行 unmount 方法,该方法从方法名看是 卸载 的方法

      1. 进入 unmount 方法
      2. unmount 方法中,虽然代码很多,但是大多数代码都没有执行
      3. 执行到 remove(vnode) ,表示删除 vnode
        1. 进入 remove 方法
        2. 同样大多数大妈没有执行
        3. 直接到 performRemove()
          1. 进入 performRemove
          2. 执行 hostRemove(el!)
            1. 进入 hostRemove,触发的是 nodeOps 中的 remove 方法
              1. 代码为 parent.removeChild(child)
    5. 至此 el 被删除

    6. 然后将 n1 = null

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

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

由以上代码可知:

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

09:框架实现:处理新旧节点不同元素时,ELEMENT 节点的更新操作

根据上一小节我们知道,当新旧节点不同元素时,执行的是一个 先删除、后挂载 。依据此思路,我们可以直接进行对应的实现。

  1. packages/runtime-core/src/renderer.tspatch 方法中增加 type 判断:

    js
      /**
        * 判断是否为相同类型节点
        */
       if (oldVNode && !isSameVNodeType(oldVNode, newVNode)) {
       	unmount(oldVNode)
       	oldVNode = null
       }
  2. packages/runtime-core/src/vnode.ts 中,创建 isSameVNodeType 方法:

    js
       /**
        * 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
       }
  3. packages/runtime-core/src/renderer.ts 实现 unmount 方法:

    js
       export interface RendererOptions {
         /**
       	 * 卸载指定dom
       	 */
       	remove(el): void
       }
       
       	/**
       	 * 解构 options,获取所有的兼容性方法
       	 */
       	const {
       		...
       		remove: hostRemove
       	} = options
       
       const unmount = (vnode) => {
       	hostRemove(vnode.el!)
       }
  4. packages/runtime-dom/src/nodeOps.ts 中,实现 remove 方法:

    js
       /**
       * 删除指定元素
       */
       remove: (child) => {
         const parent = child.parentNode
         if (parent) {
         	parent.removeChild(child)
         }
       }

此时代码完成。

创建对应测试实例 packages/vue/examples/runtime/render-element-update-2.html

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>

测试成功

10:框架实现:删除元素,ELEMENT 节点的卸载操作

那么此时我们已经有了 unmount 函数,我们知道触发 unmount 函数,即可卸载元素。

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

创建如下测试实例 packages/vue/examples/imooc/runtime/render-element-remove.html

js
<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>

当我们触发 render(null, document.querySelector('#app')) 时,vue 会删除之前渲染的 vnode。即为:卸载操作

这里查看对应源码逻辑,也非常简单,查看 packages/runtime-core/src/renderer.tsrender 函数:

js
const render: RootRenderFunction = (vnode, container, isSVG) => {
  // 不存在新的 vnode 时
  if (vnode == null) {
    // 但是存在旧的 vnode
    if (container._vnode) {
      // 则直接执行卸载操作
      unmount(container._vnode, null, null, true)
    }
  } else {
    ...
  }
  ...
}

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

packages/runtime-core/src/renderer.ts 中为 render 函数补充卸载逻辑:

js
/**
 * 渲染函数
 */
const render = (vnode, container) => {
	if (vnode == null) {
		// 卸载
		if (container._vnode) {
			unmount(container._vnode)
		}
	} else {
		// 打补丁(包括了挂载和更新)
		patch(container._vnode || null, vnode, container)
	}
	container._vnode = vnode
}

创建对应测试实例,卸载成功。

11:源码阅读:class 属性和其他属性的区分挂载

查看我们的源代码 packages/runtime-dom/src/patchProp.ts,可以发现,目前针对我们的代码而言,只能挂载 class 属性,而不能挂载其他属性。

那么针对于 其他属性 是如何进行挂载的呢?我们通过一个测试实例来看一下,创建 packages/vue/examples/imooc/runtime/render-element-props.html 测试实例:

js
<script>
  const { h, render } = Vue

  const vnode = h('textarea', {
    class: 'test-class',
    value: 'textarea value',
    type: 'text'
  })
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

在这个测试实例中,我们为 textarea 挂载了三个属性 class、valuetype,根据之前的源码阅读我们知道,属性的挂载是在 packages/runtime-dom/src/patchProp.ts 中的 patchProp 方法处进行的。

所以我们可以直接在这里进行 debugger,因为我们设置了三个属性,所以会 三次,我们一个一个来看:

  1. 第一次进入,此时的 key = class
    1. 执行:if (key === 'class')
      1. 触发 patchClass 方法
      2. 通过 el.className = value 设置 class
    2. 这一块我们是比较熟悉的
  2. 至此 class 设置完成,通过 el.className
  3. 第二次进入,此时 key = type
    1. 执行 else if,将触发 shouldSetAsProp(el, key, nextValue, isSVG) 方法
      1. 进入 shouldSetAsProp 方法
      2. 执行 if (key === 'type' && el.tagName === 'TEXTAREA'),当前 满足条件,则直接 跳出
    2. 执行 else
      1. 触发 patchAttr(el, key, nextValue, isSVG, parentComponent) 方法
        1. 进入 patchAttr 方法
        2. 最终执行 el.setAttribute(key, isBoolean ? '' : value) 设置 type
  4. 至此 type 设置完成,通过 el.setAttribute
  5. 第三次进入,此时 key = value
    1. 执行 else if,将触发 shouldSetAsProp(el, key, nextValue, isSVG) 方法
      1. 进入 shouldSetAsProp 方法
      2. 执行 return key in el 表达式,因为 el = textareakey = value
      3. 所以 value in textarea DOM ,返回为 true
    2. 执行 patchDOMProp 方法:
      1. 进入 patchDOMProp 方法
      2. 执行 el[key] = value 设置 value
  6. 至此 value 设置完成,通过 el[key] = value

至此三个属性全部设置完成。

由以上代码可知:

  1. 针对于三个属性,vue 通过了 三种不同的方式 来进行了设置:
    1. class 属性:通过 el.className 设定
    2. textarea 的 type 属性:通过 el.setAttribute 设定
    3. textarea 的 value 属性:通过 el[key] = value 设定

那么很多同学看到这里,就会非常疑惑了,为什么 要通过三种不同的形式挂载属性呢?

此时: 我们的测试案例已经成功运行到浏览器中了,让我们打开浏览器的 控制台,来测试如下代码:

js
// 初始状态:<textarea class="test-class" type="text"></textarea>

// 获取 dom 实例
const el = document.querySelector('textarea')

// 1:修改 class
el.setAttribute('class', 'm-class') // 成功
el['class'] = 'm-class' // 失败
el.className = 'm-class' // 成功

// 2:修改 type
el.setAttribute('type', 'input') // 成功
el['type'] = 'input' // 失败

// 3:修改 value
el.setAttribute('value', '你好 世界') // 失败
el['value'] = '你好 世界' // 成功

由以上代码可知,我们在 针对不同属性,使用不同的 API,得到的结果是 不同 的。

那么为什么会这样呢?

想要知道这个原因,那么我们就需要来看下一小节:HTML Attributes 和 DOM Properties

12:深入属性挂载:HTML Attributes 和 DOM Properties

当我们为一个 DOM 设置对应属性的时候,其实分成了两种情况:

  1. HTML Attributes
  2. DOM Properties

那么想要搞明白,上一小节中所出现的问题的原因,我们就需要搞明白上面的两种情况指的是什么意思。

HTML Attributes

HTML Attributes,所代表的的就是 定义在 HTML 标签上的属性。比如我们以上一小节渲染的 DOM 为例:

html
<textarea class="test-class" type="text"></textarea>

这里 HTML Attributes 指的就是 class="test-class"type="text"

DOM Properties

DOM Properties ,所代表的就是 在 DOM 对象上的属性,比如我们上一小节的 ta

js
const el = document.querySelector('textarea')

我们就可以通过 . 的形式获取对应的属性:

js
el.type // 'textarea'
el.className // 'test-class'
el.value // 'textarea value'

对比

然后我们对比 HTML Attributes 和 DOM Properties 可以发现双方对于 同样属性的描述是不同 的。而这个也是 HTML Attributes 和 DOM Properties 之间的关键。

那么明确好了这个之后,我们再来看对应方法。根据上一小节的代码,我们可以知道,设置属性,我们一共使用了两个方法:

  1. Element.setAttribute():该方法可以 设置指定元素上的某个属性值
  2. dom.xx :相当于 直接修改指定对象的属性

但是这两个方式却有一个很尴尬的问题,那就是 属性名不同

比如:

  1. 针对于 class 获取:
    1. HTML Attributesta.getAttribute('class')
    2. DOM Propertiesta.className
  2. 针对于 textarea 的 type 获取:
    1. HTML Attributesta.getAttribute('type')
    2. DOM Propertiesta.type 无法获取
  3. 针对于 taxtarea 的 value 获取:
    1. HTML Attributesta.getAttribute('value') 无法获取
    2. DOM Propertiesta.value

所以为了解决这种问题,咱们就必须要能够 针对不同属性,通过不同方式 进行属性指定。所以:vue 才会通过一系列的判断进行处理

针对于 class

除了以上内容我们需要知道之外,还有另外一个我们需要了解的知识就是:既然 class 可以使用 setAttribute 设置,也可以通过 className 设置,那么这样就存在一个问题:我们应该通过哪种方式设定 class 呢?

我们知道对于 vue 的代码而言,在 packages/runtime-dom/src/modules/class.ts 中的 patchClass 中:

js
if (value == null) {
  el.removeAttribute('class')
} else if (isSVG) {
  el.setAttribute('class', value)
} else {
  el.className = value
}

根据以上代码可知:只要 dom 不是 svg,则通过 className 设置 class。那么为什么要这么做呢?

我们创建以下测试案例,对两者之间的性能进行一下对比:

html
<body>
  <div id="app"></div>
  <div id="app2"></div>
</body>

<script>
  const divEle1 = document.querySelector('#app')
  const divEle2 = document.querySelector('#app2')

  console.time('classname')
  for (let i = 0; i < 10000; i++) {
    divEle1.className = 'test-2'
  }
  console.timeEnd('classname')

  console.time('attr')
  for (let i = 0; i < 10000; i++) {
    divEle2.setAttribute('class', 'test')
  }
  console.timeEnd('attr')
</script>

最后打印的结果为:

js
classname: 1.7470703125 ms
attr: 3.389892578125 ms

由打印结果可知 className 的性能 大于 setAttribute 的性能

所以:针对于 class, 我们应该使用 className 来进行指定。

总结

本小节,我们主要分析了:两大块问题。

  1. HTML Attributes 和 DOM Properties:我们知道想要成功的进行各种属性的设置,那么需要 针对不同属性,通过不同方式 完成
  2. classNamesetAttribute('class', '') :因为 className 的性能更高,所以我们应该尽量使用 className 进行指定。

13:框架实现:区分处理 ELEMENT 节点的各种属性挂载

根据我们以上两节的描述,那么下面我们就可以着手实现对应的逻辑了。

  1. packages/runtime-dom/src/patchProp.ts 中,增加新的判断条件:

    ts
       export const patchProp = (el, key, prevValue, nextValue) => {
       	...
         else if (shouldSetAsProp(el, key)) {
       		// 通过 DOM Properties 指定
       		patchDOMProp(el, key, nextValue)
       	} else {
       		// 其他属性
       		patchAttr(el, key, nextValue)
       	}
       }
  2. packages/runtime-dom/src/patchProp.ts 中,创建 shouldSetAsProp 方法:

    ts
       /**
        * 判断指定元素的指定属性是否可以通过 DOM Properties 指定
        */
       function shouldSetAsProp(el: Element, key: string) {
       
       	// #1787, #2840 表单元素的表单属性是只读的,必须设置为属性 attribute
       	if (key === 'form') {
       		return false
       	}
       
       	// #1526 <input list> 必须设置为属性 attribute
       	if (key === 'list' && el.tagName === 'INPUT') {
       		return false
       	}
       
       	// #2766 <textarea type> 必须设置为属性 attribute
       	if (key === 'type' && el.tagName === 'TEXTAREA') {
       		return false
       	}
       
       	return key in el
       }
  3. packages/runtime-dom/src/modules/props.ts 中,增加 patchDOMProp 方法:

    js
       /**
        * 通过 DOM Properties 指定属性
        */
       export function patchDOMProp(el: any, key: string, value: any) {
       	try {
       		el[key] = value
       	} catch (e: any) {}
       }
  4. packages/runtime-dom/src/modules/attrs.ts 中,增加 patchAttr 方法:

    js
       /**
        * 通过 setAttribute 设置属性
        */
       export function patchAttr(el: Element, key: string, value: any) {
       	if (value == null) {
       		el.removeAttribute(key)
       	} else {
       		el.setAttribute(key, value)
       	}
       }

至此,代码完成。

创建测试实例 packages/vue/examples/runtime/render-element-props.html

js
<script>
  const { h, render } = Vue

  const vnode = h('textarea', {
    class: 'test-class',
    value: 'textarea value',
    type: 'text'
  })
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

测试渲染成功。

14:源码阅读:ELEMENT 节点下, style 属性的挂载和更新

根据我们在 packages/runtime-dom/src/patchProp.tspatchProp 中的方法可知,目前我们还缺少 style事件 的挂载。

那么下面我们就来看下 style 属性的挂载的更新操作。

我们可以创建如下测试实例 packages/vue/examples/imooc/runtime/render-element-style.html

js
<script>
  const { h, render } = Vue

  const vnode = h('div', {
    style: {
      color: 'red'
    },
  }, '你好,世界')
  // 挂载
  render(vnode, document.querySelector('#app'))

  setTimeout(() => {
    const vnode2 = h('div', {
      style: {
        fontSize: '32px'
      },
    }, '你好,世界')
    // 挂载
    render(vnode2, document.querySelector('#app'))
  }, 2000);
</script>

patchProp 方法中,跟踪源码实现:

  1. 第一次进入,执行 挂载 操作

    1. 进入 patchProp 方法,此时各参数的值为:

      image-20230812234333719

    2. 代码进入 patchStyle 方法,开始进行打补丁操作

    3. 执行 const style = (el as HTMLElement).style ,此时 style 为当前 elementstyle

    4. 执行 const isCssString = isString(next) 方法:

    5. 此时 next 的值为:{color: 'red'},不是一个 string

    6. 所以 isCssString 的值为 false

    7. 执行 if (next && !isCssString),进入 if

      1. 执行 for 循环,拿到所有的 key

      2. 执行 setStyle(style, key, next[key]) 方法:

      3. 进入 setStyle 方法,此时各参数的值为:

        image-20230812234457316

      4. val 不是 Array ,直接进入 else

      5. 执行 const prefixed = autoPrefix(style, name)

        1. 进入 autoPrefix 方法
        2. 执行 let name = camelize(rawName) 方法:
          1. camelize 方法的内容比较简单,就是把 key 变为 驼峰格式 字符串
          2. 因为 rawName 此时为 color,所以 得到的 name 依然为 color
        3. 最后执行 return (prefixCache[rawName] = name) 把当前 name 进行 缓存 ,并返回 name 的值
      6. 得到 prefixed = color

      7. 最后执行 style[prefixed as any] = val ,直接为 style 对象进行赋值操作

    8. 至此 style 属性 挂载完成

    9. 但是光指定完成还不够,我们还需要 卸载旧的 style,以完成 更新

  2. 延迟两秒之后…

  3. 第二次进入,执行 更新 操作

    1. 忽略掉相同的挂载逻辑

    2. 代码执行到 patchStyle 方法下,if (prev && !isString(prev)) {......} 判断

      1. 此时存在两个变量:
        1. prev:上一次的样式 {color: 'red'}
        2. next:这一次的样式 {fontSize: '32px'}
    3. 满足 if 判断条件,进入 if

      1. 执行 for (const key in prev)遍历旧样式

      2. 执行 if (next[key] == null)旧样式不存在于样式中

      3. 则执行 setStyle(style, key, '') 方法

        1. 再次进入 setStyle 方法,此时的参数为:

          image-20230812234856997

        2. 注意: 此时 val''

        3. 再次执行 style[prefixed as any] = val,即:style[color] = ''

        4. 完成 清理旧样式 操作

      4. 至此 更新 操作完成

由以上代码可知:

  1. 整个 style 赋值的逻辑还是比较简单的
  2. 不考虑边缘情况 的前提下,vue 只是对 style 进行了 缓存赋值 两个操作
  3. 缓存是通过 prefixCache = {} 进行
  4. 赋值则是直接通过 style[xxx] = val 进行

15:框架实现:ELEMENT 节点下, style 属性的挂载和更新

根据上一小节的源码,我们可以直接实现对应的 patchStyle 函数。

  1. packages/runtime-dom/src/patchProp.ts 中,处理 style 情况:

    js
       /**
        * 为 prop 进行打补丁操作
        */
       export const patchProp = (el, key, prevValue, nextValue) => {
       	......
       	else if (key === 'style') {
       		// style
       		patchStyle(el, prevValue, nextValue)
       	} 
       	......
       }
  2. packages/runtime-dom/src/modules/style.ts 中,新建 patchStyle 方法:

    js
       /**
        * 为 style 属性进行打补丁
        */
       export function patchStyle(el: Element, prev, next) {
       	// 获取 style 对象
       	const style = (el as HTMLElement).style
       	// 判断新的样式是否为纯字符串
       	const isCssString = isString(next)
       	if (next && !isCssString) {
       		// 赋值新样式
       		for (const key in next) {
       			setStyle(style, key, next[key])
       		}
       		// 清理旧样式
       		if (prev && !isString(prev)) {
       			for (const key in prev) {
       				if (next[key] == null) {
       					setStyle(style, key, '')
       				}
       			}
       		}
       	}
       }
       
       /**
        * 赋值样式
        */
       function setStyle(
       	style: CSSStyleDeclaration,
       	name: string,
       	val: string | string[]
       ) {
       	style[name] = val
       }

代码完成

创建测试实例 packages/vue/examples/runtime/render-element-style.html

html
<script>
 const { h, render } = Vue

 const vnode = h('div', {
   style: {
     color: 'red'
   },
 }, '你好,世界')
 // 挂载
 render(vnode, document.querySelector('#app'))

 setTimeout(() => {
   const vnode2 = h('div', {
     style: {
       fontSize: '32px'
     },
   }, '你好,世界')
   // 挂载
   render(vnode2, document.querySelector('#app'))
 }, 2000);
</script>

style 挂载和更新处理完成

16:源码阅读:ELEMENT 节点下,事件的挂载和更新

在处理完成 style 的挂载和更新之后,接下来我们来看 event 事件 的挂载和更新操作。

和之前一样,我们首先先创建对应的测试实例 packages/vue/examples/imooc/runtime/render-element-event.html

js
<script>
  const { h, render } = Vue

  const vnode = h('button', {
    // 注意:不可以使用 onclick。因为 onclick 无法满足 /^on[^a-z]/ 的判断条件,这会导致 event 通过 :el[key] = value 的方式绑定(虽然这样也可以绑定 event),从而无法进入 patchEvent。在项目中,当我们通过 @click 绑定属性时,会得到 onClick 选项
    onClick() {
      alert('点击')
    },
  }, '点击')
  // 挂载
  render(vnode, document.querySelector('#app'))

  setTimeout(() => {
    const vnode2 = h('button', {
      onDblclick() {
        alert('双击')
      },
    }, '双击')
    // 挂载
    render(vnode2, document.querySelector('#app'))
  }, 2000);
</script>

**注意:**不可以使用 onclick。因为 onclick 无法满足 /^on[^a-z]/ 的判断条件,这会导致 event 通过 :el[key] = value 的方式绑定(虽然这样也可以绑定 event),从而无法进入 patchEvent

在项目中,当我们通过 @click 绑定属性时,会得到 onClick 选项

packages/runtime-dom/src/patchProp.ts 中进入 debugger

  1. 第一次进入 debugger,执行 挂载 操作:

    1. 此时各参数为:

      image-20230812235422144

    2. 执行 if (isOn(key)) 判定:

      1. 进入 isOn 方法:

        js
        const onRE = /^on[^a-z]/
        export const isOn = (key: string) => onRE.test(key)
      2. 整体的代码比较简单,就是筛选出:**on 开头,不接 a-z ** 的字符串。

      3. 即:/^on[^a-z]/.test('onClick')

    3. 当前 满足条件,触发 patchEvent(el, key, prevValue, nextValue, parentComponent) 方法:

      1. 进入 patchEvent 方法,此时各参数为:

        image-20230812235601469

      2. 执行 const invokers = el._vei || (el._vei = {})

        1. 这里涉及到了一个 _vei 对象
        2. vue 对其进行了注释:vei = vue event invokers ,即:VUE事件调用者
        3. 那么这个事件调用者是什么意思呢?
        4. 不要着急,我们继续往下看,大家现在需要记住,我们当前得到了一个 invokers 对象:invokers = {}
      3. 执行 const existingInvoker = invokers[rawName]

        1. 因为当前 invokers{}
        2. 所以得到的 existingInvoker = undefined
      4. 执行 else 判断条件:

        1. 执行 const [name, options] = parseName(rawName)
          1. 进入 parseName
          2. 该函数的作用就是拆解除 事件名 nameaddEventListeneroptions
          3. 我们这了可以直接忽略掉 options
          4. 得到 name 即可
        2. 此时 name = click
        3. 执行 if (nextValue),当前 nextValue: ƒ onClick() 函数
          1. 进入 if 判断
          2. 执行 const invoker = (invokers[rawName] = createInvoker(nextValue, instance))
            1. 进入 createInvoker 方法:
            2. 这里面的代码做了两件事情:
              1. invoker.value = initialValue
                1. 这个是需要我们 重点关注
                2. 当前的 initialValue 即为 nextValue
                3. 即:invoker 对象的 value 属性即为 onClick 函数
              2. invoker.attached = getNow() 为主的 时间 处理
                1. 这是一个边缘情况
                2. 主要应用于 onClick数组[] 时,多个回调方法的触发时机问题
                3. 我们这里 无需关注
          3. 得到 invoker 函数,并为 invokers[rawName] 进行了 缓存
          4. 执行 addEventListener(el, name, invoker, options)
            1. 该方法的代码比较简单,就是执行了 el.addEventListener(event, handler, options)
            2. 对比参数,即执行了:el.addEventListener(name, invoker, options)
  2. 支持事件 挂载 完成

  3. 等待两秒之后,第二次进行,执行 更新 操作:

    1. 进入 patchEvent 方法

    2. 因为在 挂载 时,我们已经进行了 invoker缓存,所以再次进入时:

      1. invokers 不在为 null,而是一个对象,里面包含了上一次的 onClick 方法

        image-20230813000247651

      2. existingInvoker 依然为 null

      3. existingInvoker 依然为 null

      4. 进入 else

        1. 生成 invoker 函数
        2. 通过 addEventListener 进行挂载
  4. 那么此时 双击 事件被 挂载完成

  5. 但是大家到此时可能会非常奇怪,目前为止我们分别完成了 单击事件的挂载双击事件的挂载 ,但是有一个问题,那就是 旧事件(单击事件)此时依然存在,并没有被 卸载。这是为什么呢?

  6. 我们知道 属性的挂载 其实是在 packages/runtime-core/src/renderer.ts 中的 patchProps 中进行的,观察内部方法,我们可以发现 内部进行了两次 for 循环

    1. 第一次是新增新属性
    2. 第二次是卸载旧属性
  7. 所以说,此时,我们还会 第三次 进入 patchProp 方法,本次的目的是:卸载 onClick

    1. 忽略相同逻辑,同样进入 patchEvent 方法,此时的参数为:

      image-20230813000549302

    2. 此时 invokers 的值为:

      image-20230813000622893

    3. 此时 existingInvoker 将存在值,值为 onClick 的回调方法

    4. 再次进入 else

      1. 注意: 此时因为 nextValuenull,而 existingInvoker 存在

      2. 所以会走 :

        removeEventListener(el, name, existingInvoker, options)
        invokers[rawName] = undefined
  8. 至此 卸载旧事件 完成

由以上代码可知:

  1. 我们一共三次进入 patchEvent 方法
    1. 第一次进入为 挂载 onClick 行为
    2. 第二次进入为 挂载 onDblclick 行为
    3. 第三次进入为 卸载 onClick 行为
  2. 挂载事件,通过 el.addEventListener 完成
  3. 卸载事件,通过 el.removeEventListener 完成
  4. 除此之外,还有一个 _veiinvokers 对象 和 invoker 函数,我们说两个东西需要重点关注,那么这两个对象有什么意义呢?

下一小节,我们将详细说明 invokers 对象 和 invoker 函数 在当前事件处理中存在的作用。

17:深入事件更新:vue event invokers

那么这一小节我们来看下 **invokers 对象 和 invoker 函数 (即:vue event invokers。简称:vei)**在事件处理中存在的作用。

在上一小节中,我们进行了两次挂载和一次卸载操作,本质上并没有对事件进行 更新 操作。而 vei 的作用是在更新中,才可以体现的。

我们知道,vue 对事件的处理是通过:

  1. el.addEventListener
  2. el.removeEventListener

来完成的。

那么现在我们来思考一下:

如果一个 button 最初的 click 事件,点击之后打印 hello

两秒之后,更新打印 你好

那么这样的一个更新操作,如果让我们通过 el.addEventListenerel.removeEventListener 来实现,那么我们会怎么做?

可能有同学说,这还不简单吗?很轻松的写出如下代码:

js
<script>
  const btnEle = document.querySelector('button')
  // 设置初始点击行为
  const invoker = () => {
    alert('hello')
  }
  btnEle.addEventListener('click', invoker)

  // 两秒之后,更新点击事件
  setTimeout(() => {
    // 先删除
    btnEle.removeEventListener('click', invoker)
    // 再添加
    btnEle.addEventListener('click', () => {
      alert('你好')
    })
  }, 2000);

</script>

但是我们知道如果频繁的删除、新增事件是非常消耗性能的,那么有没有更好的方案呢?

肯定是有的,这个方案就是 vei,我们来看下面这段代码:

js
<script>
  const btnEle = document.querySelector('button')
  // 设置初始点击行为
  const invoker = () => {
    invoker.value()
  }
  // 为 invoker 指定了 value 属性,对应的值是《事件点击行为》
  invoker.value = () => {
    alert('hello')
  }
  // 把 invoker 作为回调函数,invoker 内部通过触发 value,来触发真正的点击行为
  btnEle.addEventListener('click', invoker)

  // 两秒之后更新
  setTimeout(() => {
    // 因为真正的事件点击行为其实是 invoker.value,所以我们想要更新事件,就不需要再次触发 addEventListener API 了,只需要修改 invoker.value 的值即可。
    invoker.value = () => {
      alert('你好')
    }
  }, 2000);
</script>

vue 就是通过这样一种方式,来完成的事件更新操作。具体更新代码比较简单,可以看 packages/runtime-dom/src/modules/events.ts77 行:

js
  if (nextValue && existingInvoker) {
    // patch
    existingInvoker.value = nextValue
  }

至于 invokers 则充当了一个事件缓存器,把所有的事件:以事件名为 key,以事件行为为 value 。保存到 el._vei

那么这样我们搞明白了 vei 它在事件处理中所存在的意义。

明白了这个之后,下面我们就可以实现 event 事件的挂载和更新操作了。

18:框架实现:ELEMENT 节点下,事件的挂载和更新

本小节我们就来实现事件的更新和挂载操作。

  1. packages/runtime-dom/src/patchProp.ts 中,增加 patchEvent 事件处理

    js
       } else if (isOn(key)) {
       	// 事件
       	patchEvent(el, key, prevValue, nextValue)
       }
  2. packages/runtime-dom/src/modules/events.ts 中,增加 patchEventparseNamecreateInvoker 方法:

    js
       /**
        * 为 event 事件进行打补丁
        */
       export function patchEvent(
       	el: Element & { _vei?: object },
       	rawName: string,
       	prevValue,
       	nextValue
       ) {
       	// vei = vue event invokers
       	const invokers = el._vei || (el._vei = {})
       	// 是否存在缓存事件
       	const existingInvoker = invokers[rawName]
       	// 如果当前事件存在缓存,并且存在新的事件行为,则判定为更新操作。直接更新 invoker 的 value 即可
       	if (nextValue && existingInvoker) {
       		// patch
       		existingInvoker.value = nextValue
       	} else {
       		// 获取用于 addEventListener || removeEventListener 的事件名
       		const name = parseName(rawName)
       		if (nextValue) {
       			// add
       			const invoker = (invokers[rawName] = createInvoker(nextValue))
       			el.addEventListener(name, invoker)
       		} else if (existingInvoker) {
       			// remove
       			el.removeEventListener(name, existingInvoker)
       			// 删除缓存
       			invokers[rawName] = undefined
       		}
       	}
       }
       
       /**
        * 直接返回剔除 on,其余转化为小写的事件名即可
        */
       function parseName(name: string) {
       	return name.slice(2).toLowerCase()
       }
       
       /**
        * 生成 invoker 函数
        */
       function createInvoker(initialValue) {
       	const invoker = (e: Event) => {
       		invoker.value && invoker.value()
       	}
       	// value 为真实的事件行为
       	invoker.value = initialValue
       	return invoker
       }

    支持事件的打补丁处理完成。

    可以创建如下测试实例 packages/vue/examples/runtime/render-element-event.html

    js
    <script>
      const { h, render } = Vue
    
      const vnode = h('button', {
        onClick() {
          alert('点击')
        },
      }, '点击')
      // 挂载
      render(vnode, document.querySelector('#app'))
    
      setTimeout(() => {
        const vnode2 = h('button', {
          // onClick() {
          //   alert('click')
          // },
          onDblclick() {
            alert('双击')
          },
        }, '双击')
        // 挂载
        render(vnode2, document.querySelector('#app'))
      }, 2000);
    </script>

    测试成功

19:局部总结:ELEMENT 节点的挂载、更新、props 打补丁等行为总结

回顾一下本章节到目前为止的所有行为,我们已经完成了针对于 ELEMENT 的:

  1. 挂载
  2. 更新
  3. 卸载
  4. patch props 打补丁
    1. class
    2. style
    3. event
    4. attr

等行为的处理。

针对于 挂载、更新、卸载而言,我们主要使用了 packages/runtime-dom/src/nodeOps.ts 中的浏览器兼容方法进行的实现,比如:

  1. doc.createElement
  2. parent.removeChild

等等。

而对于 patch props 的操作而言,因为 HTML Attributes 和 DOM Properties 的不同问题,所以我们需要针对不同的 props 进行分开的处理。

而最后的 event,本身并不复杂,但是 vei 的更新思路也是非常值得我们学习的一种事件更新方案。

那么到这里针对于 ELEMENT 的处理,我们就暂时告一段落了。下面我们就来看 TextComment 以及 Component 的渲染行为。

20:源码阅读:renderer 渲染器下,Text 节点的挂载、更新行为

这一小节,我们来看 Text 文本 节点的挂载、更新、卸载 行为。

首先创建测试实例 packages/vue/examples/imooc/runtime/render-text.html

html
<script>
  const { h, render, Text } = Vue

  const vnode = h(Text, 'hello world')
  // 挂载
  render(vnode, document.querySelector('#app'))

  // 延迟两秒,生成新的 vnode,进行更新操作
  setTimeout(() => {
    const vnode2 = h(Text, '你好,世界')
    render(vnode2, document.querySelector('#app'))
  }, 2000);
</script>

我们知道,对于节点的打补丁操作是从 packages/runtime-core/src/renderer.ts 中的 render 函数开始的,所以我们可以直接在这里进行 debugger

  1. 第一次进入 render ,执行挂载操作:

    1. 进入 patch 方法,此时的参数为:

      image-20230813084700058

    2. 执行 switch 判定,进入 processText 方法:

      1. 进入 processText,此时的各参数为:

        image-20230813084757473

      2. 因为 n1 === null,所以 执行 hostInserthostCreateText 方法,即:挂载 操作

        1. 首先进入 hostCreateText 方法:

          1. 实际触发的是 packages/runtime-dom/src/nodeOps.ts 下的 createText 方法:

            js
            createText: text => doc.createTextNode(text),

            该方法比较简单,直接通过 doc.createTextNode(text) 生成 Text 节点

        2. 其次进入 hostInsert 方法:

          1. 该方法实际触发的是 packages/runtime-dom/src/nodeOps.ts 下的 insert 方法:

            js
            parent.insertBefore(child, anchor || null)

          该方法我们之前实现过,就不在多说了。

  2. 至此 挂载 操作完成

  3. 延迟两秒,第二次进入 render 方法,执行 更新操作

    1. 进入 patch 方法,此时的参数为:

      image-20230813085036608

    2. 执行 switch,触发 processText 方法

      1. 进入 processText 方法,此时参数为:

        image-20230813085126882

      2. 此时 n1 !== null,所以进入 else 逻辑

        1. 执行 const el = (n2.el = n1.el!) 获取同样的 el

        2. 执行 hostSetText(el, n2.children as string)

          1. 进入 hostSetText 方法,其实触发的是 packages/runtime-dom/src/nodeOps.ts 下的 setText 方法:

            js
            node.nodeValue = text
          2. 该方法非常简单,只是修改 value 而已

  4. 至此,更新操作完成。

由以上代码可知:

  1. 对于 Text 节点的 挂载和更新,整体是非常简单的:
    1. 挂载:通过 doc.createTextNode(text) 生成节点,在通过 insertBefore 插入
    2. 更新:通过 node.nodeValue 直接指定即可。

21:框架实现:renderer 渲染器下,Text 节点的挂载、更新行为

明确好了 Text 的源码逻辑之后,那么接下来我们就实现一下对应的代码:

  1. packages/runtime-core/src/renderer.ts 中,增加 processText 方法:

    js
     /**
      * Text 的打补丁操作
      */
     const processText = (oldVNode, newVNode, container, anchor) => {
     	// 不存在旧的节点,则为 挂载 操作
     	if (oldVNode == null) {
     		// 生成节点
     		newVNode.el = hostCreateText(newVNode.children as string)
     		// 挂载
     		hostInsert(newVNode.el, container, anchor)
     	}
     	// 存在旧的节点,则为 更新 操作
     	else {
     		const el = (newVNode.el = oldVNode.el!)
     		if (newVNode.children !== oldVNode.children) {
     			hostSetText(el, newVNode.children as string)
     		}
     	}
     }
  2. RendererOptions 增加 createTextsetText 方法:

    js
     /**
      * 渲染器配置对象
      */
     export interface RendererOptions {
     	...
     	/**
     	 * 创建 Text 节点
     	 */
     	createText(text: string)
     	/**
     	 * 设置 text
     	 */
     	setText(node, text): void
     }
  3. options 增加解析:

    js
     	/**
     	 * 解构 options,获取所有的兼容性方法
     	 */
     	const {
     		...
     		createText: hostCreateText,
     		setText: hostSetText
     	} = options
  4. patch 方法中,处理 Text 节点:

    js
    case Text:
    	// Text
    	processText(oldVNode, newVNode, container, anchor)
    	break
    
    
    代码块1234
  5. packages/runtime-dom/src/nodeOps.ts 增加 createTextsetText 方法:

    js
     /**
      * 创建 Text 节点
      */
     createText: (text) => doc.createTextNode(text),
     
     /**
      * 设置 text
      */
     setText: (node, text) => {
     	node.nodeValue = text
     }

代码完成。

创建测试实例 packages/vue/examples/runtime/render-text.html

js
<script>
  const { h, render, Text } = Vue

  const vnode = h(Text, 'hello world')
  // 挂载
  render(vnode, document.querySelector('#app'))

  // 延迟两秒,生成新的 vnode,进行更新操作
  setTimeout(() => {
    const vnode2 = h(Text, '你好,世界')
    render(vnode2, document.querySelector('#app'))
  }, 2000);
</script>

测试挂载和更新成功

22:源码阅读:renderer 渲染器下,Comment 节点的挂载行为

完成了 Text 节点的挂载、更新之后,那么下面我们来看 Comment 节点的挂载操作。

这里大家要注意:vue 不支持动态的 comment,即没有更新操作

其实对于 Comment 而言,它的整体流程和 Text 非常类似。

我们来看一下,创建测试实例 packages/vue/examples/imooc/runtime/render-comment.html

html
<script>
  const { h, render, Comment } = Vue

  const vnode = h(Comment, 'hello world')
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

跟踪代码实现:

  1. 进入 patch 方法,case Comment,触发 processCommentNode 函数

    1. 进入 processCommentNode 函数:

    2. 内部的代码非常简单,只触发了两个方法:

      1. hostCreateComment:内部的代码在 packages/runtime-dom/src/nodeOps.ts 中的 createComment 方法:

        js
        createComment: text => doc.createComment(text),

        创建 Comment 节点

      2. hostInsert:这个在 Text 节点时,刚看过,这里就不在查看了

至此,Comment 节点渲染成功。

由以上代码可知:

  1. 对于 Comment 而言,只存在 挂载 操作
  2. 整体的处理非常简单:
    1. 通过 doc.createComment 创建 Comment 节点
    2. 通过 hostInsert 完成挂载

23:框架实现:renderer 渲染器下,Comment 节点的挂载行为

明确好了 Comment 的挂载逻辑之后,下面我们就进行对应的实现。

  1. packages/runtime-core/src/renderer.ts 中:

    js
     export interface RendererOptions {
     	...
     	/**
     	 * 设置 text
     	 */
     	createComment(text: string)
     }
     
     const {
     	...
     	createComment: hostCreateComment
     } = options
     
     /**
      * Comment 的打补丁操作
      */
     const processCommentNode = (oldVNode, newVNode, container, anchor) => {
     	if (oldVNode == null) {
     		// 生成节点
     		newVNode.el = hostCreateComment((newVNode.children as string) || '')
     		// 挂载
     		hostInsert(newVNode.el, container, anchor)
     	} else {
     		// 无更新
     		newVNode.el = oldVNode.el
     	}
     }
     
     // patch 方法中 switch 逻辑
     case Comment:
     	// Comment
     	processCommentNode(oldVNode, newVNode, container, anchor)
     	break
  2. packages/runtime-dom/src/nodeOps.ts 中,增加 createComment 方法:

    js
     /**
      * 创建 Comment 节点
      */
     createComment: (text) => doc.createComment(text)

    代码完成。

创建测试实例 packages/vue/examples/runtime/render-comment.html:

html
<script>
  const { h, render, Comment } = Vue

  const vnode = h(Comment, 'hello world')
  // 挂载
  render(vnode, document.querySelector('#app'))
</script>

测试成功

24:源码阅读:renderer渲染器下, Fragment 节点的挂载、更新行为

最后我们来看一下 Fragment 节点的挂载和更新行为。

对于 Fragment 它是一个包裹性质的容器,本身并不渲染,只渲染子节点。

那么基于此,我们创建对应的测试实例 packages/vue/examples/imooc/runtime/render-fragment.html

js
<script>
  const { h, render, Fragment } = Vue

  const vnode = h(Fragment, 'hello world')
  // 挂载
  render(vnode, document.querySelector('#app'))

  setTimeout(() => {
    const vnode2 = h(Fragment, '你好,世界')
    // 挂载
    render(vnode2, document.querySelector('#app'))
  }, 2000);
</script>

根据之前的经验,我们直接在 patch 方法中进行 debugger, 运行代码进入 debugger

  1. 第一次进入 patch 方法,执行 挂载 操作:

    1. 触发 switch,执行 processFragment 方法

      1. 进入 processFragment 方法,该方法的代码相比较多,但是我们忽略掉无用的之后,逻辑和其他的渲染并无其别,同样是直接 mountXXX 方法进行挂载

      2. 只不过需要注意:因为 Fragment 本身并不渲染,所以它的渲染 仅渲染子节点

      3. 所以此时触发的渲染方法为:mountChildren

        1. 进入 mountChildren,此时的参数为

          image-20230813090006998

        2. 该方法内部的逻辑就比较简单了

          1. 循环 children,分别触发 patch 方法进行挂载
          2. 因为此时的 children 是一个字符串,所以循环之后的每一个 child 都是一个字符
          3. 把字符通过 normalizeVNode 加工之后,将得到一个 Text 节点 类型的 VNode
          4. 交给 patch 进行渲染即可
  2. 至此,挂载 完成

  3. 第二次进入 patch,执行 更新操作

    1. 同样通过 switch,触发 processFragment 方法
      1. 进入 processFragment
      2. 因为是 更新,所以会触发 patchChildren 方法
        1. 进入 patchChildren,此时我们知道:
          1. 挂载时:渲染的是 hello world 文本
          2. 更新时:渲染的是 你好,世界 文本
        2. 所以这次的更新本质上是一个 文本替换文本 的操作。
        3. 所以执行代码会发现,代码进入 hostSetElementText(container, c2 as string) 方法
          1. 对于该方法我们是熟悉的,它本质上就是一个 el.textContent = text 的方法执行
  4. 至此,更新 操作完成

由以上代码可知:

  1. Fragment 只是一个包裹的容器,挂载和更新时,都只会渲染 children
  2. 根据 children 的不同,Fragment 的渲染也会存在很多的不同

25:框架实现:renderer渲染器下, Fragment 节点的挂载、更新行为

明确好了 Fragment 的渲染逻辑之后,那么下面我们对此进行下实现。

  1. packages/runtime-core/src/renderer.ts 中,为 patch 新增 Fragment 的方法触发:

    js
    case Fragment:
    	// Fragment
    	processFragment(oldVNode, newVNode, container, anchor)
    	break
  2. 创建 processFragment 方法:

    js
    /**
     * Fragment 的打补丁操作
     */
    const processFragment = (oldVNode, newVNode, container, anchor) => {
      if (oldVNode == null) {
        mountChildren(newVNode.children, container, anchor)
      } else {
        patchChildren(oldVNode, newVNode, container, anchor)
      }
    }
  3. 构建 mountChildren 渲染逻辑:

    js
     /**
      * 挂载子节点
      */
     const mountChildren = (children, container, anchor) => {
       // 处理 Cannot assign to read only property '0' of string 'xxx'
         if (isString(children)) {
           children = children.split('')
         }
     	for (let i = 0; i < children.length; i++) {
     		const child = (children[i] = normalizeVNode(children[i]))
     		patch(null, child, container, anchor)
     	}
     }
  4. packages/runtime-core/src/componentRenderUtils.ts 中,为 normalizeVNode 增加处理 Text 节点逻辑:

    js
     /**
      * 标准化 VNode
      */
     export function normalizeVNode(child) {
     	if (typeof child === 'object') {
     		return cloneIfMounted(child)
     	} else {
     		return createVNode(Text, null, String(child))
     	}
     }
     
     /**
      * clone VNode
      */
     export function cloneIfMounted(child) {
     	return child
     }
  5. 更新逻辑之前已经完成过,不需要额外处理

至此,Fragment 的渲染处理完成。

我们可以创建对应测试实例 packages/vue/examples/runtime/render-fragment.html

html
<script>
  const { h, render, Fragment } = Vue

  const vnode = h(Fragment, 'hello world')
  // 挂载
  render(vnode, document.querySelector('#app'))

  setTimeout(() => {
    const vnode2 = h(Fragment, '你好,世界')
    // 挂载
    render(vnode2, document.querySelector('#app'))
  }, 2000);
</script>

挂载、更新成功

26:总结

在本章中,我们主要讲解了 render 函数的渲染机制。

我们首先讲解了渲染器 renderer 的概念,并且知道 render 函数是渲染器对象中的一个函数。但是这个渲染函数我们是不可以直接被用户访问的。所以我们额外在 packages/runtime-dom/src/index.ts 中构建了一个 render 函数,用来给用户使用 render 渲染器

在整个的 render 渲染过程中,我们把所有的核心逻辑放入到了 runtime-core 文件夹内,把所有和浏览器相关的 API 操作,放到了 runtime-dom 文件夹下。以此来达到兼容性的目的。

针对不同的节点,具体的渲染逻辑会有所不同。我们分别从:

  1. ELEMENT
  2. TEXT
  3. COMMENT

这三种节点类型入手,讲解了 挂载、更新、卸载 的对应操作逻辑。

对于这三种节点而言,ELEMENT 毫无因为是最复杂的。

对于 ELEMENT 而言,它的渲染除了本身的 挂载、更新、卸载 之外,还包含了 props 属性 的挂载、更新、卸载操作。

对于 props 属性而言,因为 HTML Attributes 和 DOM Properties 的原因,所以我们需要针对不同的 props 分别进行处理。

而对于 event 事件而言,这里 vue 通过了 vei 的方式进行了更新逻辑,这个设计是非常精妙的。

但是哪怕我们做了这么多事情之后,针对于 render 的渲染逻辑,我们依然只是处理了冰山一角而已,比如:

  1. diff:在旧节点和新节点的子节点都为 Array 时,我们需要就行 diff 运算,已完成高效的更新。
  2. component 组件:组件的处理也是 vue 中非常重要的一块内容,这个也需要我们进行单独的讲解。

Released under the MIT License.