前言
挂载 与 更新 是 渲染器 的核心功能,也是渲染器应该要提供的基本功能,而 挂载 和 更新 又是基于 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
的 初始值
正确处理普通的 props
- 通过
in
操作符判断props.key
是否存在el(即 DOM Properties)
上- 若 存在 则优先设置
DOM Properties
,即el[props.key] = props.value
- 若 不存在 则通过
el.setAttribute(key, value)
完成属性设置 - 针对 只读 属性的
DOM Properties
,不能直接进行赋值,因此也必须转换为el.setAttribute(key, value)
的处理,如:<input form="form1">
中的form
属性就是只读属性
源码中抽离了shouldSetAsProp
用于去判断是否可通过DOM Properties
去更新:
- 若 存在 则优先设置
特殊处理 class
Vue.js
对 class
做了增强:
- 指定 class 为普通 字符串
- 指定 class 为一个 对象
- 指定 class 为包含上述两种类型的 数组
由于 class
的值以多种形式存在,因此需要对 class
进行一些特殊处理,将 class
的值统一为字符串的形式,因为 HTML
只接收这样的 class
源码中通过 normaliz
处理不同的 class
类型,并统一返回字符串形式:
选择设置 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
移除事件
源码如下:
挂载节点
通过 patch(n1, n2, container, anchor = null, ...)
函数的初次调用实现元素挂载:
- 首次调用
patch
函数时,n1 = null
因为是挂载阶段,因此没有旧vnode
,当patch
函数执行时,会递归调用mountElement
函数完成挂载 - 第三个参数
anchor
是挂载点,最终通过insertBefore
插入到文档中
在挂载过程中还会触发不同生命周期钩子的执行,具体的内容就不在详细进行分析了,感兴趣的可自行阅读源码
卸载操作
卸载操作实际上是发生在更新阶段,这里的更新时指,在初次挂载完成之后,后续渲染还会触发更新,只不过新 vnode
会变成 null
,从而进入卸载阶段:
- 容器的内容可能是一个或多个组件渲染的,当卸载发生时,应该正确地调用这些组件的
beforeUnmount、unmounted
等生命周期函数 - 即使内容不是由组件渲染的,有的 元素上存在自定义指令 等,也应该要在卸载操作发生时,正确地执行对应的指令钩子函数
- 同时需要移除绑定在
DOM
元素上的事件处理函数
基于以上原因,卸载不能简单的通过 innerHTML
来完成卸载操作,源码中通过 unmount
函数,以及一些对应移除函数实现卸载操作
更新子节点最佳方式
对于一个元素来说,其子节点拥有以下 3
种情况:
- 没有子节点,即
vnode.children = null
- 子节点是 文本节点,即
vnode.children
的值为字符串 - 其他情况,无论是单个子元素,还是多个子节点(可能存在文本和元素的混合),都可以用数组来表示,即
vnode.children = [...]
有了规范化的子节点类型,那就可以总结更新子节点时的全部可能:
而在的实际的代码中,并不需要罗列去处理以上的所有情况,而更新方式必然也不是采用 “笨方式”:卸载所有子节点,在挂载所有新节点
,更好的做法是,通过 Diff 算法比较新旧两组子节点,试图最大程度复用 DOM
元素。
具体的 diff
算法,会在下一篇文章中进行介绍,并且会对比 vue2
和 vue3
中的 diff
算法。