小程序框架原理分析(mpvue 为主)

1,582 阅读19分钟

经过多年发展,微信小程序已成为微信生态重要一环。随着小程序的普及,小程序开发也成为前端开发工程师必备的技能之一。小程序原生开发效率较低,对于一些较大的项目,通常我们会采用一些框架(mpvue、uni-app、Taro等)进行开发。这些框架是怎么做到用 Vue.js/React 语法来开发小程序的呢,本文我们就对微信小程序及常见的小程序框架的基本原理进行简单介绍。

微信小程序介绍

首先,简单介绍一下微信小程序的发展历史。当微信中的 WebView 逐渐成为移动 Web 的一个重要入口时,微信就有相关的 JS API。早期,腾讯内部一些业务使用 WeixinJSBridge 调用了一些微信原生能力,但是没有对外开放。2015年初,微信发布了一整套网页开发工具包,称之为 JS-SDK,开放了更多微信原生能力。为了提供更原生的体验、更快的加载、更强大的能力,微信开始设计小程序应用。于 2016 年 9 月开始内测,2017 年 1 月正式上线。

双线程架构

小程序的运行环境分成渲染层和逻辑层,其中 WXML 模板和 WXSS 样式工作在渲染层,JS 脚本工作在逻辑层。小程序的渲染层和逻辑层分别由 2 个线程管理:渲染层的使用了 WebView 进行渲染;逻辑层采用JsCore 线程运行 JS 脚本。一个小程序存在多个界面,所以渲染层存在多个 WebView 线程,这两个线程的通信会经由Native(微信客户端)做中转,逻辑层发送网络请求也经由 Native 转发,小程序的通信模型下图所示。 image.png

关于微信小程序底层原理的更多介绍请移步此处

数据驱动

小程序采用类似 Vue.js 的数据驱动机制进行页面的渲染与更新。

在开发 UI 界面过程中,程序需要维护很多变量状态,同时要操作对应的UI元素。随着界面越来越复杂,我们需要维护很多变量状态,同时要处理很多界面上的交互事件,整个程序变得越来越复杂。通常界面视图和变量状态是相关联的,如果有某种“方法”可以让状态和视图绑定在一起(状态变更时,视图也能自动变更),那我们就可以省去手动修改视图的工作。这个方法就是“数据驱动”。

小程序的逻辑层和渲染层是分开的两个线程。在渲染层,宿主环境会把 WXML 转化成相应的 JS 对象,在逻辑层发生数据变更的时候,通过宿主环境提供的 setData 方法将数据从逻辑层传递到渲染层,再经过对比前后差异,把差异应用在原来的 DOM 树上,进而渲染出正确的 UI 界面。

关于微信小程序底层原理的更多介绍请移步此处

小程序原生开发的痛点

微信小程序定义了自己的类 Vue 的语法(WXML、WXSS、WXS),虽然学习起来成本并不高,但是其开发体验和效率仍然饱受诟病:

  1. webpack 支持不好,工程化程度低,影响开发效率;
  2. 早期不支持 npm;
  3. 早期不支持组件化;
  4. 独立的 DSL,有一定学习成本,且并不通用;

此外,随着小程序的发展,很多开发同学收到很多这样的需求:将现有的 H5 应用迁移至小程序。如何快速将 H5 应用迁移至小程序成为很多前端开发同学面临的问题。因此,存在强烈的跨端需求。

为了解决以上痛点,提升开发效率,满足跨端能力,衍生出很多小程序框架,例如:WePympvueuni-appTaro 等,这些小程序框架可以直接使用 Vue\React 语法开发,降低小程序开发学习成本,大大提升开发效率。那么它们是怎么做到的呢?下面我们就来了解一下这些小程序框架的基本原理。

小程序框架原理

虽然各框架的具体实现不一样,采用的开发语言不一样,但是大体讲,小程序框架所做的工作无非就是两个阶段的处理:编译阶段、运行阶段。

由于微信不支持 WXML 节点的动态创建和删除,因此无法像 Vue、React 那样直接根据虚拟 DOM 的更新操作 DOM 更新视图,因此必须在编译打包阶段将其他 DSL(Vue、React)转换为 WXML 模板文件。此外,还需进行 JS 转译、样式转译、框架 runtime 注入等工作。

