小程序动态渲染的探索与实践

1,914 阅读16分钟

🚀 🚀 这是一篇实验性质的文章!!!

背景

在 ToC 的小程序电商相关业务,由于电商庞大和复杂的业务属性,再加上是在小程序有限的载体资源中,想要长期可持续的支撑业务稳定发展,解决小程序自身带来的限制,是整个团队长期以来需要面对和解决的痛点。而本文介绍的内容,主要是从我负责的业务领域中展开。下面分别从业务层面和技术层面介绍一下本次分享主题的背景由来。

面临问题

  1. 对于业务更新迭代速度较快,但受限于小程序版本发布节奏
  2. 高可配置的能力导致每次细小的调整都需要依赖小程序版本发布
  3. 整个小程序所有分包大小不超过 20M,单个分包/主包大小不能超过 2M;

目的

本文的主要目的是希望通过小程序动态渲染这个主题作为切入点,一起去探索小程序动态渲染的设计过程,让大家不仅能够了解到这个技术的实现原理,同时也能够对小程序跨端框架的实现原理、低代码平台在业务场景中的使用有所了解。所以本次的分享主要包含以下内容:

  • 低代码平台在业务场景中的运用;
  • 小程序跨端框架设计及实现原理;
  • 小程序动态渲染的设计及实践;

小程序搭建整体方案设计

首先了解一下小程序搭建方案的整体架构。

image.png

其实我们在没有小程序搭建方案之前,就已经完成了弹窗搭建能力的建设,也就是通过低代码平台去开发小程序的活动弹窗,而这和当前所讲的方案,最大的不同在于,弹窗是通过小程序提供的 rich-text 组件能力,将请求到的 HTML 文本渲染在小程序中,也就是通过 SSR 能力生成了 HTML 代码(此处省略具体实现细节)。而小程序搭建的方案,则是在小程序请求到 vnode 数据进行解析,递归的渲染成真实的小程序节点。

以往开发链路

原来的业务开发链路如下,由于当时的版本迭代节奏是每周一版,并且当前版本的封版时间是上一周,因此从需求发起到需求上线,最短需要经历半个月的时间才能完成。

image.png

小程序搭建开发链路

而在小程序搭建的技术方案下,我们整个业务开发到上线的交互链路时序图如下,当需要开发一个新的业务需求或者业务调整时,我们只需要在低代码平台进行开发然后发布即可。很大程度提高了的业务迭代速度,快速完成业务验证。

image.png

这是一个包含低代码平台、Node 服务和小程序多个能力的整体链路,可以看到我们不需要去开发小程序项目的代码,只需要在低代码平台去搭建我们的相关业务需求,很大程度上减少了在小程序工程项目中的业务代码,也就减少了整个小程序包体积。主要原因是在小程序工作里有了能够渲染视图的 SDK,可以将请求的数据结构渲染到视图中。

而本文将主要介绍从小程序向 Node 服务发送请求,到最终节点渲染完成展示页面的过程实现。而下面我们来一起探索一下如何实现这样的小程序动态渲染能力。首先来了解一下小程序跨端框架的实现原理。

小程序跨端框架原理

目前市面上有很多小程序跨端框架,都是由不同的公司为了满足自家产品的业务场景所设计的,因此本文不会对具体框架的实现细节详细说明,仅大致讲解主流小程序中的一些通用核心实现原理

根据小程序框架作用时机可分为编译时和运行时,其类型大致可以分为静态编译型、原生增强型以及动态渲染型,我们来大致了解一下。

首先我们知道小程序一般需要有4份文件,拿微信小程序为例就是 .wxml.wxss.js.json,而跨端框架中这些文件的生成过程也会是下面会提到的。

静态编译型(编译时)

DSL 语法: Vue、React

代表框架: uniapp、Taro1/2

实现原理

这类框架的主要工作量是在编译过程的处理上,即在编译过程中将 Vue / React 的 DSL 语法,利用 babel 工具通过 AST 转译成小程序原生语法,使得编译后的代码能够在小程序中运行。

我们拿 Vue 跨端框架来说,本质上是直接将 Vue 框架引入进来,在编译打包的过程中,通过 vue-loader 对源码进行编译。我们来大概了解一下编译和运行过程。

编译过程

.vue 文件的内容结构我们知道是分为三部分,分别是 templatescriptstyle组成,而在编译过程中,会将这些部分拆分且编译成对应的 .wxml.wxss.js.json文件,并且对于不同部分内容的编译难度也有所不同。

image.png

Template 部分

