詳細介紹 Weex 的 JS Framework
很久以前,我寫過兩篇文章(《 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)。
像 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 中運行。
在 JS Framework 里提供了如上圖所示的接口來實現前端框架的對接。圖左側的四個接口與頁面功能有關,分別用于獲取頁面節點、監聽客戶端的任務、注冊組件、注冊模塊,目前這些功能都已經轉移到 JS Framework 內部,在前端框架里都是可選的,有特殊處理邏輯時才需要實現。圖右側的四個接口與頁面的生命周期有關,分別會在頁面初始化、創建、刷新、銷毀時調用,其中只有 createInstance 是必須提供的,其他也都是可選的(在新的 Sandbox 方案中, createInstance 已經改成了 createInstanceContext )。詳細的初始化和渲染過程會在后續章節里展開。
構建渲染指令樹
不同的前端框架里 Virtual DOM 的結構、patch 的方式都是不同的,這也反應了它們開發理念和優化策略的不同,但是最終,在瀏覽器上它們都使用一致的 DOM API 把 Virtual DOM 轉換成真實的 HTMLElement。在 Weex 里的邏輯也是類似的,只是在最后一步生成真實元素的過程中,不使用原生 DOM API,而是使用 JS Framework 里定義的一套 Weex DOM API 將操作轉化成渲染指令發給客戶端。
JS Framework 提供的 Weex DOM API 和瀏覽器提供的 DOM API 功能基本一致,在 Vue 和 Rax 內部對這些接口都做了適配,針對 Weex 和瀏覽器平臺調用不同的接口就可以實現跨平臺渲染。
此外 DOM 接口的設計相當復雜,背負了大量的歷史包袱,也不是所有特性都適合移動端。JS Framework 里將這些接口做了大量簡化,借鑒了 W3C 的標準,只保留了其中最常用到的一部分。目前的狀態是夠用、精簡高效、和 W3C 標準有很多差異,但是已經成為 Vue 和 Rax 渲染原生 UI 的事實標準,后續還會重新設計這些接口,使其變得更標準一些。JS Framework 里 DOM 結構的關系如下圖所示:
前端框架調用這些接口會在 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 代碼是運行在 js 線程上的,然而原生組件的繪制、事件的捕獲都發生在 UI 線程。在這兩個線程之間的通信用的是 callNative 和 callJS 這兩個底層接口(現在已經擴展到了很多個),它們默認都是異步的,在 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: Promise 、 Arary.from 、 Object.assign 、 Object.setPrototypeOf 等。
這一層里的東西可以說都是用來“填坑”的,也是與環境有關 Bug 的高發地帶,如果你只看代碼的話會覺得莫名奇妙,但是它很可能解決了某些版本某個環境中的某個神奇的問題,也有可能觸發了一個更神奇的問題。隨著對 JS 引擎本身的優化和定制越來越多,這一層代碼可以越來越少,最終會全部移除掉。
執行過程
上面是用空間角度介紹了 JS Framework 里包含了哪些部分,接下來從時間角度介紹一下某些功能在 JS Framework 里的處理流程。
框架初始化
JS Framework 以及 Vue 和 Rax 的代碼都是內置在了 Weex SDK 里的,隨著 Weex SDK 一起初始化。SDK 的初始化一般在 App 啟動時就已經完成了,只會執行一次。初始化過程中與 JS Framework 有關的是如下這三個操作:
- 初始化 JS 引擎 ,準備好 JS 執行環境,向其中注冊一些變量和接口,如 WXEnvironment 、 callNative 。
- 執行 JS Framework 的代碼 。
- 注冊原生組件和原生模塊 。
針對第二步,執行 JS Framework 的代碼的過程又可以分成如下幾個步驟:
- 注冊上層 DSL 框架 ,如 Vue 和 Rax。這個過程只是告訴 JS Framework 有哪些 DSL 可用,適配它們提供的接口,如 init 、 createInstance ,但是不會執行前端框架里的邏輯。
- 初始化環境變量 ,并且會將原生對象的原型鏈凍結,此時也會注冊內置的 JS Service,如 BroadcastChannel 。
- 如果 DSL 框架里實現了 init 接口,會在此時調用。
- 向全局環境中注入可供客戶端調用的接口 ,如 callJS 、 createInstance 、 registerComponents ,調用這些接口會同時觸發 DSL 中相應的接口。
再回顧看這兩個過程,可以發現原生的組件和模塊是注冊進來的,DSL 也是注冊進來的,Weex 做的比較靈活,組件模塊是可插拔的,DSL 框架也是可插拔的,有很強的擴展能力。
JS Bundle 的執行過程
在初始化好 Weex SDK 之后,就可以開始渲染頁面了。通常 Weex 的一個頁面對應了一個 js bundle 文件,頁面的渲染過程也是加載并執行 js bundle 的過程,大概的步驟如下圖所示:
首先是調用原生渲染引擎里提供的接口來加載執行 js bundle,在 Android 上是 renderByUrl ,在 iOS 上是 renderWithURL 。在得到了 js bundle 的代碼之后,會繼續執行 SDK 里的原生 createInstance 方法,給當前頁面生成一個唯一 id,并且把代碼和一些配置項傳遞給 JS Framework 提供的 createInstance 方法。
在 JS Framework 接收到頁面代碼之后,會判斷其中使用的 DSL 的類型(Vue 或者 Rax),然后找到相應的框架,執行 createInstanceContext 創建頁面所需要的環境變量。
在舊的方案中,JS Framework 會調用 runInContex 函數在特定的環境中執行 js 代碼,內部基于 new Function 實現。 在新的 Sandbox 方案中,js bundle 的代碼不再發給 JS Framework,也不再使用 new Function ,而是由客戶端直接執行 js 代碼。
頁面的渲染
Weex 里頁面的渲染過程和瀏覽器的渲染過程類似,整體可以分為【創建前端組件】-> 【構建 Virtual DOM】->【生成“真實” DOM】->【發送渲染指令】->【繪制原生 UI】這五個步驟。前兩個步驟發生在前端框架中,第三和第四個步驟在 JS Framework 中處理,最后一步是由原生渲染引擎實現的。下圖描繪了頁面渲染的大致流程:
創建前端組件
以 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 捕獲的,然而事件處理函數都是寫在前端里的,所以會有一個傳遞的過程。
如上圖所示,如果在 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 是一個跨端的技術,涉及的技術面比較多,只從前端或者客戶端的某個角度去理解都是不全面的,本文只是以前端開發者的角度介紹了 Weex 其中一部分的功能。如果你對 Weex 的 JS Framework 有什么新的想法和建議,歡迎賜教;對 Weex 有使用心得或者踩坑經歷,也歡迎分享。
來自:https://segmentfault.com/a/1190000013388649