Monorepo学习与Rush.js实践

2,371 阅读7分钟

Monorepo是什么

Monorepo简单来说,是指在一个项目仓库 (repo) 中管理多个模块/包 (package) 比如:一个电商系统会对于用户入口来说,可能有PC版、H5版、小程序版,同时,背后会有一个配套的管理系统admin、供应商对接系统等。Monorepo就是将这些package的代码都放在一个项目仓库中管理。

优势

  • 统一工作流,便于代码复用:代码都在一个代码库里,代码复用、调试快速
  • 只要搭建一套脚手架,就能管理(构建、测试、发布)多个 package
  • 代码重构的时候scope可控:一个方向的代码都收敛在一个项目中,影响范围相对清晰

问题

  • 模块代码增长时,可能出现依赖爆炸:不同package 之间有许多相同的依赖,可能使install时出现重复安装,使本来就很大的 node_modues 继续膨胀
  • 业务代码演进后,整个项目代码包越来越大的install、build效率容易受很大影响
  • 项目粒度的权限管理比较复杂:项目都在一个包里面了,不同团队的权限控制得划分到文件夹

Monorepo适合哪些场景

业务场景示例

某业务系统,业务上涉及到:用户侧(PC/H5),审核侧PC

  • 用户侧的两个端除了UI层数据层、逻辑层、接口层是一致
  • 审核侧和用户侧有很大部分的数据结构是重合的:比如备案信息、备案状态等
  • 三个端有很多共享的基础业务数据配置
  • 由于数据结构相似,存在通用业务组件
  • 用户侧PC和H5分两个代码库时,有逻辑变动,需要在两个分散的项目进行修改,新人接手的时候,容易漏改,两个项目中业务逻辑经常有不一致

可改进的

  • 加强用户侧的各个端业务逻辑一致性:用户侧的多个端统一数据层、逻辑层、接口层、在有需求变化的时候,相同的部分尽可能做到只改一次,UI层根据端情况适配
  • 提高代码复用:用户侧和审核侧底层存在大量基础数据结构是相同的,比如,前置审批信息、主体信息、负责人信息等,相同的数据结构可以复用interface、request等
  • 相同UI平台的业务组件复用:用户侧PC和审核侧PC在UI层存在许多相似的信息展示内容,比如两个端都需要展示备案详情

为什么适合Monorepo

1、主业务流程和功能规模相对稳定,规模不太可能剧增

2、业务一般由单个团队维护,在多端之间的权限相对一致,涉及端规模比较可控

3、用户侧业务逻辑有变的时候,常对上线时间有强要求,因此最好变更范围易控,一次修改可多端生效

Why Monorepo三问

  1. 业务逻辑和涉及端在两年内剧烈变化和扩张的可能性大不大?
  2. 业务不同端之间,未来会涉及到多个不同团队维护?
  3. 业务各个端之间对于相似模块的定制化开发需求是否会很多?

Why Monorepo.png

Quota from : Reference

Monorepo解决方案

前置概念学习

每一个文件都有一个唯一的 inode,它包含文件的元信息,在访问文件时,对应的元信息会被 copy 到内存去实现文件的访问。 可以通过stat命令查看

Symlink(软链接)

符号链接软链接、Symbolic link)是一类特殊的文件, 其包含有一条以绝对路径或者相对路径的形式指向其它文件或者目录的引用。 对符号链接文件进行读写的程序会表现得像直接对目标文件进行操作。 WIKI-软链接

  • 软链接可以理解为是一个单向指针,是一个独立的文件且拥有独立的 inode,永远指向源文件
  • 所以,如果文件被删了,软链还是存在,只是会变成dead link,如果文件又恢复了,软链又会生效
  • 软链的删除不会对源文件有什么影响

Hard Link(硬链接)

  • 硬链接会直接指向源文件的inode,所以修改源文件或者修改hard link都会同步修改
  • 增加一个hard link会增加节点链接数量,所以只要连接数不是0,文件就一直存在。

Lerna+yarn workspace

推荐阅读:现代前端工程化 — Lerna 从原理到实战详解

  • 无法很好的解决phantom dependencies和doppelgangers
  • Yarn 依赖提升,在 peerDependencies 场景下可能导致 BUG。
  • 对于「依赖爆炸」的问题,lerna 在安装依赖时提供了-hoist选项,相同的依赖,会「提升」到 repo 根目录下安装,但lerna 直接以字符串对比 dependency 的版本号,完全相同才提升,semver语义化的版本约定不起作用。

Rush.js + pnpm

Rush.js文档

核心优化

phantom dependencies(幽灵依赖)

没有在package.json中指定安装,却可以在项目中被引用的依赖 例如: 项目project-A中引用了component-b, 组件component-b内引用了库lib-a,项目project-A内引用lib-a能生效么? 在npm3.0后是可以的。因为npm原有的树形结构,造成了依赖冗余和路径过深。npm3后开始扁平化,把原来不统计的依赖变成了同级依赖。这样我们就可以在项目中直接引用到了。

  • 问题
    • 版本不一致,如果a和b都引用了C,但是a引用的是c v1.0.1,b引用了c v2.0.1,两个版本之间会冲突。尤其是如core-js这种基础包如果一个项目存在两个版本,可能会直接报错。

