重新介紹Weex的JSFramework

門柳發表於2018-03-16

很久以前,我寫過兩篇文章(《Weex 框架中 JS Framework 的結構》,《詳解 Weex JS Framework 的編譯過程》)介紹過 JS Framework。但是文章寫於 2016 年 8 月份,這都是一年半以前的事了,說是“詳解”其實解釋得並不詳細,而且是基於舊版 .we 框架寫的,DSL 和底層框架各部分的功能解耦得的並不是很清楚。這一年多以來 JS Framework 已經有了很大的變化,不僅支援了 Vue 和 Rax,原生容器和底層介面也做了大量改造,這裡再重新介紹一遍。

在 Weex 框架中的位置

Weex 是一個既支援多個前端框架又能跨平臺渲染的框架,JS Framework 介於前端框架和原生渲染引擎之間,處於承上啟下的位置,也是跨框架跨平臺的關鍵。無論你使用的是 Vue 還是 Rax,無論是渲染在 Android 還是 iOS,JS Framework 的程式碼都會執行到(如果是在瀏覽器和 WebView 裡執行,則不依賴 JS Framework)。

jsfm-position.png

像 Vue 和 Rax 這類前端框架雖然內部的渲染機制、Virtual DOM 的結構都是不同的,但是都是用來描述頁面結構以及開發正規化的,對 Weex 而言只屬於語法層,或者稱之為 DSL (Domain Specific Language)。無論前端框架裡資料管理和元件管理的策略是什麼樣的,它們最終都將呼叫 JS Framework 提供的介面來呼叫原生功能並且渲染真實 UI。底層渲染引擎中也不必關心上層框架中元件化的語法和更新策略是怎樣的,只需要處理 JS Framework 中統一定義的節點結構和渲染指令。多了這麼一層抽象,有利於標準的統一,也使得跨框架和跨平臺成為了可能。

圖雖然這麼畫,但是大部分人並不區分得這麼細,喜歡把 Vue 和 Rax 以及下邊這一層放一起稱為 JS Framework。

主要功能

如果將 JS Framework 的功能進一步拆解,可以分為如下幾個部分:

  • 適配前端框架
  • 構建渲染指令樹
  • JS-Native 通訊
  • JS Service
  • 準備環境介面

適配前端框架

前端框架在 Weex 和瀏覽器中的執行過程不一樣,這個應該不難理解。如何讓一個前端框架執行在 Weex 平臺上,是 JS Framework 的一個關鍵功能。

以 Vue.js 為例,在瀏覽器上執行一個頁面大概分這麼幾個步驟:首先要準備好頁面容器,可以是瀏覽器或者是 WebView,容器裡提供了標準的 Web API。然後給頁面容器傳入一個地址,通過這個地址最終獲取到一個 HTML 檔案,然後解析這個 HTML 檔案,載入並執行其中的指令碼。想要正確的渲染,應該首先載入執行 Vue.js 框架的程式碼,向瀏覽器環境中新增 Vue 這個變數,然後建立好掛載點的 DOM 元素,最後執行頁面程式碼,從入口元件開始,層層渲染好再掛載到配置的掛載點上去。

在 Weex 裡的執行過程也比較類似,不過 Weex 頁面對應的是一個 js 檔案,不是 HTML 檔案,而且不需要自行引入 Vue.js 框架的程式碼,也不需要設定掛載點。過程大概是這樣的:首先初始化好 Weex 容器,這個過程中會初始化 JS Framework,Vue.js 的程式碼也包含在了其中。然後給 Weex 容器傳入頁面地址,通過這個地址最終獲取到一個 js 檔案,客戶端會呼叫 createInstance 來建立頁面,也提供了重新整理頁面和銷燬頁面的介面。大致的渲染行為和瀏覽器一致,但是和瀏覽器的呼叫方式不一樣,前端框架中至少要適配客戶端開啟頁面、銷燬頁面(push、pop)的行為才可以在 Weex 中執行。

jsfm-apis.png

在 JS Framework 裡提供瞭如上圖所示的介面來實現前端框架的對接。圖左側的四個介面與頁面功能有關,分別用於獲取頁面節點、監聽客戶端的任務、註冊元件、註冊模組,目前這些功能都已經轉移到 JS Framework 內部,在前端框架裡都是可選的,有特殊處理邏輯時才需要實現。圖右側的四個介面與頁面的生命週期有關,分別會在頁面初始化、建立、重新整理、銷燬時呼叫,其中只有 createInstance 是必須提供的,其他也都是可選的(在新的 Sandbox 方案中,createInstance 已經改成了 createInstanceContext)。詳細的初始化和渲染過程會在後續章節裡展開。

構建渲染指令樹