这部分内容的转换需要通过语法分析成 HTML AST 的,然后通过映射转换成小程序语法的 AST ,再进行序列化,而其中使用的指令统一经过 vue-loader 处理,本质上就是模板转换的过程,即将 Vue 模板转换成小程序模板。

image.png

Script 部分

这部分的编译比较复杂,这里大概描述一下原理,我们知道每个Vue页面或者组件,都是Vue构造函数创建的实例对象,而小程序提供的则是 Page 实例,比如下面:

// Vue
new Vue({
  data(){},
  methods: {},
  mounted() {},
  computed: {},
  watch: {}
})
// 小程序
Page({
  data: {
    text: "This is page data."
  },
  onLoad: function(options) {
  // Do some initialize when page load.
  },
  onShow: function() {
  // Do something when page show.
  },
  onReady: function() {
  // Do something when page ready.
  },onHide: function() {
  // Do something when page hide.
  }
})

由于 Vue 是可以在任何 JavaScript 环境下运行的框架,所以上面的代码也是可以在小程序运行的,只要将整个 Vue 导入到小程序中。而小程序这边,每个页面必须由Page()方法创建的page实例生成的,所以为了实现 Vue 能够在小程序中运行,同时又能够遵循小程序的正常语法逻辑,那么就需要将 Vue 源码中改造,即在创建 Vue 实例的过程中,创建 page 实例,伪代码如下

new Vue() {};
Vue.init = () => { 
  // 在 vue 初始化的时候,调用了 page() 方法
  Page()
}

在 Vue 实例化的时候,会调用 init 方法,在 init 方法里面会调用 Page() 函数,生成一个小程序的 page 实例。所以现在的情况是一个页面会同时有 Vue 和 Page 实例,而这两个之间的联系简单解释一下。

image.png

image.png

Style 部分

小程序的 wxss 文件只能识别原生 css 代码,因此常用的预处理器都需要进行转换,以及一些兼容处理,比如像素单位的转换和少部分不支持属性的处理。

image.png

到这里是不是发现少了.json文件怎么生成的,其实跨端框架对于这个文件内容的写法有很多,比如每个组件都维护自己的.config.js文件,或者在.vue文件中还会有

<script type="application/json">标签用来说明当前文件的表现形式,这也就是.json文件的内容来源。

运行过程

从 Script 的编译过程可以知道,有个地方和 Web 端不同,就是小程序中不支持 Dom 操作,但可以通过 setData 驱动视图更新,那么基于这样的情况,就可以对原本的 Dom 操作进行劫持,替换成触发 setData 的方式进行视图渲染和更新操作。而框架运行的过程中框架和小程序之间会做以下几件事情:

  1. Vue 的生命周期和小程序生命周期关联;
  2. Vue 中的 data 通过 setData 驱动小程序视图渲染;
  3. 小程序监听的事件通过框架代理到 Vue 对象触发对应事件;

总的来说就是 Vue 管理数据,小程序管理事件,如下图 uniapp 官网流程图所示

小结

  • 优点:性能比较好,因为编译后的代码接近原生写的小程序,同时能够具备 Web 迁移能力。
  • 缺点:由于是通过编译实现,这也导致 Web 框架使用能力受限,例如 Taro2 中 JSX 循环限制很大,容易写出 bug。

原生增强型(编译时)

DSL 语法:类Vue

代表框架:Mpx

实现原理

编译过程

由于是基于小程序自身的技术标准进行增强,没有进行过重的DSL转换,很大程度上降低了编译成本以及差异化带来的不可预估问题,所以完全兼容原生小程序技术规范,0成本迁移原生小程序项目。模板代码如下:

<template>
  <!--动态样式-->
  <view class="container" wx:style="{{dynamicStyle}}">
    <!--数据绑定-->
    <view class="title">{{title}}</view>
    <!--计算属性数据绑定-->
    <view class="title">{{reversedTitle}}</view>
    <view class="list">
      <!--循环渲染,动态类名,事件处理内联传参-->
      <view wx:for="{{list}}" wx:key="id" class="list-item" wx:class="{{ {active:item.active} }}"
            bindtap="handleTap(index)">
        <view>{{item.content}}</view>
        <!--循环内部双向数据绑定-->
        <input type="text" wx:model="{{list[index].content}}"/>
      </view>
    </view>
    <!--自定义组件获取实例,双向绑定,自定义双向绑定属性及事件-->
    <custom-input wx:ref="ci" wx:model="{{customInfo}}" wx:model-prop="info" wx:model-event="change"/>
    <!--动态组件,is传入组件名字符串,可使用的组件需要在json中注册,全局注册也生效-->
    <component is="{{current}}"></component>
    <!--显示/隐藏dom-->
    <view class="bottom" wx:show="{{showBottom}}">
      <!--模板条件编译,__mpx_mode__为框架注入的环境变量,条件判断为false的模板不会生成到dist-->
      <view wx:if="{{__mpx_mode__ === 'wx'}}">wx env</view>
      <view wx:if="{{__mpx_mode__ === 'ali'}}">ali env</view>
    </view>
  </view>
