原發於知乎專欄:zhuanlan.zhihu.com/ne-fenode
前一段時間因爲React Licence的問題,團隊內部積極的探索React的替代方案,同時考慮到以後可能開展的移動端業務,團隊目標是但願可以找到一個遷移成本低,體量小的替代產品。通過多方探索,Preact進入了咱們的視野。從接觸到Preact開始,一路學習下來折損了許多頭髮,也收穫很多思考,這裏想和你們介紹一下Preact的實現思路,也分享一下本身的思考所得。react
一句話介紹Preact,它是React的3KB輕量替代方案,擁有一樣的ES6 API。若是以爲就這麼一句話太模糊的話,我還能夠再囉嗦幾句。Preact = performance + react,這是Preact名字的由來,其中一個performance足以窺見做者的用心。下面這張圖反映了在長列表初始化的場景下,不一樣框架的表現,能夠看出Preact確實性能出衆。git
高性能,輕量,即時生產是Preact關注的核心。基於這些主題,Preact關注於React的核心功能,實現了一套簡單可預測的diff算法使它成爲最快的虛擬 DOM 框架之一,同時preact-compat爲兼容性提供了保證,使得Preact能夠無縫對接React生態中的大量組件,同時也補充了不少Preact沒有實現的功能。
github
簡單介紹了Preact的前生今世之後,接下來講下Preact的工做流程,主要包含五個模塊:算法
流轉過程見下圖。緩存
首先是咱們定義好的組件,在渲染開始的時候,首先會進入h函數生成對應的virtual node(若是是JSX編寫,以前還須要一步轉碼)。每個vnode中包含自身節點的信息,以及子節點的信息,由此而連結成爲一棵virtual dom樹。基於生成的vnode,render模塊會結合當前dom樹的狀況進行流程控制,併爲後續的diff操做作一些準備工做。Preact的diff算法實現有別於react基於雙virtual dom樹的思路,Preact只維持一棵新的virtual dom樹,diff過程當中會基於dom樹還原出舊的virtual dom樹,再將二者進行比較,並在比較過程當中實時對dom樹進行patch操做,最終生成新的dom樹。與此同時,diff過程當中被卸載的組件和節點不會被直接刪除,而是被分別放入回收池中緩存,當再次有同類型的組件或節點被構建時,能夠在回收池中找到同名元素進行改造,避免從零構建的開銷。
閉包
關鍵詞:hook, linkState, 批量更新app
相信有過react開發經驗的同窗對component的概念都不會陌生,這裏也不作過多解釋,只是介紹一些Preact在component層面上的添加的新特性。框架
除了基本的生命週期函數外,Preact還提供三個hook函數,方便用戶在指定的時間點執行統一操做。dom
linkState針對的場景是在render方法中爲用戶操做的回調綁定this,這樣每次渲染都在局部建立一個函數閉包,這樣效率十分低下並且會迫使垃圾回收器作許多沒必要要的工做。linkState理想中的應用場景以下。
export default class App extends Component {
constructor() {
super();
this.state = {
text: 'initial'
}
}
handleChange = e => {
this.setState({
text: e.target.value
})
}
render({desc}, {text}} {
return (
<div> <input value={text} onChange={this.linkState('text', 'target.value')}> <div>{text}</div> </div> ) } }複製代碼
然而linkState的實現方式。。。是在組件初始化的時候爲每一個回調建立閉包,綁定this,同時建立一個實例屬性將綁定後回調函數緩存起來,這樣再次render的時候就不須要再次綁定。實際效果等同於在組件的constructor中綁定。尷尬之處在於,linkState內部只實現了setState操做,同時也不支持自定義參數,使用場景比較有限。
//linkState源碼
//緩存回調
linkState(key, eventPath) {
let c = this._linkedStates || (this._linkedStates = {});
return c[key+eventPath] || (c[key+eventPath] = createLinkedState(this, key, eventPath));
}
//首次註冊回調的時候建立閉包
export function createLinkedState(component, key, eventPath) {
let path = key.split('.');
return function(e) {
let t = e && e.target || this,
state = {},
obj = state,
v = isString(eventPath) ? delve(e, eventPath) : t.nodeName ? (t.type.match(/^che|rad/) ? t.checked : t.value) : e,
i = 0;
for ( ; i<path.length-1; i++) {
obj = obj[path[i]] || (obj[path[i]] = !i && component.state[path[i]] || {});
}
obj[path[i]] = v;
component.setState(state);
};
}複製代碼
Preact實現了組件的批量更新,具體實現思路就是每次執行state or props更新之時,對應的屬性會被馬上更新,可是基於new state or props的渲染操做會被push進到一個更新隊列中,在當前event loop的最後或者是在下一個event loop的開始,纔會將隊列中的操做一一執行。同一個組件狀態的屢次更新,不會重複進入隊列。以下圖所示,屬性更新以後,組件渲染以前,_dirty值爲true,所以,組件渲染以前後續的屬性更新操做都不會使組件重複入隊。
//更新隊列源碼
export function enqueueRender(component) {
if (!component._dirty && (component._dirty = true) && items.push(component)==1) {
(options.debounceRendering || defer)(rerender);
}
}複製代碼
關鍵詞:節點合併
h函數的做用如同React.CreateElement,用於生成virtual node。其接受的輸入格式以下,三個參數分別爲節點類型,節點屬性,子元素。
h('a', { href: '/', h{'span', null, 'Home'}})複製代碼
h函數在生成vnode的過程當中,會對相鄰的簡單節點進行合併操做,目的是爲了減小節點數量,減輕diff負擔。 請看下面的例子。
import { h, Component } from 'preact';
const innerinnerchildren = [['innerchild2', 'innerchild3'], 'innerchild4'];
const innerchildren = [
<div>
{innerinnerchildren}
</div>,
<span>desc</span>
]
export default class App extends Component {
render() {
return (
<div>
{innerchildren}
</div>
)
}
}複製代碼
關鍵詞:流程控制,diff準備
首先先解釋一下,這裏的render模塊泛指整個流程中將vnode插入到dom樹中的操做,然而這類操做中又有一部分工做被diff模塊承擔,因此實際上render模塊的更多承擔的是流程控制以及進入diff的前置工做。
所謂流程控制,具體的內容分爲兩部分,節點類型的判斷,是自定義的組件仍是原生的dom節點,渲染類型的判斷,是首次渲染仍是更新操做。根據不一樣狀況,指定不一樣的渲染路線,執行相應的生命週期方法,hook函數和渲染邏輯。
如前所述,Preact在內存中只維持一棵包含更新內容的新的virtual dom樹,另外一個表明被更新的舊的virtual dom樹其實是從dom樹還原回來的,與此同時,dom樹的更新操做也是在比較過程當中,一邊比較一邊patch的。爲了確保上述操做不出現混亂,在生成/更新的dom樹的以前,須要在dom節點上添加一些自定義的屬性記錄狀態。
//建立自定義屬性記錄
export function renderComponent(component, opts, mountAll, isChild) {
if (component._disable) return;
let skip, rendered,
props = component.props,
state = component.state,
context = component.context,
previousProps = component.prevProps || props,
previousState = component.prevState || state,
previousContext = component.prevContext || context,
isUpdate = component.base,
nextBase = component.nextBase,
initialBase = isUpdate || nextBase,
initialChildComponent = component._component,
inst, cbase;複製代碼
關鍵詞:DOM依賴,Disconnected or Not,DocumentFragment
diff過程主要分爲兩個階段,第一個階段是創建virual node與dom節點之間的對應關係,第二個階段即是對二者進行比較並更新dom節點。
通過前面的介紹,相信你們對Preact的virtual dom實現已經有了必定的瞭解,這裏再也不贅述。這種實現方式,優勢在於總能真實的反映以前virtual dom樹的狀況,缺點就是存在內存泄露的風險。
咱們都知道,當咱們向dom樹中的節點執行appendChild,removeChild操做的時候,每執行一次,就會觸發一次頁面的reflow,這是一個具備至關開銷的行爲。所以當咱們必須執行一系列這樣的操做的時候,能夠採起這樣的優化手段,首先建立一個節點,在這個節點上執行過全部子節點的append操做以後,再將以這個節點做爲根節點的子樹一次性的append或者replace到dom樹中,只觸發一次reflow,就完成了整個子樹的更新,這樣的更新方式稱之爲disconnected。
與之相對,在建立節點以後,馬上將節點插入到dom樹中,而後繼續進行子節點的操做,則稱之爲connected。
在闡明瞭這個前提以後,再來看Preact的實現方式,Disconnected or Connected,是一座圍城。儘管做者聲稱Preact的渲染方式是disconnected,然而事實的真相是,not always true。 從一個簡單的狀況提及,textnode的值被修改或者舊的節點被替換成textnode。Preact所作的就是建立一個textnode或者修改以前textnode的nodeValue。雖然糾結這個場景是沒有意義的,可是爲了完整的介紹diff流程,有必要先說明一下。 進入重點。先看第一個例子。爲了說明問題,咱們用一個稍微極端點的例子。
在這個例子中能夠看到,當輸入text以後,有一個div子樹向section子樹的更新,這裏爲了描述一個極端狀況,更新先後的子節點是同樣的。
//例一 placeholder所在子樹只有根節點不一樣
import { h, Component } from 'preact';
export default class App extends Component {
constructor() {
super();
this.state = {
text: ''
}
}
handlechang = e => {
this.setState({
text: e.target.value
})
}
render({desc}, { text }) {
return (
<div> <input value={text} onChange={this.handlechang}/> {text ? <section key='placeholder'> <h2>placeholder</h2> </section>: <div key='placeholder'> <h2>placeholder</h2> </div>} </div> ) } }複製代碼
接下來看一下針對這種場景,diff操做的詳細流程。
//原生dom的idiff邏輯
let out = dom, //註釋1
nodeName = String(vnode.nodeName),
prevSvgMode = isSvgMode,
vchildren = vnode.children;
isSvgMode = nodeName==='svg' ? true : nodeName==='foreignObject' ? false : isSvgMode;
if (!dom) { //註釋2
out = createNode(nodeName, isSvgMode);
}
else if (!isNamedNode(dom, nodeName)) { //註釋3
out = createNode(nodeName, isSvgMode);
while (dom.firstChild) out.appendChild(dom.firstChild);
if (dom.parentNode) dom.parentNode.replaceChild(out, dom);
recollectNodeTree(dom);
}
//子節點遞歸
……
else if (vchildren && vchildren.length || fc) {
innerDiffNode(out, vchildren, context, mountAll);
}
……複製代碼
不管參與diff的元素是自定義組件仍是原生dom,通過層層解構,最終都是以dom的形式進行比較。所以咱們只須要關注原生dom的diff邏輯。
首先看註釋1的位置,dom表示dom樹上的節點,也就是要被更新掉的節點,vnode就是待渲染的虛擬節點。在例一中,diff的起點就是最外層的div,也就是第一輪的dom變量,所以註釋2,註釋3處的斷定均爲false。以後會對out節點的子節點和對應的vnode的子節點進行遞歸的diff操做。
那麼這裏首先說明了第一處問題,渲染操做的起點始終是connected狀態的。
if (vlen) {
for (let i=0; i<vlen; i++) {
vchild = vchildren[i];
child = null;
let key = vchild.key;
// 相同key值匹配
if (key!=null) {
if (keyedLen && key in keyed) {
child = keyed[key];
keyed[key] = undefined;
keyedLen--;
}
}
// 相同nodeName匹配
else if (!child && min<childrenLen) {
for (j=min; j<childrenLen; j++) {
c = children[j];
if (c && isSameNodeType(c, vchild)) {
child = c;
children[j] = undefined;
if (j===childrenLen-1) childrenLen--;
if (j===min) min++;
break;
}
}
}
// vnode爲section節點時,dom樹中既無同key節點,也無同nodeName節點,所以爲null
child = idiff(child, vchild, context, mountAll);
……複製代碼
子節點之間的對應關係的確立依據,要麼key值相同,要麼nodeName相同,能夠知道section和div的關係並不知足上述兩種狀況。所以當再次進入idiff方法的時候,在註釋2的位置,因爲dom不存在,會新建一個section節點賦給out,這樣再次進行子元素diff的時候,因爲out是一個新建節點,不包含任何子元素,section的全部子元素diff的對象都是null,這就意味這section的全部子元素最後都是被新建出來的(不管是否設置了key值),儘管它們和舊的dom上的節點如出一轍。。。因此總結一下就是例一這種狀況,section全部的子節點都是被新建出來的,而不是被複用的,可是整個操做過程是在disconnected狀況下進行的。
那麼若是給二者加上相同的key值呢?
// 例二,組件結構相同,惟一的區別是placeholder所在子樹添加了相同的key值
import { h, Component } from 'preact';
export default class App extends Component {
constructor() {
super();
this.state = {
text: ''
}
}
handlechang = e => {
this.setState({
text: e.target.value
})
}
render({desc}, { text }) {
return (
<div> <input value={text} onChange={this.handlechang}/> {text ? <section key='placeholder'> <h2>placeholder</h2> </section>: <div key='placeholder'> <h2>placeholder</h2> </div>} </div> ) } }複製代碼
由於二者具備相同的key值,因此在vnode與dom肯定對應關係時能夠成功的配對,進入diff環節。然而一個replace操做又讓後續的全部操做都變成了connected。好消息是相同的子節點被複用了。
// 原生dom的diff邏輯
// dom節點,即div存在,且與vnode節點類型section不一樣類型
else if (!isNamedNode(dom, nodeName)) {
out = createNode(nodeName, isSvgMode);
while (dom.firstChild) out.appendChild(dom.firstChild);
if (dom.parentNode) dom.parentNode.replaceChild(out, dom);
recollectNodeTree(dom);
}複製代碼
除去上面介紹過的disconnected方法,還能夠經過DocumentFragment將一系列節點一次性插入dom。DocumentFragment 節點插入文檔樹時,插入的不是 DocumentFragment 自身,而是它的全部子孫節點。這使得 DocumentFragment 成了有用的佔位符,暫時存放那些一次插入文檔的節點。github上也有人向做者提出了一樣的問題,做者表示他曾經也嘗試過用DocumentFragment的方式試圖減小reflow的次數,然而最終的結果卻使人意外。
上圖爲做者編寫的測試案例的性能對比圖,橫座標爲Operation per second,數值越大表明執行效率越高。能夠看出不管connected仍是disconnected的狀況,DocumentFragement的表現都更差。具體緣由還有待考究。BenchMark原連接。
關鍵詞:回收池&Enhanced Mount
在將節點從dom中移除時,不會將節點直接刪除,而是會根據節點類型(組件 or node),執行一些清理邏輯以後,分別存入到兩個回收池中。在每次執行Mount操做的時候,建立方法會在回收池裏尋找同類型節點,一旦找到這樣的同類節點,它會被做爲待更新的參照節點傳入diff算法中,這樣再後續的比較過程當中,來自回收池的節點會被做爲原型進行patch改造,產生新的節點。至關於變Mount爲Update,從而避免從零構建的額外開銷。
現實的結局每每沒有童話故事般美好,回收機制最終仍是出現了意外。案發現場傳送門,回收機制會在某些狀況下致使節點被錯誤的複用……因此,如同發炎的闌尾,可能很快回收機制就會從咱們的視線裏消失了。
本文着重介紹了Preact的工做流程以及其中各個模塊的一些工做細節,但願能夠達到拋磚引玉的做用,吸引更多的人蔘與到社區的交流中來。對於文章所談及內容感興趣的朋友歡迎隨時找我交流,若是線上交流有欠暢爽的話,能夠把簡歷投到colaz1667@163.com。我能想到最浪漫的事就是和你一路收藏點點滴滴的歡笑,留到之後,坐在工位上,慢慢聊。