《从0到1手写可插拔前端框架》4.仓库架构: Monorepo仓库实践

1,519 阅读9分钟

该小节主要知识点:

  • 什么是 Monorepo ?
  • ⭐️⭐️⭐️⭐ 搭建 mini-umi 项目并实践 Monorepo 组织管理
  • Monorepo 中的 构建顺序问题 与 循环依赖
  • Turborepo 高性能构建系统 接入与开源项目实践

本小节所有代码已放入 mini-umi 仓库 /juejin/mini-umi目录 下

什么是 Monorepo ?

Monorepo 是一种组织管理代码的方式,指在一个 git 仓库中管理多个项目
项目结构一般为:

.
├── package.json
└── packages
    ├── packageA 
    ├── packageB
    └── packageC

这样管理仓库有什么 好处 呢?

  • Monorepo 可以通过 workspace 快速软链接link,方便 npm包 的本地开发
  • 可以使用统一的工程化配置 如 tsconfig eslint prettier 等
  • 抽取公共依赖 减少重复安装

搭建 mini-umi 项目并实践 Monorepo 组织管理

1.根目录搭建

新建项目目录

mkdir mini-umi
code mini-umi

搭建项目骨架

pnpm init  // 新建 package.json
pnpm i typescript -d  // 安装 typescript 
tsc --init  // 新建 tsconfig 文件

修改tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "declaration": true,
    "skipLibCheck": true,
    "baseUrl": "./",
    "moduleDetection": "auto",
    "esModuleInterop": true
  }
}

使用 father 代替 tsc

什么是 father

father 是 蚂蚁体验技术部推出的一款 NPM 包研发工具,能够帮助开发者更高效、高质量地研发 NPM 包、生成构建产物、再完成发布。它主要具备以下特性:

  • ⚔️ 双模式构建: 支持 Bundless 及 Bundle 两种构建模式,ESModule 及 CommonJS 产物使用 Bundless 模式,UMD 产物使用 Bundle 模式
  • 🎛 多构建核心: Bundle 模式使用 Webpack 作为构建核心,Bundless 模式支持 esbuild、Babel 及 SWC 三种构建核心,可通过配置自由切换
  • 🔖 类型生成: 无论是源码构建还是依赖预打包,都支持为 TypeScript 模块生成 .d.ts 类型定义
  • 🚀 持久缓存: 所有产物类型均支持持久缓存,二次构建或增量构建只需『嗖』的一下
  • 🩺 项目体检: 对 NPM 包研发常见误区做检查,让每一次发布都更加稳健
  • 🏗 微生成器: 为项目追加生成常见的工程化能力,例如使用 jest 编写测试
  • 📦 依赖预打包: 开箱即用的依赖预打包能力,帮助 Node.js 框架/库提升稳定性、不受上游依赖更新影响(实验性) 以上摘自 father-#README.md

为什么要使用father?

因为他内置了 BundlessBundle 两种构建模式,而且内置有很方便的脚手架

tips:father 也是基于Umi微内核架构开发的

这里我们不使用 father 内置脚手架

npm i father

新建 .fatherrc.ts 配置文件

import { defineConfig } from "father";

export default defineConfig({
  cjs: {
    output: 'dist',
    sourcemap: true
  }
})

新建 src/index.ts

export const yang = 'yang'
console.log(yang);

修改 package.json

{
  "name": "mini-umi",
  "version": "1.0.0",
  "description": "a simple model for Umi",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "dev": "father dev"        // 与 tsc --watch 类似
  },
  "keywords": [],
  "author": "洋",
  "license": "ISC",
  "dependencies": {
    "father": "^4.1.0"
  }
}

运行 dev 脚本

npm run dev

效果为下图即算成功

image.png

2.创建 workspace 工作区

在工作区内的 package 可以很方便的使用软链接引用

# pnpm-workspace.yaml(根目录)
packages:
  - 'packages/*'

3.子包搭建

删除src,新建packages目录 在 package目录下 创建如下结构:

.
├── package.json
└── packages
    ├── packageA 
    ├── packageB
    └── packageC

这里与根目录搭建基本一致 大家自己试着创建一下