</template>

提供了一系列增强的模板指令及语法,主需要在转换是对指定语法进行处理即可。

运行过程

对于模板语法/基础组件、json配置和wxs中的静态差异通过编译时抹平,而对应的页面/组件对象、api调用和webview bridge中js运行时差异,在运行时进行抹平;

小结

  • 优点:
    • 运行时性能可以做到极佳,因为 Vue 在编译期可以做 AOT,而运行时又有精细化的依赖追踪,所以可以保证 setData 时的更新细粒度几乎是最佳。
    • 跨平台开发以跨小程序平台为目标,大部分差异抹平工作在编译阶段进行,大大减少运行时适配层增加的包体积。
  • 缺点:和小程序靠拢过紧,不是完整的 Vue,Web 迁移能力比较弱

动态渲染型(运行时)

DSL 语法:Vue、React

代表框架:Rax、Taro3、Kbone

实现原理

其核心是利用了 Vue / React 的 render function 生成的 vnode 节点(由跨端框架订制的协议,和 Vue / React 框架不是同一套东西),并且和 Web 不同的还有对 vnode 的处理方式上,Web 项目是根据 vnode 创建页面 Dom 节点实现页面渲染,而小程序项目是对 vnode 进行解析并且通过渲染模版进行页面渲染。这是动态渲染型框架的最大特性。

由于技术方案是重运行时,借助递归动态模板模拟 DOM 环境,所以可以直接将 Vue / React 等视图层的框架直接跑在小程序上。

而这里的小程序跨端框架很大程度上使用了 Vue / React 框架层面的能力,所以和 Web 项目相似的原理就不过多描述,这里主要讲一下最大的特性,就是模版渲染的实现原理。

首先讲一下数据来源,主要小程序侧是对 vnode 数据协议进行解析,而这一部分的数据主要由框架的 render function 生成,数据协议如下:

可以看到其实每个节点是对小程序页面结构的描述,通过这个 vnode,就可以利用预先生成的渲染模板对 vnode 数据进行解析和递归渲染,这是利用了小程序提供的 template 模板能力,并借助 utils.wss 辅助解析 vnode 数据内容。整体渲染流程如下:

image.png

模板使用方式

渲染模板代码

小结

  • 优点:与静态编译型相反,Web 框架的使用不受限制,可以自由使用几乎是所有的语法,因此迁移 Web 时也很容易。
  • 缺点:性能相对来说差一些,setData 发送数据的开销和渲染的开销都相对大些,虽然框架内部对这些数据的更新通过 diff 算法降低 setData 数据传输的开销,但性能上开销依然相对较大。

小程序动态渲染方案设计

那么经过我们对现有的小程序跨端框架的理解之后,其实可以看到动态渲染型框架中核心的模版渲染能力,就是我们这次分享的主题,关于小程序动态搭建的模版渲染思路也是从这里得到启发,下面将会具体讲一下在小程序搭建架构中动态渲染模版能力的实现。

首先小程序动态渲染方案设计的目的,主要是希望从 node 服务层请求的 vnode 数据协议(即页面节点描述结构),渲染出期望展示的小程序页面。

架构及原理设计

1. UI渲染模版生成器整体方案

整体方案的设计视角是从更大的方向进行考虑,就是跨小程序的模版生成能力,每一个小程序平台都一自己的 DSL 语法和 Template 模版逻辑,所以要根据不同端生成对于渲染模版,因此中间层的多平台主要是适配层,Webpack 在打包的过程中会根据打包的目标平台代码生成指定平台的渲染模版。

2. Vnode 数据协议设计

Vnode 数据协议涉及在小程序端需要考虑以下因素:

  1. 包体积问题:对于 vnode 中的所有字段名几乎都设计成缩写的形式,考虑到节点和模版较多的情况下,所占总体模版字符的文件体积会有所增大。
  1. 样式问题:不支持传输 classname 的形式继承样式,仅通过 style 方式,主要考虑到样式表样式匹配上的问题,从远端拉去的样式表无法渲染在小程序中使用。
