作者:董宏平
Mpx 是滴滴开源的一款增强型跨端小程序框架,自2018年立项开源以来如今已经进入第六个年头,在这六年间,Mpx 根植于业务,与业务共同成长,针对小程序业务开发中遇到的各类痛点问题提出了解决方案,并在集团内部建设了完善的小程序跨端开发生态。目前,Mpx 已经覆盖支持了集团内部全量小程序业务开发,成为了集团小程序开发的统一技术标准。
随着小程序业务的发展演进,性能和包体积的重要性愈发凸显,Mpx从设计之初就非常重视性能和包体积的优化,本次的 Mpx2.9 版本更新带来的三大核心特性——原子类、SSR 和包体积优化也都与性能和包体积息息相关,下面我们逐个展开介绍。
原子类支持
原子类(utility-first CSS)是近几年流行起来的一种全新的样式开发方式,在前端社区内取得了良好的口碑,越来越多的主流网站也基于原子类进行开发,比较知名的有 Github,OpenAI,Netflix 和 NASA官网 等。使用原子类离不开原子类框架的支持,常用的原子类框架有 Tailwindcss、Windicss 和 Unocss 等。
在 Mpx2.9 版本中,我们在框架中内置了原子类支持,让小程序开发也能使用原子类。对项目进行简单配置开启原子类支持后,用户就可以在 Mpx 页面/组件模板中直接使用一些预定义的基础样式类,诸如 flex,pt-4,text-center 和 rotate-90 等,对样式进行组合定义,并且在 Mpx 支持的所有小程序平台和 web 平台中正常运行,下面是一个简单示例:
<view class="container">
<view class="flex">
<view class="py-8 px-8 inline-flex mx-auto bg-white rounded-xl shadow-md">
<view class="text-center">
<view class="text-base text-black font-semibold mb-2">
Erin Lindford
</view>
<view class="text-gray-500 font-medium pb-3">
Product Engineer
</view>
<view class="mt-2 px-4 py-1 text-sm text-purple-600 font-semibold rounded-full border border-solid border-purple-200">
Message
</view>
</view>
</view>
</view>
</view>
通过这种方式,我们在不编写任何自定义样式代码的情况下得到了一张简单的个人卡片,实际渲染效果如下:
相较于传统的自定义类编写样式的方式,使用原子类能给你带来以下这些好处:
- 不用再烦恼于为自定义类取类名,传统样式开发中,我们仅仅是为某个元素定义样式就需要绞尽脑汁发明一些抽象的类名,还得提防类名冲突,使用原子类可以完全将你从这种琐碎无趣的工作中解放;
- 停止 css 体积的无序增长,传统样式开发中,css 体积会随着你的迭代不断增长,而在原子类中,一切样式都可以复用,你几乎不需要编写新的 css;
- 让调整样式变得更加安全,传统 css 是全局的,当你修改某个样式时无法保障其不会破坏其他地方的样式,而你在模板中使用的原子类是本地的,你完全不用担心修改它会影响其他地方。
而相较于使用内联样式,原子类也有一些重要的优势:
- 约束下的设计,使用内联样式时,里面的每一个数值都是魔法数字(magic number) ,而通过原子工具类,你可以选择一些符合预定义设计规范的样式,便于构筑具有视觉一致性的UI;
- 响应式设计,你无法在内联样式中使用媒体查询,然而通过原子类框架中提供的响应式工具,你可以轻而易举地构建出响应式界面;
- Hover、focus 和其他状态,使用内联样式无法定义特定状态下的样式,如 hover 和 focus,通过原子类框架的状态变量能力,我们可以轻松为这些状态定义样式。
看到这里相信你已经迫不及待地想要在 Mpx 中体验原子类开发了吧,使用最新版本的 @mpxjs/cli
脚手架创建项目时,在 prompt 中选择使用原子类,就可以在新创建的项目模版中直接使用原子类,可使用的工具类可以参考 交互示例,在已有项目中开启原子类支持可以参考配置指南
小程序原子类使用注意事项
小程序和 web 环境对于 css 的支持存在底层差异,小程序内也存在大量自身独有的技术特性,Mpx 在支持原子类时针对这些环境特异性进行了抹平和适配,在框架的支持下,我们实现了大部分(超过90%)原子类功能和工具类在小程序环境下正常使用,并额外支持了原子类产物的分包输出和样式隔离下的原子类使用,详情如下:
特殊字符转义
现代原子类框架支持 value auto-infer
(值自动推导),可以在模版中根据相关规则书写灵活的自定义值原子类,如 p-5px bg-[hsl(211.7,81.9%,69.6%)]
等,针对原子类中出现的 [
(
,
等特殊字符,在 web 中会通过转义字符 进行转义,由于小程序环境下不支持 css 选择器中出现
转义字符,我们内置支持了一套不带 `` 的转义规则对这些特殊字符进行转义,同时替换模版和 css 文件中的类名,内建的默认转义规则如下:
const escapeMap = {
'(': '_pl_',
')': '_pr_',
'[': '_bl_',
']': '_br_',
'{': '_cl_',
'}': '_cr_',
'#': '_h_',
'!': '_i_',
'/': '_s_',
'.': '_d_',
':': '_c_',
',': '_2c_',
'%': '_p_',
''': '_q_',
'"': '_dq_',
'+': '_a_',
$: '_si_',
// unknown用于兜底不在上述范围中未知的转义字符
unknown: '_u_'
}
与此同时,用户也可以通过传递 @mpxjs/unocss-plugin
的 escapeMap 配置项来覆盖内建的转义规则。
原子类分包输出
在 web 中,原子类会被全部打包输出单个样式文件,一般会放置在顶层样式表中以供全局访问,但在小程序中这种全量的输出策略并不是最优的,主要原因在于小程序中可供全局访问的主包体积存在 2M 大小限制,主包体积十分紧缺珍贵,Mpx 在构建输出时遵循着分包优先的原则,尽可能充分利用分包体积从而减少对主包体积的占用,再进行原子类产物输出时,我们也遵循了相同的原则。
在Mpx中,我们在收集原子类时同时记录了每个原子类的引用分包,在收集结束后根据每个原子类的分包引用数量决定该原子类应该输出到主包还是分包当中,我们在 @mpxjs/unocss-plugin
中提供了 minCount 配置项来决定分包的输出规则,该配置项的默认值为2,即当一个原子类被2个或以上分包引用时,会被作为公共原子类抽取到主包中,否则输出到所属分包中,这也是全局最优的策略。当我们想要让原子类输出产物更少地占用主包体积时,我们也可以将minCount
值调大,让原子类抽取到主包的条件更加苛刻,不过这样也会伴随着原子类分包冗余的增加。
unocss.config.js
配置中定义的 safelist
原子类默认会输出到主包,为了组件局部使用的 safelist
有输出到分包的机会,我们在模版中提供了注释配置(comments config),灵感来源于 webpack
中的魔法注释(magic comments),用户可以在组件模版中通过注释配置
声明当前组件所需的 safelist
,对应的原子类也会根据上述的规则输出到主包或分包中,使用示例如下:
<template>
<!-- mpx_config_safelist: 'text-red-500 bg-blue-500' -->
<!-- 动态样式中可以使用text-red-500和bg-blue-500原子类 -->
<view wx:class="{{classObj}}">test</view>
<!-- ... -->
</template>
样式隔离与组件分包异步
在小程序中,自定义组件的样式默认是隔离的,web 中通过全局样式访问原子类的方式不再生效,不过由于小程序提供了样式隔离配置,我们可以将该组件样式隔离配置调整为 apply-shared
来获取页面或 app 中定义的原子类,但是当我们在使用传统类名和原子类混合开发或者迁移原子类的过程中,我们往往希望保留原本自定义组件的样式隔离。
针对这种情况,我们在 @mpxjs/unocss-plugin
中提供了 styleIsolation 配置项,可选设置为 isolated
|apply-shared
,当设置为 isolated
时每个组件都会通过 @import
独立引用主包或者分包的原子类样式文件,因此不会受到样式隔离的影响;当设置为 apply-shared
时,只有 app 和分包页面会引用对应的原子类样式文件,自定义组件需要通过配置样式隔离为 apply-shared
使原子类生效。
在组件分包异步的情况下对应组件即使将样式隔离配置为 apply-shared
的情况下,@mpxjs/unocss-plugin
也需要将 styleIsolation
设置为 isolated
才能正常工作,原因在于组件分包异步的情况下,组件被其他分包的页面所引用渲染,由于上述原子类样式分包输出的规则,其他分包的页面中可能并不包含当前组件所需的原子类,只有在 isolated
模式下由组件自身引用所需的原子类样式才能保证正常工作,类似于 safelist
,我们也提供了注释配置的方式对组件的 styleIsolation
模式进行局部配置,示例如下:
<template>
<!-- mpx_config_styleIsolation: 'isolated' -->
<!-- 当前组件会直接引用对应主包或分包的原子类样式 -->
<view class="@dark:(text-white bg-dark-500)">
<!-- ... -->
</template>
原子类使用参考文档
Mpx 中使用原子类的详细参考文档如下:
输出 web 支持 SSR
近些年来,SSR/SSG 由于其良好的首屏展现速度和SEO友好性逐渐成为主流的技术规范,各类SSR框架层出不穷,未来进一步提升性能表现,在 SSR 的基础上还演进出 islands architecture 和 0 hydration 等更加精细复杂的理念和架构。
近两年随着团队对于前端性能的重视,SSR/SSG 技术也在团队业务中逐步推广落地,并在首屏性能方面取得了显著的收效。但由于过去 Mpx 对 SSR 的支持不完善,使用 Mpx 开发的跨端页面一直无法享受到 SSR 带来的性能提升,在 Mpx2.9 版本中,我们对 web 输出流程进行了大量适配改造,解决了 SSR 中常见的内存泄漏、跨请求状态污染和数据预请求等问题,完整实现了 SSR 技术方案。
配置使用 SSR
在 Vue SSR 项目中,我们一般需要提供 server entry 和 client entry 两个文件作为 webpack 的构建入口,然后通过 webpack 构建出 server bundle 和 client bundle。在用户访问页面时,在服务端使用 server bundle 渲染出 HTML 字符串,作为静态页面发送到客户端,然后在客户端使用 client bundle 通过水合(hydration)对静态页面进行激活,实现可交互效果,下图展示了 Vue SSR 的大致流程。
Mpx SSR 实现的大致流程思路与 Vue 一致,不过为了保持与小程序代码的兼容性,在配置使用上有一些改动差异,下面我们详细展开介绍:
构建server/client bundle
SSR项目中,我们需要分别构建出 server bundle 和 client bundle,对于不同环境的产物构建,我们需要进行不同的配置。
在 Vue 中,我们需要提供 entry-server.js
和 entry-client.js
两个文件分别作为 server 和 client 的构建入口,与 Vue 不同,在 Mpx 中我们通过编译处理与运行时增强生命周期实现了使用 app.mpx
作为统一构建入口,无需区分 server 和 client。
服务端构建配置
服务端配置中除了将 entry 制定为 app.mpx
及其它基础配置外,最重要的是安装 vue-server-renderer
包中提供的 server-plugin
插件,该插件能够构建产出 vue-ssr-server-bundle.json
文件供 renderer
后续消费。
// webpack.server.config.js
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(baseConfig, {
// 将 entry 指向项目的 app.mpx 文件
entry: './app.mpx',
// ...
plugins: [
// 产出 `vue-ssr-server-bundle.json`
new VueSSRServerPlugin()
]
})
更加详细的配置说明可参考 Vue SSR的服务端配置
客户端构建配置
类似服务端构建配置,在客户端构建中我们需要使用 vue-server-renderer
包中 client-plugin
插件来帮助我们生成客户端环境的资源清单 vue-ssr-client-manifest.json
,并供 renderer
后续消费。
// webpack.client.config.js
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
module.exports = merge(baseConfig, {
// 将 entry 指向项目的 app.mpx 文件
entry: './app.mpx',
// ...
plugins: [
// 产出 `vue-ssr-client-manifest.json`。
new VueSSRClientPlugin()
]
})
更加详细的配置说明可参考 Vue SSR的客户端配置
准备页面模版
SSR 渲染中,我们创建 renderer
需要一个页面模板,简单的示例如下:
<!DOCTYPE html>
<html lang="en">
<head><title>Hello</title></head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
与 CSR 渲染模版不同,SSR 渲染模版中需要提供一个特殊的 <!--vue-ssr-outlet-->
注释,标记 SSR 渲染产物的插入位置,如使用 @mpxjs/cli
脚手架创建 SSR 项目,该模版已经内置于脚手架中。
集成启动 SSR 服务
当我们准备好页面模版和双端构建产物后,我们就可以创建 renderer
并与 Node 服务进行集成,启动 SSR 服务,下面以 express
为例:
//server.js
const app = require('express')()
const { createBundleRenderer } = require('vue-server-renderer')
// 通过 vue-server-renderer/server-plugin 生成的文件
const serverBundle = require('../dist/server/vue-ssr-server-bundle.json')
// 通过 vue-server-renderer/client-plugin 生成的文件
const clientManifest = require('../dist/client/vue-ssr-client-manifest.json')
// 页面模版文件
const template = require('fs').readFileSync('../src/index.template.html', 'utf-8')
// 创建 renderer 渲染器
const renderer = createBundleRenderer(serverBundle, {
runInNewContext: false,
template,
clientManifest,
});
// 注册启动 SSR 服务
app.get('*', (req, res) => {
const context = { url: req.url }
renderer.renderToString(context, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error')
return
}
res.end(html);
})
})
app.listen(8080)
SSR 生命周期
在 Mpx 2.9 的版本中,我们提供了三个全新用于 SSR 的生命周期,分别是onAppInit
、serverPrefetch
和 onSSRAppCreated
,以统一服务端与客户端的构建入口,下面展开介绍:
onAppInit
在 SSR 中用户每发出一个请求,我们都会为其生成一个新的应用实例,onAppInit
生命周期会在应用创建 new Vue(...)
前被调用,其执行的返回结果会被合并到创建应用的 options
中
很常见的使用场景在于返回新的全局状态管理实例,Mpx 中提供了 @mpxjs/pinia
作为全局状态管理工具,我们可以在 onAppInit
中返回全新的 pinia
示例避免产生跨请求状态污染,示例如下:
// app.mpx
import mpx, { createApp } from '@mpxjs/core'
import { createPinia } from '@mpxjs/pinia'
createApp({
// ...
onAppInit () {
const pinia = createPinia()
return {
pinia
}
}
})
serverPrefetch
当我们需要在 SSR 使用数据预拉取时,可以使用这个生命周期进行,使用方法与 Vue 一致, 示例如下:
选项式 API:
import { createPage } from '@mpxjs/core'
import useStore from '../store/index'
createPage({
//...
serverPrefetch () {
const query = this.$route.query
const store = useStore(this.$pinia)
// return the promise from the action, fetch data and update state
return store.fetchData(query)
}
})
组合式 API:
import { onServerPrefetch, getCurrentInstance, createPage } from '@mpxjs/core'
import useStore from '../store/index'
createPage({
setup () {
const store = userStore()
onServerPrefetch(() => {
const query = getCurrentInstance().proxy.$route.query
// return the promise from the action, fetch data and update state
return store.fetchData(query)
})
}
})
关于数据预拉取更详细的说明可以查看这里。
onSSRAppCreated
在 Vue SSR 项目中,我们会在 entry-server.js
中导出一个工厂函数,在该函数中实现创建应用实例、路由匹配和状态同步等逻辑,并返回应用实例 app
。
在 Mpx SSR 中,我们将这部分逻辑整合在 onSSRAppCreated
中执行,该生命周期执行时用户可以从参数中获取应用实例 app
、路由实例 router
、数据管理实例 pinia
和 SSR 上下文 context
,在完成必要的操作后,该生命周期需要返回一个 resolve(app)
的 promise。
通常我们会在 onSSRAppCreated
中进行路由路径设置和数据预拉取后的状态同步工作,示例如下:
// app.mpx
createApp({
// ...,
onSSRAppCreated ({ pinia, router, app, context }) {
return new Promise((resolve, reject) => {
// 设置服务端路由路径
router.push(context.url)
router.onReady(() => {
// 应用完成渲染时执行
context.rendered = () => {
// 将服务端渲染后得到的 pinia.state 同步到 context.state 中,
// context.state 会被自动序列化并通过 `window.__INITIAL_STATE__`
// 注入到 HTML 中,并在客户端运行时再读取同步
context.state = pinia.state.value
}
// 返回应用程序实例
resolve(app)
}, reject)
})
}
})
如用户没有配置 onSSRAppCreated,框架内部会执行兜底逻辑,以保障 SSR 的正常运行。
其他注意事项
-
Mpx SSR 渲染支持 i18n 的功能,但为了防止内存泄漏,当前 i18n 实例不会随着每次请求而重新创建,这是由于 Vue2.x 版本插件机制的设计缺陷所造成的,因此在使用 i18n 进行 SSR 时可能会产生跨请求状态污染的问题,这个问题会在未来 Mpx 输出 web 切换为 Vue3 后完全解决。
-
在服务端渲染阶段,对于 global 全局对象访问修改,如__mpx, __mpxRouter, __mpxPinia 都可能导致全局状态污染,所以在服务端渲染阶段请尽量避免进行相关操作;对于存在全局访问修改的方法,如 getApp(), getCurrentPages() 等在服务端渲染中被调用时,会产生相关报错提示。
-
由于服务器无法收到 URL 中的 hash 信息,使用 SSR 时需要通过修改
mpx.config.webRouteConfig
将路由模式设置成history
模式。
包体积优化
近几年来随着小程序的商业成功和用户增长,各大互联网公司都在加大对小程序业务的投入,小程序的体量和复杂性都在快速增长,在这样的背景下,小程序的包体积大小成为了制约小程序业务迭代发展的一大限制,同时过大的包体积也会显著影响小程序的性能表现。
Mpx 在设计之初就考虑了包体积问题,完整继承了 webpack 已有的代码优化能力,如公共模块抽离、tree shaking、混淆压缩等。除此之外,我们还设计开发了业内最完善的分包构建流程,完整支持基于配置声明和依赖分析的普通分包、独立分包及分包异步化的构建输出,极大地缓解了复杂小程序中的主包和总包体积过大的问题。在 Mpx2.9 版本中,我们针对复杂小程序页面组件和 JS 模块众多的特点,对构建产物进行了针对性地优化,进一步降低了构建产物体积,主要优化项如下:
模版代码优化
Mpx 通过 webpack 进行打包构建,并对小程序原生 commonjs 支持进行适配改造,以实现 webpack 构建产物在小程序环境下运行,为了将构建产物中的模块和 chunk 链接到一起,webpack 和 mpx 会在构建产物生成一些模版代码进行相关工作,下面是一个页面 js 中包含的模版代码示例:
var self = {}
self.webpackChunkmpx_test_2_8 = require('../bundle.js')
(self.webpackChunkmpx_test_2_8 = self.webpackChunkmpx_test_2_8 || []).push([[405], {
1307: function (t, e, o) {
// ...
},
4236: function (t) {
// ...
},
// ...
}, function (t) {
var e
e = 1307, t(t.s = e)
}])
可以看出模版代码主要由 chunk 链接代码,chunk id,模块 id 和模块包装函数组成,对于中小项目来说,这些模版代码一般不会占用太多体积,但是在复杂大体量小程序中众多模块的叠加效应下,这部分体积就变得不可忽视。
我们对模版代码的生成逻辑和生成产物进行分析,发现 webpack 生产模式的默认配置中,很多配置项并不是体积最优的选项,一个典型的例子在于模块/chunk id:为了保障生成产物的内容稳定,来尽可能提升浏览器的缓存利用率,webpack 默认的模块/chunk id 采用 deterministic
模式进行生成,该模式下模块 id 为模块源路径的定长数字 hash,比项目模块总数长 1 位。由于在小程序中代码包按照版本的维度进行全量管理,保证文件局部的内容稳定在小程序环境下无正向意义,这就有了优化空间,我们可以简单将模块/chunk id 的生成逻辑改为数字自增,在主小程序中就能节省出上百 KB 总包体积。
类似的可优化点还存在于 chunk 链接代码和模块包装函数当中,我们在 @mpxjs/webpack-plugin@2.9
版本中提供了一个新的配置项 optimizeSize,其中整合了一系列模版代码体积优化配置,开启后就能自动优化构建产物中的模版代码体积,在实际业务中,我们开启 optimizeSize
后可以减少总包体积约 1.6%,效果非常显著,下面是上述示例在开启 optimizeSize
后的产物对比:
var g = {}
g.c = require('../bundle.js'), (g.c = g.c || []).push([[8], {
448: function (t, e, o) {
// ...
},
463: function (t) {
// ...
}
// ...
}, function (t) {
var e
e = 448, t(t.s = e)
}])
空模块移除
Mpx 在处理 .mpx
单文件时会将其分拆为 template/js/style/json
四个新模块来进行分别处理,其中 template/style/json
部分的内容会在处理后通过内置的 extractor
抽取输出为静态文件,抽取之后原本分拆出来的模块就会以空模块的形式残留在构建产物中(template 模块在抽取后还需要保留 render 函数和 refs 等信息所以不会成为空模块),如下所示:
/***/ 533:
/***/ (function(__unused_webpack_module, __unused_webpack_exports, __webpack_require__) {
// ...
/* template */
__webpack_require__(535)
/* styles */
__webpack_require__(536)
/* json */
__webpack_require__(537)
/* script */
__webpack_require__(534)
/***/ }),
/***/ 536:
/***/ (function() {
/***/ }),
/***/ 537:
/***/ (function() {
/***/ })
从上面示例中可以看出 536/537
模块的定义和引用都是完全不必要的,但由于 webpack 本身对于空模块没有进行识别和优化的手段,在过去的版本中这些空模块代码会占用我们一部分总包体积。在 Mpx2.9 版本中,我们定义了全新的依赖类型 CommonjsExtractDependency
对于这类可能被抽取成为空模块的 request
进行识别处理,当模块内容在完成抽取后为空时,自动将其从模块图中移除,并在产物生成时不再生成其引用代码。该项优化措施内置开启,升级后自动生效,在实际业务中可减少总包体积约 0.7%。
Render函数优化
Render 函数是 Mpx 运行时 setData
优化的一项核心设计,我们在模版编译时将用户模版转换为简易的 render 函数,该函数执行时能够完全模拟视图渲染的数据访问过程,正确地收集当前视图的数据依赖,避免将视图不需要的数据通过 setData
发送到视图中。
虽然我们生成的简化版 render 函数仅保留了数据访问逻辑,在代码体积上并不算大,但是仍然存在着一定的优化空间,我们来看下面这个例子:
function render(){
this.a
if(this.c){
this.a
this.b
this.c.a
this.c.b
this.d
}
this.b
}
可以看出 if block
下存在着不必要的数据访问,this.a
和 this.b
在父级 block
中已经存在访问,this.c
在 if condition
中也存在访问(render函数执行时会对模版依赖数据进行深度diff,父路径访问后子路径就无需再进行访问),上述 render 函数可以优化为:
function render(){
this.a
if(this.c){
this.d
}
this.b
}
在 Mpx2.9 版本中,我们通过两轮 ast
遍历(2 pass ast travese
)的方式实现了这个优化,在第一轮遍历中,我们收集了数据访问信息并按照 block
结构存储在 blockVisitTree
中,第二轮遍历时依据 blockVisitTree
中的信息对不必要的数据访问进行剪枝优化。
除此之外,我们也大幅优化了 render 函数中的数据收集代码,有效地降低了 render 函数的体积占用。
该优化目前没有默认开启,可以通过 @mpxjs/webpack-plugin
中的 optimizeRenderRules 配置项配置生效范围进行开启,全量开启后在实际业务中可节省总包体积约 5%。
未来规划
在未来,我们对 Mpx 构建产物还有进一步的优化方案可以探索实施,主要分为两个方向:
- 编译注入代码优化,在功能不变的情况下简化编译注入的代码,对于 render 函数也有一种运行时有损的方案(略微增大运行时开销)可以尝试
- 模版和JSON压缩,对模版和JSON中的组件名及组件路径进行短哈希压缩,缺点是丢失dist产物的可读性,可能需要对其提供 sourceMap 支持
除此之外,我们近期的框架升级计划还包括:
- 局部运行时渲染增强
- 数据响应升级为 proxy 实现,输出 web 升级为 vue3
- 支付宝 2.0 基础库适配优化
- 微信 skyline 适配优化
- 输出 Hummer 能力完善
在构建提速方面,我们也会在以下方向进行探索:
- 输出 web 支持 vite 构建
- 基于模块联邦的分布式构建
- 基于 Rust 的高性能构建探索
最后,特别感谢@pagnkelly、@yandadaFreedom和 @zhuzhh对于 2.9 版本功能开发的突出贡献,也欢迎大家使用 Mpx 进行小程序跨端开发并加入社区共建。
Github:github.com/didi/mpx
官网/文档:mpxjs.cn/