此文主要翻译自:Building a Simple Virtual DOM from Scratch,看原文的同学请直达!

此文是作者在一次现场编程演讲时现场所做的,有关演讲的相关资料我们也可以在原英文链接找到。

背景:什么是虚拟DOM

虚拟DOM指的是用于展现真实DOM的普通JS对象。简单说就是JS的普通对象,通过这个对象可以创建真实的DOM,它保存了创建真实DOM所需的所有东西。

Virtual DOMs usually refer to plain objects representing the actual DOMs.

The Document Object Model (DOM) is a programming interface for HTML documents.

比如,我们这样写:

const $app = document.getElementById('app');

浏览器会创建DOM:

, DOM也是一种对象,它提供了自己的接口,我们可以通过JS控制DOM,如:

$app.innerHTML = 'Hello world';

如果用一个对象来描述上面创建的真实DOM 我们可以像下面这样:

const vApp = {
tagName: 'div',
attrs: {
id: 'app',
}
};

对于虚拟DOM,还没有任何严格的规则要求要怎么创建,或者说创建的对象要遵守什么编程接口,规则等。例如上面的例子,你可以用tagLabel替代tagName,或者props替代attrs。只要它能创建一个真实的DOM,那它可以认为是一个虚拟DOM。

译者注:vue.js与react.js都使用了虚拟DOM,但他们的实现不一样

虚拟DOM不像真实的DOM,它没有提供编程接口,就是普通对象。所以跟真实的DOM相比,它更加轻量。

虽然虚拟DOM更加轻量,但真实DOM才是浏览器最基础的元素,大部分浏览器都对DOM的相关操作都做了大量的优化,所以真实的DOM操作可能不是像很多人说的那样慢的。

安装

https://codesandbox.io/s/7wqm7pv476?expanddevtools=1

我们先用mkdir创建一个项目目录,然后用cd进入刚创建的目录,如下:

$ mkdir /tmp/vdommm
$ cd /tmp/vdommm

然后初始化一个git仓库,用gitignorer创建.gitignore文件,然后用npm初始化项目,如:

$ git init
$ gitignore init node
$ npm init -y

译者注:gitignorer需要npm全局安装,没有安装的可以通过npm instrall gitignorer -g先安装。

现在我们可以先进行初次提交,将现有代码提交到git仓库,如:

$ git add -A
$ git commit -am ':tada: initial commit'

然后我们可以安装 Parcel Bundler(一个正在的零配置打包工具),它支持各种格式的开箱即用。在我做现场编码演讲时会经常用到它。

$ npm install parcel-bundler

译者注:Parcel跟Webpack功能类似,上手也快。

(有趣的是:安装的时候你不在需要 --save 参数), 趁安装parcel的时候,我们创建其他的文件,如下:

src/index.html

<html>
<head>
<title>hello world</title>
</head>
<body>
Hello world
<script src="./main.js"></script>
</body>
</html>

src/main.js

const vApp = {
tagName: 'div',
attrs: {
id: 'app',
},
}; console.log(vApp);

package.json

{
...
"scripts": {
"dev": "parcel src/index.html", // add this script
}
...
}

创建完上面的文件,我们可以直接执行下面的命令,如:

$ npm run dev

## 如果成功会输出下面
Server running at http://localhost:1234
√ Built in 1.26s.

打开浏览器访问: http://localhost:1234/ 你应该会看到 hello world字样,还有控制台会输出我们定义的虚拟DOM对象。如果一切如上,那么你的环境准备完毕。

createElement

https://codesandbox.io/s/n9641jyo04?expanddevtools=1

大多的虚拟DOM实现都会有个叫createElement的方法,通常简称为 h 。这个函数只是简单地返回一个“虚拟元素”,下面让我们看看它的实现。

src/vdom/createElement.js

export default (tagName, opts) => {
return {
tagName,
attrs: opts.attrs,
children: opts.children
};
};

利用对象的析构功能,我们可以改写上面的代码,如:

export default (tagName, {attrs, children}) => {
return {
tagName,
attrs,
children
};
};

当opts为空时,也应该可以创建虚拟元素,所以继续修改代码,如:

export default (tagName, {attrs={}, children=[]}={}) => {
return {
tagName,
attrs,
children
};
};

译者注:opts默认值为{},attrs默认值为{},children默认值为[]

定义了createElement方法,我们现在可以改写main.js,让main.js调用createElement方法创建虚拟DOM,

src/main.js

import createElement from "./vdom/createElement";

