小程序多平台同构方案分析-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,所以就解决了上面管控困难的问题,架构如下:

image

每个小程序界面有 axml 与 js 文件,js 文件是页面逻辑,逻辑主要做两件事情:

  1. 响应 render 线程的事件,并执行小程序业务逻辑。
  2. 准备好数据,通过 setData 传到 page 中,由 page 进行渲染。

以上是国内微信、支付宝、头条小程序的架构,但是目前开发者如果要把一个小程序支持三个平台和 web 平台,就需要开发多次,目前出现了多种同构平台。有编译时与运行时动态转换两种。
编译时 Taro 做的很成功,Taro 可以让开发者用 React 写小程序,最终经过编译转换到不同平台的小程序。
今天讲的是另外一种方案,不靠编译时来完成,而是在运行时做适配,分别是微信提供的 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 发生变化时,不是把整棵 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。

流程如下:

image

因此所有小程序的代码都是放在 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 线程,例如:

image

差异里面记录好了是哪个节点要进行删除或添加,其中 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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容