作者:潘宇
一、背景
又到新的一年。随着古茗点单小程序在过去一年的持续迭代,主包体积也在一路"膨胀"。最终,我们再次直面一个熟悉的问题------主包容量,又双叒叕不够用了。
以下是我们已经使用过的优化手段:
- 分包异步加载:通过微信提供的
require.async方法可以实现跨分包 JS 代码引用 - 页面分包:主包仅保留默认「启动页/TabBar页」,其他页面模块均迁移至分包
- 图片资源优化:上传本地资源,并通过
Babel插件实现本地资源替换为网络地址。 - 公共模块分包:
Taro项目配置mini.optimizeMainPackage,将主包未使用的公共模块打包至对应分包。
那么还有什么我们能做的呢?比如:
减少兼容代码
随着移动设备硬件性能的提升以及微信版本的不断升级,用户设备对ES6及以上语法的支持度已显著提高。在这一背景下,大量为兼容ES5而引入的降级与垫片代码逐渐失去必要性,反而成为包体体积的负担,具备明确的优化空间。
分包异步加载样式文件
在上一次主包体积优化的分享中,我们提到通过 动态import配合splitChunks,将 React 组件的 JavaScript 逻辑拆分并编译到分包中,从而有效降低了主包的 JS 体积。然而,由于微信小程序本身缺乏样式层面的异步加载机制,这些异步组件所依赖的样式文件最终仍然需要在主包的app.wxss中统一引入。
对打包产物进行分析后可以发现,这部分异步组件的样式体积占比不小,并且会随着异步组件数量的增加而持续膨胀。由此可见,样式加载策略成为当前主包体积优化中的一个关键突破点,也是后续重点的优化方向之一。
二、目标
- 删除为兼容
ES5而引入的降级与垫片代码 - 实现样式文件的分包异步加载
三、优化方案
ES5兼容代码剔除
实现思路
调整Browserslist
Browserslist 用来描述代码最终运行环境的能力范围,构建工具会根据它决定哪些语法需要被转成 ES5、哪些兼容代码可以被剔除。因此,编译产物中是否包含 ES5 兼容代码,本质上取决于 Browserslist 的配置。
通过MiniProgram ECMAScript compatibility table可以了解到,微信小程序的语法兼容性主要取决于小程序基础库版本;再结合微信官方提供的基础库版本分布信息,确定当前需要支持的最低基础库版本。
微信官方同时提供了 miniprogram-compat,用于将基础库能力转换为 Browserslist 配置。 在此基础上,只需要确认最低支持的基础库版本,并通过 miniprogram-compat 生成对应的 Browserslist 配置,在构建阶段由发布脚本将该配置注入 process.env.BROWSERSLIST,实现 ES5 兼容代码的精准剔除。
踩的一些坑
灰度过程中,部分 iOS 14 设备出现白屏。
最终确认原因是 Browserslist 配置过于激进,低版本基础库在老 iOS 环境下对部分 ES 能力支持不稳定,导致必要的转译和兼容代码被提前剔除。
最后将微信端最低兼容基础库版本调整为 miniprogram-compat 的配置调整为 2.29.0 后,问题消失。
代码实现
javascript
import { getBrowsersList } from 'miniprogram-compat'
import type { IPluginContext } from '@tarojs/service'
interface Opt {
minBaseLibraryVersion: string
}
export default (ctx: IPluginContext, pluginOpts:Opt) => {
ctx.onBuildStart(() => {
if (process.env.TARO_ENV !== 'weapp') return
const browsersList = getBrowsersList(pluginOpts.minBaseLibraryVersion)
if (!browsersList) return
process.env.BROWSERSLIST = browsersList?.join(',')
console.log('✅ Successfully set BROWSERSLIST from minBaseLibraryVersion')
})
}
样式文件分包异步加载(失败了😭,就在这里分享一下踩坑的过程吧)
实现思路
分包页面样式是否能影响主包?
我们试着在分包里加一个空页面,希望分包加载时能把这个页面的样式一起带进来,从而影响主包页面的样式。

