本章節咱們的主題是update和diff,這一章節可能理論部分會比較多。在開始這一塊內容前,我以爲有必要先大體看一下Vue和React實現這一部分的流程的:update->diff->patch->updateDOM。在開始更新後,會進行diff算法的比對,比對後會生成一個patch補丁包,而後再根據這個補丁包進行DOM的更新。補丁包中會經過id(或者序號)之類的標識來標識真實DOM的位置,定位到位置後,再經過修改的類型(如:新增節點、刪除節點、修改節點等),來對不一樣的狀況進行DOM更新。vue
何爲diff?diff即爲比較兩顆VNode樹之間的差別,對於我目前的開發階段而言,實際上就是比較更新前和更新後的VNode(你也能夠直接拿更新前的DOM和更新後的VNode比較),轉化到數據結構來講,其實就是對兩顆樹的比較。而傳統的DFS(深度優先遍歷)算法,其自己的時間複雜度達到O(n2),由於要將樹1中每一個節點都與樹2中的每一個節點進行比對。而在咱們當前的場景下,即對比兩顆VNode樹,咱們除了遍歷比較之外,還須要選擇出一個最優操做,因此時間複雜度從O(n2)上升到了O(n3),因此一個傳統的diff過程,它的時間複雜度爲O(n3)。node
面對如此高的複雜度,diff算法應運而生。瞭解過react和vue的diff算法的人應該都知道,他們的時間複雜度爲O(n),他們diff的核心基本能夠總結爲如下幾點:react
經過分析以上四點,咱們能夠了解到react和vue的diff算法在DOM層面而言,其實並非最優的,可是它經過增大一部分DOM的開銷,來使得時間複雜度大大下降,以一種還算過得去的修改DOM的性能(主要體如今一、2兩點),來使時間複雜度達到儘可能低的階段(O(n),只需一次遍歷便可)。git
這裏插一點與本文主題無關的內容,由於想到了就寫一下。在長列表(很長很長的那種)的初次渲染中,咱們常常會遇到性能優化問題(這也是比較常見的面試題)。一個比較經常使用的解決方案是,使用Node.cloneNode
替代document.createElement
。不知道有沒有人和我同樣,一開始的時候會認爲Node.cloneNode
的效率比document.createElement
的效率更高。本着探索未知領域的原則,我特地進行了測試:github
const app = document.querySelector('#app');
console.time('create');
for(let i = 0; i < 1000000; i++) {
const newEl = document.createElement('div');
app.appendChild(newEl);
}
console.timeEnd('create');
// create: 3148.89501953125ms
複製代碼
接下來是對cloneNode的測試:面試
const app = document.querySelector('#app');
console.time('clone');
const newEl = document.createElement('div');
app.appendChild(newEl);
for(let i = 0; i < 1000000 - 1; i++) {
// true表示深拷貝
const newEl_CP = newEl.cloneNode(true);
app.appendChild(newEl_CP);
}
console.timeEnd('clone');
// clone: 2832.31982421875ms
複製代碼
能夠看到,一樣是渲染100萬個節點,二者的時間差其實並無很大。看到這裏,你們應該都有疑惑,實際上,兩種方法的效率並相差不了多少,不是嗎?一開始,我也抱有一樣的疑惑,後來仔細想一想,是否是思路上出現了問題?既然選擇了clone,那我爲何要一個一個clone呢?一組一組地clone效率不是更高嗎?咱們能夠先生成一個1000個子節點的元素,而後再循環clone
1000 - 1次不是效率更高嗎?爲了驗證這一猜測,讓咱們用代碼說話:算法
const app = document.querySelector('#app');
console.time('newClone');
// fragment是一段文檔片斷,能夠將其子節點加入到DOM樹中,且不產生額外的節點
// 用過Vue的<template>標籤和React的Fragments會很好理解
let fragment = document.createDocumentFragment();
for(let i = 0; i < 1000; i++) {
const newEl = document.createElement('div');
fragment.appendChild(newEl);
}
// 這裏爲何要先拷貝一份?
// 由於fragment中的子元素在插入到DOM樹後,能夠理解爲是一個轉移的過程,fragment全部的子元素都被移動到DOM樹中,
// 因此當調用app.appendChild(fragment)方法後,fragment就只是一個空的文檔片斷
const sourceFragment = fragment.cloneNode(true);
app.appendChild(fragment);
for(let i = 0; i < 1000 - 1; i++) {
const newEl_CP = sourceFragment.cloneNode(true);
app.appendChild(newEl_CP);
}
console.timeEnd('newClone');
// newClone: 945.964599609375ms
複製代碼
能夠看到,咱們使用這種fragment+cloneNode
方式能夠極大地下降生產插入DOM的這一過程。我想這纔是在應對長列表的渲染中,cloneNode
優於createElement
的真正緣由吧。緩存
我這裏實現的diff算法,沒有那麼複雜,我暫時不考慮對key的狀況作相應的處理(即第4點),而且,個人diff算法秉承簡單粗暴的原則,直接在diff的過程當中就對DOM進行了修改,因此也沒有patch補丁包。性能優化
首先回憶一下我以前對初次渲染的時候,涉及到DOM的這一部分實際上都經過VNode類和Element類來實現。因此咱們的diff核心就是圍繞着這兩個類執行:經過VNode來比對出哪些須要變更,Element則是對這些變更進行執行,Element充當着一個執行者的角色。理清了這一部分關係後,讓咱們回到咱們最初等待解決的問題上,咱們須要一個update方法,以供Watcher在更新時調用:數據結構
xm.$watcher = new Watcher(() => {
xm._callHook.call(xm, 'beforeUpdate');
const newVnodeTree = parseJsxObj(xm.$render());
// update方法返回一個新的vnodeTree,
xm.$vnodeTree = update(xm, newVnodeTree, xm.$vnodeTree);
}, 'render');
複製代碼
看一下update方法,很簡單,就是調用diff,返回處理後的newVNode
export const update = function(xm, newVNode, oldVNode) {
// 差別比對
diff(xm, newVNode, oldVNode);
// 這裏返回的newVNode是在diff過程當中通過處理的,添加了每一個VNode對應的Element
return newVNode;
}
複製代碼
接下來就是diff的過程,我的感受如下內容可能不大好理解,請慢慢閱讀:
/** * @param { Xue } xm Xue實例,用於在事件相關上綁定this * @param { VNode } newVNode 新的vnodeTree * @param { VNode } oldVNode 舊的vnodeTree * @param { VNode } parentVNode newVNode的父級vnode * @param { VNode } nextBroNode 當前newVNode的下一個兄弟節點,主要用在insertBefore的場景 */
export const diff = function(xm, newVNode, oldVNode, parentVNode, nextBroNode) {
// 定義變化的類型
let diffType = '';
// 舊節點不存在
// 或者舊節點爲null,新節點不爲null
if(!oldVNode || (oldVNode.tag === null && newVNode.tag !== null)) {
// 有節點新增
diffType = 'addNode';
}
// 新節點不存在
// 或者新節點爲null,舊節點不爲null
else if(!newVNode || (oldVNode.tag !== null && newVNode.tag === null)) {
// 有節點刪除
diffType = 'delNode';
}
// 節點標籤不同,直接替換
else if(oldVNode.tag !== newVNode.tag) {
// 替換節點
diffType = 'replaceNode';
}
// 文本節點時,直接用新的文本節點替換舊的文本節點
else if(newVNode.tag === '') {
diffType = 'replaceText';
}
// 比較屬性和事件
else {
diffType = 'updateAttrsAndEvents';
}
// 根據diffType調用不一樣的處理函數
diffUpdateHandler(diffType, xm, newVNode, oldVNode, parentVNode, nextBroNode);
// 遞歸處理子節點,這裏其實就是同一層級結構比較的過程
// 對於條件渲染的狀況,爲了正確處理,若是當前條件爲falsy(虛值),則必須返回一個null節點
// 讓VNode知道這裏有一個null的佔位空節點,此節點不會被渲染
for(let i = 0; i < newVNode.children.length; i++) {
// 下一個兄弟節點,爲了在新增節點時,插入至正確的位置
const nextBroNode = (i === newVNode.children.length - 1) ? null : oldVNode.children[i + 1];
// 緩存舊節點
let oldVNodeParam = oldVNode && oldVNode.children[i];
// 對於新增長的節點或者替換後的節點來講,它們的子節點在oldVNode中都被認爲是不存在的值,子節點都被直接插入至新的節點
// 其實就是對應於不一樣節點的子節點都是不一樣的
if(diffType === 'addNode') oldVNodeParam = undefined;
// 遞歸
diff(xm, newVNode.children[i], oldVNodeParam, newVNode, nextBroNode);
}
}
複製代碼
接下來再看diffUpdateHandler的邏輯,這一塊相對來講比較簡單,就是根據不一樣的diffType值調用不一樣的處理函數:
export const diffUpdateHandler = function(diffType, xm, newVNode, oldVNode, parentVNode, nextBroNode) {
switch(diffType) {
case 'addNode': diffAddNode(xm, newVNode, oldVNode, parentVNode, nextBroNode); break;
case 'delNode': diffDelNode(xm, newVNode, oldVNode, parentVNode); break;
case 'replaceNode': diffReplaceNode(xm, newVNode, oldVNode, parentVNode); break;
case 'replaceText': diffUpdateText(xm, newVNode, oldVNode, parentVNode); break;
case 'updateAttrsAndEvents': diffAttribute(xm, newVNode, oldVNode); diffEvent(xm, newVNode, oldVNode); break;
default: warn(`error diffType: ${ diffType }`);
}
}
複製代碼
在看這幾天處理函數以前,先來看一下完善以後的Element類,添加了許多處理更新DOM的方法:
class Element {
constructor(vnode, xm) {
this.xm = xm;
// 若是爲null的話,則不作任何處理
if(vnode.tag === null) return;
// 非文本節點
if(vnode.tag !== '') {
this.el = document.createElement(vnode.tag);
// 綁定屬性
Object.entries(vnode.attrs).forEach(([key, value]) => {
this.setAttribute(key, value);
});
// 綁定事件
Object.keys(vnode.events).forEach(key => {
// 緩存bind後的函數,用於以後的函數移除
vnode.events[key] = vnode.events[key].bind(xm);
this.addEventListener(key, vnode.events[key]);
});
}
// 文本節點
else this.el = document.createTextNode(vnode.text);
}
// 添加子節點
appendChild(element) {
this.el && element.el && this.el.appendChild(element.el);
}
// 移除子節點
removeChild(element) {
this.el && element.el && this.el.removeChild(element.el);
}
// 添加屬性,對className和style作特殊處理
// class是保留字,style接受一個對象
setAttribute(name, value) {
if(name === 'className') {
this.el.setAttribute('class', value);
}
else if(name === 'style') {
Object.entries(value).forEach(([styleKey, styleValue]) => {
this.el.style[styleKey] = styleValue;
})
}
else {
this.el.setAttribute(name, value);
}
}
// 添加事件監聽
addEventListener(name, handler) {
this.el.addEventListener(name, handler);
}
// 移除事件監聽
removeEventListener(name, handler) {
this.el.removeEventListener(name, handler);
}
// 更新文本內容
updateTextContent(text) {
this.el.textContent = text;
}
// 替換子節點
replaceChild(newElement, oldElement) {
this.el.replaceChild(newElement.el, oldElement.el);
}
// 在referenceElement前插入newElement,父節點爲this.el
insertBefore(newElement, referenceElement) {
// insertBefore這個方法還有一個巧妙的用法:當須要插入的節點自己就在DOM樹中時,這個節點會被移動到插入的位置
// 即在將節點附加到其餘節點以前,不須要從其父節點刪除該節點
// 能夠把這一特性應用到含key值的列表項的處理
this.el.insertBefore(newElement.el, referenceElement && referenceElement.el);
}
}
複製代碼
看完了Element類,讓咱們接着剛纔的流程,重上往下一個個看這幾個處理函數:
// 添加節點
export const diffAddNode = function(xm, newVNode, oldVNode, parentVNode, nextBroNode) {
// 建立新的Element,同時也建立了DOM對象
const newElement = new Element(newVNode, xm);
// 父vnode對應的element--即爲newElement須要插入到的父節點
// nextBroNode爲newElement的下一個兄弟節點,爲空時會直接插入到父節點的子列表的末尾
parentVNode.element.insertBefore(newElement, nextBroNode && nextBroNode.element);
// 當前的newVNode指定新的newElement
newVNode.addElement(newElement);
}
複製代碼
// 刪除舊節點
export const diffDelNode = function(xm, newVNode, oldVNode, parentVNode) {
// 調用父節點的removeChild方法刪除當前節點
parentVNode.element.removeChild(oldVNode.element);
// 當前的newVNode指定空的element佔位對象
newVNode.addElement(new Element(new VNode(null), xm));
}
複製代碼
// 替換舊節點
export const diffReplaceNode = function(xm, newVNode, oldVNode, parentVNode) {
// 新建節點
const newElement = new Element(newVNode, xm);
// 把舊節點替換爲新節點
parentVNode.element.replaceChild(newElement, oldVNode.element);
// 爲newVNode指定element
newVNode.addElement(newElement);
}
複製代碼
// 比較文本節點
export const diffUpdateText = function(xm, newVNode, oldVNode, parentVNode) {
if(newVNode.text !== oldVNode.text) {
// 更新文本的時候不須要建立新的文本節點,直接利用舊節點便可
oldVNode.element.updateTextContent(newVNode.text);
}
// 爲newVNode指定element
newVNode.addElement(oldVNode.element);
}
複製代碼
// 比較屬性
export const diffAttribute = function(xm, newVNode, oldVNode) {
// 經過Set建立須要比較的全部屬性,將新舊vnode的屬性結合
const attrs = new Set(Object.keys(newVNode.attrs).concat(Object.keys(oldVNode.attrs)));
// 對屬性值不一樣的更新屬性值
attrs.forEach(attr => newVNode.attrs[attr] !== oldVNode.attrs[attr] && oldVNode.element.setAttribute(attr, newVNode.attrs[attr]));
// 爲newVNode指定element
newVNode.addElement(oldVNode.element);
}
複製代碼
// 比較事件
export const diffEvent = function(xm, newVNode, oldVNode) {
// 拿到須要比較的全部事件
const events = new Set(Object.keys(newVNode.events).concat(Object.keys(oldVNode.events)));
events.forEach(event => {
// 當newVNode和oldVNode事件不一樣時
if(newVNode.events[event] !== oldVNode.events[event]) {
// 移除舊事件的響應函數
oldVNode.element.removeEventListener(event, oldVNode.events[event]);
// 若是新事件的響應函數存在,則添加
if(newVNode.events[event]) {
// 保存新的綁定this後的處理函數
const handler = newVNode.events[event] = newVNode.events[event].bind(xm);
oldVNode.element.addEventListener(event, handler);
}
}
});
// 爲newVNode指定element
newVNode.addElement(oldVNode.element);
}
複製代碼
到此爲止update這一塊的內容都已經結束了,接下來,讓咱們寫一個demo試試到目前爲止的效果
import Xue from './src/main';
new Xue({
root: '#app',
data() {
return {
test1: 'i am text1',
test2: {
a: 'i am text2 attr a'
}
}
},
methods: {
fn1() {
console.log(this)
console.log('i am fn1')
},
fn2() {
console.log(this)
console.log('i am fn2')
}
},
render() {
return (<div> { this.test1 } <br /> { this.test2.a } <br /> { this.test1 === 'i am text1' ? 'text1 === i am text1' : 'text1 === i am text1 change' } <br /> { this.test1 === 'i am text1' ? null : <div>i have been rendered when test1 !== i am text1 </div> } { this.test1 === 'i am text1' ? <div>i have been rendered when test1 === i am text1 </div>: null } { this.test1 === 'i am text1' ? <a>i am a node when text1 === i am text1<span> i am inner</span></a> : <span>i am a node when text1 === i am text1 change</span> } <br /> { this.test1 === 'i am text1' ? <a>i am a node when text1 === i am text1</a> : <span>i am a node when text1 === i am text1 change<span> i am inner</span></span> } <br /> <div onClick={this.test === 'i am text1' ? this.fn1 : this.fn2} className={this.test === 'i am text1' ? 'cls1' : 'cls2'} id='id1'> my attrs and events will be change </div> </div>);
},
beforeCreate() {
setTimeout(() => {
this.test1 = 'i am text1 change';
this.test2.a = 'i am text2 attr a change';
console.log('settimeout');
}, 3000);
setTimeout(() => {
this.test1 = 'i am text1';
this.test2.a = 'i am text2 attr a';
console.log('settimeout');
}, 5000)
}
});
複製代碼
初次渲染
3s後頁面更新 點擊觸發fn2,能夠看到this的指向也是正確的 5s後頁面更新回初此渲染時的狀態 點擊觸發fn1,能夠看到this的指向也是正確的下一章節內容:組件化,敬請期待......另外,下週可能拖更,緣由幹這行的都懂,難頂。
github項目地址:點此跳轉
第一章:從零開始,採用Vue的思想,開發一個本身的JS框架(一):基本架構的搭建