迷你 JS 框架 Hyperapp 原始碼解析

逆葵發表於2018-05-14

Hyperapp 是最近熱度頗高的一款迷你 JS 框架,其原始碼不到 400 行,壓縮 gzip 後只有 1kB,卻具有相當高的完成度,拿來實現簡單的 web 應用也不在話下。整體實現上,Hyperapp 的思路與 React 比較類似,都是藉助 Virtual DOM 來實現高效的 DOM 更新。在探究 Hyperapp 背後的實現原理之前,我們先看一下如何使用它。

注:本文基於 Hyperapp 1.2.5 版本。

使用

官方的文件中給出了一個示例應用(線上 demo 點我),程式碼如下:

import { h, app } from "hyperapp"

const state = {
  count: 0
}

const actions = {
  down: value => state => ({ count: state.count - value }),
  up: value => state => ({ count: state.count + value })
}

const view = (state, actions) => (
  <div>
    <h1>{state.count}</h1>
    <button onclick={() => actions.down(1)}>-</button>
    <button onclick={() => actions.up(1)}>+</button>
  </div>
)

app(state, actions, view, document.body)
複製程式碼

幾點簡單的說明幫助你快速上手 Hyperapp:

  • state 用於儲存整個應用的資料,其無法直接修改
  • 只有 actions 中的方法能夠修改 state 中的資料
  • state 中的資料修改後,檢視會自動進行更新
  • view 函式生成應用的檢視,可以使用 JSX 語法

首先,Hyperapp 對外只暴露兩個函式:happ。其中 app 用於將應用掛載到 DOM 節點上,相當於啟動函式。而 h 則用於處理 view,返回 Virtual DOM 節點。由於瀏覽器並不能理解上面示例中 view 函式使用的 JSX 語法,因此需要通過 Babel 等編譯工具進行處理(React 黨應該對這些比較熟悉)。安裝 transform-react-jsx 外掛後,在 .babel.rc 中指定該外掛,同時將 pragma 設定為 h

{
  "plugins": [["transform-react-jsx", { "pragma": "h" }]]
}
複製程式碼

如此,經過 Babel 編譯後,上面的 view 函式就變成了如下這樣:

const view = (state, actions) =>
  h("div", {}, [
    h("h1", {}, state.count),
    h("button", { onclick: () => actions.down(1) }, "-"),
    h("button", { onclick: () => actions.up(1) }, "+")
  ])
複製程式碼

我們的 h 函式一頓操作後,返回的 Virtual DOM 節點的結構長這樣:

{
  nodeName: "div",
  attributes: {},
  children: [
    {
      nodeName: "h1",
      attributes: {},
      children: [0]
    },
    {
      nodeName: "button",
      attributes: { ... },
      children: ["-"]
    },
    {
      nodeName:   "button",
      attributes: { ... },
      children: ["+"]
    }
  ]
}
複製程式碼

說白了 Virtual DOM 聽起來高大上,實際上就是用 JavaScript 中的 Object 資料型別去描述一個DOM 節點,因為儲存在記憶體中,所以更新修改很快,同時加上一些 diff 演算法的優化,能夠最大程度地降低 DOM 節點的渲染耗費。

當然,Hyperapp 也支援 @hyperapp/html, hyperx 等其他可以生成 Virtual DOM 的庫,此處不表。

原始碼解析

回到原始碼上來,由於 Hyperapp 所有的操作都在 app 函式中完成,下面就來探究一下 app 函式都做了什麼。該函式主流程相當簡單,原始碼總計十來行,先貼在下面,後面慢慢分析:

export function app(state, actions, view, container) {
  var map = [].map
  var rootElement = (container && container.children[0]) || null
  var oldNode = rootElement && recycleElement(rootElement)
  var lifecycle = []
  var skipRender
  var isRecycling = true
  var globalState = clone(state)
  var wiredActions = wireStateToActions([], globalState, clone(actions))

  scheduleRender()

  return wiredActions
}
複製程式碼

生命週期

首先我們先從整體來看一下 Hyperapp 在呼叫 app 函式啟動應用後的生命週期,如下圖所示:

迷你 JS 框架 Hyperapp 原始碼解析
當然,這只是一個相當粗略的生命週期示意,但我們也能從中瞭解到 Hyperapp 本身相對簡單的結構(對一個迷你框架來說,內部也不會複雜到哪去)。簡單解釋一下上圖中幾個函式的實現。