不同的前端框架裡 Virtual DOM 的結構、patch 的方式都是不同的,這也反應了它們開發理念和優化策略的不同,但是最終,在瀏覽器上它們都使用一致的 DOM API 把 Virtual DOM 轉換成真實的 HTMLElement。在 Weex 裡的邏輯也是類似的,只是在最後一步生成真實元素的過程中,不使用原生 DOM API,而是使用 JS Framework 裡定義的一套 Weex DOM API 將操作轉化成渲染指令發給客戶端。

weex-dom-api.png

JS Framework 提供的 Weex DOM API 和瀏覽器提供的 DOM API 功能基本一致,在 Vue 和 Rax 內部對這些介面都做了適配,針對 Weex 和瀏覽器平臺呼叫不同的介面就可以實現跨平臺渲染。

此外 DOM 介面的設計相當複雜,揹負了大量的歷史包袱,也不是所有特性都適合移動端。JS Framework 裡將這些介面做了大量簡化,借鑑了 W3C 的標準,只保留了其中最常用到的一部分。目前的狀態是夠用、精簡高效、和 W3C 標準有很多差異,但是已經成為 Vue 和 Rax 渲染原生 UI 的事實標準,後續還會重新設計這些介面,使其變得更標準一些。JS Framework 裡 DOM 結構的關係如下圖所示:

weex-dom.png

前端框架呼叫這些介面會在 JS Framework 中構建一顆樹,這顆樹中的節點不包含複雜的狀態和繫結資訊,能夠序列化轉換成 JSON 格式的渲染指令傳送給客戶端。這棵樹曾經有過很多名字:Virtual DOM Tree、Native DOM Tree,我覺的其實它應該算是一顆 “Render Directive Tree”,也就是渲染指令樹。叫什麼無所謂了,反正它就是 JS Framework 內部的一顆與 DOM 很像的樹。

這顆樹的層次結構和原生 UI 的層次結構是一致的,當前端的節點有更新時,這棵樹也會跟著更新,然後把更新結果以渲染指令的形式傳送給客戶端。這棵樹並不計算佈局,也沒有什麼副作用,操作也都是很高效的,基本都是 O(1) 級別,偶爾有些 O(n) 的操作會遍歷同層兄弟節點或者上溯找到根節點,不會遍歷整棵樹。

JS-Native 通訊

在開發頁面過程中,除了節點的渲染以外,還有原生模組的呼叫、事件繫結、回撥等功能,這些功能都依賴於 js 和 native 之間的通訊來實現。

js-native.png

首先,頁面的 js 程式碼是執行在 js 執行緒上的,然而原生元件的繪製、事件的捕獲都發生在 UI 執行緒。在這兩個執行緒之間的通訊用的是 callNativecallJS 這兩個底層介面(現在已經擴充套件到了很多個),它們預設都是非同步的,在 JS Framework 和原生渲染器內部都基於這兩個方法做了各種封裝。

callNative 是由客戶端向 JS 執行環境中注入的介面,提供給 JS Framework 呼叫,介面的節點(上文提到的渲染指令樹)、模組呼叫的方法和引數都是通過這個介面傳送給客戶端的。為了減少呼叫介面時的開銷,其實現在已經開了更多更直接的通訊介面,其中有些介面還支援同步呼叫(支援返回值),它們在原理上都和 callNative 是一樣的。

callJS 是由 JS Framework 實現的,並且也注入到了執行環境中,提供給客戶端呼叫。事件的派發、模組的回撥函式都是通過這個介面通知到 JS Framework,然後再將其傳遞給上層前端框架。

JS Service

Weex 是一個多頁面的框架,每個頁面的 js bundle 都在一個獨立的環境裡執行,不同的 Weex 頁面對應到瀏覽器上就相當於不同的“標籤頁”,普通的 js 庫沒辦法實現在多個頁面之間實現狀態共享,也很難實現跨頁通訊。

在 JS Framework 中實現了 JS Service 的功能,主要就是用來解決跨頁面複用和狀態共享的問題的,例如 BroadcastChannel 就是基於 JS Service 實現的,它可以在多個 Weex 頁面之間通訊。

準備環境介面

由於 Weex 執行環境和瀏覽器環境有很大差異,在 JS Framework 裡還對一些環境變數做了封裝,主要是為了解決解決原生環境裡的相容問題,底層使用渲染引擎提供的介面。主要的改動點是:

  • console: 原生提供了 nativeLog 介面,將其封裝成前端熟悉的 console.xxx 並可以控制日誌的輸出級別。
  • timer: 原生環境裡 timer 介面不全,名稱和引數不一致。目前來看有了原生 C/C++ 實現的 timer 後,這一層可以移除。
  • freeze: 凍結當前環境裡全域性變數的原型鏈(如 Array.prototype)。

另外還有一些 ployfill:PromiseArary.fromObject.assignObject.setPrototypeOf 等。

這一層裡的東西可以說都是用來“填坑”的,也是與環境有關 Bug 的高發地帶,如果你只看程式碼的話會覺得莫名奇妙,但是它很可能解決了某些版本某個環境中的某個神奇的問題,也有可能觸發了一個更神奇的問題。隨著對 JS 引擎本身的優化和定製越來越多,這一層程式碼可以越來越少,最終會全部移除掉。

執行過程

上面是用空間角度介紹了 JS Framework 裡包含了哪些部分,接下來從時間角度介紹一下某些功能在 JS Framework 裡的處理流程。

框架初始化

JS Framework 以及 Vue 和 Rax 的程式碼都是內建在了 Weex SDK 裡的,隨著 Weex SDK 一起初始化。SDK 的初始化一般在 App 啟動時就已經完成了,只會執行一次。初始化過程中與 JS Framework 有關的是如下這三個操作:

  1. 初始化 JS 引擎,準備好 JS 執行環境,向其中註冊一些變數和介面,如 WXEnvironmentcallNative
  2. 執行 JS Framework 的程式碼
  3. 註冊原生元件和原生模組

針對第二步,執行 JS Framework 的程式碼的過程又可以分成如下幾個步驟:

  1. 註冊上層 DSL 框架,如 Vue 和 Rax。這個過程只是告訴 JS Framework 有哪些 DSL 可用,適配它們提供的介面,如 initcreateInstance,但是不會執行前端框架裡的邏輯。
  2. 初始化環境變數,並且會將原生物件的原型鏈凍結,此時也會註冊內建的 JS Service,如 BroadcastChannel
  3. 如果 DSL 框架裡實現了 init 介面,會在此時呼叫。
  4. 向全域性環境中注入可供客戶端呼叫的介面,如 callJScreateInstanceregisterComponents,呼叫這些介面會同時觸發 DSL 中相應的介面。

再回顧看這兩個過程,可以發現原生的元件和模組是註冊進來的,DSL 也是註冊進來的,Weex 做的比較靈活,元件模組是可插拔的,DSL 框架也是可插拔的,有很強的擴充套件能力。

JS Bundle 的執行過程

在初始化好 Weex SDK 之後,就可以開始渲染頁面了。通常 Weex 的一個頁面對應了一個 js bundle 檔案,頁面的渲染過程也是載入並執行 js bundle 的過程,大概的步驟如下圖所示:

execute-js-bundle.png

首先是呼叫原生渲染引擎裡提供的介面來載入執行 js bundle,在 Android 上是 renderByUrl,在 iOS 上是 renderWithURL。在得到了 js bundle 的程式碼之後,會繼續執行 SDK 裡的原生 createInstance 方法,給當前頁面生成一個唯一 id,並且把程式碼和一些配置項傳遞給 JS Framework 提供的 createInstance 方法。

在 JS Framework 接收到頁面程式碼之後,會判斷其中使用的 DSL 的型別(Vue 或者 Rax),然後找到相應的框架,執行 createInstanceContext 建立頁面所需要的環境變數。

createInstance.png

在舊的方案中,JS Framework 會呼叫 runInContex 函式在特定的環境中執行 js 程式碼,內部基於 new Function 實現。在新的 Sandbox 方案中,js bundle 的程式碼不再發給 JS Framework,也不再使用 new Function,而是由客戶端直接執行 js 程式碼。

頁面的渲染

Weex 裡頁面的渲染過程和瀏覽器的渲染過程類似,整體可以分為【建立前端元件】-> 【構建 Virtual DOM】->【生成“真實” DOM】->【傳送渲染指令】->【繪製原生 UI】這五個步驟。前兩個步驟發生在前端框架中,第三和第四個步驟在 JS Framework 中處理,最後一步是由原生渲染引擎實現的。下圖描繪了頁面渲染的大致流程:

render-process.png

建立前端元件

以 Vue.js 為例,頁面都是以元件化的形式開發的,整個頁面可以劃分成多個層層巢狀和平鋪的元件。Vue 框架在執行渲染前,會先根據開發時編寫的模板建立相應的元件例項,可以稱為 Vue Component,它包含了元件的內部資料、生命週期以及 render 函式等。

如果給同一個模板傳入多條資料,就會生成多個元件例項,這可以算是元件的複用。如上圖所示,假如有一個元件模板和兩條資料,渲染時會建立兩個 Vue Component 的例項,每個元件例項的內部狀態是不一樣的。

構建 Virtual DOM

Vue Component 的渲染過程,可以簡單理解為元件例項執行 render 函式生成 VNode 節點樹的過程,也就是構建 Virtual DOM 的生成過程。自定義的元件在這個過程中被展開成了平臺支援的節點,例如圖中的 VNode 節點都是和平臺提供的原生節點一一對應的,它的型別必須在 Weex 支援的原生元件範圍內。

生成“真實” DOM