对于运行阶段,小程序框架会引入一个自己的 runtime。页面中除了有一个小程序页面实例之外,还会创建一个框架自身的页面实例与之映射,因此需要将框架页面实例和小程序页面实例关联,包括数据的关联、事件的关联、生命周期的关联等。

简单来说,这些框架的大致原理就是:

  • 编译阶段:将其他 DSL 转换为符合小程序语法的 WXML、WXSS、JS、JSON;
  • 运行阶段:数据、事件、生命周期等部分的处理和对接;

mpvue 框架分析

mpvue 是一个使用 Vue.js 开发小程序的前端框架。框架基于 Vue.js 核心,修改了 Vue.js 的 runtime 和 compiler 实现,使其可以运行在小程序环境中,从而为小程序开发引入了整套 Vue.js 开发体验。mpvue 很大程度上降低了小程序开发与网页开发之间的割裂,实现了技术栈的统一和多端代码的复用。

现在再来讲 mpvue 可能显得有点过时,新项目的技术选型也基本不会再采用 mpvue 开发。但是,小程序框架思想是想通的,并且 mpvue 是出现最早的小程序框架之一,uni-app、Taro 等框架的早期实现也参考了 mpvue 的部分思想。

本质上,mpvue 就是 fork 了一份 vuejs/vue@2.4.1 的代码,保留了 Vue 核心能力,添加了小程序平台的支持。

Vue.js 基本原理

首先,这里简单介绍一下 Vue 运行原理。如下图所示。多数情况下,我们会采用单文件组件 (SFC) 来写项目,在编译阶段,template 被 vue-loader 解析为 AST 抽象语法树,然后生成 render 函数语句;在运行阶段,当 Vue 实例初始化时,将会进行一系列特性的初始化,其中最重要的就是数据响应式化,然后执行 render 函数并生成虚拟 DOM 对象 vnode ;当监测到数据更新时,重新调用 render 函数并生成新的虚拟 DOM 对象,然后新旧虚拟 DOM 对象进行 patch,最终执行宿主环境 DOM API,修改视图。

yuque_diagram (1).jpg

mpvue 框架原理

简单了解 Vue 运行原理之后,我们还要明白小程序环境和 web 环境的差异。虽然 Vue 中我们不需要特别关注 DOM 操作,但是 Vue 框架本身的视图更新还是基于 DOM API 的。小程序中,我们无法直接操作 DOM,只能通过 setData API 更新 DOM。小程序采用视图层和逻辑层分开的双线程架构,二者之间通信是异步的。此外,小程序虽然是基于 web 技术开发,但是其底层是混用原生技术和 web 技术共同实现的,其运行机制更类似于混合 App 应用,有其独特的生命周期和事件。因此,Vue 无法直接工作于小程序环境。

Vue.js 本身就将平台相关 API 抽离,在 src/platforms 文件夹下实现不同平台的适配。mpvue 的思路就是增加新的平台层支持,即 mp 小程序平台。具体在源码中的表现就是:在 Vue 源码的 platforms 文件夹下面增加了 mp 目录,在里面实现了 complier(编译时) 和 runtime (运行时)支持。

下面这张图展示了 mpvue 框架的基本运行原理。在 Vue 的基础上,mpvue 主要做了以下改造:

  • 编译阶段:修改 vue-template-compiler,将 Vue 代码转换为符合小程序语法规范的 WXML、WXSS;
  • 运行阶段:初始化 Vue 组件实例的同时,初始化小程序页面实例;组件 patch 更新阶段,不再直接操作DOM,而是调用小程序页面实例的 setData 方法将视图更新;此外,还包括生命周期的处理、事件的处理等,下面会详细讲解。

yuque_diagram (2).jpg yuque_diagram (3).jpg

编译阶段

mpvue-loader

在编译阶段,mpvue 所做的工作主要是将 Vue 模板视图层代码编译为符合小程序语法的视图层代码。这部分工作主要由 mpvue-loader 完成。所做的工作主要有:标签映射、指令转换、组件支持、事件绑定、样式转译等。