最终效果如下:

image.png

问题1:根目录中的 .fatherrc.ts 为什么要改名为 .fatherrc.base.ts (内容不变)

因为我们这边子目录中的 .fatherrc.ts 配置文件要继承根目录中的配置

import { defineConfig } from "father";

export default defineConfig({
  extends: '../../.fatherrc.base.ts'
})

问题2:为什么 tsconfig 不需要在每个子项目里面再写一份呢?

还记得 Monorepo 有个优点之一 可以使用 统一的工程化配置, 像tsconfig我们只需要在根目录写一份即可在所有包里生效

4.使用workspace

1.在所有子项目 package.json 中加入构建脚本

  "scripts": {
    "dev": "father dev",
+   "build": "father build"  
  },

2.在根目录 package.json 中加入

  "scripts": {
    "dev": "father dev",
+   "build:all": "pnpm run -r build"
  },

3.在根目录运行脚本

npm run build:all

效果如下,所有子包全部打包产物 dist 目录成功,说明 workspace 设置成功 image.png

原理:pnpm run -r xxx

-r 指的是递归 所以这里会递归执行所有workspace里的 xxx 命令,上述示例中即为 build 命令

⭐️⭐️⭐️⭐ 核心:4.使用软链接

1.修改 A包 配置
//package/packageA/package.json
 "dependencies": {
+   "package-b": "workspace:*"
  }

这里修改完毕依赖之后一定要重新安装依赖 pnpm i
出现链接标记即算成功 点开观察发现 package-b 就是我们旁边的 B包

image.png

2.修改 A包 内容
// package/packageA/src/index.ts
import { yang } from 'package-b'
console.log(yang);
3.修改 B包 内容
export const yang = 'yang from b'
4.也别忘了修改 B包 导出的入口文件
// package/packageB/package.json
- "main": "index.js",
+ "main": "./dist/index.js",
5.在根目录打包所有子包产物
npm run build:all
6.在A包中
node dist/index.js

image.png

到这里,我们 mini-umi 的 Monorepo 仓库就算是基本搭建成功了,是不是很简单(〃'▽'〃)

先接着往下看

Monorepo 中的 构建顺序问题 与 循环依赖

上述步骤中的 2-4-5 其实是很方便的

5.在根目录打包所有子包产物
npm run build:all

问题一:构建顺序问题:

前面提到了 npm run build:all 时 其实执行了 pnpm run -r build

它会去自动分析依赖关系,得到递归的顺序
你会发现 它先执行了 B包的build 再去执行 A包的Build
这明显符合我们的预期

因为我们的 A包 依赖于 B包的产物,所以正确顺序应该是 先build出 B包的产物再build A包

但是,要是不使用 pnpm run -r build,该怎么解决构建顺序的问题呢?

如何解决构建顺序问题呢?

1. 手动 先build A包 再build B包

优势:可控
劣势:管理的子包一旦庞大,可能要手动 build 几十次

2. 写不同的脚本组合

这个方案就比较多了,你可以写各种各样的脚本组合在一起去保证他的构建顺序
优势:方案较多
劣势:要写脚本 我懒

3. 使用可分析的构建--Turborepo

当我们使用 Turborepo 后他会去自动分析包的引用关系(这与pnpm run -r build是一致的),从而得到正确的构建顺序
下一小节将为我们的项目引入 Turborepo

问题二:循环依赖问题

循环依赖的例子很简单:
A包依赖了B包 B包依赖了A包
这个问题乍一看很不正常 傻子才会这么用吧 但是确实有可能出现 比方说在引用 ts 类型 的时候
其实 pnpm workspace 是可以支持循环依赖
问题在于 Trubo 不支持循环依赖

image.png

所以我们在 Monorepo 仓库中应尽量避免出现 循环依赖 的问题

Turborepo - 高性能构建系统 接入与实践

什么是 Turborepo

Turborepo is a high-performance build system for JavaScript and TypeScript codebases. Turborepo 是一个用于 JavaScriptTypeScript 代码库的“高性能”构建系统。
通俗一点讲,他是我们在 Monorepo 仓库中的一种优化构建方案
这一点一定要区分于 Mutirepo 和 Monorepo 的 代码组织方案