Rush.js的Symlink机制

Rush会将项目依赖全部都安装在repo根目录的common/temp下,然后提供symlink到各个项目中去引用。原本的项目中,依然会保持原有的node_modules结构

保证只会在项目的node_modules中保留package.json中声明过的依赖的symlink,这样就解决了phantom dependencies

doppelgangers

分身重复依赖:一个依赖的不同版本被一个项目依赖的时候,至少一个版本都会被安装多次,导致项目臃肿,打包变慢 例子:rushjs.io/pages/advan…

  • pnpm可以解决重复依赖问题(如果使用yarn/npm还是存在重复依赖问题)
    • pnpm会再全局store目录中存储项目依赖
    • 如果store中有了对应依赖,会向对应项目的node_modules建立硬链接,而不用重新安装
    • pnpm中会把项目带上版本号再进行扁平化,就可以解决分身重复依赖的问题

【推荐阅读】 更多关于幽灵依赖的历史成因参考:rushjs.io/pages/advan… 实践:pnpm 解决了我的哪些痛点?

其他特点

  • 跨项目增量编译:如:package A 依赖package B,开发package A的时候,修改package B,可以实现热更新
  • 编译加速:独立项目多进程编译

Rush.js + pnpm实践

安装和常用指令

// 全局安装Rush
npm install -g @microsoft/rush

//创建一个项目文件夹
mkdir person
cd person

// 项目初始化
rush init

// 任意目录下
rush install 安装依赖

rush update:当代码依赖发生变化时执行

rush build:编译代码

rush purge:删除 rush 相关的 temp 文件

// 指定项目编译pkg-name为rush.json中配置的packageName

rush build --to pkg-name

代码结构

  • admin-pc:管理侧
  • user-h5:用户侧H5
  • user-common:通用的业务逻辑库

项目结构.png

rush.json

{
  "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json",
  "rushVersion": "5.59.2",
  "pnpmVersion": "6.7.1",
  /* 建议使用pnpm */
  "pnpmOptions": {
    "useWorkspaces": true,
    "resolutionStrategy": "fast"
  },
  /* 安装时注意指定支持的node版本 */
  "nodeSupportedVersionRange": ">=12.13.0 <13.0.0 || >=14.15.0 <17.0.0",
  "eventHooks": {
    "preRushInstall": [],
    "postRushInstall": [],
    "preRushBuild": [],
    "postRushBuild": []
  },
  /* 重要配置: projectFolder项目的路径
  packageName要和package.json当中保持一致 */
  "projects": [
    {
      "packageName": "user-h5",
      "projectFolder": "apps/user-h5"
    },
    {
      "packageName": "admin-pc",
      "projectFolder": "apps/admin-pc"
    },
    {
      "packageName": "@user-common",
      "projectFolder": "libs/user-common"
    },
    {
      "packageName": "@ui-common",
      "projectFolder": "libs/ui-common"
    }
  ]
}

添加依赖

注意:不要直接使用 yarn add 或者 npm install,而要使用 rush add 添加包依赖

需要cd到对应项目文件夹之后:

// 添加包依赖
rush add --package XXXX

// 添加指定版本的依赖,这里不支持semi语义,版本号和标识必须完全一样
rush add --package "react^17.0.2"

// 添加到devDependencies
rush add --package "typescript" --dev

每次添加完新的依赖后,需要执行 rush update更新依赖,再执行rush build

热更新

当开发中,修改libs中的库的时候我们想要apps中的库能同步热更新,可以使用watch模式

我们需要在对应项目的package.json添加对应的编译命令

// user-common:添加build:watch指令,内容和你项目选择的脚手架有关,就是编译的命令
"scripts": {
    "build:watch": "tsdx build",
}

// 主项目user-h5: &后面是主项目脚手架启动的指令,我这里是用的是reskript
"start:watch": "rush build:watch --to-except user-h5 & skr dev"

这样就可以直接hotreload,但是其实watch模式启动的时候,libs库一修改就编译,会先编译libs中的依赖库再编译主项目中的依赖库,实际开发的时候,编译速度其实不快。

真实开发的时候,我一般不开启watch模式,当libs当中修改完毕之后,直接执行rush build --to pkg-name 重新编译后,刷其实也可以达到同样的效果。

因为当主项目启动之后,实际上是直接引用的子项目编译后的dist文件夹中的编译产出,所以只要dist中的编译产出变化了,主项目也会自动更新。

CI编译流水线适配

# Install rush
workdir=$(pwd)
echo "Install rush..."
node common/scripts/install-run-rush.js install

# $PARTITION 就是文件夹的名称,根据CI的配置更换参数
export REPODIR="apps/$PARTITION"
node common/scripts/install-run-rush.js build --to=$PARTITION

参考

软链接

Monorepo + lerna & rush.js

精读《Monorepo 的优势》

github.com/ascoders/we…

All in one:项目级 monorepo 策略最佳实践

segmentfault.com/a/119000001…

Rushjs 构建 monorepo