实现 虚拟 dom 和 diff
基于前面 2 篇文章
我们分别实现了 reactive, effectWatch, setup, render
但是有个性能问题:
// ...
effectWatch(() => {
rootContainer.innerHTML = "";
// const ele = rootComponent.render(context);
// rootContainer.append(ele);
const vnode = rootComponent.render(context);
mountElement(vnode, rootContainer)
})
复制代码
每次 dom 更新,这里都是销毁所有 dom 然后重新插入所有 dom;
我们的目标:实现局部更新,这就要用到 diff 算法;
在定义 diff 算法之前,我们回想一下我们定义的 vnode 结构:
- tag
- props
- children
因此,diff 算法对比 vnode 差异,也是针对上述的 几个属性进行对比;
对比后如何更新呢?
- tag
el.replaceWith(newEl)
复制代码
- props
// 分为几种情况 去 setAttribute / removeAttribute
// - 修改
// - 新增
// - 删除
复制代码
- children
// children --> 简化的diff,使用暴力解法
// 1. newChidren -> string (oldChildren -> string oldChildren -> array)
// 2. newChildren -> array (oldChildren -> string oldchildren -> array)
复制代码
引入 diff 比较
- 修改
/core/index.js
文件代码,引入diff
代码:
import { effectWatch } from './reactivity/index.js'
import { mountElement, diff } from './renderer/index.js'
export function createApp(rootComponent) {
return {
mount(rootContainer) {
const context = rootComponent.setup();
let isMounted = false, preVnodeTree = null;
effectWatch(() => {
if (!isMounted) {
// 初始化
isMounted = true;
rootContainer.innerHTML = "";
const vnodeTree = rootComponent.render(context);
mountElement(vnodeTree, rootContainer)
preVnodeTree = vnodeTree;
} else {
console.log('update');
// dom 更新时,走 diff 算法
const vnodeNewTree = rootComponent.render(context);
diff(preVnodeTree, vnodeNewTree);
preVnodeTree = vnodeNewTree;
}
})
},
}
}
复制代码
说明
isMounted
:为了性能考虑,考虑 DOM 的挂载和更新 这两种状态,因此使用isMounted
变量来区分preVnodeTree
:因为考虑到 更新 DOM 是基于 旧的 DOM 来进行更新,那必然需要一个变量来存储 旧 DOM,因此使用preVnodeTree
来表示旧的 DOM;需要注意的是,每次挂载完或更新完 DOM 后,需要更新preVnodeTree
diff
:更新时,需要比较和更新 DOM;因此这里 diff 算法没有返回值,接下来我们来看 diff 算法的实现
diff 实现细节
文件所在位置:
/core/renderer/index.js
- diff 函数设计:接收 新旧的 vnode,对新旧 vnode 进行比较更新;
// n1 --> old
// n2 --> new
export function diff(n1, n2) {}
复制代码
接下来,我们实现内部的细节:
如上一小节我们提到的,diff 主要更新 vnode 的 3 大属性:tag, props, children
,我们来一个个实现:
- tag 更新,主要使用了 API
el.replaceWith(newEl)
// n1 --> old
// n2 --> new
export function diff(n1, n2) {
console.log('n1-old: ', n1);
console.log('n2-new: ', n2);
// tag
if (n1.tag !== n2.tag) {
n1.el.replaceWith(document.createElement(n2.tag))
}
}
复制代码
这行代码还是比较好理解的;
- props 更新:主要是对比 两个对象的 key 和 value 是否一致,以及是否新增或者删除
// 细节注意: 保证 n2 有可挂载的 el
const el = (n2.el = n1.el);
// props 属性如下结构:
// new -> {id: 'foo', class: 'bar', a: 'a' }
// old -> {id: 'foo', class: 'bar1', a: 'a', b: 'b' }
const { props: oldProps } = n1;
const { props: newProps } = n2;
// update / add
if (oldProps && newProps) {
// 检查新的 props 里面的 key-value 是否一致,不一致则更新;
// 若 old 没有,则表示为 新增属性,也一样更新;
Object.keys(newProps).forEach((key) => {
const newVal = newProps[key]
const oldVal = oldProps[key]
if (newVal !== oldVal) {
el.setAttribute(key, newVal);
}
})
}
// delete
if (oldProps) {
// 检查是否删除属性:旧dom有,新dom 没有,那么移出这个属性
Object.keys(oldProps).forEach((key) => {
if (!newProps[key]) {
el.removeAttribute(key);
}
})
}
复制代码
上述代码和注释应该还是比较好理解的,主要分2个步骤处理属性不同问题:更新/新增、删除;
- children 更新:
因为需要判断多种类型,我们先定义一个工具函数判断类型:
function isNumberOrString(value) {
return ['string', 'number'].includes(typeof value);
}
复制代码
接下来我们看 children 的 diff ,伪代码逻辑为:
- 若 新节点 为 字符串或者数字
- 若 旧节点 是否为 字符串或者为数字
- 比较值是否相等,不等则 新值 替换 旧值
- 若 旧节点 为 数组
- 则新值替换旧值
- 若 旧节点 是否为 字符串或者为数字
- 若 新节点为数组
- 若 旧节点为 字符串或数字
- 则将 旧节点的值清空,让它成为空的容器
- 在容器中挂载 新的节点(数组)
- 若 旧节点为 数组:比较两个数组的差异,这里我们使用简化的比较,仅 通过数组的长度比较公共 vnode 的差异,然后再处理长度之外的部分
- 获取公共数组的长度 length,遍历每个数组的值,递归 diff
- 若 新节点的长度大于 length,则表示节点新增,那么从 length 位置开始遍历,挂载新的节点
- 若 旧节点的长度大于 length,则表示 节点删除,那么从 length 位置开始遍历,在当前节点的父节点中删除旧节点的值
- 若 旧节点为 字符串或数字
细节实现:
// children -- diff
const { children: newChildren } = n2;
const { children: oldChildren } = n1;
// 都为字符串/数字
if (isNumberOrString(newChildren)) {
// old
if (isNumberOrString(oldChildren)) {
if (newChildren !== oldChildren) {
el.textContent = newChildren
}
} else if(Array.isArray(oldChildren)) {
el.textContent = newChildren;
}
} else if (Array.isArray(newChildren)) {
if (isNumberOrString(oldChildren)) {
el.innerText = ""
mountElement(n2, el)
} else if (Array.isArray(oldChildren)) {
// new {a, b, c}
// old {a, b, c, d}
// 理论上:每个节点都要 diff 去递归比较
// 事实上,为了简化代码,暂且暴力处理
// 处理公共的 vnode
const length = Math.min(newChildren.length, oldChildren.length)
for (let i=0; i<length; i++) {
const newVnode = newChildren[i]
const oldVnode = oldChildren[i]
diff(oldVnode, newVnode);
}
// 节点新增
// old {a, b, c}
// new {a, b, c, d}
if (newChildren.length > length) {
// 创建节点
for (let i=length; i < newChildren.length; i++) {
const newVnode = newChildren[i]
mountElement(newVnode, el)
}
}
// 节点删除
// old {a, b, c, d}
// new {a, b, c}
if (oldChildren.length > length) {
for (let i = length; i<oldChildren.length; i++) {
const oldVnode = oldChildren[i]
el.parentNode.removeChild(oldVnode.el)
}
}
}
}
复制代码
此时,通过上述代码,我们实现了简化版本的 diff 算法;
上面提到了 mountElement
方法,它是做什么的?
实际上可以认为是 基于 vnode 创建 dom,具体我们回顾一下:
export function mountElement(vnode, container) {
const { tag, props, children } = vnode;
// tag
const el = vnode.el = document.createElement(tag)
// props
for (let key in props) {
const value = props[key]
el.setAttribute(key, value);
}
// children--> string/number
if (typeof children === 'string' || typeof children === 'number') {
const textNode = document.createTextNode(children)
el.appendChild(textNode);
} else if (Array.isArray(children)) {
// 接受的若为 数组
children.forEach((v) => {
mountElement(v, el);
})
}
container.appendChild(el);
}
复制代码
至此,diff 算法的细节我们也实现啦~
验证
打开 chrome 控制台,点击最右侧倒数第二个工具图标(三个点),我们点击选择 【show console drawer】,
或者直接键盘左上角按下 【Esc】;
目的是方便同时使用 console 面板和 Elements 面板,观察控制台更新 state 的时候,视图的 dom 局部更新
- 测试数据准备:
import { reactive } from "./core/reactivity/index.js";
import { h } from './core/h.js'
export default {
render(context) {
// const div = document.createElement('div');
// div.innerHTML = context.state.count;
// return div;
return h('div',
{ id: 'div-wrapper'},
[
h('h1',
{ id: 'div-test', style: 'color: red; font-size: 24px;', key: `key-${context.state.count}` },
context.state.count
),
h('span',
{ class: 'span-str' },
context.state.str
),
])
},
setup() {
const state = reactive({
count: 1,
str: "Hello, World"
})
window.state = state;
return { state };
}
}
复制代码
- 测试一:控制台输入:
state.count ++
复制代码
页面的数据更新,dom 中也不是所有 dom 刷新了,而是含有 state.count
的组件局部更新了
- 测试二:鼠标选中控制面板中
Elements
面板中的
<span class="span-str">Hello, World</span>
复制代码
然后我们在控制面板中 console
面板中输入:
$0.textContent = "123"
复制代码
此时,我们可以看到,只有上面选中的 span
更新了;
至此,我们的简化版 diff 算法实现总算大功告成