使用Turborepo有什么好处呢?

官方的表述是:

Turborepo leverages advanced build system techniques to speed up development, both on your local machine and your CI/CD.

1.Never do the same work twice

Turborepo remembers the output of any task you run - and can skip work that's already been done.

2.Maximum Multitasking

The way you run your tasks is probably not optimized. Turborepo speeds them up with smart scheduling, minimising idle CPU's.

翻译过来就是:

1.构建缓存 -- 而且可以远程缓存,这在团队开发十分有用
2.智能调度构建加速,减少空闲CPU
3.通过 Pipeline 定义任务之间的关系,加快构建速度
4.方便快捷的配置文件
当然,和Monorepo一样,当你去执行 npm run build:all 这种构建脚本时,Turborepo会为你自动根据它们的依赖关系,达到最优的构建顺序

如何为一个项目接入 Turborepo?

其实这部分还是比较简单的,大致分为以下几步:

1.确定你的 Monorepo 项目中的 workspace 正常

因为接下来 Turborepo 构建是要基于我们的 workspace

2.安装Turbo
pnpm add turbo -Dw
3.配置 Turbo 的配置文件
//turbo.json 这里是一套完整的官方示例
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": { // 这是上面提到的任务管道
    "build": {
      // A package's `build` script depends on that package's
      // dependencies and devDependencies
      // `build` tasks  being completed first
      // (the `^` symbol signifies `upstream`).
      "dependsOn": [
        "^build"
      ],
      // note: output globs are relative to each package's `package.json`
      // (and not the monorepo root)
      "outputs": [
        ".next/**"
      ]
    },
    "test": {
      // A package's `test` script depends on that package's
      // own `build` script being completed first.
      "dependsOn": [
        "build"
      ],
      "outputs": [],
      // A package's `test` script should only be rerun when
      // either a `.tsx` or `.ts` file has changed in `src` or `test` folders.
      "inputs": [
        "src/**/*.tsx",
        "src/**/*.ts",
        "test/**/*.ts",
        "test/**/*.tsx"
      ]
    },
    "lint": {
      // A package's `lint` script has no dependencies and
      // can be run whenever. It also has no filesystem outputs.
      "outputs": []
    },
    "deploy": {
      // A package's `deploy` script depends on the `build`,
      // `test`, and `lint` scripts of the same package
      // being completed. It also has no filesystem outputs.
      "dependsOn": [
        "build",
        "test",
        "lint"
      ],
      "outputs": []
    }
  }
}

4.添加.gitignore

将它的缓存文件 ignore 掉,防止上传到 Github 仓库

// gitignore
+ .turbo
5.替换构建脚本
- ”build“: "xxxx"
+ "build": "turbo run build"
6.(可选)远程构建缓存
npx turbo login   // 登录远程缓存账号
npx turbo link    // 将当前构建仓库与远端 link

远程构建缓存团队协同开发的时候能提升不少的速度

到这里,我们的 Turborepo 接入基本上就完成了,只需要执行 npm run build 即可看到效果

下面是 开源项目 实战

Turborepo 实战案例

这边我用蚂蚁AntV团队新开源的 GraphInsight 为例接入 Turborepo