/**
 * vnode数据协议
 */
const vnode = {
  "root": {
    // 样式名 class 
    // "cl": "className",
    // 子结点 child nodes
    "cn": [
      {
        // 结点名 node name
        "nn": "pure-view",
        // 结点ID
        "sid": "_n_10",
        // style 样式
        "st": "width: 10rpx;",
        // 纯文本(一般在 text 结点或没有字结点情况使用)
        "v": "text",
        // 组件自定义/特有属性
        "属性名": "默认属性值",
        // 事件名(事件数据结构)
        "event": {
          "click": {
            "handler": "navigatorTo",
            "data": {}
          }
        },
        // 子结点 child nodes
        "cn": [
          {
            "nn": "static-text",
            "sid": "_n_11",
            "cn": [
              {
                "nn": "#text",
                "v": "Hello world!"
              }
            ]
          }
        ]
      }
    ],
    "uid": "page path"
  }
}

3. 枚举组件属性配置

这一部分需要根据小程序官方文档组件配置信息去枚举出所有组件组用到的属性配置,然后整理出组件及对于属性的配置信息表,用于模版生成阶段。

4. 模板生成和渲染规则

模版生成规则

模版渲染规则

image.png

模版解析器

模版解析器主要是对 vnode 数据结构的单节点数据进行解析并组装匹配对应的模版明,代码主要是包括标签模版匹配和container容器模版匹配,以及部分标签属性内容生成的函数。

相关问题

  1. 嵌套层级问题:由于微信小程序不支持递归模版嵌套,唯一的方案是对一种模版组件枚举出多个编号不同的模版,但是还会存在无限层级嵌套的问题,解决方案也有,就是组件相互调用的方式,即我们有两套相同的模版,两个模版之间相互引用的方式,即可解决了层级无限嵌套的问题,但是需要限制层级上限,确保渲染性能。

  1. 事件捕获/冒泡问题:从上图中可以看到,一个 view 标签会有多种形态,分别是 static、catch、纯 view 和 pure,其不同的形态所表现的标签能力也是不同的,例如 pure 就是仅仅用于页面展示,无绑定事件,static 则包含了一些动画属性,catch 即绑定了 catch 事件的 view 标签。

5. 事件派发机制设计

在小程序环境下,是不允许字符串脚本在内部执行的,即不支持使用eval函数,因此需要解决小程序中的事件执行机制的问题,首先明确几点情况:

  1. 事件类型单一:前面提到,主要针对较为单一事件类型,大多数情况是页面跳转或者事件点击等场景。因此可以通过枚举几乎能够涵盖所有业务场景的通用事件。
  2. 内置埋点事件:埋点主要分为曝光和点击事件,而曝光和点击都可以通过代码埋点的形式触发。
  3. 可扩展事件机制:除了我们提供了能够涵盖绝大部分业务场景的通用事件逻辑之外,还有极小一部分极端情况的事件逻辑,设计了可扩展的事件机制,即提供了在工程代码中注册指定事件。

可行性与性能指标分析

既然我们有了上面这些方案的设计,那么这些方案在实际的场景中可行性和性能指标都是需要进行分析和考量利弊后,才能决定最终能不能在生产环境中落地。

可行性方面

该方案对于简单的业务场景不建议使用,主要是因为整体过长的设计链路,并且包体积和业务迭代的限制较小,对于相对简单的业务来说是完全没必要的。

而对于有着包体积限制以及较庞大的业务迭代过程则可以考虑该方案。

性能指标方面

我们将主要从网络带宽和渲染性能两个方面衡量了当前方案的性能指标。

  1. 网络带宽:实际上网络资源的请求数据量将会有所增大,因为从原本的纯业务数据变成了 vnode 协议的节点信息,这方面对于首屏加载的影响将会稍有增加。(本质上可以理解为 SSR 的性质)但同时对小程序资源加载的包体积问题上有所降低,所以这部分本质上可以持平。加上通过小程序预加载能力前置网络请求也是可以解决这部分问题。
  2. 渲染性能:动态渲染相较于原生页面展示性能上会有所降低,页面耗时会有所增加,由于该渲染方案在动态渲染框架中已经有过很多大型项目的实践,因此渲染性能上是相对可观的,其实另外一个方案就是 rich-text 方式渲染,但是官方文档中其实说明了这个组件的渲染性能比较差,因此不适用于我们大量业务背景中。

扩展

  1. 由设计稿生成 schema 方案,可以参考使用 Sketch 能力。