今天这一篇分享下vue3的虚拟dom
本篇的相关的解析,我们先用文字描述逻辑,然后再转换成代码实现
其实VDOM的核心是更新,这一篇我们主要看下更新的算法,其他的顺带的讲一讲。
虚拟DOM是什么?
const VNode = {
type: 'div',
el:"真实DOM引用",
key:'',
props: {
id: 'app'
},
children: [
'你好啊!',
{
type: 'p',
children: '这是p标签'
},
{
type: 'span'
}
]
}
这就是虚拟DOM,用一个对象来描述真实Dom,而没有真实Dom上那么多我们不需要的属性。 平时我们看调用render都是renderer.render,这里renderer是做跨平台用。
接下来我们抛开跨平台,我们来讲下更新虚拟dom都有哪些事情要做:
- 首先我们需要有一个render函数用来做整个应用的入口,用来是更新还是卸载,及给根元素加个属性存vnode方便做patch更新对比用(只有根html节点存了)。
- 我们需要有一个patch函数,来对比新旧VNode来进行打补丁更新,更新分三种情况,挂载,卸载,对比更新。
- 剩下就是unmount卸载函数,mountElement挂载函数,patchElement对比更新(patchChildren更新子节点 这里是重点 会涉及到更新算法)
render函数实现
render函数接收两个参数,一个是vnode,一个是要往哪个元素上添加的 html元素,实现如下
- 先判断vnode有没有,有vnode时,如果旧的有oldvnode有那就是更新,没有那就是新增。
- 如果vnode没有,而oldvnode旧的有,代表上一次有渲染,而新的一轮vnode不存在,证明要卸载。
function render(vnode, container) {
if (vnode) {
// 如果有vnode情况下 container._vnode有是更新 没有是新增
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
// 新的vnode没有 旧的有证明 新一轮调用 旧的被卸载了 所以执行卸载
unmount(container._vnode)
}
}
container._vode = vnode
}
const VNode = {
type: 'div',
key:'1',
props: {
id: 'app1'
},
children: [
'你好啊!',
{
type: 'p',
children: '这是p标签'
},
{
type: 'span'
}
]
}
render(VNode,document.getElementById('app'))
patch函数实现
- 首先它有4个参数,n1是旧的vnode,n2是新的vnode,container是父容器(插入时插入到这个下面),anchor是锚点元素(插入到它之前,如果是null那就是最后)。
- 走到这个函数里n2一定是存在的,n2不存在那前面就执行卸载操作不会走这里了。
- 首先看看新旧vnode的type是不是一致的,如果不一致一证明从底子就变了,所以需要把旧的n1卸载。
- 在这里我们根据n2新的vnode的type来决定对应不同的处理操作。
- 如果type是字符串证明是普通html元素,然后n1老的不存在,那直接挂载(n2一定存在),如果都存在那就是更新。
- 如果type是text纯文本,那children就是字符串,如果n1老的不存在,那就直接插入文本,如果都存在然后不一样那就更新。
- 如果type是空节点Fragment,Fragment 本身并不会渲染任何内容,只处理 Fragment 的子节点就够了,n1不存在,直接n2.children挂载,如果都存在对比n1.children和n2.children更新。
- 如果type是组件,n1不存在那就直接挂载n2组件,否则组件对比更新。
/**
*
* @param n1 老的vnode
* @param n2 新的vnode
* @param container 父容器
* @param anchor 锚点元素 一般是插入它之前
* 它负责了 更新 和 挂载 和 卸载
*/
function patch(n1, n2, container, anchor) {
// 走这个函数n2 一定是有的
if (n1 && n1.type !== n2.type) {
// n1和n2 type不是同一个 从底子就变了
unmount(n1) //卸载
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
// 正常的元素标签
if (!n1) {
// 老的没有 新的有 这种情况直接挂载n2元素
mountElement(n2, container, anchor) //把n2 插入到 容器container下的 锚点anchor之前
} else {
// 新旧都有 对比更新
patchElement(n1, n2)
}
} else if (typeof type === Text) {
if (!n1) {
// 调用 createText 函数创建文本节点 新的vnode存储el
const el = (n2.el = createText(n2.children)) // children纯文本
insert(el, container) //直接添加
} else {
const el = (n2.el = n1.el) // 新的vnode存储el
if (n2.children !== n1.children) {
// children纯文本
// 调用 setText 函数更新文本节点的内容
setText(el, n2.children)
}
}
} else if (typeof type === Fragment) {
// 由于 Fragment 本身并不会渲染任何内容,所以渲染器只会渲染 Fragment 的子节点
if (!n1) {
// 如果旧 vnode 不存在,则只需要将 Fragment 的 children 逐个挂载即可
n2.children.forEach(c => patch(null, c, container))
} else {
// 如果旧 vnode 存在,则只需要更新 Fragment 的 children 即可
patchChildren(n1, n2, container) //对比更新子集
}
} else if (typeof type === 'Object' || typeof type === 'function') {
//type 是对象 --> 有状态组件 type 是函数 --> 函数式组件
if (!n1) {
//如果n1不存在 n2存在 代表老的没有 新的存在那就是挂载
mountComponent(n2, container, anchor)
} else {
//都存在就进行对比更新
patchComponent(n1, n2, anchor)
}
}
}
//虚拟 DOM 描述更多类型的真实 DOM 这种情况下会出现文本节点,所以上面patch也处理了 text节点
<div><!-- 注释节点 -->我是文本节点</div>
const Text = Symbol()
const newTextVNode = {
// 描述文本节点
type: Text,
children: '我是文本节点'
}
// 注释节点的 type 标识
const Comment = Symbol()
const newVNode = {
// 描述注释节点
type: Comment,
children: '注释节点'
}
const vnode = {
type:'div',
children:[
newVNode,
newTextVNode
]
}
unmount函数实现
-
vnode如果是空元素,空元素时是只会渲染它的子节点,所以我们直接卸载子节点。
-
vnode如果是组件,如果是keepAlive的组件这个放在后面和编译器一起讲。
-
普通组件直接卸载,正常的组件vnode.component是 模拟的组件实例,subTree是组件render返回的vnode,渲染器渲染组件时添加的一系列属性。
-
元素标签的话直接卸载,过渡组件化的时候讲。
function unmount(vnode) {
if (vnode.type === Fragment) {
// 空标签 不渲染 渲染的使它的子集 所以把子集直接卸载
vnode.children.forEach((c) => unmount(c))
return
} else if (typeof vnode.type === 'object') {
// 是组件
if (vnode.shouldKeepAlive) {
//处理keepalive 这块vnode 编译器加渲染器渲染组件时 后续会将 这里纯dom不用关注
vnode.keepAliveInstance._deActivate(vnode)
} else {
// 正常的组件 vnode.component是 模拟的组件实例,subTree是组件render返回的vnode,渲染器渲染组件时添加的一系列属性
unmount(vnode.component.subTree)
}
return
}
// vnode.type是元素标签 找他的父级 从他父级卸载
const parent = vnode.el.parentNode
if (parent) {
const performRemove = () => parent.removeChild(vnode.el)
//通过 vnode.transition 处理过渡
const needTransition = vnode.transition
if (needTransition) {
// xxx
} else {
//直接卸载元素
performRemove()
}
}
}
mountElement实现
- 挂载时主要区分 children是字符串还是数组,如果是字符串那证明有文本子节点,是数组证明有多个子元素。
- 根据type创建元素标签el
- 如果是字符串直接el设置内容然后挂载el到指定容器上。
- 如果是数组调用patch给el添加子元素之后再把el挂载到指定元素上。
function mountElement(vnode, container, anchor) {
const el = (vnode.el = createElement(vnode.type))
// 挂载子节点,首先判断 children 的类型
// 如果是字符串类型,说明是文本子节点
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children)
} else if (Array.isArray(vnode.children)) {
// 如果是数组,说明是多个子节点
vnode.children.forEach(child => {
patch(null, child, el)
})
}
if (vnode.props) {
for (const key in vnode.props) {
patchProps(el, key, null, vnode.props[key])
}
}
insert(el, container, anchor)
}
/**
*
* @param el 要插入的元素
* @param parent 插入的父级元素
* @param anchor 锚点元素 插入到它之前 为null时插入到父级最后
*/
function insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor)
}
patchElement实现
- 这个是更新html标签的函数,到这个函数里标签一定是一样的,所以它的实现主要是更新属性props。
- 这里主要就是先循环新的props看和旧的对应值是否一致,不一致就更新。
- 然后再循环旧的props,如果有的key在新的里不存在了,证明被删除了,那就删掉该属性。
- 最后调用patchChildren,更新子节点是对一个元素进行打补丁的最后 一步操作。我们将它封装到 patchChildren 函数中,更新子集我们就使用patchChildren。
/**
* 更新html元素
* @param {*} n1 旧vnode
* @param {*} n2 新vnode
*/
function patchElement(n1,n2){
const el = n2.el = n1.el;
const oldProps = n1.props;
const newProps = n2.props;
// 更新props
for(const key in newProps){
if(newProps[key] !== oldProps[key]){
patchProps(el,key,oldProps[key],newProps[key]);
}
}
// 清楚不存在的props
for(const key in oldProps){
if(!(key in newProps)){
patchProps(el,key,oldProps[key],null)
}
}
// 更新内容children
patchChildren(n1,n2,el)
}
这里我们看到了patchProps,它更新属性时对事件做了特殊处理优化,就是内部伪造处理函数invoker绑定,然后元素el上存储了实际函数最后执行,更新也是更新el上的存储,这样就可以防止频繁的解绑和绑定。 代码类似这样
el._vei={
onClick:(e)=>{ el._vei.onClick.value.forEach(fn => fn(e)或者el._vei.onClick.value(e))}
}
el._vei.onClick.value = 数组事件集合或者单一事件回调函数
el.addEventListener(name, el._vei.onClick)
//就是把函数存储到el上,然后模拟了一个el._vei.onClick点击事件绑定,每次就更新el._vei.onClick.value,防止频繁绑定事件
1 patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const invokers = el._vei || (el._vei = {})
let invoker = invokers[key]
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
invoker = el._vei[key] = (e) => {
// 如果 invoker.value 是数组,则遍历它并逐个调用事件处理函数
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
// 否则直接作为函数调用
invoker.value(e)
}
}
invoker.value = nextValue
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}
patchChildren实现
- 对比更新其实就是children的三种情况,它是字符串,它是数组,它不存在,然后新旧vnode.children都有这三种情况,我们把他们过一下就可以。
- 因为是渲染新vnode.children,所以我们以新的children为主,还是老样子n1是旧,n2是新。
- n2.children是字符串时,n1.children是数组那就卸载,否则直接用n2.children替换n1.children.
- n2.children是数组时,n1.children是数组那就对比更新(重点算法,后面补充),否则容器清空再挂载n2.children.
- n2.children不存在,n1.children是数组那就卸载,是字符串那就清空容器,要是n1.children不存在那就什么都不用做。
/**
* 对比子节点更新
* @param {*} n1 老的vnode
* @param {*} n2 新的vnode
* @param {*} container 正在被打补丁的 DOM 元素 el
*/
function patchChildren(n1, n2, container) {
//判断新子节点的类型是否是文本子节点
if (typeof n2.children === 'string') {
// 老节点有三种情况 一组子节点children数组 没有子节点 文本子节点
if (Array.isArray(n1.children)) {
// 老的直接卸载掉 新的就一个字符串 老的都不一样了
n1.children.forEach(function (child) { unmount(child) })
}
// 将新的文本节点内容设置给容器元素 这个操作可以直接包含上面所有了
setElementText(container, n2.children)
} else if (Array.isArray(n2.children)) {
// 说明新子节点是一组子节点
if (Array.isArray(n1.children)) {
// 新旧都是一个数组 这里需要核心算法 对比新旧子节点
// 这里临时写一个 最简陋的处理 后面写主要的算法补充这里
//卸载旧的
n1.children.forEach(child => { unmount(child) })
//挂载新的
n2.children.forEach(child => { patch(null, child, container) })
} else {
// 这会老子节点要么是字符串 要么不存在
// 无论那种情况 我们只需要把容器清空 然后挂载新的这组子节点就够了
setElementText(container, '')
n2.children.forEach(child => { patch(null, child, container) })
}
} else { //到这里说明新节点不存在,新节点和老的一样也是三种情况 字符串 数组前面都处理了
//这里处理新节点不存在
if (Array.isArray(n1.children)) {
//老的是数组
n1.children.forEach(function (child) { unmount(child) })
} else if (typeof n1.children === 'string') {
// 老的是字符串 给他清空
setElementText(container, '')
}
// 如果也没有旧子节点,那么什么都不需要做
}
}
到这里基础的虚拟dom树的同级比对的顺序和逻辑就完了,下一篇算法篇我们来,具体的看新旧VNode都有children子集时,涉及到常见的几种算法。