mpvue-loader 是 vue-loader 的一个扩展延伸版,类似于超集的关系,除了 vue-loader 本身所具备的能力之外,它还会产出微信小程序所需要的文件结构和模块内容。mpvue-loader 的大致原理就是,从单文件组件(SFC)中提取出 AST,然后改造 AST,最后再将 AST 转译为小程序模板代码。以下面的代码为例:

<template>
    <div class="my-component">
        <h1>{{msg}}</h1>
        <other-component :msg="msg"></other-component>
    </div>
</template>
<script>
import otherComponent from './otherComponent.vue'

export default {
  components: { otherComponent },
  data () {
    return { msg: 'Hello Vue.js!' }
  }
}
</script>

上面 Vue 组件代码,将会被转换为如下代码:

<import src="components/other-component$hash.wxml" />
<template name="component$hash">
    <view class="my-component">
        <view class="_h1">{{msg}}</view>
        <template is="other-component$hash" wx:if="{{ $c[0] }}" data="{{ ...$c[0] }}"></template>
    </view>
</template>

style scoped

在 vue-loader 中对 style scoped 的处理方式是给每个样式加一个 attr 来标记 module-id,然后在 css 中也给每条 rule 后添加 [module-id],最终可以形成一个 css 的“作用域空间”。在微信小程序中目前是不支持 attr 选择器的,所以 mpvue 做了一点改动,把 attr 上的 [module-id] 直接写到了 class 里。示例:

<!-- .vue -->
<template>
    <div class="container">
        // ...
    </div>
</template>
<style scoped>
    .container {
        color: red;
    }
</style>

<!-- vue-loader -->
<template>
    <div class="container" data-v-23e58823>
        // ...
    </div>
</template>
<style scoped>
    .container[data-v-23e58823] {
        color: red;
    }
</style>

<!-- mpvue-loader -->
<template>
    <div class="container data-v-23e58823">
        // ...
    </div>
</template>
<style scoped>
    .container.data-v-23e58823 {
        color: red;
    }
</style>

其他配套工具

运行阶段

关键词:双实例、数据同步机制、事件代理机制、生命周期关联 ​

双实例

mpvue 将 Vue 运行时引入至小程序运行环境。Vue 负责维护数据模型和虚拟 DOM,小程序负责视图层展示,所有业务逻辑收敛到 Vue 中,Vue 数据变更后再同步到小程序视图。

运行阶段,一个页面中同时包含 Vue 实例与小程序 Page 实例。那么这两个实例是如何协作,如何运行的呢?若想让 Vue 工作于小程序环境,我们需要解决以下三个核心问题:

  1. 如何将 Vue 中的数据状态同步至小程序视图?
  2. 小程序页面触发某个生命周期钩子的时候,如何调用 Vue 中对应的生命周期函数?
  3. 小程序页面触发某个事件的时候,如何调用 Vue 中对应的事件响应函数?

带着以上疑问,阅读 mpvue 源码,可以梳理出其大致运行原理,如下图所示。首先,入口是 Vue 实例的初始化;在 Vue 组件 mount 之前,初始化小程序页面实例,小程序页面实例中包含页面 data、一个函数handleProxy、以及注册所有的小程序页面生命周期钩子;小程序触发 onLoad 生命周期,此时将 Vue 页面实例和小程序页面实例进行关联,同时通过 callHook 方法调用 Vue 组件中对应的生命周期函数;小程序触发 onReady 时,这时才会真正执行 Vue 组件的 mount;然后执行 render 函数,生成 vnode,这是 Vue 自己维护的一份小程序页面节点的虚拟 DOM;然后执行 patch 进行页面的渲染,不同于 web 环境的是,小程序环境无法直接操作DOM,需要采用 setData API 间接渲染,mpvue 中将相应逻辑写在了 updateDataToMP 方法里;上面就是 mpvue 页面初始化的一个流程。当小程序页面触发某一个生命周期时,小程序页面实例会调用 callHook 方法,callHook 方法则会去 Vue 实例 vm 中找到对应的回调函数。当用户交互触发某一个事件时,都会调用 handleProxy 方法,而 handleProxy 方法会调用 Vue 实例上的 handleProxyWithVue方法,handleProxyWithVue 方法, handleProxyWithVue 方法底层则会去找到对用的 Vue 组件实例以及对应的回调函数,最终执行该事件对应的回调函数。

