若無法打開文中引用鏈接,那么可能是你上網的姿勢不對
virtual dom中心思想
如果沒有理解virtual dom的構建思想,那么你可以參考這篇精致文章Boiling React Down to a Few Lines in jQuery
virtual dom優化開發的方式是:通過vnode,來實現無狀態組件,結合單向數據流(undirectional data flow),進行UI更新,整體代碼結構是:
var newVnode = render(vnode, state)
var oldVnode = patch(oldVnode, newVnode)
state.dispatch('change')
var newVnode = render(vnode, state)
var oldVnode = patch(oldVnode, newVnode)
...
virtual dom庫選擇
在眾多virtual dom庫中,我們選擇snabbdom庫,原因有很多:
- snabbdom性能排名靠前,雖然這個benchmark的參考性不高
- snabbdom示例豐富
- snabbdom具有一定的生態圈,如motorcycle.js,cycle-snabbdom,cerebral
- snabbdom實現的十分優雅,使用的是recursive方式調用patch,對比infernojs優化痕跡明顯的代碼,snabbdom更易讀。
- 在閱讀過程中發現,snabbdom的模塊化,插件支持做得極佳
snabbdom的工作方式
如果不理解view層的工作原理,那么可以參考這篇文章React-less Virtual DOM with Snabbdom。接下來,我們來查看snabbdom基本使用方式。
// snabbdom在./snabbdom.js
var snabbdom = require('snabbdom')
// 初始化snabbdom,得到patch。隨后,我們可以看到snabbdom設計的精妙之處
var patch = snabbdom.init([
require('snabbdom/modules/class'),
require('snabbdom/modules/props'),
require('snabbdom/modules/style'),
require('snabbdom/modules/eventlisteners')
])
// h是一個生成vnode的包裝函數,factory模式?對生成vnode更精細的包裝就是使用jsx
// 在工程里,我們通常使用webpack或者browserify對jsx編譯
var h = require('snabbdom/h')
// 構造一個virtual dom,在實際中,我們通常希望一個無狀態的vnode
// 并且我們通過state來創造vnode
// react使用具有render方法的對象來作為組件,這個組件可以接受props和state
// 在snabbdom里面,我們同樣可以實現類似效果
// function component(state){return h(...)}
var vnode =
h(
'div#container.two.classes',
{on: {click: someFn}},
[
h('span', {style: {fontWeight: 'bold'}}, 'This is bold'),
' and this is just normal text',
h('a', {props: {href: '/foo'}},
'I\'ll take you places!')
]
)
// 得到初始的容器,注意container是一個dom element
var container = document.getElementById('container')
// 將vnode patch到container中
// patch函數會對第一個參數做處理,如果第一個參數不是vnode,那么就把它包裝成vnode
// patch過后,vnode發生變化,代表了現在virtual dom的狀態
patch(container, vnode)
// 創建一個新的vnode
var newVnode =
h(
'div#container.two.classes',
{on: {click: anotherEventHandler}},
[
h('span', {style: {fontWeight: 'normal', fontStyle: 'italics'}},
'This is now italics'),
' and this is still just normal text',
h('a', {props: {href: '/bar'}}, 'I\'ll take you places!')
]
)
// 將新的vnode patch到vnode上,現在newVnode代表vdom的狀態
patch(vnode, newVnode)
閱讀源代碼
vnode的定義
閱讀vdom實現,首先弄清楚vnode的定義
vnode的定義在./vnode.js中
vnode具備的屬性
- tagName 可以是custom tag,可以是'div','span',etc,代表這個virtual dom的tag name
- data, virtual dom數據,它們與dom element的prop、attr的語義類似。但是virtual dom包含的數據可以更靈活。
比如利用./modules/class.js插件,我們在data里面輕松toggle一個類名
h('p', {class: {'hide': hideIntro}})
- children, 對應element的children,但是這是vdom的children。vdom的實現重點就在對children的patch上
- text, 對應element.textContent,在children里定義一個string,那么我們會為這個string創建一個textNode
- elm, 對dom element的引用
- key,用于提示children patch過程,隨后將詳細說明
h參數
隨后是h函數的包裝
h的實現在./h.js
包裝函數一共注意三點
- 對svg的包裝,創建svg需要namespace
- 將vdom.text統一轉化為string類型
- 將vdom.children中的string element轉化為textNode
與dom api的對接
采用adapter模式,對dom api進行包裝,然后將htmldomapi作為默認的瀏覽器接口
這種設計很機智。在擴展snabbdom的兼容性的時候,只需要改變snabbdom.init使用的瀏覽器接口,而不用改變patch等方法的實現
snabbdom的patch解析
snabbdom的核心內容實現在./snabbdom.js。snabbdom的核心實現不到三百行(233 sloc),非常簡短。
在snabbdom里面實現了snabbdom的virtual dom diff算法與virtual dom lifecycle hook支持。
virtual dom diff
vdom diff是virtual dom的核心算法,snabbdom的實現原理與react官方文檔Reconciliation一致
總結起來有:
- 對兩個樹結構進行完整的diff和patch,復雜度增長為O(n^3),幾乎不可用
- 對兩個數結構進行啟發式diff,將大大節省開銷
一篇閱讀量頗豐的文章React’s diff algorithm也說明的就是啟發過程,可惜,沒有實際的代碼參照。現在,我們根據snabbdom代碼來看啟發規則的運用,結束后,你會明白virtual dom的實現有多簡單。
首先來到snabbdom.js中init函數的return語句
return function(oldVnode, vnode) {
var i, elm, parent;
// insertedVnodeQueue存在于整個patch過程
// 用于收集patch中新插入的vnode
var insertedVnodeQueue = [];
// 在進行patch之前,我們需要運行prepatch hook
// cbs是init函數變量,即,這個return語句中函數的閉包
// 這里,我們不理會lifecycle hook,而只關注vdom diff算法
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
// 如果oldVnode不是vnode(在第一次調用時,oldVnode是dom element)
// 那么用emptyNodeAt函數來將其包裝為vnode
if (isUndef(oldVnode.sel)) {
oldVnode = emptyNodeAt(oldVnode);
}
// sameVnode是上述“值不值得patch”的核心
// sameVnode實現很簡單,查看兩個vnode的key與sel是否分別相同
// ()=>{vnode1.key === vnode2.key && vnode1.sel === vnode2.
// 比較語義不同的結構沒有意義,比如diff一個'div'和'span'
// 而應該移除div,根據span vnode插入新的span
// diff兩個key不相同的vnode同樣沒有意義
// 指定key就是為了區分element
// 對于不同key的element,不應該去根據newVnode來改變oldVnode的數據
// 而應該移除不再oldVnode,添加newVnode
if (sameVnode(oldVnode, vnode)) {
// oldVnode與vnode的sel和key分別相同,那么這兩個vnode值得去比較
//patchVnode根據vnode來更新oldVnode
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
//不值得去patch的,我們就暴力點
// 移除oldVnode,根據newVnode創建elm,并添加至parent中
elm = oldVnode.elm;
parent = api.parentNode(elm);
// createElm根據vnode創建element
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
// 將新創建的element添加到parent中
api.insertBefore(parent, vnode.elm, api.nextSibling(elm));
// 同時移除oldVnode
removeVnodes(parent, [oldVnode], 0, 0);
}
}
// 結束以后,調用插入vnode的insert hook
for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data.hook.insert(insertedVnodeQueue[i]);
}
// 整個patch結束,調用cbs中的post hook
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
return vnode;
};
然后我們閱讀patch的過程
function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
var i, hook;
// 如前,在patch之前,調用prepatch hook,但是這個是vnode在data里定義的prepatch hook,而不是全局定義的prepatch hook
if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
i(oldVnode, vnode);
}
var elm = vnode.elm = oldVnode.elm, oldCh = oldVnode.children, ch = vnode.children;
// 如果oldVnode和vnode引用相同,則沒必要比較。在良好設計的vdom里,大部分時間我們都在執行這個返回語句。
if (oldVnode === vnode) return;
// 如果兩次引用不同,那說明新的vnode創建了
// 與之前一樣,我們先看這兩個vnode值不值得去patch
if (!sameVnode(oldVnode, vnode)) {
// 這四條語句是否與init返回函數里那四條相同?
var parentElm = api.parentNode(oldVnode.elm);
elm = createElm(vnode, insertedVnodeQueue);
api.insertBefore(parentElm, elm, oldVnode.elm);
removeVnodes(parentElm, [oldVnode], 0, 0);
return;
}
// 這兩個vnode值得去patch
// 我們先patch vnode,patch的方法就是先調用全局的update hook
// 然后調用vnode.data定義的update hook
if (isDef(vnode.data)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
i = vnode.data.hook;
if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
}
// patch兩個vnode的text和children
// 查看vnode.text定義
// vdom中規定,具有text屬性的vnode不應該具備children
// 對于<p>foo:<b>123</b></p>的良好寫法是
// h('p', [ 'foo:', h('b', '123')]), 而非
// h('p', 'foo:', [h('b', '123')])
if (isUndef(vnode.text)) {
// vnode不是text node,我們再查看他們是否有children
if (isDef(oldCh) && isDef(ch)) {
// 兩個vnode都有children,那么就調用updateChildren
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
} else if (isDef(ch)) {
// 只有新的vnode有children,那么添加vnode的children
if (isDef(oldVnode.text)) api.setTextContent(elm, '');
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 只有舊vnode有children,那么移除oldCh
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
// 兩者都沒有children,并且oldVnode.text不為空,vnode.text未定義,則清空elm.textContent
api.setTextContent(elm, '');
}
} else if (oldVnode.text !== vnode.text) {
// vnode是一個text node,我們改變對應的elm.textContent
// 在這里我們使用api.setText api
api.setTextContent(elm, vnode.text);
}
if (isDef(hook) && isDef(i = hook.postpatch)) {
i(oldVnode, vnode);
}
}
patch的實現是否簡單明了?甚至有覺得“啊?這就patch完了”的感覺。當然,我們還差最后一個,這個是重頭戲——updateChildren。
最后閱讀updateChildren*
updateChildren的代碼較長且密集,但是算法十分簡單
oldCh是一個包含oldVnode的children數組,newCh同理
我們先遍歷兩個數組(while語句),維護四個變量
- 遍歷oldCh的頭索引 - oldStartIdx
- 遍歷oldCh的尾索引 - oldEndIdx
- 遍歷newCh的頭索引 - newStartIdx
- 遍歷newCh的尾索引 - newEndIdx
當oldStartIdx > oldEndIdx或者newStartIdx > newOldStartIdx的時候停止遍歷。
遍歷過程中有五種比較
前四種比較
- oldStartVnode和newStartVnode,兩者elm相對位置不變,若值得(sameVnode)比較,這patch這兩個vnode
- oldEndVnode和newEndVnode,同上,elm相對位置不變,做相同patch檢測
- oldStartVnode和newEndVnode,如果oldStartVnode和newEndVnode值得比較,說明oldCh中的這個oldStartVnode.elm向右移動了。那么執行
api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm))
調整它的位置 - oldEndVnode和newStartVnode,同上,但這是oldVnode.elm向左移,需要調整它的位置
最后一種比較
- 利用vnode.key,在
ul>li*n
的結構里,我們很有可能使用key來標志li
的唯一性,那么我們就會來到最后一種情況。這個時候,我們先產生一個index-key表(createKeyToOldIdx),然后根據這個表來進行更改。
更改規則
- 如果newVnode.key不在表中,那么這個newVnode就是新的vnode,將其插入
- 如果newVnode.key在表中,那么對應的oldVnode存在,我們需要patch這兩個vnode,并在patch之后,將這個oldVnode置為undefined(
oldCh[idxInOld] = undefined
),同時將oldVnode.elm位置變換到當前oldStartIdx之前,以免影響接下來的遍歷
遍歷結束后,檢查四個變量,對移除剩余的oldCh或添加剩余的newCh
patch總結
閱讀完init函數return語句,patch,updateChildren,我們可以理解整個diff和patch的過程
有些函數createElm,removeVnodes并不重要
lifecycle hook
閱讀完virtual dom diff算法實現后,我們可能會奇怪,關于style、class、attr的patch在哪里?這些實現都在modules,并通過lifecycle發揮作用
snabbdom的生命周期鉤子函數定義在core doc - hook中。
再查看modules里的class會發現,class module通過兩個hook鉤子來對elm的class進行patch。這兩個鉤子是create和update。
回到init函數,這兩個鉤子在函數體開頭注冊
for (i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = [];
for (j = 0; j < modules.length; ++j) {
if (modules[j][hooks[i]] !== undefined)
cbs[hooks[i]].push(modules[j][hooks[i]]);
}
}
- create hook在createElm中調用。createElm是唯一添加vnode的方法,所以
insertedVnodeQueue.push
只發生在createElm中。
- update hook在patch中調用
尋找每個lifecycle hook的調用位置,你會更清楚lifecycle hook對snabbdom擴展性的好處。
lifecycle hook的強大之處
snabbdom有一個示例是animated list
animated list示例可以表現一個框架對動畫的支持性,對比其他框架對animated list的實現:
react - flip-flop
vue - animatd list
我們發現,react提供成熟的lifecycle hook,輕松實現animated list。vue也可以輕松實現,但實現的意義卻不及snabbdom和react,因為vue對list渲染方法進行了monkey patch,這并不屬于vue的api。
寫在最后
隨著react社區逐漸龐大,關于virtual dom的討論也越加深入。很多概念,像pure function, immutable data, flux, rx,都在孕育新的框架。特別是,react
類的開發體驗無論在prototype階段,還是在測試階段,都優于data-bind
類(MVC, MVVM)開發,相信越來越多人的會偏向react社區。
最后推薦一些資料和有趣的線索
- snabbdom作者另一個github repo——functional-frontend-architecture,里面充滿了作為業界標榜的創造力!
- 更短,但是不完整的vdom,它甚至看起來不像vdom—— frzr。frzr的實現很像snabbdom,如作者所說,他聽取了很多來自snabbdom作者的建議,對于frzr的闡述,作者本人有一篇很好的文章
- 函數編程對前端的影響
- 比較react dom和其他框架的好文章virtual dom vs incremental dom vs glimmer,另外除了incremental dom,還有一個有趣的庫——morphdom
- 一個對前端框架的優秀benchmark website,