评论

小程序多平台同构方案分析-kbone 与 remax

小程序同构方案 remax 与 kbone 技术方案分析与对比

当前国内小程序平台众多,微信小程序、支付宝小程序、头条小程序、以及未来还会出现的新小程序平台,所以为了解决一套代码可以在多个小程序平台上运行,出现了多种方案来解决,京东的 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 这两块做的还不够,希望后期他们可以加快开发速度,完善相关配套。


最后一次编辑于  11-13  
点赞 3
收藏
评论

1 个评论

  • linus
    linus
    11-14
    👹
    11-14
    赞同
    回复