Vuejs 设计与实现 —— 渲染器核心:挂载与更新

  • A+
所属分类:学习笔记 CRMEB

前言

挂载 与 更新 是 渲染器 的核心功能,也是渲染器应该要提供的基本功能,而 挂载 和 更新 又是基于 VNode 虚拟节点的,因为 VNode 节点描述了其对应的 真实 DOM 应该是什么样子的。

挂载与卸载

VNode 节点

无论是 vue 还是 react 都引入了 虚拟 DOM,只不过它们定义 虚拟 DOM 的结构不同,但本质上都只是一个普通的 JavaScript 对象。

VDOM 和 VNode 是从 本质上 看是一个东西,因为 VDOM 由 VNode 节点组成,每个 VNode 节点也能代表局部 VDOM,上篇文章中也提到过:VNode 和 VDOM 是可以互换的。

但从 整体上 看显然 VDOM 是包含或者等于 VNode,也就是说从严格意义上来讲,它们并不是一直相等的,取决于你的 VNode 节点的个数,如果它的节点数量是 1 那么它们是相等的。

不过现在所谈论的 VNode 就是 VDOM,谈论的 VDOM 就是 VNode,这只不过是一个简单的概念,不必过于纠结。

下面是 Vue3.x 中定义最基本的 VNode 结构:

vnode.type 是节点类型:标签、文本、注释、Fragment、Component 等
vnode.props 是节点属性数据:HTML Attributes 和 DOM Properties

const vnode = {
   
    __v_isVNode: true,
    __v_skip: true,
    type,
    props,
    key: props && normalizeKey(props),
    ref: props && normalizeRef(props),
    scopeId: currentScopeId,
    slotScopeIds: null,
    children,
    component: null,
    suspense: null,
    ssContent: null,
    ssFallback: null,
    dirs: null,
    transition: null,
    el: null,
    anchor: null,
    target: null,
    targetAnchor: null,
    staticCount: 0,
    shapeFlag,
    patchFlag,
    dynamicProps,
    dynamicChildren: null,
    appContext: null
  } as VNode
复制代码

设置正确的元素属性

HTML Attributes 和 DOM Properties

HTML Attributes 指的就是定义在 HTML 标签上的属性,如:id=“app”、type=“text”、value=“hello world” 等等

DOM Properties 指的是通过 JavaScript 来访问真实 DOM 元素时能够访问到的属性,很多 HTML Attributes 都能在 DOM Properties 上存在同名属性(如:el.id、el.title)等,不同名属性(如:el.className、el.textContext)等

核心原则:HTML Attributes 的作用是设置 DOM Properties 的 初始值

Vuejs 设计与实现 —— 渲染器核心:挂载与更新

正确处理普通的 props

通过 in 操作符判断 props.key 是否存在 el(即 DOM Properties) 上
若 存在 则优先设置 DOM Properties,即 el[props.key] = props.value
若 不存在 则通过 el.setAttribute(key, value) 完成属性设置
针对 只读 属性的 DOM Properties,不能直接进行赋值,因此也必须转换为 el.setAttribute(key, value) 的处理,如: 中的 form 属性就是只读属性
源码中抽离了 shouldSetAsProp 用于去判断是否可通过 DOM Properties 去更新:

Vuejs 设计与实现 —— 渲染器核心:挂载与更新

特殊处理 class

Vue.js 对 class 做了增强:

指定 class 为普通 字符串
指定 class 为一个 对象
指定 class 为包含上述两种类型的 数组
由于 class 的值以多种形式存在,因此需要对 class 进行一些特殊处理,将 class 的值统一为字符串的形式,因为 HTML 只接收这样的 class

源码中通过 normaliz 处理不同的 class 类型,并统一返回字符串形式:

Vuejs 设计与实现 —— 渲染器核心:挂载与更新

选择设置 class 最合适的方式

浏览器中设置 class 的方式有三种:el.className、el.classList、el.setAttribute,既然有多种方式,那么在选择时肯定要选择最优的设置方式,而其中最优的方式就是 el.className

可以做个小测试,时间不一定准确,但是差值却很明显:

const body = document.documentElement;

console.time('className:')
for (let i = 0; i < 1000; i++) {
   
  body.className += i;
}
console.timeEnd('className:')

console.time('setAttribute:')
for (let i = 0; i < 1000; i++) {
   
  body.setAttribute('class', body.className + ' ' + i); 
}
console.timeEnd('setAttribute:')

console.time('classList:')
for (let i = 0; i < 1000; i++) {
   
  body.classList.add(i+''); 
}
console.timeEnd('classList:')