const vApp = createElement('div', {
attrs: {
id: 'app'
},
children: [
createElement('img', {
attrs: {
src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif'
}
})
]
}); console.log(vApp);

修改后的main.js, 我们添加了一张来自giphy的图片。回到浏览器,刷新刚才的页面,你会看到控制台输出了新的虚拟DOM。

字面量对象(如:{a: 3})会自动继承自Object。就是说我们的虚拟DOM对象会自动包含hasOwnProperty, toString等这些方法。我们可以用Object.create(null)创建对象,这样不会继承只Object,也可以让我们的虚拟DOM更加的“纯”。所以修改createElement方法如下:

src/vdom/createElement.js

export default (tagName, {attrs={}, children=[]}={}) => {
const vElem = Object.create(null);
Object.assign(vElem, {
tagName,
attrs,
children
});
return vElem;
};

render(vNode)

https://codesandbox.io/s/pp9wnl5nj0?expanddevtools=1

渲染虚拟元素

现在我们已经有了一个函数能创建虚拟DOM,下面一个方法将虚拟DOM翻译成真实的DOM。定义render(vNode)方法,如下:

src/vdom/render.js

const render = vNode => {
const $el = document.createElement(vNode.tagName);
for(const [k,v] of Object.entries(vNode.attrs)){
$el.setAttribute(k, v);
}
for(const child of vNode.children){
$el.appendChild(render(child));
}
return $el;
}; export default render;

上面的代码很简单,就不做过多解释了。

ElementNode与TextNode

对于真实的DOM,实际有8种类型的节点,这里我们只看2种类型:

  1. ElementNode, 比如:
    ,
  2. TextNode, 纯文本

看我们上面创建的虚拟DOM对象 {tagName, attrs, children},它只能创建真实DOM中的 ElementNode。因此,我们还需要增加能创建TextNode的功能。我们将用String来创建TextNode

为了好展示,让我们添加一些文本到虚拟DOM,如下:

src/main.js

import createElement from "./vdom/createElement";

const vApp = createElement('div', {
attrs: {
id: 'app'
},
children: [
'Hello world',
createElement('img', {
attrs: {
src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif'
}
})
]
}); console.log(vApp);

扩展render函数以便支持文本节点(TextNode)

想我上面提到的,我们需要考虑两种类型的DOM节点。当前的render方法只能渲染ElementNode。所以让我们扩展render方法以便支持渲染文本节点。

我们将原来的render方法改名为renderElem,同时修改一下参数,将原来的vNode析构为{tagName, attrs, children},如下:

const renderElem = ({tagName, attrs, children}) => {
const $el = document.createElement(tagName);
for(const [k,v] of Object.entries(attrs)){
$el.setAttribute(k, v);
}
for(const child of children){
$el.appendChild(render(child));
}
return $el;
}; export default render;

接下来,我们重新定义一个render函数。新的render函数只需要检查vNode是否是string类型,如果是就调用document.createTextNode(string)来渲染文本节点,否则调用上面定义的renderElem即可,如下:

src/vdom/render.js

const renderElem = ({tagName, attrs, children}) => {
const $el = document.createElement(tagName);
for(const [k,v] of Object.entries(attrs)){
$el.setAttribute(k, v);
}
for(const child of children){
$el.appendChild(render(child));
}
return $el;
}; const render = vNode => {
if(typeof vNode === 'string'){
return document.createTextNode(vNode);
}
return renderElem(vNode);
}; export default render;

渲染我们的vApp

现在让我们来渲染我们的vApp,并输出它!

src/main.js

import createElement from "./vdom/createElement";
import render from "./vdom/render"; const vApp = createElement('div', {
attrs: {
id: 'app'
},
children: [
'Hello world',
createElement('img', {
attrs: {
src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif'
}
})
]
});
const $app = render(vApp);
console.log($app);

控制台输出:

<div id="app">
Hello world
<img src="https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif">
</div>

mount($node, $target)

https://codesandbox.io/s/vjpk91op47

现在我们已经可以创建虚拟DOM,并且能将它渲染成真实的DOM。接下来我们需要把真实的DOM显示在页面上。

首先,我们需要一个挂载点,我们将原页面上的hello world替换为一个div元素,如下:

src/index.html

<html>
<head>
<title>hello world</title>
</head>
<body>
<div id="app"></div>
<script src="./main.js"></script>
</body>
</html>

接下来我们要做的是用$app替换掉空的div元素。如果不考虑IE与Safari这将非常容易,我们只要用ChildNode.replaceWith方法即可。

