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

引言

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

优化方式

页面分包

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

公共模块分包

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

  • common.js包含了业务中的公共组件、工具方法、hooks等逻辑
  • common.wxss包含了业务中公共组件的样式、全局样式
  • vendors.js包含了三方依赖逻辑

解决方案

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

技术实现

文档链接:docs.taro.zone/docs/config...

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

源码解析

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

  1. 收集分包入口数据用于后续判断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}\\/`))

    // ... 省略部分代码 ... 
  }

  // ... 省略部分代码 ...
}
  1. 找到分包入口chunk。循环构成chunkmodule。其中没有被主包引用,且被多个分包引用的记录在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 生成
          }
        })
      })

    })
  }
  // ... 省略部分代码 ...
}
  1. SplitChunksPlugin完成chunks拆分后收集分包下的sub-vendorssub-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
  }
  // ... 省略部分代码 ...
}
  1. 基于收集的分包下的sub-vendorssub-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
        }
      })
    }
                                   }

  // ... 省略部分代码 ...
}
  1. 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的使用方式不变

技术实现

  1. 引入插件babel-plugin-import,并配置组件路径与组件文件路径之间的转换关系
javascript 复制代码
module.exports = {
  plugins: [
    [
      'import',
      {
        libraryName: '@/components',
        libraryDirectory: '',
        transformToDefaultImport: false,
      },
    ],
  ],
};
  1. 按照配置的文件名与文件路径之间的转换关系定义组件
javascript 复制代码
// component file -> @/components/component-a
export const ComponentA = ()=> <View/>

// business code 
import {ComponentA} from '@/components'

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

图片资源优化

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

解决方案

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

  • ❎ 人工逐个替换
    • 需要修改使用到图片资源的业务代码工作量大且易出错
    • 且后续图片资源使用也很繁琐需要开发上传图片资源后使用网络地址编码。
  • ✅ 使用babel插件批量替换
    • 仅需要实现对应的babel插件逻辑并引入,无需修改业务代码
    • 开发无感,图片资源的使用方式不变

技术实现

  1. 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));
    })
}
  1. 使用babel插件替换tsjs中导入的图片
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);
            }
        }
    }
}
  1. 使用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枚举编译优化、小程序分包异步化、提取公共样式、原子化样式组件等。

相关推荐
三木吧2 小时前
开发微信小程序的过程与心得
人工智能·微信小程序·小程序
Kika写代码2 小时前
【微信小程序】3|首页搜索框 | 我的咖啡店-综合实训
微信小程序·小程序
金金金__2 小时前
微信小程序:解决顶部被遮挡的问题
微信小程序·小程序
兔C14 小时前
微信小程序的轮播图学习报告
学习·微信小程序·小程序
用户480622604141516 小时前
使用uniapp开发微信小程序-框架搭建
微信小程序·uni-app
嘟嘟实验室16 小时前
微信小程序xr-frame透明视频实现
微信小程序·ffmpeg·音视频·xr
Stanford_110619 小时前
高级的SQL查询技巧有哪些?
sql·微信小程序·twitter·微信开放平台
美美的海顿21 小时前
spring boot 火车售票微信小程序LW
spring boot·后端·微信小程序·小程序·毕业设计
Kika写代码1 天前
【微信小程序】1|底部图标 | 我的咖啡店-综合实训
微信小程序·小程序
JSON_L1 天前
小程序 - 模拟时钟
微信·微信小程序·小程序