随处可见的VDOM
VDOM,也叫虚拟DOM,并不是什么高大上的新事物,它是仅存于内存中的DOM,因为还未展示到页面中,所以称为VDOM。
var a = document.createElement("div");
如上所示,大家对此应该不陌生吧?没错,这就是VDOM。
问题来了,如果让VDOM变成真实的DOM呢?
其实很简单……只需将节点append到页面中
var a = document.createElement("div");
document.body.append(a);
所以,请大家不要把VDOM想得太复杂!它随处可见~
React中的VDOM
常见的DOM操作
在讲React中的VDOM前,有必要说下,我们日常中常见的DOM操作有哪些?
事实上,就三类:增、删、改。对应的DOM操作如下:
- 增加一个节点 => appendChild
- 删除一个节点 => removeChild
- 更改一个节点 => replaceChild
现实中,很多前端小伙伴在处理前端模板变动时,是简单粗暴的,不管是哪种情况,都会直接使用类似jQuery的html方法整块替换~(全局搜下你代码,是不是有不少$(...).html())
这样做有什么问题呢?——性能问题。如果页面比较小,问题还不是很大,如果页面庞大,这样做势必会出现卡顿,用户体验绝对是不好的。
如何解决呢?——这就引入了差量更新!
差量更新
什么是差量更新?就是只对局面的HTML片段进行更新。比如你加了一个节点,那么我就只更新这个节点,我无需整个模板替换。
这样一来,效率就提高了。
可问题来了,我怎么知道哪个节点更新了,哪个节点删除了,哪个节点替换了呢?——我们需要对DOM建模!
VDOM建模
说是建模,简单点说就是用一个JS对象来表示VDOM。
如果我们可以用一个JS对象来表示VDOM,那么这个对象上多一个属性(增加节点),少一个属性(删除节点),或者属性值变了(更改节点),就一目了然了!
那如何建模呢?
这个麽!我们就要化繁为简了。思考下,DOM也叫DOM树,是一个树形结构,DOM树上有很多元素节点。
我们要对VDOM进行建模,本质上就是对一个个元素节点进行建模,然后再把节点放回DOM树的指定位置,这样不就完成对DOM树的建模了么?
别把建模想得太复杂,无非就是用JS对象的形式来展示罢了。
如何对元素节点进行建模呢?
这个简单,我们不难发现,每个节点无非都是由以下三部分组成:
- type : 元素类型
- props : 元素属性
- children : 子元素集合
比如<div id="main">test</div>,type是div,props是id="main",children是“test”。
我们希望的结果是:
{type:"div",props:{"id":"main"},children:[
test
]}
如果是更复杂的结构,比如div中有一个图片,我们可以写成
{type:"div",props:{"style":""},children:[
{type:"img",props:{"src":"..."}}
]}
以上也是React对VDOM建模的结果。是不是很简单呢?
如何快捷建模?
如何把真实的DOM,转化成建模后的VDOM呢?
这个简单,transform-react-jsx
已经帮我们实现了,使用webpack或者rollup的朋友可以直接使用这个插件。
以下附加rollup的配置文件,供参考:
import babel from 'rollup-plugin-babel';
export default {
input : 'src/main.js',
output : {
file : 'dist/main.js',
format : 'cjs'
},
banner : "/* fed123.com */",
plugins : [
babel({
'presets' : [[
'env',
{
modules : false
}
]],
"plugins" : [
["transform-react-jsx" , {
"pragma" : "vnode"
}]
]
})
]
}
可能你还是一知半解,下面给出一个例子:
// React 常见的DOM写法
const vdom = (
<div id="_Q5" style="border:1px solid red">
<div style="text-align:center;">
<img src="https://m.baidu.com/static/index/plus/plus_logo.png" height="56"/>
</div>
Hello
</div>
);
// 转义后的
var vdom = vnode(
"div",
{ id: "_Q5", style: "border:1px solid red" },
vnode(
"div",
{ style: "text-align:center;" },
vnode("img", { src: "https://m.baidu.com/static/index/plus/plus_logo.png", height: "56", onClick: function onClick() {
alert(1);
} })
),
"Hello"
);
如何将VDOM变成真实DOM呢?
我们知道,将DOM变成VDOM,是为了差量更新,最终我们还是要把VDOM还原成DOM的!VDOM只是个桥梁,如果不能还原成DOM,VDOM就没意义了!
怎么做呢?
可以参考如下代码:
// 把vdom挂载到页面上
function createElement(node) {
if (typeof node === 'string') {
return document.createTextNode(node);
}
const $el = document.createElement(node.type);
let appendChild = $el.appendChild.bind($el);
node.children
.map(createElement)
.map(appendChild);
return $el;
}
我们判断,如果是子节点是个字符串节点,直接插入页面即可,如果子节点是个DOM节点,那么就递归调用~
通过这个思路,我们就可以将VDOM还原成DOM了。
DIFF Virtual DOM & Update
以上,是VDOM的准备工作,主要包括两个步骤:
- 对VDOM进行建模,方便后续的差量更新
- 将VDOM转成真实的DOM
接下来才是主菜。
我们先看思考下,如何判断DOM发生了变化,并找到这个变化?
DIFF算法
DIFF算法是React框架采用的方法。也就是判断DOM是否发生了变化、然后找到这个变化,这样我们才能实现差量更新。
DOM的变化主要有三种:appendChild、replaceChild、removeChild.
还记得我们对VDOM的建模么?
{type:"div",props:{"style":""},children:[
{type:"img",props:{"src":"..."}}
]}
每个节点都包含一个children,DIFF的过程,其实也是diff children的过程。通过递归children的方式,就可以判断不同的children并对其操作。有以下几种情况:
- 没有旧的节点,则创建新的节点,并插入父节点。
- 如果没有新的节点,则摧毁旧的节点。
- 如果节点发生了变化,则用replaceChild改变节点信息
- 如果节点没有变化,则对比该节点的子节点进行判断,使用递归调用
function updateElement($parent, newNode, oldNode, index = 0) {
if(!oldNode) {
$parent.appendChild(
createElement(newNode)
);
} else if (!newNode) {
$parent.removeChild(
$parent.childNodes[index]
);
} else if (changed(newNode, oldNode)) {
$parent.replaceChild(
createElement(newNode),
$parent.childNodes[index]
);
} else if(newNode.type) {
const newLength = newNode.children.length;
const oldLength = oldNode.children.length;
for(let i = 0; i < newLength || i < oldLength; i++) {
updateElement(
$parent.childNodes[index],
newNode.children[i],
oldNode.children[i],
i
);
}
}
}
为什么要DIFF children呢?因为我们必须DOM树是由一个个元素节点组成的,DOM树变化的最小单位也是元素节点。
通过递归的方式,我们就可以从最底层的children开始,层层遍历,找到变化的节点,然后对这些节点差量更新了。
而所谓的差量更新,就是上述提到的三种操作:appendChild、replaceChild、removeChild。这在上面的代码都有体现到。
Handle Props & Event
通过上述的步骤,我们就可以把DOM树进行差量更新并呈现到页面上,但我们知道,DOM树可不只有节点,还有参数跟事件,所以我们需要把参数跟事件加上。
再看一眼我们对VDOM的建模!
{type:"div",props:{"style":""},children:[
{type:"img",props:{"src":"..."}}
]}
我们要做的,就是把props加载到对应的元素节点上,这个步骤简称:DIFF props。
DIFF props,同DIFF VDOM,找到props的不同,然后setAttribute跟removeAttribute。
这里直接上代码:
function updateProps ($target, newProps, oldProps = {}){
const props = Object.assign({},oldProps, newProps);
Object.keys(props).forEach(name => {
updateProp($target, name, newProps[name], oldProps[name]);
})
}
function updateProp ($target, name, newVal, oldVal) {
if (!newVal) {
removeProp($target, name, oldVal);
} else if (!oldVal || newVal !== oldVal) {
setProp($target, name, newVal);
}
}
function setProp ($target, name, value) {
if (typeof value === "boolean") {
handleBooleanProp($target, name, value);
}
$target.setAttribute(name, value);
}
function setBooleanProp($target, name, value) {
if (!!value) {
$target.setAttribute(name, value);
$target[name] = true;
} else {
$target[name] = false;
}
}
function removeProp($target, name, value) {
if (typeof value === 'boolean') {
$target[name] = false;
}
$target.removeAttribute(name);
}