小程序框架的演化史及未来方向

1,088 阅读12分钟

小程序框架的演化史及未来方向

简介

长期以来,业界一直在寻找一种既能够媲美 Native 的使用体验,又可以以低门槛且能够快速开发迭代的方式来更新应用的技术。

在这个过程中,人们做了很多尝试,如 React Native 和 Weex,本质上轻量化 Native 开发,在 Native 开发上做减法,利用 JSBridge 和原生沟通;H5 开发速度快,但是无法调用起 Native 的能力,体验方面差强人意。

在现在海量 APP 的现状下,很多 APP 甚至都没下载过几次,依赖于超级流量的商家越来越多,导流需求愈发强烈的当下,小程序应运而生,它的做法是,通过限制级别的类 Web 开发体验,赋予 Native 的能力,为小程序提供大量 Native API 层面的支持,从而获得类 Native 的体验。

小程序的优越性在于,它保留了 H5 开发的便利性和灵活性,同时又达到了媲美 Native 的使用体验,再配合超级 APP 的流量支持,转化率会非常高,可预见的未来会有越来越多的商家选择开发小程序。

原理分析

小程序的出现并不是一蹴而就,而是在移动互联网近 10 年的业务、场景积累上逐步发展起来的。

Web 开发,灵活、高效、动态性很强,但是也有局限性,就是太依赖浏览器/webview 的能力,通常浏览器能力的上限就是 Web 的上限,为了克服这种局限性,于是便有了 JS-Bridge 这种方式,将很多常用组件内置到应用中,当内嵌组件足够多的时候,再做一层封装,形成了 JS-SDK,其本质没有差别。

小程序在 JS-SDK 的基础上,一方面进一步开放和拓展原生的能力给到 Web 前端调用,另一方面,页面渲染(Webview Render)的 UI 层和逻辑层,使用了两个独立的线程。

双线程模型

小程序的渲染层和逻辑层分别由 2 个线程管理:视图层的界面使用了 WebView 进行渲染,逻辑层采用 JsCore 线程运行 JS 脚本。

  • 逻辑层:创建一个单独的线程去执行 JavaScript,在这里执行的都是有关小程序业务逻辑的代码,负责逻辑处理、数据请求、接口调用等

  • 视图层:界面渲染相关的任务全都在 WebView 线程里执行,通过逻辑层代码去控制渲染哪些界面。一个小程序存在多个界面,所以视图层存在多个 WebView 线程

  • JSBridge 起到架起上层开发与 Native(系统层)的桥梁,使得小程序可通过 API 使用原生的功能,且部分组件为原生组件实现,从而有良好体验

这张图大家一定很熟悉:

可是为什么要这么做呢?

引用开发指南中的话:

  • 快速的加载
  • 更强大的能力
  • 原生的体验
  • 易用且安全的数据开放
  • 高效和简单的开发

因为浏览器中 UI 线程和 JS 线程冲突,就会导致 UI 线程阻塞、页面切换卡顿等问题,同时,为了管控和安全,小程序中的双线程模型,将逻辑层和视图层分离,阻止开发者使用一些,例如浏览器的 window 对象,跳转页面、操作 DOM、动态执行脚本的开放性接口,很好的避免了这些问题。

开荒阶段/原因

在早期阶段,由于小程序拥有自己的一套 DSL,且市面上还没有相关的框架出现,吃螃蟹的开发者都会选择原生开发模式。

但是原生开发者发现,小程序冗杂的代码结构,孱弱的字符串模版,不完善的开发流程,依赖管理混乱等等原因,致使开发效率大大降低。

当⼀⻔语⾔的能⼒不⾜,⽽⽤户的运⾏环境⼜不⽀持其它选择的时候,这⻔语⾔就会沦为 “编译⽬标” 语⾔。

这时,大家都开始寻觅一种类似前端 Web 开发工程化的开发方式。

摸索前进

由于小程序的 DSL,JS 无法直接操作 DOM,这就导致了小程序不能动态生成页面,而小程序的双线程架构,使得 JS 和视图层主要通过 Event 和 Data 进行通信,同时通过 JSBridge 调用原生的 API,所以对于开发者来说,原生部分就是一个黑盒,开发者根本动不了,开发者能够优化的部分只有 JS 和 Page。

也就是说,只需要在逻辑层处理数据,提供生命周期和事件函数,同时提供对应的视图模版,小程序就能跑起来了,这也就是大多数小程序框架重点处理的部分。

有痛点就有需求,有需求就要解决。

就这这些问题,各个大厂八仙过海,各显神通,诞生出了一大批框架。