yuque_diagram (4).jpg

从上述流程可以看出,mpvue 运行阶段所做的主要工作其实就是将 Vue 实例与小程序 Page 实例建立关联,主要包括:

  1. 数据同步机制,Vue 实例中数据变更能同步至小程序页面;
  2. 事件代理机制,页面中触发的事件能调用 Vue 中对应的事件函数;
  3. 生命周期关联,能在小程序生命周期中调用 Vue 中设置的页面生命周期函数;

yuque_diagram (5).png

Vue 和小程序的数据彼此隔离,各自有不同的更新机制。mpvue 从生命周期和事件回调函数切入,在 Vue 触发数据更新时实现数据同步。小程序通过视图层呈现给用户、通过事件响应用户交互,Vue.js 在背后维护着数据变更和逻辑。可以看到,数据更新发端于小程序,处理自 Vue,Vue 数据变更后再同步到小程序。

生命周期关联

小程序页面触发某个生命周期的时候,如何调用 Vue 中对应的生命周期函数?

其实思路比较简单。主要是在小程序 Page 实例化时,将小程序所有的页面生命周期 hook 进行注册。当触发相应 hook 时,统一都调用 callHook 方法,callHook 方法则是调用 Vue 实例 vm 及其子组件 vm 中绑定的 hook 回调函数。

mpvue 中支持在 Vue 子组件中注册小程序页面的生命周期,正是因为 callHook 方法中会对子组件递归调用 callHook。下面是 callHook 方法源码:

function callHook (vm, hook, params) {
  let handlers = vm.$options[hook]
  if (hook === 'onError' && handlers) {
    handlers = [handlers]
  } else if (hook === 'onPageNotFound' && handlers) {
    handlers = [handlers]
  }

  let ret
  if (handlers) {
    for (let i = 0, j = handlers.length; i < j; i++) {
      try {
        ret = handlers[i].call(vm, params)
      } catch (e) {
        handleError(e, vm, `${hook} hook`)
      }
    }
  }
  if (vm._hasHookEvent) {
    vm.$emit('hook:' + hook)
  }

  // for child
  if (vm.$children.length) {
    vm.$children.forEach(v => callHook(v, hook, params))
  }

  return ret
}

注意,我们只需要处理小程序的生命周期函数,而不需要考虑 Vue 自己的生命周期,因为我们引入了 Vue 完整的运行时,只是稍加修改,整体运行逻辑和生命周期仍然不变。

事件代理机制

上面我们说过,Vue 并不做视图渲染,视图仍然由小程序自己渲染。此时的情况是:事件响应函数存在于 Vue中,事件触发却在小程序节点上,二者并无联系,却又必须做到通过触发小程序节点事件准确调用到对应的 Vue.js 事件函数(事件函数内触发 Vue 数据更新,数据更新后通过 update 机制同步到小程序,最终实现小程序视图更新)。

小程序页面触发某个生命周期钩子的时候,如何调用 Vue 中对应的生命周期函数?mpvue 的采用方案是事件代理机制。具体讲:所有在小程序标签上的事件响应函数,都统一到一个特定的事件代理函数,在函数内通过当前标签上下文信息映射到与之对应的 Vue.js 事件函数。

小程序视图触发事件后,会将 event 对象通知到 Page 实例,那么我们只需要将视图层中所有的事件都代理到 handleProxy 这个方法中,然后再靠这个方法从 Vue 的实例树上找到对应的 vm 和 handler 做事件处理。为了实现这一目的,在构建阶段对模版进行编译时,除了要将事件监听方法转换为 handleProxy 以外,还通过 data- 在元素上标记对应的组件 compid 和节点 nodeid,用于标记当前层级信息,如下所示:

<!-- 编译前的 Vue 模版 .vue -->
<div @click="onClick"></div>

<!-- 编译后的小程序模版 .wxml -->
<view bindtap="proxy" data-compid="0" data-nodeid="0"></view>

事件触发时,handleProxy 方法会从 event 对象上获取对应的 id 信息和事件类型,进而从 Vue 的根 vm 开始查找,最终在 vnode 上找到对应的 handler 并执行事件处理,完成小程序事件到 Vue 实例的事件代理。