// 输出结果:
className:: 5.760009765625 ms
setAttribute:: 651.76611328125 ms
classList:: 1750.427978515625 ms
复制代码

事件处理

区分事件

在虚拟 DOM 中,事件可以被看作是一种特殊的属性,在 vue 中约定 vnode.props 对象中,凡是以字符串 on 开头的属性都视为 事件.

const vnode = {
   
   type: 'div',
   props: {
   
       onClick: () => {
   
         alert('hello');
       }
   },
   children: 'click here'
}
复制代码

注册和更新事件

注册事件 通过 el.addEventListener 的方式进行注册即可,那如何实现 更新事件 呢?

最简单的方法:

移除 之前的事件处理函数
重新绑定 新的事件处理函数
但这种方式并不是最优的方式,毕竟需要来回 移除、注册 才能实现事件更新,有没有什么方法是可以只注册一次事件,也能实现事件更新的方式呢?

确实有,vue 中也是这么设计的:

伪造一个事件处理函数 invoker.value,将真正的事件处理函数设置为 invoker.value 属性的值
事件绑定时,先从 el._vei 读取对应的 invoker,若不存在,则将伪造的 invoker 作为事件处理函数,并将它缓存到 el._vei 属性中
将真正的事件处理函数赋值给 invoker.value 属性,把伪造的 invoker 函数作为事件处理函数绑定到元素上
事件触发时,实际上执行的是伪造的 invoker 函数,而 invoker 事件处理函数中会执行 invoker.value() 即 真正的事件处理函数
事件需要进行更新时,直接将 invoker.value 的值重新赋值即可,不需通过 removeEventListener 移除事件
当然若事件更新时确实属于事件移除操作,则还是需要通过 removeEventListener 移除事件
源码如下:

Vuejs 设计与实现 —— 渲染器核心:挂载与更新

挂载节点
通过 patch(n1, n2, container, anchor = null, …) 函数的初次调用实现元素挂载:

首次调用 patch 函数时,n1 = null 因为是挂载阶段,因此没有旧 vnode,当 patch 函数执行时,会递归调用 mountElement 函数完成挂载
第三个参数 anchor 是挂载点,最终通过 insertBefore 插入到文档中
在挂载过程中还会触发不同生命周期钩子的执行,具体的内容就不在详细进行分析了,感兴趣的可自行阅读源码

卸载操作

卸载操作实际上是发生在更新阶段,这里的更新时指,在初次挂载完成之后,后续渲染还会触发更新,只不过新 vnode 会变成 null,从而进入卸载阶段:

容器的内容可能是某个或多个组件渲染的,当卸载发生时,应该正确地调用这些组件的 beforeUnmount、unmounted 等生命周期函数
即使内容不是由组件渲染的,有的元素上存在自定义指令等,也应该要在卸载操作发生时,正确地执行对应的指令钩子函数
同时需要移除绑定在 DOM 元素上的事件处理函数
基于以上原因,卸载不能简单的通过 innerHTML 来完成卸载操作,源码中通过 unmount 函数,以及一些对应移除函数实现卸载操作

Vuejs 设计与实现 —— 渲染器核心:挂载与更新

更新子节点最佳方式

对于一个元素来说,其子节点拥有以下 3 种情况:

没有子节点,即 vnode.children = null
子节点是 文本节点,即 vnode.children 的值为字符串
其他情况,无论是单个子元素,还是多个子节点(可能存在文本和元素的混合),都可以用数组来表示,即 vnode.children = […]
有了规范化的子节点类型,那就可以总结更新子节点时的全部可能:

Vuejs 设计与实现 —— 渲染器核心:挂载与更新

而在的实际的代码中,并不需要罗列去处理以上的所有情况,而更新方式必然也不是采用 “笨方式”:卸载所有子节点,在挂载所有新节点,更好的做法是,通过 Diff 算法比较新旧两组子节点,试图最大程度复用 DOM 元素。

源码附件已经打包好上传到百度云了,大家自行下载即可~

链接: https://pan.baidu.com/s/14G-bpVthImHD4eosZUNSFA?pwd=yu27
提取码: yu27
百度云链接不稳定,随时可能会失效,大家抓紧保存哈。

如果百度云链接失效了的话,请留言告诉我,我看到后会及时更新~

开源地址

码云地址:
http://github.crmeb.net/u/defu

Github 地址:
http://github.crmeb.net/u/defu

开源不易,Star 以表尊重,感兴趣的朋友欢迎 Star,提交 PR,一起维护开源项目,造福更多人!

w3cjava

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: