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

像 Vue 和 Rax 這類前端架構雖然内部的渲染機制、Virtual DOM 的結構都是不同的,但是都是用來描述頁面結構以及開發範式的,對 Weex 而言隻屬于文法層,或者稱之為 DSL (Domain Specific Language)。無論前端架構裡資料管理群組件管理的政策是什麼樣的,它們最終都将調用 JS Framework 提供的接口來調用原生功能并且渲染真實 UI。底層渲染引擎中也不必關心上層架構中元件化的文法和更新政策是怎樣的,隻需要處理 JS Framework 中統一定義的節點結構和渲染指令。多了這麼一層抽象,有利于标準的統一,也使得跨架構和跨平台成為了可能
圖雖然這麼畫,但是大部分人并不區分得這麼細,喜歡把 Vue 和 Rax 以及下邊這一層放一起稱為 JS Framework。
如果将 JS Framework 的功能進一步拆解,可以分為如下幾個部分:
1 适配前端架構
2 建構渲染指令樹
3 JS-Native 通信
4 JS Service
5 準備環境接口
适配前端架構
前端架構在 Weex 和浏覽器中的執行過程不一樣,這個應該不難了解。如何讓一個前端架構運作在 Weex 平台上,是 JS Framework 的一個關鍵功能。
以 Vue.js 為例,在浏覽器上運作一個頁面大概分這麼幾個步驟:首先要準備好頁面容器,可以是浏覽器或者是 WebView,容器裡提供了标準的 Web API。然後給頁面容器傳入一個位址,通過這個位址最終擷取到一個 HTML 檔案,然後解析這個 HTML 檔案,加載并執行其中的腳本。想要正确的渲染,應該首先加載執行 Vue.js 架構的代碼,向浏覽器環境中添加 <code>Vue</code> 這個變量,然後建立好挂載點的 DOM 元素,最後執行頁面代碼,從入口元件開始,層層渲染好再挂載到配置的挂載點上去。
在 Weex 裡的執行過程也比較類似,不過 Weex 頁面對應的是一個 js 檔案,不是 HTML 檔案,而且不需要自行引入 Vue.js 架構的代碼,也不需要設定挂載點。過程大概是這樣的:首先初始化好 Weex 容器,這個過程中會初始化 JS Framework,Vue.js 的代碼也包含在了其中。然後給 Weex 容器傳入頁面位址,通過這個位址最終擷取到一個 js 檔案,用戶端會調用 createInstance 來建立頁面,也提供了重新整理頁面和銷毀頁面的接口。大緻的渲染行為和浏覽器一緻,但是和浏覽器的調用方式不一樣,前端架構中至少要适配用戶端打開頁面、銷毀頁面(push、pop)的行為才可以在 Weex 中運作。
在 JS Framework 裡提供了如上圖所示的接口來實作前端架構的對接。圖左側的四個接口與頁面功能有關,分别用于擷取頁面節點、監聽用戶端的任務、注冊元件、注冊子產品,目前這些功能都已經轉移到 JS Framework 内部,在前端架構裡都是可選的,有特殊處理邏輯時才需要實作。圖右側的四個接口與頁面的生命周期有關,分别會在頁面初始化、建立、重新整理、銷毀時調用,其中隻有 <code>createInstance</code> 是必須提供的,其他也都是可選的(在新的 Sandbox 方案中,<code>createInstance</code> 已經改成了 <code>createInstanceContext</code>)。詳細的初始化和渲染過程會在後續章節裡展開。
首先,頁面的 js 代碼是運作在 js 線程上的,然而原生元件的繪制、事件的捕獲都發生在 UI 線程。在這兩個線程之間的通信用的是 <code>callNative</code> 和 <code>callJS</code> 這兩個底層接口(現在已經擴充到了很多個),它們預設都是異步的,在 JS Framework 和原生渲染器内部都基于這兩個方法做了各種封裝。
<code>callNative</code> 是由用戶端向 JS 執行環境中注入的接口,提供給 JS Framework 調用,界面的節點(上文提到的渲染指令樹)、子產品調用的方法和參數都是通過這個接口發送給用戶端的。為了減少調用接口時的開銷,其實作在已經開了更多更直接的通信接口,其中有些接口還支援同步調用(支援傳回值),它們在原理上都和 <code>callNative</code> 是一樣的。
<code>callJS</code> 是由 JS Framework 實作的,并且也注入到了執行環境中,提供給用戶端調用。事件的派發、子產品的回調函數都是通過這個接口通知到 JS Framework,然後再将其傳遞給上層前端架構。
console: 原生提供了 <code>nativeLog</code> 接口,将其封裝成前端熟悉的 <code>console.xxx</code> 并可以控制日志的輸出級别。
timer: 原生環境裡 timer 接口不全,名稱和參數不一緻。目前來看有了原生 C/C++ 實作的 timer 後,這一層可以移除。
freeze: 當機目前環境裡全局變量的原型鍊(如 Array.prototype)。
另外還有一些 ployfill:<code>Promise</code> 、<code>Arary.from</code> 、<code>Object.assign</code> 、<code>Object.setPrototypeOf</code> 等。
這一層裡的東西可以說都是用來“填坑”的,也是與環境有關 Bug 的高發地帶,如果你隻看代碼的話會覺得莫名奇妙,但是它很可能解決了某些版本某個環境中的某個神奇的問題,也有可能觸發了一個更神奇的問題。随着對 JS 引擎本身的優化和定制越來越多,這一層代碼可以越來越少,最終會全部移除掉。
執行過程
JS Framework 以及 Vue 和 Rax 的代碼都是内置在了 Weex SDK 裡的,随着 Weex SDK 一起初始化。SDK 的初始化一般在 App 啟動時就已經完成了,隻會執行一次。初始化過程中與 JS Framework 有關的是如下這三個操作:
初始化 JS 引擎,準備好 JS 執行環境,向其中注冊一些變量和接口,如 <code>WXEnvironment</code>、<code>callNative</code>。
執行 JS Framework 的代碼。
注冊原生元件和原生子產品。
針對第二步,執行 JS Framework 的代碼的過程又可以分成如下幾個步驟:
注冊上層 DSL 架構,如 Vue 和 Rax。這個過程隻是告訴 JS Framework 有哪些 DSL 可用,适配它們提供的接口,如 <code>init</code>、<code>createInstance</code>,但是不會執行前端架構裡的邏輯。
初始化環境變量,并且會将原生對象的原型鍊當機,此時也會注冊内置的 JS Service,如 <code>BroadcastChannel</code>。
如果 DSL 架構裡實作了 <code>init</code> 接口,會在此時調用。
向全局環境中注入可供用戶端調用的接口,如 <code>callJS</code>、<code>createInstance</code>、<code>registerComponents</code>,調用這些接口會同時觸發 DSL 中相應的接口。
再回顧看這兩個過程,可以發現原生的元件和子產品是注冊進來的,DSL 也是注冊進來的,Weex 做的比較靈活,元件子產品是可插拔的,DSL 架構也是可插拔的,有很強的擴充能力。
在初始化好 Weex SDK 之後,就可以開始渲染頁面了。通常 Weex 的一個頁面對應了一個 js bundle 檔案,頁面的渲染過程也是加載并執行 js bundle 的過程,大概的步驟如下圖所示:
首先是調用原生渲染引擎裡提供的接口來加載執行 js bundle,在 Android 上是 <code>renderByUrl</code>,在 iOS 上是 <code>renderWithURL</code>。在得到了 js bundle 的代碼之後,會繼續執行 SDK 裡的原生 <code>createInstance</code> 方法,給目前頁面生成一個唯一 id,并且把代碼和一些配置項傳遞給 JS Framework 提供的 <code>createInstance</code> 方法。
在 JS Framework 接收到頁面代碼之後,會判斷其中使用的 DSL 的類型(Vue 或者 Rax),然後找到相應的架構,執行 <code>createInstanceContext</code> 建立頁面所需要的環境變量。
在舊的方案中,JS Framework 會調用 <code>runInContex</code> 函數在特定的環境中執行 js 代碼,内部基于 <code>new Function</code> 實作。在新的 Sandbox 方案中,js bundle 的代碼不再發給 JS Framework,也不再使用 <code>new Function</code>,而是由用戶端直接執行 js 代碼。
頁面的渲染
Weex 裡頁面的渲染過程和浏覽器的渲染過程類似,整體可以分為【建立前端元件】-> 【建構 Virtual DOM】->【生成“真實” DOM】->【發送渲染指令】->【繪制原生 UI】這五個步驟。前兩個步驟發生在前端架構中,第三和第四個步驟在 JS Framework 中處理,最後一步是由原生渲染引擎實作的。下圖描繪了頁面渲染的大緻流程:
以 Vue.js 為例,頁面都是以元件化的形式開發的,整個頁面可以劃分成多個層層嵌套和平鋪的元件。Vue 架構在執行渲染前,會先根據開發時編寫的模闆建立相應的元件執行個體,可以稱為 Vue Component,它包含了元件的内部資料、生命周期以及 <code>render</code> 函數等。
如果給同一個模闆傳入多條資料,就會生成多個元件執行個體,這可以算是元件的複用。如上圖所示,假如有一個元件模闆和兩條資料,渲染時會建立兩個 Vue Component 的執行個體,每個元件執行個體的内部狀态是不一樣的。
Vue Component 的渲染過程,可以簡單了解為元件執行個體執行 <code>render</code> 函數生成 <code>VNode</code> 節點樹的過程,也就是建構 Virtual DOM 的生成過程。自定義的元件在這個過程中被展開成了平台支援的節點,例如圖中的 <code>VNode</code> 節點都是和平台提供的原生節點一一對應的,它的類型必須在 Weex 支援的原生元件(http://weex-project.io/references/components/index.html)範圍内。
以上過程在 Weex 和浏覽器裡都是完全一樣的,從生成真實 DOM 這一步開始,Weex 使用了不同的渲染方式。前面提到過 JS Framework 中提供了和 DOM 接口類似的 Weex DOM API,在 Vue 裡會使用這些接口将 <code>VNode</code>渲染生成适用于 Weex 平台的 <code>Element</code> 對象,和 DOM 很像,但并不是“真實”的 DOM。
在 JS Framework 内部和用戶端渲染引擎約定了一系列的指令接口,對應了一個原子的 DOM 操作,如 <code>addElement</code> <code>removeElement</code> <code>updateAttrs</code> <code>updateStyle</code> 等。JS Framework 使用這些接口将自己内部建構的 Element 節點樹以渲染指令的形式發給用戶端。
用戶端接收 JS Framework 發送的渲染指令,建立相應的原生元件,最終調用系統提供的接口繪制原生 UI。具體細節這裡就不展開了。
無論是在浏覽器還是 Weex 裡,事件都是由原生 UI 捕獲的,然而事件處理函數都是寫在前端裡的,是以會有一個傳遞的過程。
如上圖所示,如果在 Vue.js 裡某個标簽上綁定了事件,會在内部執行 <code>addEventListener</code> 給節點綁定事件,這個接口在 Weex 平台下調用的是 JS Framework 提供的 <code>addEvent</code> 方法向元素上添加事件,傳遞了事件類型和處理函數。JS Framework 不會立即向用戶端發送添加事件的指令,而是把事件類型和處理函數記錄下來,節點建構好以後再一起發給用戶端,發送的節點中隻包含了事件類型,不含事件處理函數。用戶端在渲染節點時,如果發現節點上包含事件,就監聽原生 UI 上的指定事件。
當原生 UI 監聽到使用者觸發的事件以後,會派發 <code>fireEvent</code> 指令把節點的 ref、事件類型以及事件對象發給 JS Framework。JS Framework 根據 ref 和事件類型找到相應的事件處理函數,并且以事件對象 <code>event</code> 為參數執行事件處理函數。目前 Weex 裡的事件模型相對比較簡單,并不區分捕獲階段和冒泡階段,而是隻派發給觸發了事件的節點,并不向上冒泡,類似 DOM 模型裡 level 0 級别的事件。
上述過程裡,事件隻會綁定一次,但是很可能會觸發多次,例如 <code>touchmove</code> 事件,在手指移動過程中,每秒可能會派發幾十次,每次事件都對應了一次 <code>fireEvent</code> -> <code>invokeHandler</code> 的處理過程,很容易損傷性能,浏覽器也是如此。針對這種情況,可以使用用 expression binding 來将事件處理函數轉成表達式,在綁定事件時一起發給用戶端,這樣用戶端在監聽到原生事件以後可以直接解析并執行綁定的表達式,而不需要把事件再派發給前端。
原文釋出時間為:2018-02-27
本文作者:門柳