前言:
讀React 原始碼大概是最幸福的事情,因為在社群裡有數之不盡的高手都會給出自己很獨到的見解,即使有不懂的地方也會能找到前人努力挖掘的痕跡。我反問自己,然後即使在這樣優越的環境下,還不去讀原始碼,是不是就太懶了。 # 我會亂說? #
約定
這一篇都通過虛擬碼實現,保證首先從總體上走通流程,後面的篇章都是基於這樣一個流程去實現。
開始之前
這裡必須明確一個概念,所謂的 自定義標籤
都是由很多原生標籤諸如<div>
去實現的。
因此自定義標籤
就可以想象成
1 2 3 |
<MyTag> <div class="MyTag"> <SomeTag></someTag> ===> <div class="someTag"></div> </MyTag> </div> |
流程
- 建立虛擬DOM
- 真實DOM 連線 虛擬DOM
- 檢視更新
- 計算 [ 新虛擬DOM ] 和 [ 舊虛擬DOM ] 的差異
( diff )
- 根據計算的 差異, 更新真實DOM
( patch )
這裡牽涉到兩個詞語 diff
和 patch
,稍後再解釋,這裡簡單理解為 [計算差異]和[應用差異]。
虛擬碼實現
注:雖然這是這個系列的第一篇,但這已經是第二遍寫了。原因是第一遍想完整模擬的時候發現,自己對演算法的瞭解太粗淺,深搜和最短字元演算法都不懂,最近和死月大大請教,所以這裡偏向思路多一點。
1. 這裡我們期望的真實DOM結構是這樣的,下面我們一步步實現
1 2 3 |
<div id="wrap"> <span id="txt">i am some text</span> </div> |
2. 建立虛擬DOM
1 2 3 4 5 6 7 8 9 10 11 |
// 虛擬DOM的建構函式 function VirtualDOM(type,props,children){ this.type = type; this.props = props || {}; this.children = children || []; this.rootId = null; // 本節點id this.mountId = null; // 掛載節點id } var textDom = new VirtualDOM('span',{id:'txt'},'i am some text'); var wrapDom = new VirtualDOM('div',{id:'wrap'},[textDom]); |
3. 虛擬DOM不能夠影響真實DOM,這裡我們需要建立連線
最終目的得到這樣的字串,可以供真實DOM使用
1 |
"<div v-id="0"><span v-id="0.0">i am some text</span></div> |
簡單實現 ( 這裡需要記錄一下每個DOM的id )
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
var rootIdx = 0; // 標誌每一個DOM的唯一id,用'.'區分層級 function mountElement(ele){ ele.rootId = rootIdx++; var tagOpen = '<' + ele.type + ' v-id="'+ ele.rootId +'">'; var tagClose = '</' + ele.type + '>'; var type; // 遍歷拼湊出虛擬DOM的innerHTML ele.children.forEach(function(item,index){ item.rootId = ele.rootId + '.' + index; // 區分層級 item.moutId = ele.rootId; // 需要知道掛載在哪個物件上 if(Array.isArray(item.children)){ // 暫且用是否陣列判斷是否為文字dom tagOpen += mountElement(item); }else{ type = item.type; tagOpen += '<' + type +' v-id="' + item.rootId + '">'; tagOpen += item.children; tagOpen += '</' +type + '>'; } }); tagOpen += tagClose; return tagOpen; } var vituralHTML = mountElement(wrapDom); document.querySelector('#realDom').innerHTML = mountElement(ele); |
這裡做的事情,就是遍歷虛擬DOM,然後拼合虛擬DOM後,以字串形式輸出。
4. 現在我們已經建立了連線了,現在需要模擬一下檢視更新,於是我們新生成一個虛擬DOM。
1 2 |
var newtextDom = new VirtualDOM('span',{id:'txt'},'look,i am change!'); var newDom = new VirtualDOM('div',{id:'wrap'},[newtextDom]); |
注意:由於React是基於setState的方法去觸發檢視更新的,但這裡要描述的重點是diff,因此我們簡單帶過。
5. 我們終於進入主題了!我們激動的去比較一下它們的差異
先別激動!
為了更好的描述這個問題,我們這裡改變一下上面獲取的兩個虛擬DOM。
我們打算把結構換成這樣的,注意註釋的部分,就是兩個DOM的不同之處。
1 2 3 4 5 6 7 8 9 10 |
<div v-id="0"> <div v-id="0.0"> <span v-id="0.0.0"></span> <span v-id="0.0.1"></span> </div> <div v-id="0.1"> <span v-id="0.1.0"></span> <span v-id="0.1.1">老的字元</span> // <span v-id="0.1.1">新的字元</span> </div> </div> |
6. 比較差異
1 2 3 4 5 6 7 8 9 10 11 |
var diffQueue = []; // 記錄差異的陣列 var diffDepth = 0; // 每下去一層節點就+1 function updateDom(oldDom,newDom){ diffDepth++; diff(oldDom,newDom,diffQueue);// 比較差異 diffDepth--; if(diffDepth === 0){ patch(oldDom,diffQueue); diffQueue = []; } } |
6. 扁平化
為了方便比較,我們把虛擬DOM,變成Map型別的資料
1 2 3 4 5 6 7 8 9 |
function flat(children){ var key; var result = {}; children.forEach(item,index){ key = item.props.key ? item.props.key : index.toString(36); result[key] = item; } return result; } |
8. diff
這裡開始我們就正式開始比較了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
function diff(oldDom,newDom,diffQueue){ var oldFlat = flat(oldDom.childern); var newFlat = generateNewMap(oldDom.childern,newDom); var newIndex = 0; // 作為記錄map遍歷的順序 // 遍歷新的虛擬DOM for(key in newFlat){ var oldItem = oldFlat[key]; var newItem = generate[key]; // 差異型別1 :移動 if(oldItem === newItem){ // 元素完全相同,則認為是順序移動了 diffQueue.push({ type:UPDATE_TYPES.MOVE, wrapId:oldItem.mountId, // 之前掛在物件的id fromIndex:oldItem.rootId, // 本身位置的id toIndex: nextIndex // 當前遍歷的位置 }); } else { // 差異型別2 :刪除 if(oldItem){ // 舊的和新的不符合,先刪除 diffQueue.push({ type:UPDATE_TYPES.REMOVE, wrapID:oldItem.mountId, fromIndex:oldItem.rootId, toIndex:null }); } // 差異型別3 :插入 diffQueue.push({ type:UPDATE_TYPES.INSERT, wrapId:oldItem.mountId, fromIndex:null, toIndex:nextIndex, ele:newItem // 方便後面渲染出innerHTML }); } nextIndex++; } // 遍歷老的Map,如果新的節點裡不存在,也刪除掉 for(var key in oldFlat){ var oldItem = oldFlat[key]; var newItem = newFlat[key]; // 差異型別2 :刪除 diffQueue.push({ wrapId: oldItem.mountId, type: UPATE_TYPES.REMOVE, fromIndex: oldItem.mountId, toIndex: null }) } } |
這裡注意到我們生成新的Map是通過 generateNewMap 方法的。 generateNewMap 實際上就是去遞迴呼叫diff,完成所有子類的差異比較,最終返回到差異陣列。
1 2 3 4 5 6 7 8 |
function generateNewMap(oldChildren,newDom,diffQueue){ newDom.children.forEach(function(newItem,index){ var oldItem = oldChildren[index]; if(shouldUpdate(oldItem,newItem)){ diff(oldItem,newItem,diffQueue); } }) } |
7. 差異型別
這裡簡單用整型數字作為紀錄
1 2 3 4 5 6 |
var UPDATE_TYPES = { MOVE:1, //移動節點 REMOVE:2, // 刪除節點 INSERT:3, // 插入節點 TEXT:4 // 節點內容更新 }; |
8. 應用差異patch
我們已經得到了所有的差異diffQueue,是時候應用這些改動了。
拿插入作例子,我們知道了掛載的物件以及插入的位置,那麼我就可以用原生的insertBefore去執行。
1 2 3 4 |
function insertAt(target,source,index){ var oldDom = target.children[index]; // 獲取目標物件下的的index位置的子元素 source.insertBefore(oldDom); } |
那麼通過這些計算得到的source子元素和在目標掛載元素target中的位置index,我們就能夠應用這些變化。
下面簡單程式碼再描述一下
function patch(diffQueue){
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
var deleteArr = []; var updateSpec = {}; diffQueue.forEach(function(update,index){ if(update.type === UPDATE_TYPES.MOVE || update.type === UPDATE_TYPE.REMOVE){ // 無論是刪除還是移動,先儲存在刪除陣列裡 var updateIndex = update.fromIndex; var updateTarget = document.querySelector('[v-id='+updateIndex+']'); var wrapIndex = update.wrapId; // 儲存下來,方便移動使用 updateSpec[wrapIndex] = updateSpec[wrapIndex] || []; updateSpec[wrapIndex][updateIndex] = updateTarget; // 記錄刪除 removeArr.push(updateTarget); } // 刪除節點 updateTarget.forEach(function(d){ d.remove(); }); // 再次遍歷,將移動節點的變化處理掉 diffQueue.forEach(function(update){ var target = document.querySelector(update.wrapId); switch (update.type) { case UPATE_TYPES.INSERT_MARKUP: var source = mountEle(update.ele); insertChildAt(target, source, update.toIndex); break; case UPATE_TYPES.MOVE_EXISTING: insertChildAt(target, updateSpec[update.wrapId][update.fromIndex], update.toIndex); break; }); }); } |
10. 大體流程再次迴歸
- diff遞迴找出不同,存入差異陣列。每一個差異元素包含自身位置,父元件位置,差異型別
- 根據差異型別和差異資訊,對舊的虛擬DOM作處理
- 所有處理結束後,一次性操作真實DOM完成處理
9. 總結
呼…雖然是第二遍寫,還是不能很流暢表達,感覺抽象出來的粒度還不夠,不過機智的我已經預料到了這一切。
所以第二篇會開始用演算法真刀真槍去模擬整個庫的形式去解讀。每學習到一種解決方案還是很開心的,啦啦啦。
10. 特別推薦
- http://purplebamboo.github.io/
博主貌似叫竹隱,文章質量太高了,是那種看完久久不能平復,就算是半夜都想爬起來擼程式碼,這篇文章就是照擼了一遍,還不夠爽,第二遍自己用虛擬碼擼的。
- https://github.com/livoras/blog/issues
抽象能力非常強,加以通俗的語言解釋高深的演算法,受啟發好大
- https://github.com/Matt-Esch/virtual-dom
權威的virtual-dom解讀,看issues的討論感覺腦洞真的好大…
- http://facebook.github.io/react/docs/reconciliation.html
http://grfia.dlsi.ua.es/ml/algorithms/references/editsurvey_bille.pdf官方的解讀和完整的演算法解釋( 論文 ) …心目中前端的最終形態