这是他现在的 package.json

  "scripts": {
    "preinstall": "npx only-allow pnpm",
    "postinstall": "npm run build:all:es",
    "build:all:es": "pnpm run -r build:es",
    "start": "cd packages/gi-site && npm run start",
    "gi-common-component": "cd packages/gi-common-components && npm run build:es",
    "gi-sdk": "cd packages/gi-sdk && npm run build:es",
    "gi-assets-basic": "cd packages/gi-assets-basic && npm run build:es",
    "gi-assets-advance": "cd packages/gi-assets-advance && npm run build:es",
    "gi-assets-algorithm": "cd packages/gi-assets-algorithm && npm run build:es",
    "gi-assets-scene": "cd packages/gi-assets-scene && npm run build:es",
    "gi-assets-graphscope": "cd packages/gi-assets-graphscope && npm run build:es",
    "gi-assets-neo4j": "cd packages/gi-assets-neo4j && npm run build:es",
    "gi-assets-tugraph": "cd packages/gi-assets-tugraph && npm run build:es",
    "gi-theme-antd": "cd packages/gi-theme-antd && npm run build:es",
    "build": "npm run build:site && npm run move:dist",
    "build:assets": "cd packages/gi-assets-basic && NODE_OPTIONS=--max_old_space_size=2048 npm run build",
    "build:basicAssets": "cd packages/gi-assets-basic && NODE_OPTIONS=--max_old_space_size=2048 npm run build",
    "build:core": "cd packages/gi && NODE_OPTIONS=--max_old_space_size=2048 npm run build",
    "build:site": "cd packages/gi-site && pnpm install && NODE_OPTIONS=--max_old_space_size=2048 npm run build",
    "build:testing": "cd packages/gi-assets-testing && NODE_OPTIONS=--max_old_space_size=2048 npm run build",
    "clean:all": "pnpm run -r clean",
    "core": "cd packages/gi && npm run start",
    "move:dist": "node ./scripts/deploy.js",
    "site": "cd packages/gi-site && NODE_OPTIONS=--max_old_space_size=2048 npm run start"
  },

之前是

image.png

这里的 build:all:es 为了保障执行顺序,属实是 '辛苦' 它了,所以我给他提了pr,将他用 pnpm run -r build:es 替换掉了

现在我们使用 Turborepo 替换掉它

1.安装 Turbo
pnpm add turbo -Dw 
2.加上配置文件 Pipeline
// turbo.json
+{
+  "$schema": "https://turbo.build/schema.json",
+  "pipeline": {
+    "build:es": {
+      "dependsOn": [
+        "^build:es"
+      ],
+      "outputs": [
+        "es/**",
+        "lib/**"
+      ]
+    }
+  },
+  "globalDependencies": [
+    ".prettierrc.js"
+  ]
+}
3.替换构建脚本
// package.json
-     "build:all:es": "pnpm run -r build:es",
+     "build:all:es": "turbo run build:es",
4.缓存文件加入gitignore
// gitignore
+ .turbo

执行 "npm run build:all:es" 即可看到效果:

⭐️ 替换之前的 "pnpm run -r build:es"

共计用时:5.1+9.5+10.2+28.2+20.4+26 s image.png

⭐️ 替换之后的 "turo run build:es" 第一次构建+缓存

共计用时 44 s image.png

⭐️ 缓存之后的 "turo run build:es" 构建

平均时间: 800+ms image.png

image.png

image.png

40s+ 优化到了 1s 以下,只能说非常香了

直接给 AntV 提交 Pr

image.png

小结:

本小节我们

1.首先为大家介绍了什么是 Monorepo。它其实是一种代码仓库的组织管理方式,通过Monorepo的管理,我们可以很方便的在 workspace 中开发本地 npm库,统一规范配置

2.通过代码带着大家搭建好了一个实用的 Monorepo仓库,防止大家踩坑,并学会了一些Monorepo开发中的小技巧,也引出了我们下文中的 pnpm构建顺序问题 以及 Turborepo

3.为大家介绍了 pnpm 使用 Monorepo 管理仓库出现的两个问题:构建顺序以及循环依赖问题以及它们的解决方案

4.引入了现代高性能构建方案-Turborepo,简述了如何在一个 Monorepo 项目中接入Turbo,并以蚂蚁体验技术部开源产品 GraphInsight 为了进行实战接入演示效果

下一小节

小节思考:

1.Monorepo 管理仓库 究竟有哪些好处呢?它与传统 Mutirepo仓库 有哪些优劣你能说的上来吗
2.你能在我们的新建的 mini-umi 项目中接入 Turborepo 吗,试一下吧
3.你知道新建 .fatherrc.ts 配置文件的时候 为什么 export default 后面要使用一个defineConfig 函数
2.1 已知信息一:defineConfig函数 参数是 config 返回值也是config
2.2 已知信息二:vite 配置文件中也是同样的用法

import { defineConfig } from "father";
export default defineConfig({
  cjs: {
    output: 'dist',
    sourcemap: true
  }
})