image.png

数据更新机制

Vue 视图层渲染由 render 方法完成,根据内存中维护着的一份虚拟 DOM,调用宿主环境(浏览器)提供的 DOM API 进行视图渲染。然而小程序没有提供相关 API,只能通过页面实例上的 setData API 与视图层进行通信。因此 mpvue 对 Vue 的 patch 的基础上,额外调用了 updateDataToMP 方法,最终通过 setData 进行视图更新。

uni-app 框架分析

简介

uni-app 是一个使用 Vue.js 开发所有前端应用的框架,开发者编写一套代码,可发布到iOS、Android、Web(响应式)、以及各种小程序(微信/支付宝/百度/头条/QQ/快手/钉钉/淘宝)、快应用等多个平台。uni-app 是一个叫做 DCloud 的公司的开源产品,有专人维护,具有很强的跨端能力和完善的生态。 ​

uniapp 在初期借鉴了mpvue,早期版本实现原理和 mpvue 基本一致。mpvue 的实现,总的来说是将 Vue 实例和小程序页面实例简单粗暴地做了关联,以达到使用 Vue 开发小程序的目的。mpvue 的思路是很好的,但是,mpvue 并没有做深入优化,性能一般。同时,由于 mpvue 后续基本处于无人维护状态,因此无法跟上微信小程序的技术更新(比如自定义组件、分享朋友圈功能)。uniapp 则在 mpvue 的基础上,做了很多的性能优化,也有团队维护。因此新项目基本不推荐采用 mpvue 开发了,更推荐采用 uniapp。

我们这里主要讲讲,uniapp 框架比 mpvue 框架为什么性能更优?主要有一下三点:

  • 基于小程序自定义组件实现 Vue.js 的组件化开发;
  • Vue 层取消 vnode 对比;
  • 更彻底的 diff 计算,setData() 通讯数据量更少;

组件实现方式不一样

mpvue/wepy 诞生之初,微信小程序尚不支持自定义组件,无法进行组件化开发;mpvue/wepy 为解决这个问题,创造性的将用户编写的 Vue 组件,编译为 WXML 中的模板(template),这样变相实现了组件化开发能力,提高代码复用性,这在当时的技术条件下是很棒的技术方案。但如此方案,也导致 Vue 组件中的数据会被编译为Page 中的数据,对组件进行数据更新也会基于路径映射调用 Page.setData。特别是组件较多、数据量交大的页面中,每个组件的局部更新会引发页面级别的全局更新,产生极大的性能开销。

微信后来推出的自定义组件,其支持组件级别的局部更新。组件级别的数据更新,相比页面全局更新,有大幅性能提升。另外,mpvue 在 Vue 层进行的 vnode 对比及数据 diff 计算不彻底,也会消耗部分性能。基于这些原因,uniapp 团队开始了微信端的框架重写工作。uniapp 抛弃以 template 的方式实现组件,采用小程序自定义组件,从而实现了数据的组件级别更新,性能更优。

setData 优化

setData 是小程序开发中使用最频繁的接口,也是最容易引发性能问题的接口。小程序的视图层目前使用 WebView 作为渲染载体,而逻辑层是由独立的 JavascriptCore 作为运行环境。在架构上,WebView 和 JavascriptCore 都是独立的模块,并不具备数据直接共享的通道。当前,视图层和逻辑层的数据传输,实际上通过两边提供的 evaluateJavascript 所实现。即用户传输的数据,需要将其转换为字符串形式传递,同时把转换后的数据内容拼接成一份 JS 脚本,再通过执行 JS 脚本的形式传递到两边独立环境。而 evaluateJavascript 的执行会受很多方面的影响,数据到达视图层并不是实时的。

mpvue 的实现很粗糙,数据更新是全量更新。而 uniapp 是将数据进行 diff,然后通过 setData 进行路径级别的更新,例如:

this.setData({
    'array[0].text':'changed data'
 })

通过 setData 的优化,uniapp 可以极大减少视图层和逻辑层的数据传输,明显提升应用的运行时性能。

mpvue 和 uniapp 生命周期不一致

