引言
在使用Taro开发偏大型小程序应用过程中,我们可能经常会遇到这么个问题:小程序的主包体积超过了2M,没办法发布。针对这个问题,本文讲一讲我在业务中经常使用到的4种优化手段。
优化方式
页面分包
微信主包体积限制2MB
主包空间寸土寸金,仅放置默认启动页面/TabBar 页面,其他页面均迁移至分包。这也是主包体积最基本的优化方式。
公共模块分包
改造后分包加载的页面体积不计入主包体积内,但是在默认配置下被多个页面所引用的模块会被打包进主包。这里截取了未做优化页面分包后直接打包后的代码依赖分析图。其中:
common.js
包含了业务中的公共组件、工具方法、hooks
等逻辑common.wxss
包含了业务中公共组件的样式、全局样式vendors.js
包含了三方依赖逻辑
解决方案
那么我们能不能识别哪些页面使用了这些公共模块,如果某个公共模块虽然被多个分包使用,但是使用它的分包均不在主包中那么我们这个模块是不是应该被打包进对应的分包内减少主包体积占用。
技术实现
Taro
配置mini.optimizeMainPackage
就能实现这一功能Taro
官方对这一配置的描述是:可以避免主包没有引入的module
不被提取到commonChunks
中,该功能会在打包时分析module
和chunk
的依赖关系,筛选出主包没有引用到的module
把它提取到分包内。 开启mini.optimizeMainPackage
后的代码依赖分析图如下:
源码解析
那么Taro
是如何实现这一功能的呢?我们来看源码:
- 收集分包入口数据用于后续判断
chunk
是否属于分包
typescript
const PLUGIN_NAME = 'MiniSplitChunkPlugin'
export default class MiniSplitChunksPlugin extends SplitChunksPlugin {
// 分包配置
subPackages: SubPackage[]
// 分包根路径
subRoots: string[]
// 分包根路径正则
subRootRegExps: RegExp[]
// ... 省略部分代码 ...
apply (compiler: any) {
this.context = compiler.context
// 获取分包配置
this.subPackages = this.getSubpackageConfig(compiler).map((subPackage: SubPackage) => ({
...subPackage,
root: this.formatSubRoot(subPackage.root) // 格式化根路径,去掉尾部的/
}))
// 获取分包根路径
this.subRoots = this.subPackages.map((subPackage: SubPackage) => subPackage.root)
// 生成分包根路径正则
this.subRootRegExps = this.subRoots.map((subRoot: string) => new RegExp(`^${subRoot}\\/`))
// ... 省略部分代码 ...
}
// ... 省略部分代码 ...
}
- 找到分包入口
chunk
。循环构成chunk
的module
。其中没有被主包引用,且被多个分包引用的记录在subCommonDeps
中。并基于subCommonDeps
生成新的cacheGroups
配置用于SplitChunksPlugin
作为配置拆分chunks
。
javascript
const PLUGIN_NAME = 'MiniSplitChunkPlugin'
export default class MiniSplitChunksPlugin extends SplitChunksPlugin {
// 所有分包公共依赖
subCommonDeps: Map<string, DepInfo>
// 各个分包的公共依赖Map
chunkSubCommons: Map<string, Set<string>>
// 分包三方依赖
subPackagesVendors: Map<string, webpack.compilation.Chunk>
// ... 省略部分代码 ...
apply (compiler: any) {
// ... 省略部分代码 ...
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: any) => {
compilation.hooks.optimizeChunks.tap(PLUGIN_NAME, (chunks: webpack.compilation.Chunk[]) => {
const splitChunksOriginConfig = {
...compiler?.options?.optimization?.splitChunks
}
this.subCommonDeps = new Map()
this.chunkSubCommons = new Map()
this.subPackagesVendors = new Map()
/**
* 找出分包入口chunks
*/
const subChunks = chunks.filter(chunk => this.isSubChunk(chunk))
// 不存在分包
if (subChunks.length === 0) {
this.options = SplitChunksPlugin.normalizeOptions(splitChunksOriginConfig)
return
}
subChunks.forEach((subChunk: webpack.compilation.Chunk) => {
subChunk.modulesIterable.forEach((module: any) => {
// ... 省略部分代码 ...
const chunks: webpack.compilation.Chunk[] = Array.from(module.chunksIterable)
const chunkNames: string[] = chunks.map(chunk => chunk.name)
/**
* 找出没有被主包引用,且被多个分包引用的module,并记录在subCommonDeps中
*/
if (!this.hasMainChunk(chunkNames) && this.isSubsDep(chunkNames)) {
// 此处生成 subCommonDeps、subCommonDepChunks 用于生成新的cacheGroups配置
// ... 省略部分代码 ...
}
})
})
/**
* 用新的option配置生成新的cacheGroups配置
*/
this.options = SplitChunksPlugin.normalizeOptions({
...splitChunksOriginConfig,
cacheGroups: {
...splitChunksOriginConfig?.cacheGroups,
...this.getSubPackageVendorsCacheGroup(),
...this.getSubCommonCacheGroup() // 该方法返回值基于 this.subCommonDeps 生成
}
})
})
})
}
// ... 省略部分代码 ...
}
- 在
SplitChunksPlugin
完成chunks
拆分后收集分包下的sub-vendors
和sub-common
下的公共模块信息
typescript
export default class MiniSplitChunksPlugin extends SplitChunksPlugin {
// ... 省略部分代码 ...
apply (compiler: any) {
// ... 省略部分代码 ...
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: any) => {
// ... 省略部分代码 ...
compilation.hooks.afterOptimizeChunks.tap(PLUGIN_NAME, (chunks: webpack.compilation.Chunk[]) => {
const existSubCommonDeps = new Map()
chunks.forEach(chunk => {
const chunkName = chunk.name
if (this.matchSubVendors(chunk)) {
const subRoot = this.subRoots.find(subRoot => new RegExp(`^${subRoot}\\/`).test(chunkName)) as string
this.subPackagesVendors.set(subRoot, chunk)
}
if (this.matchSubCommon(chunk)) {
const depName = chunkName.replace(new RegExp(`^${this.subCommonDir}\\/(.*)`), '$1')
if (this.subCommonDeps.has(depName)) {
existSubCommonDeps.set(depName, this.subCommonDeps.get(depName))
}
}
})
this.setChunkSubCommons(existSubCommonDeps)
// 这里收集了SplitChunksPlugin 完成 chunks 拆分后分包内的 subCommonDep(ps: 这里的赋值有点奇怪,因为后续的流程并没有使用)
this.subCommonDeps = existSubCommonDeps
})
}
}
setChunkSubCommons (subCommonDeps: Map<string, DepInfo>) {
const chunkSubCommons: Map<string, Set<string>> = new Map()
subCommonDeps.forEach((depInfo: DepInfo, depName: string) => {
const chunks: string[] = [...depInfo.chunks]
chunks.forEach(chunk => {
if (chunkSubCommons.has(chunk)) {
const chunkSubCommon = chunkSubCommons.get(chunk) as Set<string>
chunkSubCommon.add(depName)
chunkSubCommons.set(chunk, chunkSubCommon)
} else {
chunkSubCommons.set(chunk, new Set([depName]))
}
})
})
this.chunkSubCommons = chunkSubCommons
}
// ... 省略部分代码 ...
}
- 基于收集的分包下的
sub-vendors
和sub-common
下的公共模块信息。为分包require
对应公共模块。SplitChunksPlugin
导出路径为编译产物根目录即主包根目录,这里为了不占主包体积所以这里需要将sub-common
迁移至对应分包,故此处require
的文件路径都是基于分包根目录。
typescript
export default class MiniSplitChunksPlugin extends SplitChunksPlugin {
// ... 省略部分代码 ...
apply (compiler: any) {
// ... 省略部分代码 ...
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: any) => {
compilation.chunkTemplate.hooks.renderWithEntry.tap(PLUGIN_NAME, (modules, chunk) => {
if (this.isSubChunk(chunk)) {
const chunkName = chunk.name
const chunkSubRoot = this.subRoots.find(subRoot => new RegExp(`^${subRoot}\\/`).test(chunkName)) as string
const chunkAbsulutePath = path.resolve(this.distPath, chunkName)
const source = new ConcatSource()
const hasSubVendors = this.subPackagesVendors.has(chunkSubRoot)
const subVendors = this.subPackagesVendors.get(chunkSubRoot) as webpack.compilation.Chunk
const subCommon = [...(this.chunkSubCommons.get(chunkName) || [])]
/**
* require该分包下的sub-vendors
*/
if (hasSubVendors) {
// ... 此处省略文件路径生成逻辑 ...
source.add(`require(${JSON.stringify(relativePath)});\n`)
}
// require sub-common下的模块
if (subCommon.length > 0) {
if (this.needAllInOne()) {
// ... 此处省略文件路径生成逻辑 ...
source.add(`require(${JSON.stringify(relativePath)});\n`)
} else {
subCommon.forEach(moduleName => {
// ... 此处省略文件路径生成逻辑 ...
source.add(`require(${JSON.stringify(relativePath)});\n`)
})
}
}
source.add(modules)
source.add(';')
return source
}
})
}
}
// ... 省略部分代码 ...
}
require
的文件路径基于分包根目录。所以对应的文件也需要做迁移。
typescript
export default class MiniSplitChunksPlugin extends SplitChunksPlugin {
// ... 省略部分代码 ...
apply (compiler: any) {
// ... 省略部分代码 ...
compiler.hooks.emit.tapAsync(PLUGIN_NAME, this.tryAsync((compilation) => {
const assets = compilation.assets
const subChunks = compilation.entries.filter(entry => this.isSubChunk(entry))
const needAllInOne = this.needAllInOne()
subChunks.forEach(subChunk => {
const subChunkName = subChunk.name
const subRoot = this.subRoots.find(subRoot => new RegExp(`^${subRoot}\\/`).test(subChunkName)) as string
const chunkWxssName = `${subChunkName}${FileExtsMap.STYLE}`
const subCommon = [...(this.chunkSubCommons.get(subChunkName) || [])]
const wxssAbsulutePath = path.resolve(this.distPath, chunkWxssName)
const subVendorsWxssPath = path.join(subRoot, `${this.subVendorsName}${FileExtsMap.STYLE}`)
const source = new ConcatSource()
if (subCommon.length > 0) {
let hasSubCssCommon = false
subCommon.forEach(moduleName => {
// ... 省略部分代码 ...
// 复制sub-common下的资源到分包下
for (const key in FileExtsMap) {
const ext = FileExtsMap[key]
const assetName = path.join(this.subCommonDir, `${moduleName}${ext}`)
const subAssetName = path.join(subRoot, assetName)
const assetSource = assets[normalizePath(assetName)]
if (assetSource) {
assets[normalizePath(subAssetName)] = {
size: () => assetSource.source().length,
source: () => assetSource.source()
}
}
}
})
// ... 省略部分代码 ...
}
if (assets[normalizePath(subVendorsWxssPath)]) {
const subVendorsAbsolutePath = path.resolve(this.distPath, subVendorsWxssPath)
const relativePath = this.getRealRelativePath(wxssAbsulutePath, subVendorsAbsolutePath)
source.add(`@import ${JSON.stringify(relativePath)};\n`)
}
if (assets[chunkWxssName]) {
const originSource = assets[chunkWxssName].source()
source.add(originSource)
}
assets[chunkWxssName] = {
size: () => source.source().length,
source: () => source.source()
}
})
// 删除根目录下的sub-common资源文件
for (const assetPath in assets) {
if (new RegExp(`^${this.subCommonDir}\\/.*`).test(assetPath)) {
delete assets[assetPath]
}
}
// ... 省略部分代码 ...
}))
}
}
以上就是 MiniSplitChunksPlugin
实现公共模块分包的核心流程
引用方式
在完成公共模块分包后主包体积的确有所减少,但是在后续的迭代中发现公共组件并没有全部都按页面分包打包。以@/components
举例,通过排查发现@/components
是通过index.ts
统一导出内部的子模块页面通过import { ComponentName } from '@/components'
方式引入。 而这种导出方式会使webpack
将这个@/components
识别为一个单独模块,由于主包内存在页面引用@/component
下的公共组件,所以@/components
会被完整的打包进主包内。
解决方案
解决方法也很简单就是将@/components
修改为@/components/component-path
,跳过index.ts
直接引用内部组件文件。 那么我们如何将现存项目中使用的组件引用路径都替换掉呢?
- ❎ 人工逐个替换
- 需要修改使用到公共组件的业务代码工作量大且易出错
- 且后续全局组件使用也较繁琐需要直接引用文件路径
@/components/component-path
。
- ✅ 使用
babel
插件批量替换- 仅需引入插件
babel-plugin-import
做对应配置,无需修改业务代码。 - 开发无感,
@/components
的使用方式不变
- 仅需引入插件
技术实现
- 引入插件
babel-plugin-import
,并配置组件路径与组件文件路径之间的转换关系
javascript
module.exports = {
plugins: [
[
'import',
{
libraryName: '@/components',
libraryDirectory: '',
transformToDefaultImport: false,
},
],
],
};
- 按照配置的文件名与文件路径之间的转换关系定义组件
javascript
// component file -> @/components/component-a
export const ComponentA = ()=> <View/>
// business code
import {ComponentA} from '@/components'
这里需要注意如果组件路径与组件名不符合所配置的规范,编译时会找不到对应的组件。
图片资源优化
这里截取了「古茗点单小程序」在不采用其他优化手段直接将图片资源打包后的代码依赖分析图,可以看到其中图片资源的尺寸足足有22.07MB
,这与微信限制的主包大小2MB
整整相差了20MB
。
解决方案
我们可以将这22.07MB
的图片资源上传至云端。在小程序使用时直接使用网络路径。那么打包时这部分资源尺寸就不会计算在主包尺寸中。 那么我们如何将现存项目中使用的图片资源路径替换成网络路径?
- ❎ 人工逐个替换
- 需要修改使用到图片资源的业务代码工作量大且易出错
- 且后续图片资源使用也很繁琐需要开发上传图片资源后使用网络地址编码。
- ✅ 使用
babel
插件批量替换- 仅需要实现对应的
babel
插件逻辑并引入,无需修改业务代码 - 开发无感,图片资源的使用方式不变
- 仅需要实现对应的
技术实现
- Taro 编译开始时使用
taro
插件上传本地图片资源
typescript
import type {IPluginContext} from '@tarojs/service'
import {PromisePool} from '@supercharge/promise-pool'
import path from 'path';
import fs from 'fs';
import md5 from 'md5'
const cacheFileName = "imgCache.json"
/**
* 递归查找文件
*/
const travelFiles = (dir: string): string[] => {
const files = fs.readdirSync(dir);
return files.reduce<string[]>((result, file) => {
const filePath = path.join(dir, file);
if (!fs.statSync(filePath).isDirectory()) return [...result, filePath];
return [...result, ...travelFiles(filePath)];
}, [])
}
/**
* 文件路径格式化
*/
const filePathFormat = (ctx: IPluginContext, filePath: string) => {
return filePath.replace(ctx.paths.sourcePath, "@").replace(/\\/g, "/");
}
/**
* 生成文件 key
*/
const generateFileUniqueKey = (filePath: string) => {
const {dir, base, ext} = path.parse(filePath);
const buffer = fs.readFileSync(`${dir}${path.sep}${base}`);
return md5(buffer)
}
const cacheFile = path.join(__dirname, cacheFileName);
interface PluginOpts {
fileDir: string,
upload: (filePath: string, fileKey: string) => Promise<string>
}
module.exports = (ctx: IPluginContext, pluginOpts: PluginOpts) => {
ctx.onBuildStart(async () => {
const {fileDir, upload} = pluginOpts
const fileDirPath = `${ctx.paths.sourcePath}/${fileDir}`;
const filePathList = travelFiles(fileDirPath);
// 上传文件
const {results: fileUrlList} = await PromisePool.withConcurrency(2)
.for(filePathList)
.process(async (filePath) => {
const fileUrl = await upload(filePath, generateFileUniqueKey(filePath))
return {filePath, fileUrl}
})
// 生成文件缓存数据
const fileUrlMap = fileUrlList.reduce((result, item) => {
const tempKey = filePathFormat(ctx, item.filePath)
return {...result, [tempKey]: item.fileUrl}
}, {})
fs.writeFileSync(cacheFile, JSON.stringify(fileUrlMap));
})
}
- 使用
babel
插件替换ts
或js
中导入的图片
typescript
import type {NodePath, PluginItem, PluginPass} from '@babel/core'
import type {ImportDeclaration, Statement} from "@babel/types";
import template from '@babel/template'
import path from "path";
import fs from "fs";
const cacheFileName = "imgCache.json"
const getCacheData = (filePath: string): Record<string, string> => {
try {
fs.accessSync(filePath);
return JSON.parse(fs.readFileSync(filePath).toString());
} catch (error) {
return {}
}
}
module.exports = (): PluginItem => {
const cacheMap = getCacheData(path.join(__dirname, cacheFileName));
return {
visitor: {
ImportDeclaration(importDeclarationAstPath: NodePath<ImportDeclaration>, state: PluginPass) {
if (state.file.opts.filename?.includes("node_modules")) return;
const {node} = importDeclarationAstPath;
const {value} = node.source;
const fileUrl = cacheMap[value]
if (!fileUrl) return;
const [specifier] = node.specifiers
const assignExpression = template.ast(`const ${specifier.local.name} = '${fileUrl}';`);
importDeclarationAstPath.replaceWith(assignExpression as Statement);
}
}
}
}
- 使用
postcss
插件替换样式文件中导入的图片
typescript
import {AcceptedPlugin} from "postcss";
import path from "path";
import fs from "fs";
const cacheFileName = "imgCache.json"
const getCacheData = (filePath: string): Record<string, string> => {
try {
fs.accessSync(filePath);
return JSON.parse(fs.readFileSync(filePath).toString());
} catch (error) {
return {}
}
}
const urlRegexp = /url\(['"]([^'"]*)['"]\)/
const filePathFormat = (filePath: string) => filePath.replace('~@', '@')
module.exports = (): AcceptedPlugin => {
const cacheMap = getCacheData(path.join(__dirname, cacheFileName));
return {
postcssPlugin: 'auto-replace-assets-url',
Declaration(decl) {
if (!urlRegexp.test(decl.value)) return
let [_, filePath] = decl.value.match(urlRegexp)!;
filePath = filePathFormat(filePath)
if (!cacheMap[filePath]) return;
decl.value = `url(${cacheMap[filePath]})`
}
}
}
总结
这里介绍了页面分包、公共模块分包和图片资源优化的方式优化小程序包体积。我们还可以共同探讨其他优化策略如:TS枚举编译优化、小程序分包异步化、提取公共样式、原子化样式组件等。