当前位置:   article > 正文

小程序主包体积的优化方案与技术实现

taro减少体积方案
 
 

大厂技术  高级前端  Node进阶

  1. 点击上方 程序员成长指北,关注公众号
  2. 回复1,加入高级Node交流群

引言

在使用Taro开发偏大型小程序应用过程中,我们可能经常会遇到这么个问题:小程序的主包体积超过了2M,没办法发布。针对这个问题,本文讲一讲我在业务中经常使用到的4种优化手段。

优化方式

页面分包

微信主包体积限制2MB主包空间寸土寸金,仅放置默认启动页面/TabBar 页面,其他页面均迁移至分包。这也是主包体积最基本的优化方式。

公共模块分包

851256ab4dc510ef2735e954b7267469.png改造后分包加载的页面体积不计入主包体积内,但是在默认配置下被多个页面所引用的模块会被打包进主包。这里截取了未做优化页面分包后直接打包后的代码依赖分析图。其中:

  • common.js包含了业务中的公共组件、工具方法、hooks等逻辑

  • common.wxss包含了业务中公共组件的样式、全局样式

  • vendors.js包含了三方依赖逻辑

解决方案

那么我们能不能识别哪些页面使用了这些公共模块,如果某个公共模块虽然被多个分包使用,但是使用它的分包均不在主包中那么我们这个模块是不是应该被打包进对应的分包内减少主包体积占用。

技术实现

文档链接:https://docs.taro.zone/docs/config-detail#minioptimizemainpackage

Taro配置mini.optimizeMainPackage就能实现这一功能Taro官方对这一配置的描述是:可以避免主包没有引入的module不被提取到commonChunks中,该功能会在打包时分析modulechunk的依赖关系,筛选出主包没有引用到的module把它提取到分包内。开启mini.optimizeMainPackage后的代码依赖分析图如下:0e4694a839f9018c6982b943d05fe426.png

源码解析

