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

 
 

大厂技术  高级前端  Node进阶

点击上方 程序员成长指北,关注公众号
回复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是否属于分包

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

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下的公共模块信息

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的文件路径都是基于分包根目录。

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的文件路径基于分包根目录。所以对应的文件也需要做迁移。

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,并配置组件路径与组件文件路径之间的转换关系

module.exports = {
  plugins: [
    [
      'import',
      {
        libraryName: '@/components',
        libraryDirectory: '',
        transformToDefaultImport: false,
      },
    ],
  ],
};
  1. 按照配置的文件名与文件路径之间的转换关系定义组件

// component file -> @/components/component-a
export const ComponentA = ()=> <View/>

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

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

图片资源优化

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

解决方案

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

  • ❎ 人工逐个替换

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

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

  • ✅ 使用babel插件批量替换

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

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

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

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中导入的图片

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插件替换样式文件中导入的图片

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枚举编译优化、小程序分包异步化、提取公共样式、原子化样式组件等。

最后

Node 社群

 
 

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

af039de11e7e926ae5dd422245c1d277.png

“分享、点赞、在看” 支持一下
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
MATLAB经典拓扑优化程序169是一种基于MATLAB软件开发出的用于进行拓扑优化设计的程序,其目标是实现结构体积最小化。该程序通过分析给定的结构模型,自动生成优化设计方案,以满足特定的约束条件,如最小结构刚度、最小构件尺寸、最小频率等。该程序的主要步骤包括: 1. 建立结构模型:在程序中,我们首先需要建立结构的几何模型,并在MATLAB中进行相应的参数化。这一步骤可以使用建模工具,如CAD软件,将结构的几何形状转换为数字化的模型。 2. 设计变量设置:确定设计变量以及其取值范围。设计变量可以是结构的材料属性、横截面尺寸、连接方式等。通过设定不同的设计变量,可以探索多种结构设计方案。 3. 生成约束条件:根据设计需求,确定各种约束条件,如结构的稳定性、刚度、与外界环境的交互等。这些约束条件将用于筛选和优化设计方案。 4. 目标函数设定:设定目标函数,即优化问题的目标。在拓扑优化中,最常用的目标是最小体积、最小质量、最小应变能等。目标函数将根据设计变量和约束条件进行优化。 5. 优化算法选取:选择合适的优化算法来求解拓扑优化问题。常用的优化算法包括遗传算法、粒子群算法、模拟退火算法等。 6. 进行优化计算:基于所选的优化算法,进行优化计算。程序将根据设计变量的变化,不断迭代优化,以得到最优的结构方案。 7. 结果分析与验证:通过分析优化结果,评估优化设计方案的性能,并根据需求进行相应的调整。 MATLAB经典拓扑优化程序169是一个全面的工具,可以用于各种结构的优化设计。它提供了便利的界面和功能,使用户能够快速、准确地进行结构拓扑优化

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值