从零开始手把手教你实现一个Virtual DOM

作者:zach5078

segmentfault.com/a/1190000014572815

假如你的项目使用了React,你知道怎么做性能优化吗?

你知道为什么React让你写shouldComponentUpdate或者React.PureComponent吗?

你知道为什么React让你写Immutable Data Structures吗?

你知道为什么React让你在渲染列表时,一定要给每个子项加一个key吗?

你知道为什么React让你在条件渲染时,不写if而写&&操作符或三元操作符吗?

一切的答案都在Virtual DOM上!

只要你跟着我完成了这个手写Virtual DOM的系列,上面的所有问题你都将得到解答,从此进入react高手的阵营!

现在当我们谈virtual DOM (VDOM)的时候,通常会将React捆绑在一起谈。可实际上VDOM并不是React创造的,React将这个概念拿过来以后融会贯通慢慢地成为目前前端最炙手可热的框架之一。

什么是VDOM?

首先我们都知道什么是DOM(Document Object Model),简单说就是将一个HTML文档抽象表达为树结构。VDOM则是将DOM再抽象一层生成的简化版js对象,这个对象也拥有DOM上的一些属性,比如id, class等,但它是完全脱离于浏览器而存在的。

为什么要用VDOM?

随着前端技术的发展,现在的网页应用变得越来越庞大,DOM树也随之变得复杂。当我们要修改DOM的某个元素时,我们需要先遍历找个这个元素,然后才修改能修改。而且如果我们大量地去修改DOM,每次DOM修改浏览器就得重绘(repaint)页面,有的时候甚至还得重排(reflow)页面,浏览器的重排重绘是很损耗性能的。

React是怎么用VDOM解决这个问题的呢?

首先,在React当我们要去修改数据的时候,我们会调用React提供的setState方法来修改数据;

React根据新的数据生成一个新的VDOM,因为VDOM本质上只是一个普通的js对象,所以这个过程是很快的;

然后React拿着新生成VDOM和之前的VDOM进行对比(diff算法),找出不同的地方(新增,删除,修改),生成一个个的补丁(patches);

最后React把这些补丁一次性打到DOM上,完成视图的修改。

原理其实还是很直观的,但React到底是怎么用代码实现的呢?其中最关键的一步是React是怎么diff的?如果搞清楚了内部的实现原理,对于我们使用React来写出性能更高的代码至关重要。所以今天我要手把手教大家怎么从零开始实现VDOM。

我们的设计蓝图

我们将采用跟React类似的方式

使用JSX来编写组件;

用Babel将JSX转化为纯js(类似hyperscript);

将hyperscript转化成我们的VDOM;

将VDOM渲染到页面,形成真实的DOM;

手动更新数据并手动触发更新视图操作(这部分是react做的,跟VDOM的实现无关,所以我们手动模拟一下);

重复步骤二和步骤三,得到新的VDOM;

diff新VDOM和旧VDOM,得到需要修改真实DOM的patches;

把patches一次性打到DOM上,只更新DOM上需要更改的地方。

下面我们开始正式进入写代码环节,建议大家打开编辑器跟着我一步一步的敲代码。这样手把手教你敲代码的的博主你去哪里找?还不抓住这个千载难逢的机会?

项目结构

大家可以新建一个目录,然后新建1-4这四个文件

package.json

{

"name":"vdom",

"version":"1.0.0",

"description":"",

"scripts":{

"compile":"babel index.js --out-file compiled.js"

},

"author":"",

"license":"",

"devDependencies":{

"babel-cli":"^6.23.0",

"babel-plugin-transform-react-jsx":"^6.23.0"

}

}

这里主要主要两点:

devDependencies中依赖babel-cli和babel-plugin-transform-react-jsx这两个库,前者提供Babel的命令行功能,后者主要帮我们把jsx转化成js。

scripts中我们指定了一条命令:complile,每次当我们在当前目录下的命令行中敲npm run compile时,babal就会将我们的index.js转化后新建一个compile.js文件。

完成后,在命令行中输入npm install安装下依赖。

.babelrc

{

"plugins":[

["transform-react-jsx",{

"pragma":"h"// default pragma is React.createElement

}]

]

}

在babel的配置文件中,我们指定transform-react-jsx这个插件将转化后的函数名设置为h。默认的函数名是React.createElement,我们不依赖react,所以显然换个自己的名字更合适。这里不清楚h是干什么的不要紧,等会看到代码你就知道了。

index.html

VDOM

body{margin:;font-size:24;font-family:sans-serif}