那么Taro是如何实现这一功能的呢?我们来看源码:

  1. 收集分包入口数据用于后续判断chunk是否属于分包

  1. const PLUGIN_NAME = 'MiniSplitChunkPlugin'
  2. export default class MiniSplitChunksPlugin extends SplitChunksPlugin {
  3.    // 分包配置
  4.   subPackages: SubPackage[]
  5.   // 分包根路径
  6.   subRoots: string[]
  7.   // 分包根路径正则
  8.   subRootRegExps: RegExp[]
  9.   // ... 省略部分代码 ... 
  10.   apply (compiler: any) {
  11.     this.context = compiler.context
  12.     // 获取分包配置
  13.     this.subPackages = this.getSubpackageConfig(compiler).map((subPackage: SubPackage) => ({
  14.       ...subPackage,
  15.       root: this.formatSubRoot(subPackage.root) // 格式化根路径,去掉尾部的/
  16.     }))
  17.     // 获取分包根路径
  18.     this.subRoots = this.subPackages.map((subPackage: SubPackage) => subPackage.root)
  19.     // 生成分包根路径正则
  20.     this.subRootRegExps = this.subRoots.map((subRoot: string) => new RegExp(`^${subRoot}\\/`))
  21.     // ... 省略部分代码 ... 
  22.   }
  23.   // ... 省略部分代码 ...
  24. }
  1. 找到分包入口chunk。循环构成chunkmodule。其中没有被主包引用,且被多个分包引用的记录在subCommonDeps中。并基于subCommonDeps生成新的cacheGroups配置用于SplitChunksPlugin作为配置拆分chunks

  1. const PLUGIN_NAME = 'MiniSplitChunkPlugin'
  2. export default class MiniSplitChunksPlugin extends SplitChunksPlugin {
  3.   // 所有分包公共依赖
  4.   subCommonDeps: Map<string, DepInfo>
  5.   // 各个分包的公共依赖Map
  6.   chunkSubCommons: Map<string, Set<string>>
  7.   // 分包三方依赖
  8.   subPackagesVendors: Map<string, webpack.compilation.Chunk>
  9.   // ... 省略部分代码 ...
  10.   apply (compiler: any) {
  11.     // ... 省略部分代码 ...
  12.     compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: any) => {
  13.       compilation.hooks.optimizeChunks.tap(PLUGIN_NAME, (chunks: webpack.compilation.Chunk[]) => {
  14.         const splitChunksOriginConfig = {
  15.           ...compiler?.options?.optimization?.splitChunks
  16.         }
  17.         this.subCommonDeps = new Map()
  18.         this.chunkSubCommons = new Map()
  19.         this.subPackagesVendors = new Map()
  20.         /**
  21.          * 找出分包入口chunks
  22.          */
  23.         const subChunks = chunks.filter(chunk => this.isSubChunk(chunk))
  24.         // 不存在分包
  25.         if (subChunks.length === 0) {
  26.           this.options = SplitChunksPlugin.normalizeOptions(splitChunksOriginConfig)
  27.           return
  28.         }
  29.         subChunks.forEach((subChunk: webpack.compilation.Chunk) => {
  30.           subChunk.modulesIterable.forEach((module: any) => {
  31.             // ... 省略部分代码 ...
  32.             const chunks: webpack.compilation.Chunk[] = Array.from(module.chunksIterable)
  33.             const chunkNames: string[] = chunks.map(chunk => chunk.name)
  34.             /**
  35.              * 找出没有被主包引用,且被多个分包引用的module,并记录在subCommonDeps中
  36.              */
  37.             if (!this.hasMainChunk(chunkNames) && this.isSubsDep(chunkNames)) {
  38.               // 此处生成 subCommonDeps、subCommonDepChunks 用于生成新的cacheGroups配置
  39.               // ... 省略部分代码 ...
  40.             }
  41.           })
  42.         })
  43.         /**
  44.          * 用新的option配置生成新的cacheGroups配置
  45.          */
  46.         this.options = SplitChunksPlugin.normalizeOptions({
  47.           ...splitChunksOriginConfig,
  48.           cacheGroups: {
  49.             ...splitChunksOriginConfig?.cacheGroups,
  50.             ...this.getSubPackageVendorsCacheGroup(), 
  51.             ...this.getSubCommonCacheGroup() // 该方法返回值基于 this.subCommonDeps 生成
  52.           }
  53.         })
  54.       })
  55.     })
  56.   }
  57.   // ... 省略部分代码 ...
  58. }
  1. SplitChunksPlugin完成chunks拆分后收集分包下的sub-vendorssub-common下的公共模块信息

  1. export default class MiniSplitChunksPlugin extends SplitChunksPlugin {
  2.   // ... 省略部分代码 ...
  3.   apply (compiler: any) {
  4.     // ... 省略部分代码 ...
  5.     compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: any) => {
  6.       // ... 省略部分代码 ...
  7.       compilation.hooks.afterOptimizeChunks.tap(PLUGIN_NAME, (chunks: webpack.compilation.Chunk[]) => {
  8.         const existSubCommonDeps = new Map()
  9.         chunks.forEach(chunk => {
  10.           const chunkName = chunk.name
  11.           if (this.matchSubVendors(chunk)) {
  12.             const subRoot = this.subRoots.find(subRoot => new RegExp(`^${subRoot}\\/`).test(chunkName)) as string
  13.             this.subPackagesVendors.set(subRoot, chunk)
  14.           }
  15.           if (this.matchSubCommon(chunk)) {
  16.             const depName = chunkName.replace(new RegExp(`^${this.subCommonDir}\\/(.*)`), '$1')
  17.             if (this.subCommonDeps.has(depName)) {
  18.               existSubCommonDeps.set(depName, this.subCommonDeps.get(depName))
  19.             }
  20.           }
  21.         })
  22.         this.setChunkSubCommons(existSubCommonDeps)
  23.         // 这里收集了SplitChunksPlugin 完成 chunks 拆分后分包内的 subCommonDep(ps: 这里的赋值有点奇怪,因为后续的流程并没有使用)
  24.         this.subCommonDeps = existSubCommonDeps
  25.       })
  26.     }
  27.                                    }
  28.   
  29.   setChunkSubCommons (subCommonDeps: Map<string, DepInfo>) {
  30.     const chunkSubCommons: Map<string, Set<string>> = new Map()
  31.     subCommonDeps.forEach((depInfo: DepInfo, depName: string) => {
  32.       const chunks: string[] = [...depInfo.chunks]
  33.       chunks.forEach(chunk => {
  34.         if (chunkSubCommons.has(chunk)) {
  35.           const chunkSubCommon = chunkSubCommons.get(chunk) as Set<string>
  36.           chunkSubCommon.add(depName)
  37.           chunkSubCommons.set(chunk, chunkSubCommon)
  38.         } else {
  39.           chunkSubCommons.set(chunk, new Set([depName]))
  40.         }
  41.       })
  42.     })
  43.     this.chunkSubCommons = chunkSubCommons
  44.   }
  45.   // ... 省略部分代码 ...
  46. }
  1. 基于收集的分包下的sub-vendorssub-common下的公共模块信息。为分包require对应公共模块。SplitChunksPlugin导出路径为编译产物根目录即主包根目录,这里为了不占主包体积所以这里需要将sub-common迁移至对应分包,故此处require的文件路径都是基于分包根目录。

  1. export default class MiniSplitChunksPlugin extends SplitChunksPlugin {
  2.   // ... 省略部分代码 ...
  3.   apply (compiler: any) {
  4.     // ... 省略部分代码 ...
  5.     compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation: any) => {
  6.       compilation.chunkTemplate.hooks.renderWithEntry.tap(PLUGIN_NAME, (modules, chunk) => {
  7.         if (this.isSubChunk(chunk)) {
  8.           const chunkName = chunk.name
  9.           const chunkSubRoot = this.subRoots.find(subRoot => new RegExp(`^${subRoot}\\/`).test(chunkName)) as string
  10.           const chunkAbsulutePath = path.resolve(this.distPath, chunkName)
  11.           const source = new ConcatSource()
  12.           const hasSubVendors = this.subPackagesVendors.has(chunkSubRoot)
  13.           const subVendors = this.subPackagesVendors.get(chunkSubRoot) as webpack.compilation.Chunk
  14.           const subCommon = [...(this.chunkSubCommons.get(chunkName) || [])]
  15.           /**
  16.            * require该分包下的sub-vendors
  17.            */
  18.           if (hasSubVendors) {
  19.    // ... 此处省略文件路径生成逻辑 ...
  20.             source.add(`require(${JSON.stringify(relativePath)});\n`)
  21.           }
  22.           // require sub-common下的模块
  23.           if (subCommon.length > 0) {
  24.             if (this.needAllInOne()) {
  25.         // ... 此处省略文件路径生成逻辑 ...
  26.               source.add(`require(${JSON.stringify(relativePath)});\n`)
  27.             } else {
  28.               subCommon.forEach(moduleName => {
  29.      // ... 此处省略文件路径生成逻辑 ...
  30.                 source.add(`require(${JSON.stringify(relativePath)});\n`)
  31.               })
  32.             }
  33.           }
  34.           source.add(modules)
  35.           source.add(';')
  36.           return source
  37.         }
  38.       })
  39.     }
  40.                                    }
  41.   // ... 省略部分代码 ...
  42. }
  1. require的文件路径基于分包根目录。所以对应的文件也需要做迁移。

  1. export default class MiniSplitChunksPlugin extends SplitChunksPlugin {
  2.   // ... 省略部分代码 ...
  3.   apply (compiler: any) {
  4.     // ... 省略部分代码 ...
  5.     compiler.hooks.emit.tapAsync(PLUGIN_NAME, this.tryAsync((compilation) => {
  6.       const assets = compilation.assets
  7.       const subChunks = compilation.entries.filter(entry => this.isSubChunk(entry))
  8.       const needAllInOne = this.needAllInOne()
  9.       subChunks.forEach(subChunk => {
  10.         const subChunkName = subChunk.name
  11.         const subRoot = this.subRoots.find(subRoot => new RegExp(`^${subRoot}\\/`).test(subChunkName)) as string
  12.         const chunkWxssName = `${subChunkName}${FileExtsMap.STYLE}`
  13.         const subCommon = [...(this.chunkSubCommons.get(subChunkName) || [])]
  14.         const wxssAbsulutePath = path.resolve(this.distPath, chunkWxssName)
  15.         const subVendorsWxssPath = path.join(subRoot, `${this.subVendorsName}${FileExtsMap.STYLE}`)
  16.         const source = new ConcatSource()
  17.         if (subCommon.length > 0) {
  18.           let hasSubCssCommon = false
  19.           subCommon.forEach(moduleName => {
  20.             // ... 省略部分代码 ...
  21.             // 复制sub-common下的资源到分包下
  22.             for (const key in FileExtsMap) {
  23.               const ext = FileExtsMap[key]
  24.               const assetName = path.join(this.subCommonDir, `${moduleName}${ext}`)
  25.               const subAssetName = path.join(subRoot, assetName)
  26.               const assetSource = assets[normalizePath(assetName)]
  27.               if (assetSource) {
  28.                 assets[normalizePath(subAssetName)] = {
  29.                   size: () => assetSource.source().length,
  30.                   source: () => assetSource.source()
  31.                 }
  32.               }
  33.             }
  34.           })
  35.           // ... 省略部分代码 ...
  36.         }
  37.         if (assets[normalizePath(subVendorsWxssPath)]) {
  38.           const subVendorsAbsolutePath = path.resolve(this.distPath, subVendorsWxssPath)
  39.           const relativePath = this.getRealRelativePath(wxssAbsulutePath, subVendorsAbsolutePath)
  40.           source.add(`@import ${JSON.stringify(relativePath)};\n`)
  41.         }
  42.         if (assets[chunkWxssName]) {
  43.           const originSource = assets[chunkWxssName].source()
  44.           source.add(originSource)
  45.         }
  46.         assets[chunkWxssName] = {
  47.           size: () => source.source().length,
  48.           source: () => source.source()
  49.         }
  50.       })
  51.       // 删除根目录下的sub-common资源文件
  52.       for (const assetPath in assets) {
  53.         if (new RegExp(`^${this.subCommonDir}\\/.*`).test(assetPath)) {
  54.           delete assets[assetPath]
  55.         }
  56.       }
  57.       // ... 省略部分代码 ...
  58.     }))
  59.   }
  60. }

