小程序多平臺同構方案分析-kbone 與 remax

當前國內小程序平臺衆多,微信小程序、支付寶小程序、頭條小程序、以及未來還會出現的新小程序平臺,所以爲了解決一套代碼可以在多個小程序平臺上運行,出現了多種方案來解決,京東的 Taro、螞蟻的 Remax、微信的 Kbone,各有特點,主要歸爲兩種類型,編譯時與運行時適配兩種。

此文介紹國內主流小程序的架構,以及通過運行時適配可達到一套小程序代碼運行在多個小程序平臺上的方案,主要介紹 kbone 與 remax 兩套方案,他們原理基本一致,所有小程序代碼都在 worker 線程上運行,最終在 worker 線程生成一棵 dom tree,再把 dom tree 同步到 render 線程上通過 w/axml 進行渲染。

小程序架構

小程序本質上是運行在 webview 上的一個 H5 應用,代碼經過打包後分別運行在 render 線程與 worker 線程,這麼做最大的原因是保證平臺安全性,不能讓開發者控制 render 線程,控制 render 線程將會造成小程序平臺方管控困難,比如通過 js dom api 操作 dom 元素,通過 location.href 隨意跳轉,那整個小程序就完全不可控,可以輕意繞過小程序審覈,上線時是個正常小程序,開發者可以隨意控制界面上展示的內容或隨意跳轉到賭博或黃色頁面。小程序平臺就把 view 與邏輯分離,view 放在 render 線程,提供了一種特殊的語言(微信叫 wxml 、支付寶叫 axml)來寫 view,並且不能寫入 js 代碼,邏輯就放在 worker 線程,由於 worker 並不能操作 dom,所以就解決了上面管控困難的問題,架構如下:

 

每個小程序界面有 axml 與 js 文件,js 文件是頁面邏輯,邏輯主要做兩件事情:

  1. 響應 render 線程的事件,並執行小程序業務邏輯。
  2. 準備好數據,通過 setData 傳到 page 中,由 page 進行渲染。

以上是國內微信、支付寶、頭條小程序的架構,但是目前開發者如果要把一個小程序支持三個平臺和 web 平臺,就需要開發多次,目前出現了多種同構平臺。有編譯時與運行時動態轉換兩種。
編譯時 Taro 做的很成功,Taro 可以讓開發者用 React 寫小程序,最終經過編譯轉換到不同平臺的小程序。
今天講的是另外一種方案,不靠編譯時來完成,而是在運行時做適配,分別是微信提供的 kbone 與支付寶提供的 remax 兩個方案。

兩個方案對比:

  1. 相同點
    1. 都是在 worker 線程維護一棵 vdom tree,然後同步到 render 線程通過 w|axml 來進行渲染。
  2. 不同點
    1. kbone 是適配了 js dom api ,上層可以用任何框架,如 react、vue、原生 js 來寫小程序。remax 是自已寫了一套 react 的 renderer,上層只支持 react。
    2. remax 在 dom tree 發生變化時,不是把整棵 vdom tree 傳到 render 線程,而是計算差異,把差異傳到 render 線程,這點可以加快了兩個線程之間的數據傳輸速度。

kbone

kbone 在 worker 線程適配了一套 js dom api,上層不管是哪種前端框架(react、vue)或原生 js 最終都需要調用 js dom api 操作 dom,適配的 js dom api 則接管了所有的 dom 操作,並在內存中維護了一棵 dom tree,所有上層最終調用的 dom 操作都會更新到這棵 dom tree 中,每次操作(有節流)後會把 dom tree 同步到 render 線程中,通過 wxml 自定義組件進行 render。

流程如下:

因此所有小程序的代碼都是放在 worker 上跑,開發者可以通過不同的前端框架(react、vue、angular) 或原生 js 來構建小程序了。

worker 線程

worker 線程會運行所有的小程序代碼,並適配了 js dom api 和定義一套數據結構來描述一棵 dom tree。

模擬 js dom api 就是把 api 函數重新實現一次,這些函數用來操作自己在內存中維護的 dom tree,例如如下 api 方法:

  1. document.createElement
  2. document.createTextNode

在 worker 線程中本身是沒有 document 對象的,只需要把自己模擬的 document 對象存放到全局變量中,那上層的前端框架或原生 js 代碼就能調用到了。通過 document 創建的每個節點有四個重要的屬性:

  1. type: 當前節點類型
  2. parentNode:父節點對象
  3. childNodes: 孩子節點對象數組

當 worker 線程創建好了 dom tree 後,在內存中的大概長下面這樣:

