Gadzan

Monorepo 项目打包优化

monorepo-package-improve

为何要使用 Monorepo

在实际项目中,为了服务同一种需求可能需要管理不同类型的应用,在开发或打包时需要不同的配置,但应用之间也需要不同程度地复用一些共同的业务逻辑与 UI 组件代码。

根据功能我们可以将可复用的代码抽离为不同的 package 包,独立编译、测试和发布到私有 npm 仓库。Package 包在应用中可以通过 import 语法引用和复用。

同时,也可以根据需要在 package 内部共享一些通用的 hook、util 函数等。

在构建时,可以使用统一的构建配置和工具链。每个 package 不需要自己配置构建脚本,全部继承自主项目。

为了更好地复用这些共同代码,使用 Monorepo 的项目结构是最合适的。

打包是个问题

由于 Monorepo 的项目存在复杂的内部依赖关系,打包流程会变得更加复杂。

主应用在导入内部包时,需要的其实是这些包已经构建打包后的产出文件。所以在打包主应用之前,必须先打包内部的所有依赖包。而其中一些依赖包如果还依赖了其他内部包,那么这些被依赖的包也必须先打包。可以看出,这需要进行层层的串行打包,非常耗时。

这种预构建的要求,不仅体现在正式打包上,在本地开发时也存在。如果没有确保所有内部依赖包已经预构建,启动本地服务时就会报各种找不到模块的错误。

相比于传统项目,这种复杂的层层打包流程势必会严重拖慢 Monorepo 项目的初次构建速度,无论是在本地环境还是CI/CD的流水线构建中。解决打包流程的低效是 Monorepo 项目急需解决的痛点。

解决办法

大部分情况下在开发环境或打包时,应用都不需要使用打包后的资源,直接引用依赖包源码就能跳过依赖打包阶段。

package.json 文件提供了 exports 字段作为自定义导出规则,即路径映射,具体可以参考这篇文章介绍:一文彻底搞懂package.json中的exports, main, module, type

那么我们只需要统一内部包的规范,在各自 package.json 中添加一条指向源码的 exports 规则,并在内部应用引用时,使用包指向的源码路径即可:

// @my/ui package.json
{
  "exports": {
    ".": {
      "default": "./dist/xxxxxx.js",
      "import": "./dist/xxxxxx.js",
      "require": "./dist/xxxxxx.umd.cjs"
    },
    "./source": {
      "default": "./index.ts"
    }
  }
}
// @my/app index.ts

// ...
import ui from @my/ui/source

app.use(ui).mount('#app');

假如不想对应用的代码有侵入式的修改,还可以用拦截的手段,

修改 tsconfig path

如果项目使用 TypeScript 可以直接修改 tsconfig 的 compilerOptions path 将 package 包指向源码中的入口文件;

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
        "@my/ui": ["../packages/ui/index.ts"],
        "@my/tools": ["../packages/tools/index.ts"],
        // ...
    },
}

拦截并修改包路径

修改 tsconfig 文件,相对繁琐,每次新增都需要手动在 tsconfig 中配置一遍,维护成本比较高。

如果项目中使用 vite 或者 rollup,我们可以利用其强大的插件功能来拦截包的名称并将其重定向。

vite 与 rollup 为插件提供了 resolveId 的方法来拦截包名称:

// vite.config.js
import { defineConfig } from 'vite'
import monorepoPlugin from './monorepoPlugin'

export default defineConfig({
  plugins: [
    monorepoPlugin() 
  ]  
})

// monorepoPlugin.js
export default function monorepoPlugin() {
  return {
    name: 'monorepo-plugin',
    resolveId(id) {
      if (id.startsWith('@my/ui')) {
        return id.replace('@my/ui', '../packages/ui')  
      }
    }
  }
}

对每个包分别定义不同的路径,维护成本还是有点高,而且不同的应用可能相对被依赖包的路径不一样。结合 exports 映射,我们可以优化一下 monorepoPlugin.js 文件:

// monorepoPlugin.js
export default function monorepoPlugin() {
  return {
    name: 'monorepo-plugin',
    resolveId(id) {
      if (id.startsWith('@my/')) {
        return id + '/source';
      }
    }
  }
}

这样每次访问 @my/ui 就会被重定向到 @my/ui/source 并经由包管理器指向包源码的 ./index.ts

.pnpmfile.cjs 钩子重定向

项目中如果使用了 pnpm 包管理器,可以使用 .pnpmfile.cjs 的钩子去修改对内部包的 package.json 文件,具体可以在这里了解使用方法。

在 monorepo 的根目录下定义 .pnpmfile.cjs 文件并填入以下代码,原理与 vite 的 resolveId 一样,通过重定向包名到源码:

function readPackage(pkg, context) {
  if (pkg.name.startsWith('@my/')) {
    pkg.exports = {
      ...pkg.exports,
      '/source': './index.ts'
    }
  }
  return pkg;
}

module.exports = {
  hooks: {
    readPackage
  }
}

总结

在解决 Monorepo 打包流程低效的问题时,我们可以采取上面介绍的这几种方法:

  1. 在内部包的 package.json 文件中添加一个指向源码入口的字段将入口映射到源码,在内部应用引用时使用指向源码的入口。
  2. 修改 tsconfigpath,将包的引用路径重定向到源码入口文件。
  3. 利用 Vite 或 Rollup 插件的 resolveId 方法,通过拦截和重定向包的名称来解决路径问题。
  4. 使用 pnpm 包管理器,在 .pnpmfile.cjs 文件中使用钩子函数来修改内部包的 package.json 文件,实现重定向。

通过这些方法,我们可以优化 Monorepo 项目的打包流程,提高构建速度,避免因为依赖包未正确构建而导致的错误。这对于在本地环境和 CI/CD 流水线中构建项目都是非常重要的优化点。


打赏码

知识共享许可协议 本作品采用知识共享署名 4.0 国际许可协议进行许可。

评论