以上就是 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的使用方式不变

技术实现
  1. 引入插件babel-plugin-import,并配置组件路径与组件文件路径之间的转换关系

  1. module.exports = {
  2.   plugins: [
  3.     [
  4.       'import',
  5.       {
  6.         libraryName: '@/components',
  7.         libraryDirectory: '',
  8.         transformToDefaultImport: false,
  9.       },
  10.     ],
  11.   ],
  12. };
  1. 按照配置的文件名与文件路径之间的转换关系定义组件

  1. // component file -> @/components/component-a
  2. export const ComponentA = ()=> <View/>
  3. // business code 
  4. import {ComponentA} from '@/components'

这里需要注意如果组件路径与组件名不符合所配置的规范,编译时会找不到对应的组件。

图片资源优化

306483237be011bab080c239c1e47458.png这里截取了「古茗点单小程序」在不采用其他优化手段直接将图片资源打包后的代码依赖分析图,可以看到其中图片资源的尺寸足足有22.07MB,这与微信限制的主包大小2MB整整相差了20MB

解决方案

我们可以将这22.07MB的图片资源上传至云端。在小程序使用时直接使用网络路径。那么打包时这部分资源尺寸就不会计算在主包尺寸中。那么我们如何将现存项目中使用的图片资源路径替换成网络路径?

  • ❎ 人工逐个替换

    • 需要修改使用到图片资源的业务代码工作量大且易出错

    • 且后续图片资源使用也很繁琐需要开发上传图片资源后使用网络地址编码。

  • ✅ 使用babel插件批量替换

    • 仅需要实现对应的babel插件逻辑并引入,无需修改业务代码

    • 开发无感,图片资源的使用方式不变