{
    "innerChildNodes": [],
    "childNodes": [{
        "nodeId": "b-1573463704434",
        "pageId": "p-1573463704431-/pages/index/index",
        "type": "element",
        "tagName": "div",
        "id": "app",
        "class": "h5-div node-b-1573463704434 ", 
        "childNodes": [{
            "nodeId": "b-1573463704435",
            "pageId": "p-1573463704431-/pages/index/index",
            "type": "element",
            "tagName": "div",
            "id": "",
            "class": "h5-div node-b-1573463704435 ", 
            "childNodes": [{
                "nodeId": "b-1573463704436",
                "pageId": "p-1573463704431-/pages/index/index",
                "type": "element",
                "tagName": "button",
                "id": "",
                "class": "h5-button node-b-1573463704436 ", 
            }, {
                "nodeId": "b-1573463704438",
                "pageId": "p-1573463704431-/pages/index/index",
                "type": "element",
                "tagName": "span",
                "id": "",
                "class": "h5-span node-b-1573463704438 ", 
            } ]
        }]
    }]
}

這是一棵多叉樹,每個節點定義了當前節點的屬性和孩子節點。接下來就是把這棵樹傳到 render 線程,並由 render 線程把他顯示出來。這裏傳到 render 線程採用的是小程序提供的方法 setData,把這棵 dom tree 當成數據傳到 render 界面。

 

render 線程

<view>
  <picker></picker>
  <button>點我</button>
  <Element>
    <button></button>
    <button></button>
  </Element>
</view>

上面代碼是 wxml 語法寫的一個小程序界面,worker 線程中的內存 dom tree 可以和 wxml 裏的節點一一對應,只需要把 dom tree 通過遞歸迭代映射到 wxml 的節點。

kbone 定義了一個 [Element 自定義組件],用於渲染 dom tree 上的每個節點和他的孩子節點。
Element 節點做的事情比較簡單,首先是把自己渲染出來,然後再把子節點渲染出來,同時子節點的子節點又通過 Element 來渲染,這樣就通過自定義組件實現了遞歸功能,這是 wxml 自定義組件提供的自引用特性,每個節點通過 dom 節點的 type 來區分,從而把一棵內存 dom tree 通過 wxml 渲染出來了。

Element 代碼如下(簡略):

<!--當前節點-->
<cover-view wx:elif="{{wxCompName === 'cover-view'}}" id="{{id}}" class="{{class}}" style="{{style}}" hidden="{{hidden}}" scroll-top="{{scrollTop}}">
  <template is="subtree-cover" data="{{childNodes: innerChildNodes}}" />
</cover-view><scroll-view wx:elif="{{wxCompName === 'scroll-view'}}" id="{{id}}" class="{{class}}" style="{{style}}" hidden="{{hidden}}" scroll-x="{{scrollX}}" scroll-y="{{scrollY}}" upper-threshold="{{upperThreshold}}" lower-threshold="{{lowerThreshold}}" scroll-top="{{scrollTop}}" scroll-left="{{scrollLeft}}" scroll-into-view="{{scrollIntoView}}" scroll-with-animation="{{scrollWithAnimation}}" enable-back-to-top="{{enableBackToTop}}" enable-flex="{{enableFlex}}" bindscrolltoupper="onScrollViewScrolltoupper" bindscrolltolower="onScrollViewScrolltolower" bindscroll="onScrollViewScroll">
  <template is="subtree" data="{{childNodes: innerChildNodes, inCover}}" />
</scroll-view>
<live-player wx:elif="{{wxCompName === 'live-player'}}" id="{{id}}" class="{{class}}" style="{{style}}" hidden="{{hidden}}" src="{{src}}" mode="{{mode}}" autoplay="{{autoplay}}" muted="{{muted}}" orientation="{{orientation}}" object-fit="{{objectFit}}" background-mute="{{backgroundMute}}" min-cache="{{minCache}}" max-cache="{{maxCache}}" sound-mode="{{soundMode}}" auto-pause-if-navigate="{{autoPauseIfNavigate}}" auto-pause-if-open-native="{{autoPauseIfOpenNative}}" bindstatechange="onLivePlayerStateChange" bindfullscreenchange="onLivePlayerFullScreenChange" bindnetstatus="onLivePlayerNetStatus">
  <!--遞歸-->
  <template is="subtree-cover" data="{{childNodes: innerChildNodes}}" />
</live-player>