app 函式執行後,經過一系列準備動作後,會呼叫 scheduleRender 函式進行檢視渲染。顧名思義,該函式是排程渲染的意思。我們看一下原始碼:

  function scheduleRender() {
    if (!skipRender) {
      skipRender = true
      setTimeout(render)
    }
  }
複製程式碼

可以看到,實際執行渲染的操作交由 render 函式來處理,執行的時機由 setTimeout(function(){}, 0) 決定,也就是下一個 event loop 開始後,是非同步進行的。而這裡 skipRender 是一個鎖變數,保證在每一個 event loop 中 state 無論有多少次改變只會進行一次渲染。想象一下這樣一個場景:我們在一個迴圈中執行了 1000 次 actions 中的某個方法來改變 state 中的值,如果不進行以上的操作,那麼檢視會渲染 1000 次,相當消耗效能,而這是非常不合理的。實際上 Hyperapp 的處理也略顯粗糙,在更為複雜的前端框架中,會有非常完備的方案,比如 Vue 的 $nextTick 實現就複雜許多,詳情可以參考這篇文章——Vue nextTick 機制

render 呼叫 resolveNode 以獲取最新的 Virtual DOM 形式的節點,再交由 patch 函式進行新舊節點的對比然後更新檢視,同時把新節點的值賦給舊節點,方便下次比較更新。除了在最後 patch 更新檢視時會進行 DOM 操作,其他時候,節點都是以 Virtual DOM 形式儲存於記憶體中,只要新舊節點的 diff 演算法足夠高效,就能保持較高的檢視更新效率。

除了初始化時的渲染之外,每當 actions 中的方法修改了 state 中的資料時,也會觸發渲染。當然,Hyperapp 並沒有去 “observe” state,而是通過對 actions 中的方法進行包裝實現了這個功能(這也是 Hyperapp 規定只有 actions 中的方法能夠修改 state 中的資料的原因)。

actions 處理

下面就來看一下 Hyperapp 如何對 actions 中的方法進行處理以使其在呼叫後能夠觸發 scheduleRender 的。app 函式執行初次渲染之前的準備工作裡,最重要的操作就是處理 actions 中的方法。在研究其原始碼前,我們先看一下 Hyperapp 對 actions 中的方法制定的規範,當 state 中無巢狀物件時,總結起來大致是以下幾條:

  • 必須是一元函式(只接受一個引數)
  • 函式返回值必須是以下幾種:
    • “a partial state object”,也就是包含 state 中部分狀態的 object。新的 state 將是原有的 state 與該返回值的淺合併(shallow merge)。例如:
      const state = {
        name: 'chris',
        age: 20
      }
      
      const actions = {
        setAge: newAge => ({ age: newAge })
      }
    複製程式碼
    • 一個接受當前 stateactions 為引數的函式,該函式的返回值必須為“a partial state object”。注意此時不能將接受的 state 引數直接修改後返回。正確的示例如下:
      const actions = {
        down: value => state => ({ count: state.count - value }),
        up: value => state => ({ count: state.count + value })
      }
    複製程式碼
    • Promise/null/undefined。此時將不會觸發檢視的重新渲染。

state 中有巢狀物件時,actions 中對應的屬性值為一個 partial state object,其實本質上沒有區別,看下面的示例應該就能理解:

const state = {
  counter: {
    count: 0
  }
}

const actions = {
  counter: {
    down: value => state => ({ count: state.count - value }),
    up: value => state => ({ count: state.count + value })
  }
}
複製程式碼

