Preact:一個備胎的自我修養

前端新能源發表於2019-03-03

原發於知乎專欄:zhuanlan.zhihu.com/ne-fe

前一段時間由於React Licence的問題,團隊內部積極的探索React的替代方案,同時考慮到之後可能開展的移動端業務,團隊目標是希望能夠找到一個遷移成本低,體量小的替代產品。經過多方探索,Preact進入了我們的視野。從接觸到Preact開始,一路學習下來折損了許多頭髮,也收穫不少思考,這裡想和大家介紹一下Preact的實現思路,也分享一下自己的思考所得。

Preact是什麼

一句話介紹Preact,它是React的3KB輕量替代方案,擁有同樣的ES6 API。如果覺得就這麼一句話太模糊的話,我還可以再囉嗦幾句。Preact = performance + react,這是Preact名字的由來,其中一個performance足以窺見作者的用心。下面這張圖反映了在長列表初始化的場景下,不同框架的表現,可以看出Preact確實效能出眾。

高效能,輕量,即時生產是Preact關注的核心。基於這些主題,Preact關注於React的核心功能,實現了一套簡單可預測的diff演算法使它成為最快的虛擬 DOM 框架之一,同時preact-compat為相容性提供了保證,使得Preact可以無縫對接React生態中的大量元件,同時也補充了很多Preact沒有實現的功能。

長列表初始化時間對比
長列表初始化時間對比

Preact的工作流程

簡單介紹了Preact的前生今世以後,接下來說下Preact的工作流程,主要包含五個模組:

  • component
  • h函式
  • render
  • diff演算法
  • 回收機制

流轉過程見下圖。

首先是我們定義好的元件,在渲染開始的時候,首先會進入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過程中被解除安裝的元件和節點不會被直接刪除,而是被分別放入回收池中快取,當再次有同型別的元件或節點被構建時,可以在回收池中找到同名元素進行改造,避免從零構建的開銷。

Preact工作流程圖
Preact工作流程圖

在瞭解了Preact的工作流程之後,接下來會對上文提到的五個模組一一解讀。

Component

關鍵詞:hook, linkState, 批量更新

相信有過react開發經驗的同學對component的概念都不會陌生,這裡也不做過多解釋,只是介紹一些Preact在component層面上的新增的新特性。

hook函式

除了基本的生命週期函式外,Preact還提供三個hook函式,方便使用者在指定的時間點執行統一操作。

  • afterMount
  • afterUpdate
  • beforeUnmount

linkState

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函式

關鍵詞:節點合併

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>
    )
  }
}複製程式碼

Render

關鍵詞:流程控制,diff準備

首先先解釋一下,這裡的render模組泛指整個流程中將vnode插入到dom樹中的操作,然而這類操作中又有一部分工作被diff模組承擔,所以實際上render模組的更多承擔的是流程控制以及進入diff的前置工作。

流程控制

所謂流程控制,具體的內容分為兩部分,節點型別的判斷,是自定義的元件還是原生的dom節點,渲染型別的判斷,是首次渲染還是更新操作。根據不同情況,指定不同的渲染路線,執行相應的生命週期方法,hook函式和渲染邏輯。

Diff準備

如前所述,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;複製程式碼

Diff演算法

關鍵詞:DOM依賴,Disconnected or Not,DocumentFragment

diff過程主要分為兩個階段,第一個階段是建立virual node與dom節點之間的對應關係,第二個階段便是對兩者進行比較並更新dom節點。

  • 在實際執行過程中,diff操作的起點是update元件的根節點與代表其下一個狀態的vnode之前的比較。這一步中兩者之間的對應關係十分明確,而到了下一步,則需要在兩者的子元素中確定對應關係,具體的方法是首先對相同key值的子節點配對,之後將同型別的節點配對,最後沒有被配對的vnode視為新新增的節點,而落單的dom節點的命運則是被回收。
  • 進入到更新階段之後,會根據virtual node的型別和dom樹中參照節點的情況分類處理,並在diff的過程中實時的進行patch操作,最終生成新的dom節點,然後對子節點遞迴。
    Diff流程圖
    Diff流程圖

DOM依賴

經過前面的介紹,相信大家對Preact的virtual dom實現已經有了一定的瞭解,這裡不再贅述。這種實現方式,優點在於總能真實的反映之前virtual dom樹的情況,缺點就是存在記憶體洩露的風險。

Disconnected or Not

  • What does Disconnected mean

我們都知道,當我們向dom樹中的節點執行appendChild,removeChild操作的時候,每執行一次,就會觸發一次頁面的reflow,這是一個具有相當開銷的行為。因此當我們必須執行一系列這樣的操作的時候,可以採取這樣的優化手段,首先建立一個節點,在這個節點上執行過所有子節點的append操作之後,再將以這個節點作為根節點的子樹一次性的append或者replace到dom樹中,只觸發一次reflow,就完成了整個子樹的更新,這樣的更新方式稱之為disconnected。

與之相對,在建立節點之後,立刻將節點插入到dom樹中,然後繼續進行子節點的操作,則稱之為connected。

  • Go ahead to Preact

在闡明瞭這個前提之後,再來看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);
}複製程式碼

DocumentFragment

除去上面介紹過的disconnected方法,還可以通過DocumentFragment將一系列節點一次性插入dom。DocumentFragment 節點插入文件樹時,插入的不是 DocumentFragment 自身,而是它的所有子孫節點。這使得 DocumentFragment 成了有用的佔位符,暫時存放那些一次插入文件的節點。github上也有人向作者提出了同樣的問題,作者表示他曾經也嘗試過用DocumentFragment的方式試圖減少reflow的次數,然而最終的結果卻令人意外。

上圖為作者編寫的測試案例的效能對比圖,橫座標為Operation per second,數值越大代表執行效率越高。可以看出無論connected還是disconnected的情況,DocumentFragement的表現都更差。具體原因還有待考究。BenchMark原連結

回收機制

關鍵詞:回收池&Enhanced Mount

回收池&Enhanced Mount

在將節點從dom中移除時,不會將節點直接刪除,而是會根據節點型別(元件 or node),執行一些清理邏輯之後,分別存入到兩個回收池中。在每次執行Mount操作的時候,建立方法會在回收池裡尋找同型別節點,一旦找到這樣的同類節點,它會被作為待更新的參照節點傳入diff演算法中,這樣再後續的比較過程中,來自回收池的節點會被作為原型進行patch改造,產生新的節點。相當於變Mount為Update,從而避免從零構建的額外開銷。

現實的結局往往沒有童話故事般美好,回收機制最終還是出現了意外。案發現場傳送門,回收機制會在某些情況下導致節點被錯誤的複用……所以,如同發炎的闌尾,可能很快回收機制就會從我們的視線裡消失了。

結束語

本文著重介紹了Preact的工作流程以及其中各個模組的一些工作細節,希望可以達到拋磚引玉的作用,吸引更多的人蔘與到社群的交流中來。對於文章所談及內容感興趣的朋友歡迎隨時找我交流,如果線上交流有欠暢爽的話,可以把簡歷投到colaz1667@163.com。我能想到最浪漫的事就是和你一路收藏點點滴滴的歡笑,留到以後,坐在工位上,慢慢聊。

相關文章