取几个典型的例子:

  • 美团的 mp-vue
  • 蚂蚁的 Remax
  • 京东的 Taro
  • DCloud 的 uni-app
  • 淘宝的 Rax(后起之秀)

这些框架的特点就是,脱离小程序的 DSL 的写法,使用前端开发的方式开发小程序,最后转成小程序的 DSL,不过技术都是相近的,绕来绕去脱离不了编译时和运行时的套路,更多的是语言支持度,生态完善度。

我有幸参与了 Taro 1.x,2.x,3.x 框架的演化历程,作为 Taro 的开源作者,也算对整个小程序框架的演进有一定的理解。

演进历程

静态编译的妥协

编译时的原理很简单,就是转编译,简单来说,就是将代码通过 Babel 转换成小程序代码,如 JS,AXML,AXSS 等。

使用 babel-parser 将代码解析成抽象语法树,然后通过 babel-types 对抽象语法树进行一系列修改、转换操作,最后再通过 babel-generate 生成对应的目标代码。

其中 Taro 的 1.x 和 2.x,uni-app,mp-vue 都是编译时框架,虽说是编译时,但是主要是以编译时为主,运行时为辅。

Taro 1.x 和 Taro 2.x 的区别就在于,Taro 1.x 是自建的 Cli 打包,维护困难,Taro 2.x 是利用 webpack 的能力,分工明确。

这种编译时框架原理很简单,好处也非常明显,就是一套代码能转到各个端,兼具性能和跨平台的特性。但是这种方案的流行,里面的工作一点都不简单,JSX 写法千变万化,而小程序语法却有限制,如无法操作 DOM 等,转编译过程中有大量的适配工作,而且很多语法特性由于小程序端不支持,所以使用 React/Vue 写法写时将会有大量的限制,这一点 Taro 和 uni-app,mp-vue 官方都有详细说明。

其次即使有详细说明,但是编译时需要做的转编译工作量巨大,各种写法都需要支持,这就导致了绝大部分的 ISSUE 问题都是和编译相关,如果小程序有新的特性需要支持或者改变,静态转编译需要保持同步,维护难度加剧。

面向开发者的运行时

运行时方案,更像是面向未来的方案,彻底摆脱小程序的 DSL 限制。

让我们回到原点,我们站在浏览器的角度来思考前端的本质,无论开发这是用的是什么框架,React 也好,Vue 也罢,最终代码经过运行之后都是调用了浏览器的那几个 BOM/DOM 的 API,浏览器并不能直接跑前端框架,浏览器识别的仍然是 JS,HTML,CSS 这三剑客,而目前市面上的前端 SPA 框架最终的目的就是,使用 JS 来动态绘制 HTML,使用虚拟 DOM 的技术,Diff 算法的加持,动态改变节点,降低开发者直接操作节点的繁杂性和复杂度。

由于小程序采用双线程机制,内部通过一个 webview 用于承载页面渲染,但小程序屏蔽了它原本的 DOM/BOM接口,自己定义了一套组件规范;另一方面,使用独立的js-core 负责对 javascript 代码进行解析,让页面和js-core之间进行相互通信(setData),从而达到执行与渲染的分离。而浏览器的 DOM接口是大量web得以显示的底层依赖,这也是h5代码无法直接在小程序中运行的主要原因。

那么我们如何突破在小程序上无法操作 DOM 的问题呢?我们是否可以直接用 JS 来动态生成 Page 呢?

最直接的思路就是,模拟。

仿造一层浏览器环境 DOM 相关的标准接口,让用户的 JS 代码可以无感知自由操作 DOM。我们底层模拟 DOM 可以是实时渲染出一颗 DOM 树,然后转换成小程序的 DOM 树,最后由小程序真正渲染出来。

如何突破在小程序上无法操作 DOM 的问题?

小程序提供了 setData 用来逻辑层和视图层进行通信,我们将小程序 DOM 树保存在逻辑层,通过 setData 来进行操作 DOM。

组件树千差万别,该如何渲染

在运行时并没有组件之说。

在编译时,代码会被编译成对应的代码文件,甚至组件也会单独编译一份进行引用,但是在运行时,没有引入组件之说,页面模版都是一模一样,只是实时生成的 DOM 树进行递归遍历渲染。

例如 DOM 树是如下结构

{
  "id": 0,
  "type": "root",
  "children": [
    {
      "id": 1,
      "type": "view",
      "props": {
        "className": "greeting"
      },
      "children": [
        {
          "id": 2,
          "type": "text",
          "props": {},
          "children": [
            {
              "type": "plain-text",
              "text": "Hello"
            }
          ]
        }
      ]
    }
  ]
}

