迷你 JS 框架 Hyperapp 源碼解析

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

注:本文基於 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:node

  • state 用於保存整個應用的數據,其沒法直接修改
  • 只有 actions 中的方法可以修改 state 中的數據
  • state 中的數據修改後,視圖會自動進行更新
  • view 函數生成應用的視圖,可使用 JSX 語法

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

{
  "plugins": [["transform-react-jsx", { "pragma": "h" }]]
}

如此,通過 Babel 編譯後,上面的 view 函數就變成了以下這樣:git

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 節點的結構長這樣:github

{
  nodeName: "div",
  attributes: {},
  children: [
    {
      nodeName: "h1",
      attributes: {},
      children: [0]
    },
    {
      nodeName: "button",
      attributes: { ... },
      children: ["-"]
    },
    {
      nodeName:   "button",
      attributes: { ... },
      children: ["+"]
    }
  ]
}

說白了 Virtual DOM 聽起來高大上,實際上就是用 JavaScript 中的 Object 數據類型去描述一個DOM 節點,由於保存在內存中,因此更新修改很快,同時加上一些 diff 算法的優化,可以最大程度地下降 DOM 節點的渲染耗費。web

固然,Hyperapp 也支持 @hyperapp/html, hyperx 等其餘能夠生成 Virtual DOM 的庫,此處不表。算法

源碼解析

回到源碼上來,因爲 Hyperapp 全部的操做都在 app 函數中完成,下面就來探究一下 app 函數都作了什麼。該函數主流程至關簡單,源碼總計十來行,先貼在下面,後面慢慢分析:json

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 函數啓動應用後的生命週期,以下圖所示:
圖片描述
固然,這只是一個至關粗略的生命週期示意,但咱們也能從中瞭解到 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 屬性值須要保證在兄弟節點中中惟一 )。這樣在更新時能夠直接將其插入到新的位置,而不用低效率地刪除再新建節點。下面的流程圖說明了這裏的策略:
    patch_children

Hyperapp 是一個頗有意思的框架,除了以上分析的特色,藉助 JSX 其還實現了組件化、組件懶加載、子組件插槽、節點生命週期鉤子函數等高級特性。項目地址在此,你們能夠自行查看學習。

本文首發於個人博客(點此查看),歡迎關注。

相關文章
相關標籤/搜索