一、前言
目前最流行的两大前端框架,React 和 Vue,都不约而同的借助 Virtual DOM 技术提高页面的渲染效率。那么,什么是 Virtual DOM ?它是通过什么方式去提升页面渲染效率的呢?本系列文章会详细讲解 Virtual DOM 的创建过程,并实现一个简单的 Diff 算法来更新页面。本文的内容脱离于任何的前端框架,只讲最纯粹的 Virtual DOM 。敲单词太累了,下文 Virtual DOM 一律用 VD 表示。
这是 VD 系列文章的第二篇,本文将会实现一个简单的 VD Diff 算法,计算出差异并反映到真实的 DOM 上去。
二、思路
使用 VD 的框架,一般的设计思路都是页面等于页面状态的映射,即 UI=render(state)
。当需要更新页面的时候,无需关心 DOM 具体的变换方式,只需要改变 state
即可,剩下的事情( render
)将由框架代劳。我们考虑最简单的情况,当 state 发生变化时,我们重新生成整个 VD ,触发比较的操作。上述过程分为以下四步:
state 变化,生成新的 VD
比较 VD 与之前 VD 的异同
生成差异对象(
patch
)遍历差异对象并更新 DOM 差异对象的数据结构是下面这个样子,与每一个 VDOM 元素一一对应:
{
type,
vdom,
props: [{
type,
key,
value
}]
children
}
最外层的 type 对应的是 DOM 元素的变化类型,有 4 种:新建、删除、替换和更新。props 变化的 type 只有 2 种:更新和删除。枚举值如下:
const nodePatchTypes = {
CREATE: 'create node',
REMOVE: 'remove node',
REPLACE: 'replace node',
UPDATE: 'update node'
}
const propPatchTypes = {
REMOVE: 'remove prop',
UPDATE: 'update prop'
}
三、代码实现
我们做一个定时器,500 毫秒运行一次,每次对 state 加 1。页面的 li
元素的数量随着 state 而变。
let state = { num: 5 };
let timer;
let preVDom;
function render(element) {
// 初始化的 VD
const vdom = view();
preVDom = vdom;
const dom = createElement(vdom);
element.appendChild(dom);
timer = setInterval(() => {
state.num += 1;
tick(element);
}, 500);
}
function tick(element) {
if (state.num > 20) {
clearTimeout(timer);
return;
}
const newVDom = view();
}
function view() {
return (
<div>
Hello World
<ul>
{
// 生成元素为0到n-1的数组
[...Array(state.num).keys()]
.map( i => (
<li id={i} class={`li-${i}`}>
第{i * state.num}
</li>
))
}
</ul>
</div>
);
}
接下来,通过对比 2 个 VD,生成差异对象。
function tick(element) {
if (state.num > 20) {
clearTimeout(timer);
return;
}
const newVDom = view();
// 生成差异对象
const patchObj = diff(preVDom, newVDom);
}
function diff(oldVDom, newVDom) {
// 新建 node
if (oldVDom == undefined) {
return {
type: nodePatchTypes.CREATE,
vdom: newVDom
}
}
// 删除 node
if (newVDom == undefined) {
return {
type: nodePatchTypes.REMOVE
}
}
// 替换 node
if (
typeof oldVDom !== typeof newVDom ||
((typeof oldVDom === 'string' || typeof oldVDom === 'number') && oldVDom !== newVDom) ||
oldVDom.tag !== newVDom.tag
) {
return {
type: nodePatchTypes.REPLACE,
vdom: newVDom
}
}
// 更新 node
if (oldVDom.tag) {
// 比较 props 的变化
const propsDiff = diffProps(oldVDom, newVDom);
// 比较 children 的变化
const childrenDiff = diffChildren(oldVDom, newVDom);
// 如果 props 或者 children 有变化,才需要更新
if (propsDiff.length > 0 || childrenDiff.some( patchObj => (patchObj !== undefined) )) {
return {
type: nodePatchTypes.UPDATE,
props: propsDiff,
children: childrenDiff
}
}
}
}
// 比较 props 的变化
function diffProps(oldVDom, newVDom) {
const patches = [];
const allProps = {...oldVDom.props, ...newVDom.props};
// 获取新旧所有属性名后,再逐一判断新旧属性值
Object.keys(allProps).forEach((key) => {
const oldValue = oldVDom.props[key];
const newValue = newVDom.props[key];
// 删除属性
if (newValue == undefined) {
patches.push({
type: propPatchTypes.REMOVE,
key
});
}
// 更新属性
else if (oldValue == undefined || oldValue !== newValue) {
patches.push({
type: propPatchTypes.UPDATE,
key,
value: newValue
});
}
}
)
return patches;
}
// 比较 children 的变化
function diffChildren(oldVDom, newVDom) {
const patches = [];
// 获取子元素最大长度
const childLength = Math.max(oldVDom.children.length, newVDom.children.length);
// 遍历并diff子元素
for (let i = 0; i < childLength; i++) {
patches.push(diff(oldVDom.children[i], newVDom.children[i]));
}
return patches;
}
计算得出的差异对象是这个样子的:
{
type: "update node",
props: [],
children: [
null,
{
type: "update node",
props: [],
children: [
null,
{
type: "update node",
props: [],
children: [
null,
{
type: "replace node",
vdom: 6
}
]
}
]
},
{
type: "create node",
vdom: {
tag: "li",
props: {
id: 5,
class: "li-5"
},
children: ["第", 30]
}
}
]
}
下一步就是遍历差异对象并更新 DOM 了:
function tick(element) {
if (state.num > 20) {
clearTimeout(timer);
return;
}
const newVDom = view();
// 生成差异对象
const patchObj = diff(preVDom, newVDom);
preVDom = newVDom;
// 给 DOM 打个补丁
patch(element, patchObj);
}
// 给 DOM 打个补丁
function patch(parent, patchObj, index=0) {
if (!patchObj) {
return;
}
// 新建元素
if (patchObj.type === nodePatchTypes.CREATE) {
return parent.appendChild(createElement(patchObj.vdom));
}
const element = parent.childNodes[index];
// 删除元素
if (patchObj.type === nodePatchTypes.REMOVE) {
return parent.removeChild(element);
}
// 替换元素
if (patchObj.type === nodePatchTypes.REPLACE) {
return parent.replaceChild(createElement(patchObj.vdom), element);
}
// 更新元素
if (patchObj.type === nodePatchTypes.UPDATE) {
const {props, children} = patchObj;
// 更新属性
patchProps(element, props);
// 更新子元素
children.forEach( (patchObj, i) => {
// 更新子元素时,需要将子元素的序号传入
patch(element, patchObj, i)
});
}
}
// 更新属性
function patchProps(element, props) {
if (!props) {
return;
}
props.forEach( patchObj => {
// 删除属性
if (patchObj.type === propPatchTypes.REMOVE) {
element.removeAttribute(patchObj.key);
}
// 更新或新建属性
else if (patchObj.type === propPatchTypes.UPDATE) {
element.setAttribute(patchObj.key, patchObj.value);
}
})
}
到此为止,整个更新的流程就执行完了。可以看到页面跟我们预期的一样,每 500 毫秒刷新一次,构造渲染树和绘制页面花的时间也非常少。
作为对比,如果我们在生成新的 VD 后,不经过比较,而是直接重新渲染整个 DOM 的时候,会怎样呢?我们修改一下代码:
function tick(element) {
if (state.num > 20) {
clearTimeout(timer);
return;
}
const newVDom = view();
newDom = createElement(newVDom);
element.replaceChild(newDom, dom);
dom = newDom;
/*
// 生成差异对象
const patchObj = diff(preVDom, newVDom);
preVDom = newVDom;
// 给 DOM 打个补丁
patch(element, patchObj);
*/
}
效果如下:
可以看到,构造渲染树( Rendering
)和绘制页面( Painting
)的时间要多一些。但另一方面花在 JS 计算( Scripting
)的时间要少一些,因为不需要比较节点的变化。如果算总时间的话,重新渲染整个 DOM 花费的时间反而更少,这是为什么呢?
其实原因很简单,因为我们的 DOM 树太简单了!节点很少,使用到的 css 也很少,所以构造渲染树和绘制页面就花不了多少时间。VD 真正的效果还是要在真实的项目中才体现得出来。
四、总结
本文详细介绍如何实现一个简单的 VD Diff 算法,再根据计算出的差异去更新真实的 DOM 。然后对性能做了一个简单的分析,得出使用 VD 在减少渲染时间的同时增加了 JS 计算时间的结论。基于当前这个版本的代码还能做怎样的优化呢,请期待下一篇的内容:《你不知道的 Virtual DOM(三):Virtual DOM 更新优化》
P.S: 想看完整代码见这里,如果有必要建一个仓库的话请留言给我:代码(https://gist.github.com/dickenslian/a0a8d41a88d566d86271de16cd7738f0)
更多内容推荐
解析 React 性能利器 — Fiber
在两次硬件刷新之间浏览器进行两次重绘是没有意义的只会消耗性能。浏览器会利用这个间隔 16ms(一帧)适当地对绘制进行节流,如果在 16ms 内做了太多事情,会阻塞渲染,造成页面卡顿, 因此 16ms 就成为页面渲染优化的一个关键时间
从 React 源码角度看 useCallback,useMemo,useContext
useCallback和useMemo是一样的东西,只是入参有所不同。
2022-12-13
FastApi-12-Form 表单
相信你一定听过或者见过 HTMl 的 form 元素,这里所指的 Form 表单就是 FastApi 用来获取 HTML 中 form 元素的对象。
2021-08-12
39|语法扩展:通过 JSX 来做语法扩展
这节课我们就来看看JSX是如何用在Web UI开发中的。即使你不使用React,这样的模版模式也有很大的借鉴意义。
2022-12-17
从源码角度看 React-Hydrate 原理
React 渲染过程,即ReactDOM.render执行过程分为两个大的阶段:render 阶段以及 commit 阶段。React.hydrate渲染过程和ReactDOM.render差不多,两者之间最大的区别就是,ReactDOM.hydrate 在 render 阶段,会尝试复用(hydrate)浏览器现有的 dom 节点,并相互
2022-10-17
10|动态渲染组件:如何实现 Vue 的动态渲染组件?
设计动态渲染组件的使用函数方法的API,越简洁越好,核心是要控制组件渲染的挂载和卸载的生命周期.
2022-12-14
从 React 源码角度看 useCallback,useMemo,useContext
useCallback和useMemo是一样的东西,只是入参有所不同。
2022-09-30
从 React 源码角度看 useCallback,useMemo,useContext
useCallback和useMemo是一样的东西,只是入参有所不同。
2022-11-23
15|不可变数据:为什么对 React 这么重要?
我们这节课的主要内容是不可变数据。
2022-10-04
从 React 源码角度看 useCallback,useMemo,useContext
useCallback和useMemo是一样的东西,只是入参有所不同。
2022-11-09
从源码角度看 React-Hydrate 原理
React 渲染过程,即ReactDOM.render执行过程分为两个大的阶段:render 阶段以及 commit 阶段。React.hydrate渲染过程和ReactDOM.render差不多,两者之间最大的区别就是,ReactDOM.hydrate 在 render 阶段,会尝试复用(hydrate)浏览器现有的 dom 节点,并相互
2022-11-02
从 React 源码角度看 useCallback,useMemo,useContext
useCallback和useMemo是一样的东西,只是入参有所不同。
2022-10-28
呵呵,JavaScript 真好玩(苦笑脸)
首先,问个问题:在 JavaScript 中,怎样使 x !== x ?
2022-05-06
26|特殊型:前端有哪些处理加载和渲染的特殊“模式”?
系统学习响应式编程在JS中的设计模式,包括组件化、加载渲染和性能优化模式。
2022-11-17
vue 指令 -1
前言:指令是带有v-前缀的特殊属性,其值限定为单个表达式。指令的作用是,当表达式得值发现变化,将其产生的连带影响应用到DOM上
2022-06-05
32|Fabric:新渲染器的演进之路
对核心渲染流程的持续迭代和优化,是 React Native 能够广受欢迎的重要原因之一。
2023-01-01
【Flutter 专题】106 图解 AnimatedWidget & AnimatedBuilder 动画应用
0 基础学习 Flutter,第一百零六步:学习一下 AnimatedWidget 和 AnimatedBuilder 简单的动画应用!
2021-06-12
细说节流(Throttle)和防抖(Debounce)
节流(Throttle)和防抖(Debounce)对于前端开发人员来说应该是十分熟悉的,节流(Throttle)和防抖(Debounce)是两种可以节省性能的编程技术,两者的目的都是为了优化性能,提高用户体验,都是基于 DOM 事件限制正在执行的 JavaScript 数量的方法。但两者
2021-07-24
16|单页面应用:如何理解和实现单页面应用开发?
希望你充分明白单页面路由的技术原理,理解API背后是如何运行的,而不只是停留在vue-router的API使用。
2022-12-30
从源码角度看 React-Hydrate 原理
React 渲染过程,即ReactDOM.render执行过程分为两个大的阶段:render 阶段以及 commit 阶段。React.hydrate渲染过程和ReactDOM.render差不多,两者之间最大的区别就是,ReactDOM.hydrate 在 render 阶段,会尝试复用(hydrate)浏览器现有的 dom 节点,并相互
2022-11-25
推荐阅读
ReactDOM.render 在 react 源码中执行之后发生了什么?
2023-02-19
04|Vue 概览:Vue 哪些内容是你必须要掌握的?
2023-05-01
观点碰撞燃爆会场|2023 开放原子全球开源峰会区块链分论坛圆满落幕
2023-06-19
特别加餐|用 ChatGPT 开发一个看板项目
2023-05-22
25|后台搭建功能:如何设计和实现 Vue.js 运营后台的搭建功能?
2023-02-06
从 React 源码角度看 useCallback,useMemo,useContext
2023-02-28
从源码角度看 React-Hydrate 原理
2023-01-05
电子书
大厂实战PPT下载
换一换 郭凤钊(已晨) | 菜鸟网络 高级技术专家
周华 | 智源研究院 大模型行业应用总监
肖然 | Thoughtworks 全球数字化转型专家
推荐阅读
ReactDOM.render 在 react 源码中执行之后发生了什么?
2023-02-19
04|Vue 概览:Vue 哪些内容是你必须要掌握的?
2023-05-01
观点碰撞燃爆会场|2023 开放原子全球开源峰会区块链分论坛圆满落幕
2023-06-19
特别加餐|用 ChatGPT 开发一个看板项目
2023-05-22
25|后台搭建功能:如何设计和实现 Vue.js 运营后台的搭建功能?
2023-02-06
从 React 源码角度看 useCallback,useMemo,useContext
2023-02-28
从源码角度看 React-Hydrate 原理
2023-01-05
评论