在介绍 mpvue 时我们提到过,mpvue 中子组件可以注册小程序页面生命周期钩子。但是 uniapp 并不支持。这是由于两个框架对于字组件的实现方式不一致导致的。其实小程序本身自定义组件对页面生命周期也是不支持的。

Taro 设计思想及架构

当前 Taro 已进入 3.x 时代,相较于 Taro 1/2 编译时架构,Taro 3 采用了重运行时的架构,让开发者可以获得完整的 React / Vue 等框架的开发体验。这里主要参考《小程序跨框架开发的探索与实践》

Taro 1/2 的架构

Taro 架构同样分为:编译时和运行时。编译时主要是将 Taro 代码通过 Babel 转换成 小程序的代码,如:JS、WXML、WXSS、JSON。运行时主要是进行一些:生命周期、事件、data 等部分的处理和对接。 image.png

编译时

有过 Babel 插件开发经验的应该对一下流程十分熟悉,Taro 的编译时也是遵循了此流程,使用 babel-parser 将 Taro 代码解析成抽象语法树,然后通过 babel-types 对抽象语法树进行一系列修改、转换操作,最后再通过 babel-generate 生成对应的目标代码。 image.png 整个编译时最复杂的部分在于 JSX 编译。我们都知道 JSX 是一个 JavaScript 的语法扩展,它的写法千变万化,十分灵活。这里我们只能采用穷举的方式对 JSX 可能的写法进行了一一适配,这一部分工作量很大,实际上 Taro 有大量的 Commit 都是为了更完善的支持 JSX 的各种写法。但尽管如此,我们也不可能完全覆盖所有的情况,因此还是推荐大家按照官方规范书写 React 代码。 image.png

运行时

接下来,我们可以对比一下编译后的代码,可以发现,编译后的代码中,React 的核心 render 方法 没有了。同时代码里增加了 BaseComponent 和 createComponent,它们是 Taro 运行时的核心。主要是对 React 的一些核心方法:setState、forceUpdate 等进行了替换和重写,结合前面编译后 render 方法被替换,大家不难猜出:Taro 当前架构只是在开发时遵循了 React 的语法,在代码编译之后实际运行时,和 React 并没有关系。

而 createComponent 主要作用是调用 Component() 构建页面;对接事件、生命周期等;进行 Diff Data 并调用 setData 方法更新数据。

特点

  • 重编译时,轻运行时:这从两边代码行数的对比就可见一斑。
  • 直接使用 Babel 进行编译:这也导致当前 Taro 在工程化和插件方面的羸弱。
  • Taro 当前架构只是在开发时遵循了 React 的语法,在代码编译之后实际运行时,和 React 并没有关系。

Taro 3 的架构

这一次,我们站在浏览器的角度来思考前端的本质:无论开发这是用的是什么框架,React 也好,Vue 也罢,最终代码经过运行之后都是调用了浏览器的那几个 BOM/DOM 的 API ,如:createElement、appendChild、removeChild 等。

因此,我们创建了 taro-runtime 的包,然后在这个包中实现了 一套 高效、精简版的 DOM/BOM API。然后,我们通过 Webpack 的 ProvidePlugin 插件,注入到小程序的逻辑层。这样,在小程序的运行时,就有了 一套高效、精简版的 DOM/BOM API。

在 DOM/BOM 注入之后,理想情况下,React、Vue 等就可以直接在小程序汇总运行了。但是还是有很多工作要做的。这里就不多介绍了,有兴趣的可以移步《小程序跨框架开发的探索与实践》

新架构特点

  • 全运行时:和之前的架构不同,Taro Next 是近乎全运行。
  • 无 DSL 限制:无论是你们团队是 React 还是 Vue 技术栈,都能够使用 Taro 开发。
  • 模版动态构建:和之前模版通过编译生成的不同,Taro Next 的模版是固定的,然后基于组件的 template,动态 “递归” 渲染整棵 Taro DOM 树。
  • 新特性无缝支持:由于 Taro Next 本质上是将 React/Vue 运行在小程序上,因此,各种新特性也就无缝支持了。
  • 社区贡献更简单:错误栈将和 React/Vue 一致,团队只需要维护核心的 taro-runtime。
  • 基于 Webpack:Taro Next 基于 Webpack 实现了多端的工程化,提供了插件功能。

参考