让我们定义render($node, $target)方法,它会将$target用$node替换掉,然后返回$node,如下:

src/vdom/mount.js

export default ($node, $target) => {
$target.replaceWith($node);
return $node;
};

现在修改main.js,调用mount方法,如下:

src/main.js

import createElement from "./vdom/createElement";
import render from "./vdom/render";
import mount from "./vdom/mount"; const vApp = createElement('div', {
attrs: {
id: 'app'
},
children: [
'Hello world',
createElement('img', {
attrs: {
src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif'
}
})
]
});
const $app = render(vApp);
const $target = document.getElementById('app');
mount($app, $target);

让我们的app再有趣一点

https://codesandbox.io/s/ox02294zo5

接下来让我们的app变得有趣一点。我们用函数createVApp来创建vApp,createVApp接收一个count参数,用于创建vApp。如下:

src/main.js

import createElement from "./vdom/createElement";
import render from "./vdom/render";
import mount from "./vdom/mount"; const createVApp = count => createElement('div', {
attrs: {
id: 'app',
dataCount: count
},
children: [
'The current count is: ',
String(count),
createElement('img', {
attrs: {
src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif'
}
})
]
}) let count = 0;
const vApp = createVApp(count);
const $app = render(vApp);
const $target = document.getElementById('app');
let $rootEl = mount($app, $target); setInterval(() => {
count++;
$rootEl = mount(render(createVApp(count)), $rootEl);
},1000) // function start(count){
// let vApp = createVApp(count);
// const $app = render(vApp);
// const $target = document.getElementById('app');
// return mount($app, $target);
// } // let count = 0;
// let $rootEl = start(count); // setInterval(() => {
// $rootEl = start(++count);
// },4000)

我们用$rootEle保存每次挂载后的根元素,mount函数每次都挂载到新的rootEl元素。现在我们回到浏览器界面,你应该会看到计数每隔1秒增加一次,完美!

到现在我们已经可以以声明的方式创建应用了。通过上面的几行代码,应用能按照我们预期的渲染,这其中的秘密就是这么简单。如果你知道JQuery是怎么做渲染的,对比我们现在的方法,你将会感叹这是多么简洁的做法。

然而,上面的做法是每次每隔1秒会重新渲染整个节点,这将会有以下一些问题:

  1. 真实DOM比虚拟DOM笨重,每次都将整个节点重新渲染为真实DOM可能比较耗时。
  2. 元素会丢失状态。比如input元素会丢失焦点。

下面我们看看如何解决上面的问题。

diff(oldVTree, newVTree)

https://codesandbox.io/s/0xv007yqnv

想像我们有一个diff(oldVTree, newVTree)函数,它用来比较旧的虚拟DOM与新的虚拟DOM之间的不同,为后面渲染做准备。diff函数返回一个patch函数,patch函数以旧的真实DOM为参数,对真实的DOM执行一些必要的操作,使其看上去像是从新的虚拟DOM创建出来的一样。

下面我们尝试实现diff函数,我们从简单的情形开始:

  1. newVTree是undefined

    因为新的虚拟DOM已经不存在了,所以在patch函数里面可以将传入的旧的真实DOM直接删除。

  2. newVTree与oldVTree都是文本节点(TextNode)

    当两者都是文本时,如果两者相等,则无需处理;如果不相等,则用新虚拟DOM渲染出来的元素替换掉旧的真实DOM,即:用render(newTree)替换$node.

  3. 一个是文本节点一个元素节点

    这种情况,很显然两者不同,直接用新虚拟DOM渲染出来的元素替换掉旧的真实DOM。

  4. 新旧虚拟DOM的tagName不一样

    tagName不一样,即为不同的元素,直接用新虚拟DOM渲染出来的元素替换掉旧的真实DOM。react的算法中也是这么做的。

src/vdom/diff.js

import render from './render';