页面大致文件是这样的

<block a:for="{{root.children}}" a:key="{{item.id}}">
  <template is="{{'TPL_' + item.type}}" data="{{item: item}}" />
</block>

<template name="TPL_view">
  <view class="{{item.props['className']}}">
    <block a:for="{{item.children}}" key="{{item.id}}">
      <template is="{{'TPL_' + item.type}}" data="{{item: item}}" />
    </block>
  </view>
</template>

<template name="TPL_text">
  <text>
    <block a:for="{{item.children}}" key="{{item.id}}">
      <template is="{{'TPL_' + item.type}}" data="{{item: item}}" />
    </block>
  </text>
</template>

<template name="TPL_plain-text">
  <block>{{item.text}}</block>
</template>

运行时框架的区别

相对于编译型的小程序框架而言,运行时小程序框架有一个(或多个)基础模板,基础模板接受不同的数据渲染与之对应的内容,小程序框架主要的工作是把开发者的业务逻辑转换成基础模板可接受的数据去驱动小程序渲染。

Taro3.x,Remax,Rax(结合 Kbone) 都是运行时框架,原理基本相通,但技术选型,内部实现,优化思路都有很大的不同。

Remax

Remax 更加专注于 React 形式开发小程序,通过 react-reconciler 实现了一个小程序渲染器。但是由于微信小程序不支持递归,所以 Remax 会为微信小程序生成一个 20 层的模板调用,当层级超过这个阈值,可能会导致爆栈等异常。

在渲染方面,Remax 有一个静态模板列出了所有开发者声明过的组件,静态模板通过遍历渲染器返回的数据(类似于一颗 DOM 树)实现渲染。

Rax(结合Kbone)

Kbone 内部实现了轻量级的 DOM 和 BOM API,把 DOM 更改绑定到小程序的视图更改。也就是说,Kbone 并不太关心开发者使用什么框架,只要框架使用的 DOM API 被 Kbone 实现的 DOM API 覆盖到,框架就能通过 Kbone 在小程序运行。简单来说就是可以用 React/Vue 形式进行开发。

在更新方面,Kbone 以组件为粒度进行更新,每次视图改变小程序 setData 的数据是组件的 DOM 树。

同时为了防止递归限制,Kbone 限制了一个层级上限,超过这个层级数会包一层自定义组件。

Taro3.x

同样支持多种形式开发,基于 template 形式解决递归问题,不同于 Kbone 的组件级别的更新,更类似于 Remax 的更新策略,对组件路径的值进行更新,大幅度提高了 update 性能问题,有兴趣的可以了解一下。

总体上实现模式差不多,这里就不细谈了。

运行/编译两者差异

编译时

优点
  • 对模版可以进行精细化处理,性能更好
缺点
  • DSL 语法限制
  • 编译维护困难
  • 排查问题困难

运行时

优点
  • 不限制 DSL
  • 更友好的 sourceMap
  • 基于 Webpack,更友好的插件功能
缺点
  • 性能较于编译时偏弱

一点思考

  1. 底层驱动技术 OR 技术驱动底层?

    还是那个问题,浏览器最终只是识别 JS、CSS、HTML 三个文件,所有框架的最终目的就是编译成浏览器可识别的文件,这么看来 React/Vue 的区别就没那么大,都是调用了 DOM/BOM 几个常用的 API。

    对于开发者来说,技术的选型仍然非常重要,React/Vue DSL 差异巨大,生态建设差异也有一定距离,选择对能提升很多效率。

    再往深层次挖一点,渲染层是一致的,如果把浏览器当做 Flutter 呢?是不是无论是 Dart 语言还是 JS 语言,最终编译成渲染层能够识别的内容不就行了?

  2. Write Any Where OR Run Any Where

    这是一个难以抉择的平衡。

    作为开发者,我们当然希望 Run Any Where,但是作为不同平台的差异性,往往需要付出巨大的工作量才能将差异性抹平,仿佛回到了编译时的老路。

    Write Any Where 又需要维护多份代码,加剧了开发者的工作量。

未来方向

  1. 小程序的优化仍是重点,元素过多渲染卡顿,尤其是内嵌 WebView,白屏时间过长。

  2. 可视化IDE搭建,目前市面上已有相关苗头。 --> aotu.io/notes/2020/…

  3. 目前 Rax 已经能够同时支持编译时和运行时,那么这两者能否相互结合?

    ...