以上過程在 Weex 和瀏覽器裡都是完全一樣的,從生成真實 DOM 這一步開始,Weex 使用了不同的渲染方式。前面提到過 JS Framework 中提供了和 DOM 介面類似的 Weex DOM API,在 Vue 裡會使用這些介面將 VNode 渲染生成適用於 Weex 平臺的 Element 物件,和 DOM 很像,但並不是“真實”的 DOM。

傳送渲染指令

在 JS Framework 內部和客戶端渲染引擎約定了一系列的指令介面,對應了一個原子的 DOM 操作,如 addElement removeElement updateAttrs updateStyle 等。JS Framework 使用這些介面將自己內部構建的 Element 節點樹以渲染指令的形式發給客戶端。

繪製原生 UI

客戶端接收 JS Framework 傳送的渲染指令,建立相應的原生元件,最終呼叫系統提供的介面繪製原生 UI。具體細節這裡就不展開了。

事件的響應過程

無論是在瀏覽器還是 Weex 裡,事件都是由原生 UI 捕獲的,然而事件處理函式都是寫在前端裡的,所以會有一個傳遞的過程。

fire-event.png

如上圖所示,如果在 Vue.js 裡某個標籤上繫結了事件,會在內部執行 addEventListener 給節點繫結事件,這個介面在 Weex 平臺下呼叫的是 JS Framework 提供的 addEvent 方法向元素上新增事件,傳遞了事件型別和處理函式。JS Framework 不會立即向客戶端傳送新增事件的指令,而是把事件型別和處理函式記錄下來,節點構建好以後再一起發給客戶端,傳送的節點中只包含了事件型別,不含事件處理函式。客戶端在渲染節點時,如果發現節點上包含事件,就監聽原生 UI 上的指定事件。

當原生 UI 監聽到使用者觸發的事件以後,會派發 fireEvent 命令把節點的 ref、事件型別以及事件物件發給 JS Framework。JS Framework 根據 ref 和事件型別找到相應的事件處理函式,並且以事件物件 event 為引數執行事件處理函式。目前 Weex 裡的事件模型相對比較簡單,並不區分捕獲階段和冒泡階段,而是隻派發給觸發了事件的節點,並不向上冒泡,類似 DOM 模型裡 level 0 級別的事件。

上述過程裡,事件只會繫結一次,但是很可能會觸發多次,例如 touchmove 事件,在手指移動過程中,每秒可能會派發幾十次,每次事件都對應了一次 fireEvent -> invokeHandler 的處理過程,很容易損傷效能,瀏覽器也是如此。針對這種情況,可以使用用 expression binding 來將事件處理函式轉成表示式,在繫結事件時一起發給客戶端,這樣客戶端在監聽到原生事件以後可以直接解析並執行繫結的表示式,而不需要把事件再派發給前端。

演進方向

其實在 Weex 裡,能跨多個渲染引擎通用的不止 JS Framework,還有 Weex Core,它們要解決的問題差不多,然而 JS Framework 是用 javascript 寫的,Weex Core 是用 C/C++ 寫的,實現的功能更底層一些,其實你可以將 JS Framework 理解為 js 版本的 Weex Core。不過 Weex Core 目前的功能還比較少,和 JS Framework 沒有重疊,只包含了對 JS 引擎的優化和新的 CSS 佈局引擎。

文章最開始的第一張圖,其實應該畫成這樣:

jsfm-weexcore-1.png

隨著技術的演進,JS Framework 的大部分功能將逐漸轉移到 Weex Core 中,文章裡介紹的用 js 實現的功能,最終將會用 C/C++ 實現,效能會有大幅提升,結構也會簡化。 這個過程不是簡單的複製複製程式碼、調調介面就能完成的事,語言都變了,大部分介面和特性都要重新設計,還得做到向下相容,而且也不能是埋頭做個三五個月然後再出結果,每個步驟都要保證功能可用。不能停車,邊開邊升級發動機。

具體來講,首先要做的就是在 Weex Core 中實現一份 DOM 介面,將會設計得更加標準、更加符合規範,有了原生的 documentElement 這些物件以後,前端框架就可以直接呼叫原生介面不必經過 JS Framework,渲染效能會有提升,JS Framework 裡的那顆不知道怎麼稱呼的樹也就可以拿掉了,也不需要將節點傳送客戶端了,這樣通訊的邏輯也可以大幅簡化。等把原生模組的呼叫、回撥、事件響應這些問題解決了之後,JS-Native 之間的通訊也可以拿掉了。

jsfm-weexcore-2.png

就像上面這幅圖畫得那樣,JS Framework 會變成非常薄的一層,僅負責適配前端框架和修復一些相容性的問題,最終在未來某個版本里,可能根本就不存在了。

寫在最後

如果你對 Weex 的 JS Framework 有什麼新的想法和建議,歡迎來找我聊。@門柳


相關文章