但实际测试下来,这条路走不通。(ps:也可能是实现有问题不一定对)
查阅微信小程序组件系统文档可以确认,组件之间本身就存在样式隔离。虽然文档里没有明确说明页面级别的隔离规则,但结合这次尝试的结论猜测页面之间同样是隔离的。因此单纯指望通过分包页面来加载样式,并不能实现样式文件分包异步加载。
分包自定义组件样式是否能影响主包?
查阅微信小程序自定义组件样式隔离文档后发现,组件的样式隔离其实是可以配置的。只要把自定义组件的styleIsolation设置为shared,组件样式就可以直接作用到页面上。
再结合跨分包自定义组件引用这一能力,就可以把样式放进分包组件里,随着组件的加载一起生效,从而实现样式的分包异步加载。

经过实际验证,这套方案是可行的。既然机制成立,下一步就是把思路变成工程能力。将其实现为一个 Taro 插件,把样式拆分、分包组件生成以及跨分包引用等逻辑自动化,尽量减少对业务侧的改造成本。
无法彻底解决的问题
我们在实际用下来之后,还是存在一个无法解决的问题:页面会短暂闪一下"没样式的 DOM"。
本质原因其实也很简单:样式是动态加载的,但它到得比 JS 慢。JS 已经跑完了,组件也渲染出来了,但样式还没到位,于是用户就能看到一瞬间的"裸页面"。
结合下来,主要有这么几种情况:
冷启动的时候顺序不对
在冷启动场景下,用来注入样式的自定义组件 ,挂到页面上的时机会比 require.async 加载的 JS 还慢。
结果就是:
- JS 先执行
- React 组件先渲染
- 样式对应的自定义组件还没准备好
于是首屏就会闪一下没样式的内容。
JS 有缓存,样式没有
异步 JS 这块,用的是 React.lazy,加载过一次之后是有缓存的,后面再进页面基本就是同步的。
但样式这边不一样:自定义组件注入样式是没法缓存的,每次进页面都得重新走一遍流程。
所以就会出现这种情况:
- JS 一下子就执行完了
- 样式却还是慢悠悠地在加载
- 最终样式总是追不上渲染节奏
无法准确的知道样式文件生效的时机
在组件实例进入页面节点树时触发的 attached,以及渲染线程初始化完成时触发的 ready,都无法作为"样式已生效"的可靠判定时机。
在小程序渲染线程繁忙的情况下,样式实际生效可能会明显延迟,导致页面短暂出现"无样式 DOM"的闪烁现象
这也是该方案无法从根本上解决样式分包问题的核心原因。
当前可行的缓解方案
为了解决「冷启动的时候顺序不对」与「JS 有缓存,样式没有」,我们在样式真正注入完成之前,不让异步组件内容出来。
具体做法是:在自定义组件把样式注入完之前,先展示 Suspense 的 fallback,等样式 ready 了再一起把内容放出来,这样用户就看不到那一下没样式的闪屏了。
所以后面我们自己实现了一套 react-lazy-enhanced :在每次页面渲染异步组件的时候 ,都会额外等一下自定义组件的样式注入完成。在这之前,页面就一直停在 fallback 状态,不会提前渲染真实 DOM。
简单说就是一句话:JS 可以先到,但组件一定要等样式准备好再出来。
最终放弃的方案
针对「无法准确判断样式文件何时真正生效」的问题,我们也考虑过一种方案: 在页面中插入一个用户不可见的检测节点,通过轮询该节点的计算样式(getComputedStyle),判断目标样式是否已经生效,从而人为构造一个"样式 ready"钩子。 也就是说,不再依赖组件生命周期,而是直接通过渲染结果反向推断样式是否生效。
但这种方式存在明显问题:检测节点本身可能会受到其他用户自定义样式、全局样式或样式覆盖规则的影响,导致计算结果不稳定。也就是说,我们无法保证检测到的样式变化一定来自目标样式文件本身。 一旦样式优先级、合并策略或运行环境发生变化,判断逻辑就可能失效。
因此,这种方案本质上仍然是基于副作用的黑盒推断,稳定性和可维护性都难以保证,最终没有采用。
代码实现(基于上一次主包体积优化的分享中提到的插件进行改造)
尽管方案没有达到预期目标,但其中的探索过程和实现思路仍然值得讨论,欢迎感兴趣的同学查看我们的代码实现并交流想法。
插件入口
- 改造
splitChunk逻辑,让样式文件和js文件统一输出到分包目录 - 调用
transformWebpackRuntime和TransformBeforeCompressionPluginName修改runtime.js - 拷贝编译后
SingletonPromise到dist目录 - 调用
transformAppConfig修改app.json输出 - 调用
transformPagesWXml为全局页面插入自定义组件
javascript
import fs from 'fs';
import path from 'path';
import type { IPluginContext } from '@tarojs/service';
import { RawSource } from 'webpack-sources';
import {
InjectStyleComponentPlugin,
PLUGIN_NAME as InjectStyleComponentPluginName,
InjectStyleComponentName,
} from './inject-style-component';
import { transformAppConfig } from './transform-app-config';
import {
TransformOpt,
TransformBeforeCompressionPlugin,
PLUGIN_NAME as TransformBeforeCompressionPluginName,
} from './transform-before-compression-plugin';
import { transformPagesWXml } from './transform-pages-wxml';
import { transformWebpackRuntime } from './transform-webpack-runtime';
import { AsyncPackOpts } from './types';
export { AsyncPackOpts } from './types';
const dynamicPackOptsDefaultOpt: AsyncPackOpts = {
dynamicPackageName: 'dynamic-common',
};
export default (ctx: IPluginContext, pluginOpts: AsyncPackOpts) => {
const finalOpts = { ...dynamicPackOptsDefaultOpt, ...pluginOpts };
if (process.env.TARO_ENV !== 'weapp') return;
ctx.modifyWebpackChain(({ chain }) => {
// 动态获取现有的 splitChunks 配置
const existingSplitChunks = chain.optimization.get('splitChunks') || {};
const { common, vendors } = existingSplitChunks.cacheGroups;
const newCommonChunks = common ? { ...common, chunks: 'initial' } : common;
const newVendorsChunks = vendors ? { ...vendors, chunks: 'initial' } : vendors;
chain.optimization.merge({
splitChunks: {
...existingSplitChunks,
cacheGroups: {
...existingSplitChunks.cacheGroups,
common: newCommonChunks,
vendors: newVendorsChunks,
},
},
});
chain.merge({
output: {
chunkFilename: `${finalOpts.dynamicPackageName}/[chunkhash].js`, // 异步模块输出路径
path: ctx.paths.outputPath,
clean: true,
},
});
chain.plugin('miniCssExtractPlugin').tap((args) => {
const [options] = args;
const chunkFilename = `${finalOpts.dynamicPackageName}/[chunkhash].wxss`;
const finalOption = { ...options, chunkFilename };
return [finalOption];
});
chain.module
.rule('script')
.use('babelLoader')
.tap((opts) => {
const pluginConfig = path.resolve(__dirname, './transform-react-lazy');
return { ...opts, plugins: [pluginConfig, ...(opts.plugins || [])] };
});
chain.plugin(TransformBeforeCompressionPluginName).use(TransformBeforeCompressionPlugin, [
{
test: /^(runtime\.js)$/,
transform: (opt: TransformOpt) => {
const { source, assets } = opt;
const transformOpts = { ...finalOpts, assets };
return transformWebpackRuntime(source as string, transformOpts);
},
},
]);
chain.plugin(InjectStyleComponentPluginName).use(InjectStyleComponentPlugin, [finalOpts]);
});
ctx.modifyBuildAssets(({ assets }) => {
const hasDynamicModule = Object.keys(assets).some((key) =>
key.startsWith(`${finalOpts.dynamicPackageName}/`)
);
if (!hasDynamicModule) return;
const asyncComponentPath = `./${InjectStyleComponentPlugin.generateComponentPath(finalOpts)}`;
const asyncComponents = { [InjectStyleComponentName]: asyncComponentPath };
const singletonPromisePath = path.resolve(__dirname, './singleton-promise.js');
const fileContent = fs.readFileSync(singletonPromisePath, { encoding: 'utf-8' });
assets['singleton-promise.js'] = new RawSource(fileContent);
transformAppConfig({ ...finalOpts, assets, asyncComponents });
transformPagesWXml({ assets, asyncComponents });
});
};
实现增强lazy
- 用于每次组件加载触发
Suspense的fallback
javascript
import React, {
ComponentPropsWithoutRef,
ComponentRef,
ComponentType,
ForwardRefExoticComponent,
useEffect,
} from 'react';
enum Status {
Uninitialized = 'uninitialized',
Pending = 'pending',
Resolved = 'resolved',
Rejected = 'rejected',
}
interface Result<T> {
default: T;
}
interface LoadData<T> {
status: Status;
result?: T | Error;
promise?: Promise<void>;
}
type Factory<T extends ComponentType<any>> = () => Promise<Result<T>>;
export const lazy = <T extends ComponentType<any>>(factory: Factory<T>) => {
const LazyComponent = React.lazy(factory) as ForwardRefExoticComponent<any>;
const loadData: LoadData<T> = { status: Status.Uninitialized };
const load = () => {
if (loadData.status !== Status.Uninitialized) return;
const successCallback = (res: Result<T>) => {
loadData.status = Status.Resolved;
loadData.result = res.default;
};
const errorCallback = (err: Error) => {
loadData.status = Status.Rejected;
loadData.result = err;
};
loadData.promise = factory().then(successCallback, errorCallback);
loadData.status = Status.Pending;
};
const resetLoadData = () => {
loadData.status = Status.Uninitialized;
loadData.result = undefined;
loadData.promise = undefined;
};
return React.forwardRef<ComponentRef<T>, ComponentPropsWithoutRef<T>>((props, ref) => {
if (loadData.status === Status.Uninitialized) load();
if (loadData.status === Status.Pending) throw loadData.promise;
if (loadData.status === Status.Rejected) throw loadData.result;
useEffect(() => {
return resetLoadData();
}, []);
return <LazyComponent {...props} ref={ref} />;
});
};
实现单例promise(singleton-promise)
- 用于自定义组件通知
runtime.js样式文件已加载完成
javascript
export class SingletonPromise {
// 静态属性存放单例
private static instance?: Map<string, SingletonPromise>;
static getInstance() {
if (!SingletonPromise.instance) SingletonPromise.instance = new Map();
return SingletonPromise.instance;
}
static wait() {
const instance = this.getInstance();
const currentPages = getCurrentPages();
return Promise.all(
currentPages.map((page) => {
if (!instance.has(page.route)) instance.set(page.route, new SingletonPromise());
return instance.get(page.route)?.promise;
})
);
}
static loaded(pageRoute: string) {
const instance = this.getInstance();
if (!instance.has(pageRoute)) instance.set(pageRoute, new SingletonPromise());
instance.get(pageRoute)?.resolve?.();
}
static unloaded(pageRoute: string) {
const instance = this.getInstance();
instance.delete(pageRoute);
}
private promise?: Promise<void>;
private resolve?: () => void;
constructor() {
this.resetPromise();
}
private resetPromise() {
this.promise = new Promise<void>((resolve) => {
this.resolve = resolve;
});
}
}
插入样式自定义组件(inject-style-component)
- 调用
singleton-promise通知runtime.js样式注册完成
javascript
import path from 'path';
import { Compiler, Compilation } from 'webpack';
import { AsyncPackOpts } from './types';
export const PLUGIN_NAME = 'InjectStyleComponent';
export const InjectStyleComponentName = 'inject-style';
const injectStyleComponentCode = `
const { SingletonPromise } = require('~/singleton-promise.js')
Component({
properties: {
route: String,
},
lifetimes: {
attached: function () {
// 在这里通知页面样式已加载完成(但实际测试当渲染进程忙时还是会出现裸样式的情况)
return SingletonPromise.loaded(this.data.route)
},
detached: function () {
return SingletonPromise.unloaded(this.data.route)
}
}
})
`;
type Opt = AsyncPackOpts;
export class InjectStyleComponentPlugin {
static generateComponentPath(opt: Opt) {
return `${opt.dynamicPackageName}/${InjectStyleComponentName}`;
}
private readonly opt: Opt;
private readonly WXmlContent: string = '<block/>';
private readonly JsonContent: string = '{"component": true,"styleIsolation": "shared"}';
private readonly JsContent: string = injectStyleComponentCode;
constructor(opt: Opt) {
this.opt = opt;
}
apply(compiler: Compiler) {
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: Compilation) => {
const stage = compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL; // 最早阶段,在优化前
compilation.hooks.processAssets.tap({ name: PLUGIN_NAME, stage }, (assets) => {
const { dynamicPackageName } = this.opt;
const dynamicPackageStyleFileRegExp = new RegExp(`^${dynamicPackageName}\\/.*\\.wxss$`);
const styleFileContent = Object.keys(assets).reduce((result, assetPath) => {
if (!dynamicPackageStyleFileRegExp.test(assetPath)) return result;
const relativePath = path.relative(dynamicPackageName, assetPath);
const code = `@import './${relativePath}';`;
return `${result + code}\n`;
}, '');
const componentPath = InjectStyleComponentPlugin.generateComponentPath(this.opt);
const { RawSource } = compiler.webpack.sources;
compilation.assets[`${componentPath}.wxss`] = new RawSource(styleFileContent);
compilation.assets[`${componentPath}.wxml`] = new RawSource(this.WXmlContent);
compilation.assets[`${componentPath}.json`] = new RawSource(this.JsonContent);
compilation.assets[`${componentPath}.js`] = new RawSource(this.JsContent);
});
});
}
}
转换页面wxml(transformPagesWXml)
- 为所有页面插入自定义组件
javascript
import { AppConfig } from '@tarojs/taro';
import { RawSource } from 'webpack-sources';
interface Opts {
assets: Record<string, RawSource>;
asyncComponents: Record<string, string>;
}
const appConfigAssetKey = 'app.json';
export const transformPagesWXml = (opts: Opts) => {
const { assets, asyncComponents } = opts;
const curAppConfig: AppConfig = JSON.parse(assets[appConfigAssetKey].source() as string);
const pageWXmlPaths = (() => {
const { pages = [], subpackages, subPackages, tabBar } = curAppConfig;
const tabBarPagePaths = tabBar?.list?.map((item) => item.pagePath) || [];
const curSubPackages = subPackages || subpackages || [];
const subPackagePagePaths = curSubPackages.reduce<string[]>((result, item) => {
const subPackagePagePath = item.root || '';
return [...result, ...(item.pages || []).map((page) => `${subPackagePagePath}/${page}`)];
}, []);
return [...pages, ...tabBarPagePaths, ...subPackagePagePaths].map((item) => `${item}.wxml`);
})();
Object.keys(assets).forEach((assetPath) => {
if (!pageWXmlPaths.includes(assetPath)) return;
const source = assets[assetPath].source() as string;
const pageRoute = assetPath.replace(/\.wxml$/, '');
const asyncComponentCode = Object.keys(asyncComponents).map(
(item) => `<${item} route="${pageRoute}"/>`
);
const tempCode = [source, ...asyncComponentCode].join('\n');
assets[assetPath] = new RawSource(tempCode);
});
};
转换Appconfig(transform-app-config)
- 注册异步分包
- 注册异步样式自定义组件
- 注册路径别名
javascript
import { RawSource } from 'webpack-sources';
import type { AsyncPackOpts } from './types';
interface Opts extends AsyncPackOpts {
assets: Record<string, RawSource>;
asyncComponents: Record<string, string>;
}
const appConfigAssetKey = 'app.json';
export const transformAppConfig = (opts: Opts) => {
const { dynamicPackageName, asyncComponents, assets } = opts;
const curAppConfig = JSON.parse(assets[appConfigAssetKey].source() as string);
const {
subPackages,
subpackages,
resolveAlias = {},
usingComponents = {},
componentPlaceholder = {},
...otherAppJSON
} = curAppConfig;
const finalSubPackages = subPackages || subpackages || [];
const dynamicPackagesConfig = { root: dynamicPackageName, pages: [] };
const asyncComponentPlaceholder = Object.keys(asyncComponents).reduce((result, item) => {
return { ...result, [item]: 'block' };
}, {});
const finalAppConfig = {
...otherAppJSON,
usingComponents: { ...usingComponents, ...asyncComponents },
componentPlaceholder: { ...componentPlaceholder, ...asyncComponentPlaceholder },
subPackages: [...finalSubPackages, dynamicPackagesConfig],
resolveAlias: { ...resolveAlias, '~/*': '/*' },
};
assets[appConfigAssetKey] = new RawSource(JSON.stringify(finalAppConfig));
};
全局替换React.lazy
javascript
import path from 'path';
import { PluginObj, NodePath, PluginPass } from '@babel/core';
import * as types from '@babel/types';
import { CallExpression, ImportDeclaration, Program, VariableDeclarator } from '@babel/types';
const customLazySource = '@guming/taro-plugin-async-pack/build/esm/react-lazy-enhanced';
const normalFilename = (filename: string) => path.normalize(filename).replace(/\\/g, '/');
interface State extends PluginPass {
reactNamespaces: Set<string>;
reactLazyBindings: Set<string>;
}
export default (): PluginObj<State> => {
return {
visitor: {
Program: {
enter(programPath: NodePath<Program>, state: State) {
const { filename = '' } = state;
if (new RegExp(customLazySource).test(normalFilename(filename))) return;
state.reactNamespaces = new Set();
state.reactLazyBindings = new Set();
programPath.traverse({
ImportDeclaration(path: NodePath<ImportDeclaration>) {
if (!types.isStringLiteral(path.node.source, { value: 'react' })) return;
const { specifiers } = path.node;
specifiers.forEach((spec) => {
const { name } = spec.local;
// import React from 'react'
if (types.isImportDefaultSpecifier(spec)) return state.reactNamespaces.add(name);
// import * as React from 'react'
if (types.isImportNamespaceSpecifier(spec)) return state.reactNamespaces.add(name);
// import { lazy } from 'react'
// eslint-disable-next-line prettier/prettier
if (types.isImportSpecifier(spec) && types.isIdentifier(spec.imported, { name: 'lazy' })) {
state.reactLazyBindings.add(name);
}
});
},
});
programPath.traverse({
VariableDeclarator(path: NodePath<VariableDeclarator>) {
const { id, init } = path.node;
// const a = React.lazy
if (types.isIdentifier(id)) {
if (!types.isMemberExpression(init)) return;
if (!types.isIdentifier(init.object)) return;
if (!types.isIdentifier(init.property, { name: 'lazy' })) return;
if (!state.reactNamespaces.has(init.object.name)) return;
state.reactLazyBindings.add(id.name);
}
// const { lazy: a } = React;
if (types.isObjectPattern(id) && types.isIdentifier(init)) {
const { properties } = id;
if (!state.reactNamespaces.has(init.name)) return;
properties.forEach((prop) => {
if (!types.isObjectProperty(prop)) return;
if (!types.isIdentifier(prop.key, { name: 'lazy' })) return;
if (!types.isIdentifier(prop.value)) return;
state.reactLazyBindings.add(prop.value.name);
});
}
},
});
},
exit(programPath: NodePath<Program>, state: State) {
let needInject = false;
const customLazyId = programPath.scope.generateUidIdentifier('customLazy');
const { filename = '' } = state;
if (new RegExp(customLazySource).test(normalFilename(filename))) return;
programPath.traverse({
CallExpression(path: NodePath<CallExpression>) {
const { callee } = path.node;
if (types.isMemberExpression(callee)) {
if (!types.isIdentifier(callee.object)) return;
if (!state.reactNamespaces.has(callee.object.name)) return;
if (!types.isIdentifier(callee.property, { name: 'lazy' })) return;
path.node.callee = customLazyId;
needInject = true;
}
if (types.isIdentifier(callee) && state.reactLazyBindings.has(callee.name)) {
path.node.callee = customLazyId;
needInject = true;
}
},
});
const hasImport = programPath.node.body.some((node) => {
return types.isImportDeclaration(node) && node.source.value === customLazySource;
});
if (hasImport || !needInject) return;
const specifier = types.importSpecifier(customLazyId, types.identifier('lazy'));
const nodes = types.importDeclaration([specifier], types.stringLiteral(customLazySource));
programPath.unshiftContainer('body', nodes);
},
},
},
};
};
实现插件在webpack压缩前进行代码转换
- 用于调用
transform-webpack-runtime
javascript
import webpack from 'webpack';
import { CompilationAssets } from './types';
export interface TransformOpt {
assetName: string;
source: string | Buffer;
assets: CompilationAssets;
}
export interface PluginOpt {
test?: RegExp;
transform: (opt: TransformOpt) => string;
}
export const PLUGIN_NAME = 'TransformBeforeCompression';
export class TransformBeforeCompressionPlugin {
private readonly opt: PluginOpt;
constructor(opt: PluginOpt) {
this.opt = opt;
}
apply(compiler: webpack.Compiler) {
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: webpack.Compilation) => {
const stage = webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE; // 压缩前
compilation.hooks.processAssets.tap({ name: PLUGIN_NAME, stage }, (assets) => {
const { test, transform } = this.opt;
const assetNames = Object.keys(assets);
assetNames.forEach((assetName) => {
if (!test || !test.test(assetName)) return;
const source = assets[assetName].source();
const transformResult = transform({ assetName, source, assets } as TransformOpt);
compilation.updateAsset(assetName, new webpack.sources.RawSource(transformResult));
});
});
});
}
}
转换webpack产物runtime
- 转换产物
runtime
javascript
import { NodePath, template } from '@babel/core';
import generator from '@babel/generator';
import * as parser from '@babel/parser';
import traverse, { Node } from '@babel/traverse';
import * as types from '@babel/types';
import type {
AssignmentExpression,
ObjectMethod,
ObjectProperty,
SpreadElement,
VariableDeclarator,
} from '@babel/types';
import type { CompilationAssets, AsyncPackOpts } from './types';
interface Opts extends AsyncPackOpts {
assets: CompilationAssets;
}
const webpackLoadDynamicModuleTemplateDep = `
var loadedDynamicModules = {};
var loadDynamicModule = function (dynamicModulePath) {
var loadDynamicModuleFn = loadDynamicModuleFnMap[dynamicModulePath];
return loadDynamicModuleFn ? loadDynamicModuleFn() : Promise.reject();
};
var promiseRetry = function (apply,retries = 6,delay = 500) {
return apply().catch(function (error) {
if (retries <= 0) return Promise.reject(error);
return new Promise(function (resolve) {
setTimeout(resolve, delay);
})
.then(function () {
return promiseRetry(apply, retries - 1, delay)
});
})
}
`;
const webpackLoadDynamicModuleTemplate = `
__webpack_require__.l = function (dynamicModulePath, done, key, chunkId) {
if (inProgress[dynamicModulePath]) {
inProgress[dynamicModulePath].push(done);
return;
}
const target = { src: dynamicModulePath };
if (loadedDynamicModules[dynamicModulePath]) return done({ type: 'loaded', target });
promiseRetry(function () {
return loadDynamicModule(dynamicModulePath);
}).then(function () {
return done({ type: 'loaded', target });
}).catch(function () {
return done({ type: 'error', target });
});
};
`;
const replaceWebpackLoadScriptFn = (
assignmentExpressionNodePath: NodePath<AssignmentExpression>,
opts: Opts
) => {
const { left, right } = assignmentExpressionNodePath.node || {};
if (!types.isMemberExpression(left)) return;
if (!types.isFunctionExpression(right)) return;
if (!types.isIdentifier(left.object, { name: '__webpack_require__' })) return;
if (!types.isIdentifier(left.property, { name: 'l' })) return;
const isProcessed = right.params.some((item) => {
return types.isIdentifier(item, { name: 'dynamicModulePath' });
});
if (isProcessed) return;
const { assets, dynamicPackageName } = opts;
const dynamicJsAssets = Object.keys(assets).filter((assetName) => {
return new RegExp(`^${dynamicPackageName}/.*\\.js$`).test(assetName);
});
const dynamicWXssAssets = Object.keys(assets).filter((assetName) => {
return new RegExp(`^${dynamicPackageName}/.*\\.wxss$`).test(assetName);
});
const loadDynamicModuleFnMapCode = (() => {
const dynamicAssetsRequireTempCode = dynamicJsAssets.map((dynamicJsAsset) => {
return `'/${dynamicJsAsset}':function (){ return require.async('~/${dynamicJsAsset}'); }`;
});
return `var loadDynamicModuleFnMap = {${dynamicAssetsRequireTempCode.join(',')}}`;
})();
const hasStyleDynamicAssetsListCode = (() => {
const hasStyleDynamicAssetsList = dynamicJsAssets.filter((dynamicJsAsset) => {
const matchWXssAssets = dynamicJsAsset.replace(/\.js$/, '.wxss');
return dynamicWXssAssets.includes(matchWXssAssets);
});
return `var hasStyleDynamicModuleList = [${hasStyleDynamicAssetsList
.map((item) => `'/${item}'`)
.join(',')}]`;
})();
const templateCodeAst = template.ast(webpackLoadDynamicModuleTemplate);
const templateCodeDepAst = template.ast(webpackLoadDynamicModuleTemplateDep);
const loadDynamicModuleFnMapAst = template.ast(loadDynamicModuleFnMapCode);
const hasStyleDynamicAssetsListAst = template.ast(hasStyleDynamicAssetsListCode);
assignmentExpressionNodePath.replaceWith(templateCodeAst as Node);
assignmentExpressionNodePath.insertBefore(hasStyleDynamicAssetsListAst);
assignmentExpressionNodePath.insertBefore(loadDynamicModuleFnMapAst);
assignmentExpressionNodePath.insertBefore(templateCodeDepAst);
};
const webpackLoadDynamicStylesheetTemplate = `
__webpack_require__.f.miniCss = function (dynamicStylesheetChunkId, promises) {
var cssChunks = CSS_CHUNKS;
if(installedCssChunks[dynamicStylesheetChunkId] !== 0 && cssChunks[dynamicStylesheetChunkId]){
promises.push(loadStylesheet())
}
}
`;
const replaceWebpackLoadDynamicModuleStylesheetFn = (
assignmentExpressionNodePath: NodePath<AssignmentExpression>
) => {
const { left, right } = assignmentExpressionNodePath.node || {};
if (!types.isMemberExpression(left)) return;
if (!types.isFunctionExpression(right)) return;
if (!types.isMemberExpression(left.object)) return;
if (!types.isIdentifier(left.object.object, { name: '__webpack_require__' })) return;
if (!types.isIdentifier(left.object.property, { name: 'f' })) return;
if (!types.isIdentifier(left.property, { name: 'miniCss' })) return;
const isProcessed = right.params.some((item) => {
return types.isIdentifier(item, { name: 'dynamicStylesheetChunkId' });
});
if (isProcessed) return;
const cssChunksValueAst: Array<ObjectMethod | ObjectProperty | SpreadElement> = [];
assignmentExpressionNodePath.traverse({
VariableDeclarator: (nodePath: NodePath<VariableDeclarator>) => {
const { id, init } = nodePath.node || {};
if (!types.isIdentifier(id, { name: 'cssChunks' })) return;
if (!types.isObjectExpression(init)) return;
cssChunksValueAst.push(...init.properties);
},
});
const CSS_CHUNKS = types.objectExpression(cssChunksValueAst);
const templateCodeAst = template.statement(webpackLoadDynamicStylesheetTemplate)({ CSS_CHUNKS });
assignmentExpressionNodePath.replaceWith(templateCodeAst as Node);
};
const webpackLoadStylesheetTemplate = `
loadStylesheet = function () {
const { SingletonPromise } = require('~/singleton-promise.js');
return SingletonPromise.wait();
}
`;
const replaceLoadStylesheetFn = (nodePath: NodePath<VariableDeclarator>) => {
const { id, init } = nodePath.node || {};
if (!types.isIdentifier(id, { name: 'loadStylesheet' })) return;
if (!types.isFunctionExpression(init)) return;
const isProcessed = !init.params.length;
if (isProcessed) return;
const templateCodeAst = template.ast(webpackLoadStylesheetTemplate);
nodePath.replaceWith(templateCodeAst as Node);
};
const removeCreateStylesheetFn = (nodePath: NodePath<VariableDeclarator>) => {
const { id } = nodePath.node || {};
if (!types.isIdentifier(id, { name: 'createStylesheet' })) return;
nodePath.remove();
};
const removeFindStylesheetFn = (nodePath: NodePath<VariableDeclarator>) => {
const { id } = nodePath.node || {};
if (!types.isIdentifier(id, { name: 'findStylesheet' })) return;
nodePath.remove();
};
export const transformWebpackRuntime = (code: string, opts: Opts) => {
const ast = parser.parse(code); // 将代码解析为 AST
traverse(ast, {
AssignmentExpression: (nodePath: NodePath<AssignmentExpression>) => {
replaceWebpackLoadScriptFn(nodePath, opts);
replaceWebpackLoadDynamicModuleStylesheetFn(nodePath);
},
VariableDeclarator(nodePath: NodePath<VariableDeclarator>) {
replaceLoadStylesheetFn(nodePath);
removeCreateStylesheetFn(nodePath);
removeFindStylesheetFn(nodePath);
},
});
return generator(ast).code;
};
类型定义
javascript
import { Source } from 'webpack-sources';
export interface AsyncPackOpts {
dynamicPackageName: string;
}
export type CompilationAssets = Record<string, Source>;
四、总结
通过剔除 ES5 兼容代码,我们成功优化了小程序主包体积;但在样式分包的异步加载能力上,仍然无法做到真正完善。
核心问题始终在于:开发者无法准确获知"样式已真正生效"的时机。缺少一个明确、可靠的生命周期或回调信号,使得我们的方案在机制层面始终存在不确定性。
在此也想向微信官方表达一个小小的期待:未来是否有可能提供一个明确的"样式已生效"钩子或事件?哪怕只是一个样式注入完成的回调机制,也足以让这类能力变得真正可控、可维护。
或者进一步,是否可以提供官方支持的异步样式加载 API,使样式的加载、注入与生效具备可观测、可编排的生命周期。