<!--子節點-->
<block wx:for="{{childNodes}}" wx:key="nodeId" wx:for-item="item1">
  <block wx:if="{{item1.type === 'text'}}">{{item1.content}}</block>
  <image wx:elif="{{item1.isImage}}" data-private-node-id="{{item1.nodeId}}" data-private-page-id="{{item1.pageId}}" id="{{item1.id}}" class="{{item1.class || ''}}" style="{{item1.style || ''}}" src="{{item1.src}}" rendering-mode="{{item1.mode ? 'backgroundImage' : 'img'}}" mode="{{item1.mode}}" lazy-load="{{item1.lazyLoad}}" show-menu-by-longpress="{{item1.showMenuByLongpress}}" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd" bindtouchcancel="onTouchCancel" bindtap="onTap" bindload="onImgLoad" binderror="onImgError"></image>
  <view wx:elif="{{item1.isLeaf || item1.isSimple}}" data-private-node-id="{{item1.nodeId}}" data-private-page-id="{{item1.pageId}}" id="{{item1.id}}" class="{{item1.class || ''}}" style="{{item1.style || ''}}" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd" bindtouchcancel="onTouchCancel" bindtap="onTap">
    {{item1.content}}
    <block wx:for="{{item1.childNodes}}" wx:key="nodeId" wx:for-item="item2">
      <block wx:if="{{item2.type === 'text'}}">{{item2.content}}</block>
      <image wx:elif="{{item2.isImage}}" data-private-node-id="{{item2.nodeId}}" data-private-page-id="{{item2.pageId}}" id="{{item2.id}}" class="{{item2.class || ''}}" style="{{item2.style || ''}}" src="{{item2.src}}" rendering-mode="{{item2.mode ? 'backgroundImage' : 'img'}}" mode="{{item2.mode}}" lazy-load="{{item2.lazyLoad}}" show-menu-by-longpress="{{item2.showMenuByLongpress}}" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd" bindtouchcancel="onTouchCancel" bindtap="onTap" bindload="onImgLoad" binderror="onImgError"></image>
      <view wx:elif="{{item2.isLeaf || item2.isSimple}}" data-private-node-id="{{item2.nodeId}}" data-private-page-id="{{item2.pageId}}" id="{{item2.id}}" class="{{item2.class || ''}}" style="{{item2.style || ''}}" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd" bindtouchcancel="onTouchCancel" bindtap="onTap">
          {{item2.content}} 
        </view>
      <!--遞歸-->
      <element wx:elif="{{item2.type === 'element'}}" in-cover="{{inCover}}" data-private-node-id="{{item2.nodeId}}" data-private-page-id="{{item2.pageId}}" id="{{item2.id}}" class="{{item2.class || ''}}" style="{{item2.style || ''}}" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd" bindtouchcancel="onTouchCancel" bindtap="onTap" generic:custom-component="custom-component"></element>
    </block>
  </view>
  <element wx:elif="{{item1.type === 'element'}}" in-cover="{{inCover}}" data-private-node-id="{{item1.nodeId}}" data-private-page-id="{{item1.pageId}}" id="{{item1.id}}" class="{{item1.class || ''}}" style="{{item1.style || ''}}" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd" bindtouchcancel="onTouchCancel" bindtap="onTap" generic:custom-component="custom-component"></element>
</block>

remax

remax 是通過 react 來寫小程序,整個小程序是運行在 worker 線程,remax 實現了一套自定義的 renderer,原理是在 worker 線程維護了一套 vdom tree,這個 vdom tree 會通過小程序提供的 setData 方法傳到 render 線程,render 線程則把 vdom tree 遞歸的遍歷出來。

所以整體實現和 kbone 類似,都是在 worker 線程維護一棵 dom tree,再把這棵 dom tree 傳到 render 線程進行渲染,唯一的區別是 remax dom tree 發生變化時,會計算差異,而不需要把整棵樹都傳到 render 線程,此功能是 react 提供的,就是在 diff 完後找出差異,則把差異傳到 render 線程,例如:

 

差異裏面記錄好了是哪個節點要進行刪除或添加,其中 path 變量標識是樹上的哪個節點,如 root.children.0.children.1,他代表的意思就是頂節點下第 0 個孩子節點下的第 1 個孩子節點。

render 線程會記錄一棵 vdom tree 在內存中,每次 worker 線程傳過來的 patch 會標識要操作樹上的哪些節點,把這些節點 patch 到 render 線程的 vdom tree 上後,再更新到界面上。

總結

小程序同構方案出現過很多,把 vue 或 react 替換掉現有的小程序開發方式真是很不錯,開發者可以拿自己熟悉的開發框架來開發小程序,同時 vue 與 react 的社區生態這麼成熟,如組件庫、狀態管理框架等都可以直接拿來使用,加快了小程序的開發速度。
kbone 與 remax 兩套方案,感覺 kbone 發展前景不錯,他可以讓你通過 vue 與 react 等所有框架來開發小程序。但是裏面肯定還有很多坑要解決,一個成熟的框架還需要相關配套都成熟,目前 kbone 與 remax 這兩塊做的還不夠,希望後期他們可以加快開發速度,完善相關配套。

作者:國勇

原文:https://zhuanlan.zhihu.com/p/91408586

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章