技术实现
  1. Taro 编译开始时使用taro插件上传本地图片资源

  1. import type {IPluginContext} from '@tarojs/service'
  2. import {PromisePool} from '@supercharge/promise-pool'
  3. import path from 'path';
  4. import fs from 'fs';
  5. import md5 from 'md5'
  6. const cacheFileName = "imgCache.json"
  7. /**
  8.  * 递归查找文件
  9.  */
  10. const travelFiles = (dir: string): string[] => {
  11.     const files = fs.readdirSync(dir);
  12.     return files.reduce<string[]>((result, file) => {
  13.         const filePath = path.join(dir, file);
  14.         if (!fs.statSync(filePath).isDirectory()) return [...result, filePath];
  15.         return [...result, ...travelFiles(filePath)];
  16.     }, [])
  17. }
  18. /**
  19.  * 文件路径格式化
  20.  */
  21. const filePathFormat = (ctx: IPluginContext, filePath: string) => {
  22.     return filePath.replace(ctx.paths.sourcePath, "@").replace(/\\/g, "/");
  23. }
  24. /**
  25.  * 生成文件 key
  26.  */
  27. const generateFileUniqueKey = (filePath: string) => {
  28.     const {dir, base, ext} = path.parse(filePath);
  29.     const buffer = fs.readFileSync(`${dir}${path.sep}${base}`);
  30.     return md5(buffer)
  31. }
  32. const cacheFile = path.join(__dirname, cacheFileName);
  33. interface PluginOpts {
  34.     fileDir: string,
  35.     upload: (filePath: string, fileKey: string) => Promise<string>
  36. }
  37. module.exports = (ctx: IPluginContext, pluginOpts: PluginOpts) => {
  38.     ctx.onBuildStart(async () => {
  39.         const {fileDir, upload} = pluginOpts
  40.         const fileDirPath = `${ctx.paths.sourcePath}/${fileDir}`;
  41.         const filePathList = travelFiles(fileDirPath);
  42.         // 上传文件
  43.         const {results: fileUrlList} = await PromisePool.withConcurrency(2)
  44.             .for(filePathList)
  45.             .process(async (filePath) => {
  46.                 const fileUrl = await upload(filePath, generateFileUniqueKey(filePath))
  47.                 return {filePath, fileUrl}
  48.             })
  49.         // 生成文件缓存数据
  50.         const fileUrlMap = fileUrlList.reduce((result, item) => {
  51.             const tempKey = filePathFormat(ctx, item.filePath)
  52.             return {...result, [tempKey]: item.fileUrl}
  53.         }, {})
  54.         fs.writeFileSync(cacheFile, JSON.stringify(fileUrlMap));
  55.     })
  56. }
  1. 使用babel插件替换tsjs中导入的图片

  1. import type {NodePath, PluginItem, PluginPass} from '@babel/core'
  2. import type {ImportDeclaration, Statement} from "@babel/types";
  3. import template from '@babel/template'
  4. import path from "path";
  5. import fs from "fs";
  6. const cacheFileName = "imgCache.json"
  7. const getCacheData = (filePath: string): Record<stringstring> => {
  8.     try {
  9.         fs.accessSync(filePath);
  10.         return JSON.parse(fs.readFileSync(filePath).toString());
  11.     } catch (error) {
  12.         return {}
  13.     }
  14. }
  15. module.exports = (): PluginItem => {
  16.     const cacheMap = getCacheData(path.join(__dirname, cacheFileName));
  17.     return {
  18.         visitor: {
  19.             ImportDeclaration(importDeclarationAstPath: NodePath<ImportDeclaration>, state: PluginPass) {
  20.                 if (state.file.opts.filename?.includes("node_modules")) return;
  21.                 const {node} = importDeclarationAstPath;
  22.                 const {value} = node.source;
  23.                 const fileUrl = cacheMap[value]
  24.                 if (!fileUrl) return;
  25.                 const [specifier] = node.specifiers
  26.                 const assignExpression = template.ast(`const ${specifier.local.name} = '${fileUrl}';`);
  27.                 importDeclarationAstPath.replaceWith(assignExpression as Statement);
  28.             }
  29.         }
  30.     }
  31. }
  1. 使用postcss插件替换样式文件中导入的图片

  1. import {AcceptedPlugin} from "postcss";
  2. import path from "path";
  3. import fs from "fs";
  4. const cacheFileName = "imgCache.json"
  5. const getCacheData = (filePath: string): Record<stringstring> => {
  6.     try {
  7.         fs.accessSync(filePath);
  8.         return JSON.parse(fs.readFileSync(filePath).toString());
  9.     } catch (error) {
  10.         return {}
  11.     }
  12. }
  13. const urlRegexp = /url\(['"]([^'"]*)['"]\)/
  14. const filePathFormat = (filePath: string) => filePath.replace('~@''@')
  15. module.exports = (): AcceptedPlugin => {
  16.     const cacheMap = getCacheData(path.join(__dirname, cacheFileName));
  17.     return {
  18.         postcssPlugin: 'auto-replace-assets-url',
  19.         Declaration(decl) {
  20.             if (!urlRegexp.test(decl.value)) return
  21.             let [_, filePath] = decl.value.match(urlRegexp)!;
  22.             filePath = filePathFormat(filePath)
  23.             if (!cacheMap[filePath]) return;
  24.             decl.value = `url(${cacheMap[filePath]})`
  25.         }
  26.     }
  27. }

总结

这里介绍了页面分包、公共模块分包和图片资源优化的方式优化小程序包体积。我们还可以共同探讨其他优化策略如:TS枚举编译优化、小程序分包异步化、提取公共样式、原子化样式组件等。

最后

Node 社群

 
 

我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。

af039de11e7e926ae5dd422245c1d277.png

“分享、点赞、在看” 支持一下
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/盐析白兔/article/detail/232582
推荐阅读
相关标签
  

闽ICP备14008679号