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>
通过这种方式,我们在不编写任何自定义样式代码的情况下得到了一张简单的个人卡片,实际渲染效果如下:
相较于传统自定义类编写样式的方式,使用原子类有以下优势:
而相较于使用内联样式,原子类也有一些重要的优势:
看到这里,是否想马上在 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环境中,原子类会被全部打包为单个样式文件,一般放在顶层样式表中以便全局访问。然而,在小程序环境中,这种全量输出策略并非最优,主要是因为小程序全局访问的主包体积有2MB的限制,主包体积非常宝贵。因此, 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。
1、服务端构建配置
服务端配置中除了将 entry 制定为 app.mpx 及其它基础配置外,最重要的是安装 vue-server-renderer 包中提供的 server-plugin 插件,该插件能够构建产出 vue-ssr-server-bundle.json 文件供renderer后续消费。
// webpack.server.config.jsconst 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() ]})
2、客户端构建配置
类似服务端构建配置,在客户端构建中我们需要使用 vue-server-renderer 包中 client-plugin 插件来帮助我们生成客户端环境的资源清单vue-ssr-client-manifest.json,并供 renderer后续消费。
// webpack.client.config.jsconst 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() ]})
准备页面模版
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.jsconst 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.mpximport mpx, { createApp } from '@mpxjs/core'import { createPinia } from '@mpxjs/pinia'createApp({ // ... onAppInit () { const pinia = createPinia() return { pinia } }})
serverPrefetch
当我们需要在 SSR 使用数据预拉取时,可以使用 serverPrefetch 生命周期进行,使用方法与 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.mpxcreateApp({ // ..., 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 在设计之初就考虑了包体积问题,完整继承了 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 本身无法识别和优化空模块,在过去的版本中,这些空模块代码会占用我们的总包体积。在 Mpx 2.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.a 和this.c.b 在if condition 中已经访问过this.c,因此也不再需要访问(render 函数执行时会对模版依赖数据进行深度 diff,父路径访问后子路径就无需再进行访问),上述 render 函数可以优化为:
function render(){ this.a if(this.c){ this.d } this.b}
在 Mpx 2.9 版本中,我们通过两轮 ast 遍历(2 pass ast travese)实现了这一优化。在第一轮遍历中,我们收集了数据访问信息,并将其按block结构存储在blockVisitTree中。在第二轮遍历时,我们根据blockVisitTree中的信息剪除了不必要的数据访问。
此外,我们还大幅优化了 render 函数中的数据收集代码,有效降低了 render 函数的体积。
目前,此优化尚未默认开启。可以通过 @mpxjs/webpack-plugin中的 optimizerenderrules 配置项配置生效范围并开启。在全量开启后,实际业务中的总包体积可节省约5%。
未来规划
在未来,我们对 Mpx 构建产物还有进一步的优化方案可以探索实施,主要分为两个方向:
除此之外,我们近期的框架升级计划还包括:
在构建提速方面,我们也会在以下方向进行探索:
最后,特别感谢@pagnkelly、@yandadaFreedom和@zhuzhh 对于 2.9 版本功能开发的突出贡献,也欢迎大家使用 Mpx 进行小程序跨端开发并加入社区共建。
作者:董宏平
来源:微信公众号:滴滴技术
出处
:https://mp.weixin.qq.com/s/WY_7RKwTJOglQKBfDhNGbg