.list{text-decoration:none}

.list .main{color:red}

varapp=document.getElementById('app')

render(app)

这个HTML还是很直观的,类似React,我们有一个根节点id是app。然后我们render函数最终生成的DOM会插入到app这个根节点里。注意我们引用的compile.js文件是babel根据等会要写的index.js文件自动生成的。

index.js

首先,我们用JSX来编写“模板”:

{

"plugins":[

["transform-react-jsx",{

"pragma":"h"// default pragma is React.createElement

}]

]

}

接下来,我们要将JSX编译成js, 也就是hyperscript。我们先用Babel编译一下,看这段JSX转成js会是什么样子,打开命令行,输入npm run compile,得到的compile.js:

functionview(){

returnh(

"ul",

{id:"filmList",className:"list"},

h(

"li",

{className:"main"},

"Detective Chinatown Vol 2"

),

h(

"li",

null,

"Ferdinand"

),

h(

"li",

null,

"Paddington 2"

)

);

}

可以看出h函数接收的参数,第一个参数是node的类型,比如ul,li,第二个参数是node的属性,之后的参数是node的children,假如child又是一个node的话,就会继续调用h函数。

清楚了Babel会将我们的JSX编译成什么样子后,接下来我们就可以继续在index.js中来写h函数了。

functionflatten(arr){

return[].concat(...arr)

}

functionh(type,props,...children){

return{

type,

props:props||{},

children:flatten(children)

}

}

我们的h函数主要的工作就是返回我们真正需要的hyperscript对象,只有三个参数,第一个参数是节点类型,第二个参数是属性对象,第三个是子节点的数组。

这里主要用了ES6的rest, spread参数,不清楚代码中两个...分别是什么意思的可以先去看我的介绍ES6文章30分钟掌握ES6/ES2015核心内容(上)。简单来说,rest就是上面的...children,它将函数多余的参数放到一个数组里,所以children此时变成了一个数组。而spread则是rest的逆运算,也就是上面的...arr,它将一个数组转为用逗号分隔的参数序列。

flatten(children)这个操作是因为children这个数组里的元素有可能也是个数组,那样就成了一个二维数组,所以我们需要将数组拍平成一维数组。[].concat(...arr)是ES6写法,传统的写法是[].concat.apply([], arr)

我们现在可以先来看一下h函数最终返回的对象长什么样子。

functionrender(){

console.log(view())

}

我们在render函数中打印出执行完view()的结果,再npm run compile后,用浏览器打开我们的index.html,看控制台输出的结果。

可以,很完美!这个对象就是我们的VDOM了!

下面我们就可以根据VDOM, 来渲染真实DOM了。先改写render函数:

functionrender(el){

el.appendChild(createElement(view()))

}

createElement函数生成DOM,然后再插入到我们在index.html中写的根节点app。注意render函数式在index.html中被调用的。

functioncreateElement(node){

if(typeof(node)==='string'){

returndocument.createTextNode(node)

}

let{type,props,children}=node

constel=document.createElement(type)

setProps(el,props)

children.map(createElement)

.forEach(el.appendChild.bind(el))

returnel

}

functionsetProp(target,name,value){

if(name==='className'){

returntarget.setAttribute('class',value)

}

target.setAttribute(name,value)

}

functionsetProps(target,props){

Object.keys(props).forEach(key=>{

setProp(target,key,props[key])

})

}

我们来仔细看下createElement函数。假如说node,即VDOM的类型是文本,我们直接返回一个创建好的文本节点。否则的话,我们取出node中类型,属性和子节点, 先根据类型创建相应的目标节点,然后再调用setProps函数依次设置好目标节点的属性,最后遍历子节点,递归调用createElement方法,将返回的子节点插入到刚刚创建的目标节点里。最后返回这个目标节点。

还需要注意的一点是,jsx中class的写成了className,所以我需要特殊处理一下。

大功告成,complie后浏览器打开index.html看看结果吧。

今天我们成功的完成了蓝图的左半部分,将JSX转化成hyperscript,再转化成VDOM,最后根据VDOM生成DOM,渲染到页面。明天,我们迎接挑战,开始处理数据变动引起的重新渲染,我们要如何DIFF新旧VDOM,生成补丁,修改DOM。

觉得本文对你有帮助?请分享给更多人

关注「前端大全」,提升前端技能

  • 发表于:
  • 原文链接http://kuaibao.qq.com/s/20180502B1HV7900?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。

扫码关注云+社区

领取腾讯云代金券

年度创作总结 领取年终奖励