const diff = (oldVTree, newVTree) => {

    // 如果新的虚拟dom是undefined
if(newVTree === undefined){
// 返回patch函数,$node为传入的旧的真实DOM元素
return $node => {
// 删除旧的元素
$node.remove();
// patch函数必须返回一个根元素,这种情况没有元素,所以返回undefined。
return undefined;
};
} // 如果两者都是文本节点
if(typeof oldVTree === 'string' || typeof newVTree === 'string'){
// 文本内容不等
if(oldVTree !== newVTree){
return $node => {
// 通过新的虚拟DOM渲染得到新的真实DOM
const $newNode = render(newVTree);
// 将新的DOM替换旧的
$node.replaceWith($newNode);
return $node;
}
}else{
// 文本相等,无需处理。
return $node => $node
}
} // 如果两者的tagName不同
if(oldVTree.tagName !== newVTree.tagName){
return $node => {
// 通过新的虚拟DOM渲染得到新的真实DOM
const $newNode = render(newVTree);
// 将新的DOM替换旧的
$node.replaceWith($newNode);
return $node;
}
} // (A) --- }; export default diff;

上面的代码完全根据我们比较算法实现,我们只考虑了三种大的情况(元素被删,文本类型,不同元素类型),如果代码执行到(A)的位置,那又如何处理?如果执行到(A)处,情况比较复杂,至少有以下几点我们知道:

  1. oldVTree与newVTree都是虚拟DOM
  2. 它们有相同的tagName
  3. 它们可能有不同的attrs和children

我们将实现两个独立的方法来处理attrs与children的比较。暂时分别命名为diffAttrs(oldAttrs, newAttrs)diffChildren(oldVChildren, newVChildren),它们都会返回各自的patch函数。

src/vdom/diff.js

// 此处省略上面的相同代码
... const patchAttrs = diffAttrs(oldVTree.attrs, newVTree.attrs);
const patchChildren = diffChildren(oldVTree.children, newVTree.children); return $node => {
patchAttrs($node);
patchChildren($node);
return $node;
} ....

diffAttrs(oldAttrs, newAttrs)

让我聚焦到diffAttrs函数。实际它非常简单。首先我们要将所有新的属性设置到dom上,然后将那些存在于旧的属性而不存在新的属性的属性全部删除。代码如下:

const diffAttrs = (oldAttrs, newAttrs) => {
const patches = []; // patch函数的数组
// 将新的属性设置进去
for(const [k, v] of Object.entries(newAttrs)){
patches.push($node => {
$node.setAttribute(k, v);
return $node;
});
}
// 删除不存在于新属性集而存在于旧属性集的属性
for(const k in oldAttrs){
if(!(k in newAttrs)){
patches.push($node => {
$node.removeAttribute(k);
return $node;
});
}
} return $node => {
for(const patch of patches){
patch($node);
}
return $node;
} };

diffChildren(oldVChildren, newVChildren)

Children的比较会有点复杂,我们需要考虑下面三种情况:

  1. oldVChildren.length === newVChildren.length

    说明子元素个数一样,此时我们必须对子元素逐个比较,即:diff(oldVChildren[i], newVChildren[i]),i从0到oldVChildren.length

  2. oldVChildren.length > newVChildren.length

    还是需要逐个比较子元素,直到newVChildren变为undefined,因为我们在diff里考虑了newVTree为undefined的情况,所以这种情况的处理方法跟第一种一样,即:diff(oldVChildren[i], newVChildren[i]),i从0到oldVChildren.length

  3. oldVChildren.length < newVChildren.length

    先遍历oldVChildren,执行diff(oldVChildren[i], newVChildren[i]),然后将newVChildren中没有遍历到的虚拟dom,渲染为真实dom,手动加入到旧dom的子元素中。

const diffChildren = (oldVChildren, newVChildren) => {
const childrenPatches = [];
// 1. 当oldVChildren.length === newVChildren.length
// 2. 当oldVChildren.length > newVChildren.length
// 3. 当oldVChildren.length < newVChildren.length
oldVChildren.forEach((oldVChild, i)=>{
childrenPatches.push(diff(oldVChild, newVChildren[i]));
}); // 上面的执行完毕后,只有第三种情况中newVChildren中的部分节点没有处理到
// 处理余下的节点
const additionalPatches = [];
for(const additionalVChild of newVChildren.slice(oldVChildren.length)){
additionalPatches.push($node => {
$node.appendChild(render(additionalVChild));
return $node;
});
} return $parent => {
$parent.childNodes.forEach(($child, i)=>{
childrenPatches[i]($child);
});
for(const patch of additionalPatches){
patch($parent);
}
return $parent;
}
};

译者注:作者原文用了一个zip函数来同时处理两个数组的循环,这里没有放出来。

最终的diff.js

import render from './render';

const diffAttrs = (oldAttrs, newAttrs) => {
const patches = []; // patch函数的数组
// 将新的属性设置进去
for(const [k, v] of Object.entries(newAttrs)){
patches.push($node => {
$node.setAttribute(k, v);
return $node;
});
}
// 删除不存在于新属性集而存在于旧属性集的属性
for(const k in oldAttrs){
if(!(k in newAttrs)){
patches.push($node => {
$node.removeAttribute(k);
return $node;
});
}
} return $node => {
for(const patch of patches){
patch($node);
}
return $node;
} }; const diffChildren = (oldVChildren, newVChildren) => {
const childrenPatches = [];
// 1. 当oldVChildren.length === newVChildren.length
// 2. 当oldVChildren.length > newVChildren.length
// 3. 当oldVChildren.length < newVChildren.length
oldVChildren.forEach((oldVChild, i)=>{
childrenPatches.push(diff(oldVChild, newVChildren[i]));
}); // 上面的执行完毕后,只有第三种情况中newVChildren中的部分节点没有处理到
// 处理余下的节点
const additionalPatches = [];
for(const additionalVChild of newVChildren.slice(oldVChildren.length)){
additionalPatches.push($node => {
$node.appendChild(render(additionalVChild));
return $node;
});
} return $parent => {
$parent.childNodes.forEach(($child, i)=>{
// childrenPatches[i]($child); -- 原文有错
// 因为diff可能返回undefined,所以需要判断patch是否存在。
childrenPatches[i]&&childrenPatches[i]($child);
});
for(const patch of additionalPatches){
patch($parent);
}
return $parent;
}
}; const diff = (oldVTree, newVTree) => { // 如果新的虚拟dom是undefined
if(newVTree === undefined){
// 返回patch函数,$node为传入的旧的真实DOM元素
return $node => {
// 删除旧的元素
$node.remove();
// patch函数必须返回一个根元素,这种情况没有元素,所以返回undefined。
return undefined;
};
} // 如果两者都是文本节点
if(typeof oldVTree === 'string' || typeof newVTree === 'string'){
// 文本内容不等
if(oldVTree !== newVTree){
return $node => {
// 通过新的虚拟DOM渲染得到新的真实DOM
const $newNode = render(newVTree);
// 将新的DOM替换旧的
$node.replaceWith($newNode);
return $node;
}
}else{
// 文本相等,无需处理。
return $node => $node
}
} // 如果两者的tagName不同
if(oldVTree.tagName !== newVTree.tagName){
return $node => {
// 通过新的虚拟DOM渲染得到新的真实DOM
const $newNode = render(newVTree);
// 将新的DOM替换旧的
$node.replaceWith($newNode);
return $node;
}
} const patchAttrs = diffAttrs(oldVTree.attrs, newVTree.attrs);
const patchChidlren = diffChildren(oldVTree.children, newVTree.children); return $node => {
patchAttrs($node);
patchChidlren($node);
return $node;
}; }; export default diff;

让我们的app再复杂一点

https://codesandbox.io/s/mpmo2yy69

我们当前的应用实际是没有用到虚拟DOM的全部功能的。为了展示虚拟DOM的强大功能,让我们再次修改我们应用,让它变得再复杂一点。

src/main.js

import createElement from "./vdom/createElement";
import render from "./vdom/render";
import mount from "./vdom/mount";
import diff from './vdom/diff'; const createVApp = count => createElement('div', {
attrs: {
id: 'app',
dataCount: count
},
children: [
'The current count is: ',
String(count),
...Array.from({length: count}, ()=> createElement('img',{
attrs: {
src: 'https://media.giphy.com/media/cuPm4p4pClZVC/giphy.gif'
}
}))
]
}) let vApp = createVApp(0);
const $app = render(vApp);
const $target = document.getElementById('app');
let $rootEl = mount($app, $target); setInterval(() => {
const n = Math.floor(Math.random()*10);
let newVApp = createVApp(n);
const patch = diff(vApp, newVApp);
$rootEl = patch($rootEl);
vApp = newVApp;
},1000) // function start(count){
// let vApp = createVApp(count);
// const $app = render(vApp);
// const $target = document.getElementById('app');
// return mount($app, $target);
// } // let count = 0;
// let $rootEl = start(count); // setInterval(() => {
// $rootEl = start(++count);
// },4000)

感谢

感谢您花时间一直阅读到这里,这篇文章确实有点长。读完还请留言!~

原文中已经提供了文章中所有代码的链接,还有git仓库。这里我也按照作者的代码自己实现了一遍,需要的请链接:

https://github.com/ywxgod/learningExamples/tree/master/tanslations/building_a_simple_virtual_dom_from_scratch