小程序多平臺同構(gòu)方案分析-kbone 與 remax

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

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

小程序架構(gòu)

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

image

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

  1. 響應(yīng) render 線程的事件,并執(zhí)行小程序業(yè)務(wù)邏輯。
  2. 準備好數(shù)據(jù),通過 setData 傳到 page 中,由 page 進行渲染。

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

兩個方案對比:

  1. 相同點

  2. 都是在 worker 線程維護一棵 vdom tree,然后同步到 render 線程通過 w|axml 來進行渲染。

  3. 不同點

  4. kbone 是適配了 js dom api ,上層可以用任何框架,如 react、vue、原生 js 來寫小程序。remax 是自已寫了一套 react 的 renderer,上層只支持 react。

  5. remax 在 dom tree 發(fā)生變化時,不是把整棵 vdom tree 傳到 render 線程,而是計算差異,把差異傳到 render 線程,這點可以加快了兩個線程之間的數(shù)據(jù)傳輸速度。

kbone

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

流程如下:

image

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

worker 線程

worker 線程會運行所有的小程序代碼,并適配了 js dom api 和定義一套數(shù)據(jù)結(jié)構(gòu)來描述一棵 dom tree。

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

  1. document.createElement
  2. document.createTextNode

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

  1. type: 當(dāng)前節(jié)點類型
  2. parentNode:父節(jié)點對象
  3. childNodes: 孩子節(jié)點對象數(shù)組

當(dāng) worker 線程創(chuàng)建好了 dom tree 后,在內(nèi)存中的大概長下面這樣:

{
    "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 ", 
            } ]
        }]
    }]
}

這是一棵多叉樹,每個節(jié)點定義了當(dāng)前節(jié)點的屬性和孩子節(jié)點。接下來就是把這棵樹傳到 render 線程,并由 render 線程把他顯示出來。這里傳到 render 線程采用的是小程序提供的方法 setData,把這棵 dom tree 當(dāng)成數(shù)據(jù)傳到 render 界面。

render 線程

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

上面代碼是 wxml 語法寫的一個小程序界面,worker 線程中的內(nèi)存 dom tree 可以和 wxml 里的節(jié)點一一對應(yīng),只需要把 dom tree 通過遞歸迭代映射到 wxml 的節(jié)點。

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

Element 代碼如下(簡略):

<!--當(dāng)前節(jié)點-->
<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>

<!--子節(jié)點-->
<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 實現(xiàn)了一套自定義的 renderer,原理是在 worker 線程維護了一套 vdom tree,這個 vdom tree 會通過小程序提供的 setData 方法傳到 render 線程,render 線程則把 vdom tree 遞歸的遍歷出來。

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

image

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

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

總結(jié)

小程序同構(gòu)方案出現(xiàn)過很多,把 vue 或 react 替換掉現(xiàn)有的小程序開發(fā)方式真是很不錯,開發(fā)者可以拿自己熟悉的開發(fā)框架來開發(fā)小程序,同時 vue 與 react 的社區(qū)生態(tài)這么成熟,如組件庫、狀態(tài)管理框架等都可以直接拿來使用,加快了小程序的開發(fā)速度。

kbone 與 remax 兩套方案,感覺 kbone 發(fā)展前景不錯,他可以讓你通過 vue 與 react 等所有框架來開發(fā)小程序。但是里面肯定還有很多坑要解決,一個成熟的框架還需要相關(guān)配套都成熟,目前 kbone 與 remax 這兩塊做的還不夠,希望后期他們可以加快開發(fā)速度,完善相關(guān)配套。

作者:國勇
原文:https://zhuanlan.zhihu.com/p/91408586

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容