現在我們來看一下 Hyperapp 對 actions 中方法的處理:

  /**
   * 
   * @param {Array} path  儲存 state 中每層的 key,用於獲取和設定 partial state object
   * @param {Object} state 
   * @param {Object} actions 
   */
  function wireStateToActions(path, state, actions) {
    // 遍歷 actions
    for (var key in actions) {
      typeof actions[key] === "function"
        // actions 中屬性值為函式時,重新封裝
        ? (function(key, action) {
            actions[key] = function(data) {
              // 執行方法
              var result = action(data)
              
              /*返回值是函式時,傳入 state 和 actions 再次執行之
                得到 partial state object
               */
              if (typeof result === "function") {
                result = result(getPartialState(path, globalState), actions)
              }
              
              /* result 不是 Promise/null/undefined
                 意味著 result 返回的是 partial state object
                 同時 result 與當前的 globalState(儲存在全域性的 state 的副本)中的 partial state object 不一致時 
                 呼叫 scheduleRender 重新渲染檢視
               */
              if (
                result &&
                result !== (state = getPartialState(path, globalState)) &&
                !result.then // !isPromise
              ) {
                // globalState 立即更新
                // 安排檢視渲染
                scheduleRender(
                  (globalState = setPartialState(
                    path,
                    clone(state, result),
                    globalState
                  ))
                )
              }
              return result
            }
          })(key, actions[key])
          // 直接返回 partial state object 
        : wireStateToActions(
            // 當 state 有巢狀時,規範要求 actions 中也有相同的巢狀層級
            path.concat(key),
            (state[key] = clone(state[key])),
            (actions[key] = clone(actions[key]))
          )
    }
    // 返回處理之後的所有函式
    // 作為對外介面
    return actions
  }
複製程式碼

註釋已經說的比較詳細,總結一下就是 Hyperapp 把 actions 中的所有方法遍歷了一遍,在其執行完對 state 中資料的“修改”後,呼叫 scheduleRender 重新渲染檢視。這裡之所以給“修改”打上引號,是因為實際上 actions 並沒有真的去修改 state 中資料的值,而是每次用一個新的 object 去替換了 state。這裡涉及到一個 “Immutability” 的概念,也就是不可變性。這種特性使得我們可以像時光穿梭一般去除錯程式碼(因為每一步操作的 state 都儲存在記憶體中,類似快照一般)。這也是為什麼上面的程式碼中我們可以直接用 === 去比較兩個 object 的原因。

Virtual DOM

繼續順著生命週期看下去,在頁面渲染開始前,Hyperapp 會將初始化時傳入 app 函式的根節點以及 view 函式生成的節點全部處理為 Virtual DOM,其形式如文章開頭第一節所示。在此基礎上,Hyperapp 提供了 createElement/updateElement/removeElement/removeChildren/updateAttribute 等方法,用於處理從 Virtual DOM 到真實 DOM 節點的對映。

新舊節點 diff 更新

下面就是最關鍵的節點更新的部分了。可以說,diff 更新是決定類 React 框架效能最重要的部分。我們來看 Hyperapp 是如何做的。新舊節點的 diff 和更新都由 patch 函式完成。其接受以下 4 個引數(實際為 5 個,第 5 個引數為 svg 相關,此處暫不討論):parent(當前層級根節點的父節點,DOM 節點)、element(當前層級的根節點,DOM 節點,初始由 oldNode 對映生成)、oldNode(Virtual DOM)、newNode(Virtual DOM)。patch 函式根據新舊節點的不同可以按照先後優先順序進行以下四種操作:

  1. 新舊節點相同(可直接通過 === 判斷)時:不進行任何操作,直接返回
  2. 舊節點不存在或者新舊節點不同(通過 nodeName 判斷)時: 呼叫 createElement 建立新節點,並插入到 parent 的子元素中。如果舊節點存在,呼叫 removeElement 刪除之。
  3. 新舊節點均為非元素節點時: 將 elementnodeValue 值賦為 newNode。根據 DOM Level 2 規範,除 text,comment,CDATA 和 attribute 節點之外的其他型別節點,其 nodeValue 均為 null。而對於以上四種節點,直接更新其 nodeValue 值即可完成節點更新
  4. 新舊節點均存在,同時節點名稱相同(即新舊節點 nodeName 相同但二者不是同一節點,區別於情況一): 邏輯上是先更新節點屬性,然後進入 children 陣列中遞迴呼叫 patch 函式進行更新。不過 Hyperapp 為了提高效能,為節點提供了 key 屬性。擁有 key 屬性的 Virtual DOM 將對應特定的 DOM 節點(每個節點的 key 屬性值需要保證在兄弟節點中中唯一 )。這樣在更新時可以直接將其插入到新的位置,而不用低效率地刪除再新建節點。下面的流程圖說明了這裡的策略:

迷你 JS 框架 Hyperapp 原始碼解析

Hyperapp 是一個很有意思的框架,除了以上分析的特點,藉助 JSX 其還實現了元件化、元件懶載入、子元件插槽、節點生命週期鉤子函式等高階特性。專案地址在此,大家可以自行檢視學習。

本文首發於我的部落格(點此檢視),歡迎關注。

相關文章