本文為系列文章:
手寫虛擬DOM(一)—— VirtualDOM介紹
手寫虛擬DOM(二)—— VirtualDOM Diff
手寫虛擬DOM(三)—— Diff算法優(yōu)化
手寫虛擬DOM(四)—— 進一步提升Diff效率之關鍵字Key
手寫虛擬DOM(五)—— 自定義組件
手寫虛擬DOM(六)—— 事件處理
手寫虛擬DOM(七)—— 異步更新
一、前言
本文繼續(xù)上一節(jié)的Virtual DOM Diff,來聊一聊:
在渲染數(shù)組元素的時候,編譯器會提醒加上key這個屬性,那么key是用來做什么的呢?
二、key的作用
在渲染數(shù)組元素時,它們一般都有相同的結(jié)構,只是內(nèi)容有些不同而已,比如:
<ul>
<li>
<span>商品:蘋果</span>
<span>數(shù)量:1</span>
</li>
<li>
<span>商品:香蕉</span>
<span>數(shù)量:2</span>
</li>
<li>
<span>商品:雪梨</span>
<span>數(shù)量:3</span>
</li>
</ul>
可以把這個例子想象成一個購物車。此時如果想往購物車里面添加一件商品,性能不會有任何問題,因為只是簡單的在ul的末尾追加元素,前面的元素都不需要更新:
<ul>
<li>
<span>商品:蘋果</span>
<span>數(shù)量:1</span>
</li>
<li>
<span>商品:香蕉</span>
<span>數(shù)量:2</span>
</li>
<li>
<span>商品:雪梨</span>
<span>數(shù)量:3</span>
</li>
<li>
<span>商品:橙子</span>
<span>數(shù)量:2</span>
</li>
</ul>
但是,如果我要刪除第一個元素,根據(jù)VD的比較邏輯,后面的元素全部都要進行更新的操作。dom結(jié)構簡單還好說,如果是一個復雜的結(jié)構,那頁面渲染的性能將會受到很大的影響。
<ul>
<li>
<span>商品:香蕉</span>
<span>數(shù)量:2</span>
</li>
<li>
<span>商品:雪梨</span>
<span>數(shù)量:3</span>
</li>
<li>
<span>商品:橙子</span>
<span>數(shù)量:2</span>
</li>
</ul>
最直觀的方法肯定是直接刪除第一個元素然后其它元素保持不變了。
但程序沒有這么智能,可以像我們一樣一眼就看出變化。程序能做到的是盡量少的修改元素,通過移動元素而不是修改元素來達到更新的目的。為了告訴程序要怎么移動元素,我們必須給每個元素加上一個唯一標識,也就是key。
<ul>
<li key="apple">
<span>商品:蘋果</span>
<span>數(shù)量:1</span>
</li>
<li key="banana">
<span>商品:香蕉</span>
<span>數(shù)量:2</span>
</li>
<li key="pear">
<span>商品:雪梨</span>
<span>數(shù)量:3</span>
</li>
<li key="orange">
<span>商品:橙子</span>
<span>數(shù)量:2</span>
</li>
</ul>
當把蘋果刪掉的時候,Virtual DOM里面第一個元素是香蕉,而實際dom里面第一個元素是蘋果。
當元素有key屬性的時候,框架就會嘗試根據(jù)這個key去找對應的元素,找到了就將這個元素移動到第一個位置,循環(huán)往復。
最后Virtual DOM里面沒有第四個元素了,才會把蘋果從dom移除。
三、代碼實現(xiàn)
在上一個版本代碼的基礎上,主要的改動點是diffChildren這個函數(shù)。原來的實現(xiàn)很簡單,遞歸的調(diào)用diff就可以了:
function diffChildren(vdom, element) {
const nodes = element.childNodes || [];
const children = vdom.children || [];
const hasKeys = {};
let noKeys = [];
// 根據(jù)是否有key先進行分組
nodes.forEach(node => {
const props = node['props'];
if (props && props.key !== undefined) {
hasKeys[props.key] = node;
} else {
noKeys.push(node);
}
});
// 遍歷vdom
children.forEach((child, index) => {
let dom;
const key = child.props && child.props.key !== undefined ? child.props.key : undefined;
if (key != null && hasKeys[key]) {
dom = hasKeys[key];
delete hasKeys[key];
} else {
for (let i = 0; i < noKeys.length; i ++) {
const node = noKeys[i];
if (isEqual(child, node)) {
dom = node;
noKeys.splice(i, 1);
break;
}
}
}
const isUpdate = diff(dom, child, element);
if (isUpdate) {
// 更新(移動),則移到當前元素的位置,當前元素向后延一位
const origin = nodes[index];
if (origin !== child) {
element.insertBefore(dom, origin);
}
}
});
// 移除不在新的vdom中的節(jié)點
const list = Object.keys(hasKeys);
if (list.length > 0) {
list.forEach(key => {
element.removeChild(hasKeys[key]);
});
}
if (noKeys.length > 0) {
noKeys.forEach(node => {
element.removeChild(node);
});
}
}
主要是以下幾個步驟:
- 將所有dom子元素分為有key和沒key兩組
- 遍歷VD子元素:
- 如果VD子元素有key,則去查找有key的分組;
- 如果沒key,則去沒key的分組找一個類型相同的元素出來;
- diff一下,得出是否更新元素的類型
- 如果是更新元素且子元素不是原來的,則移動元素
- 最后清理刪除沒用上的dom子元素
diff也要改造一下,如果是新建、刪除或者替換元素,返回false。更新元素則返回true:
function diff(srcDOM, destDOM, parent) {
// 原dom沒有,新vdom有,則表明是新增節(jié)點
if (srcDOM === undefined) {
parent.appendChild(createElement(destDOM));
return false;
}
// 原dom有,新vdom沒有,則表明是移除節(jié)點
if (destDOM === undefined) {
parent.removeChild(srcDOM);
return false;
}
// 新老節(jié)點(類型不同 or tag不同 or 內(nèi)容不同),則表明是替換節(jié)點
if (!isEqual(destDOM, srcDOM)) {
parent.replaceChild(createElement(destDOM), srcDOM);
return false;
}
// 至此,只有可能是當前vdom的自身props變化 or 其children發(fā)生變化
if (srcDOM.nodeType === Node.ELEMENT_NODE) {
diffProps(destDOM.props, srcDOM);
diffChildren(destDOM, srcDOM);
}
return true;
}
為了看效果,view函數(shù)也要改造下:
const state = {
list: []
};
function add(key) {
let index = -1;
for (let i = 0; i < state.list.length; i ++) {
const param = state.list[i];
if (param.id === key) {
index = i;
break;
}
}
if (index === -1) {
state.list.push({
id: key,
number: 1
});
} else {
state.list[index].number ++;
}
render(root);
}
function del(key) {
for (let i = 0; i < state.list.length; i ++) {
const param = state.list[i];
if (param.id === key) {
state.list.splice(i, 1);
break;
}
}
render(root);
}
// 根據(jù) state.number 來計算有多少個 div
function view() {
const goods = [];
for (let i = 0; i < 5; i ++) {
goods.push(
<span class="goods" onClick={"add(" + i + ")"} key={i}>商品{i+1}</span>
)
}
const car = [];
state.list.forEach(item => {
car.push(
<li key={item.id} onClick={"del(" + item.id + ")"}>
<span>商品:{item.id}....</span>
<span>數(shù)量:{item.number}</span>
</li>
)
});
return (
<div data-list={state.list.length}>
<div>{goods}</div>
<ul>{car}</ul>
</div>
);
}
四、總結(jié)
本文基于上一個版本的代碼,加入了對唯一標識(key)的支持,很好的提高了更新數(shù)組元素的效率。
項目源碼:
https://github.com/qingye/VirtualDOM-Study/tree/master/VirtualDOM-Study-04