多项目逻辑复用与monorepo

去年接手了一个新的项目,主要分为了移动端和PC端,在项目初期,两端承担不同的业务职责,在后期迭代过程中,陆续出现了一些相同和相似的业务逻辑,随之而来的是很多相似的代码。回想之前,也经历过类似的场景,比如从一个to B的项目中再拆分一个to C的项目,两个项目具有业务或平台的差异,但也有很多相同的地方。

本文主要研究通过monorepo来处理这种多项目中逻辑复用问题。

<!--more-->

1. 单项目

对于单个项目而言,相同的逻辑往往会基于文件封装模块或组件,然后通过引入模块的方式复用公共逻辑即可。

下面是一个常见的单项目目录结构

├── App.vue
├── main.js
├── api
├── assets
├── components
├── router
├── store
├── util
└── views

2. 多项目

而多个项目之间可能存在千丝万缕的联系,比如有相似的开发环境、相同的业务逻辑等等

2.1. 脚手架

如果是多个项目使用了相似的技术栈或开发环境,可以通过封装脚手架cli来实现,可以参考vue-clicreate-react-app

2.2. 逻辑复用

随着开发项目的逐渐变多,我们会发现某些业务场景会经常遇见,最简单的做法是复制过来,稍微改动一下就可以用了。

经验告诉我们这样做肯定是不明智的,那么该如何处理多个项目之前的公共逻辑呢?

将公共逻辑封装成模块是最简单的方法,由于是跨项目的模块,还需要找个地方进行托管,没错,说的就是npm包。

将模块发布到模块仓库,每个项目安装对应的模块依赖,这是前端模块化最常规的做法,npm仓库存在无数的模块。

然而在实际开发中,这种方式存在一些硬伤。

2.3. 调试本地模块

首先是开发体验变差,将源代码放在一起进行调试无疑是最方便的;但如果是基于模块,就必须走node modules模块这条路,开发模块->打包模块->发布包->重新安装新模块->使用新模块就跟我们在本地改动一下代码,然后发布到线上去调试一样,,想想都很麻烦,也违背了工程化的意义

npm提供了安装本地包的方式,npm pack将模块目录打包成tgz,然后可以通过npm install xxx.module.tgz,这样就可以直接安装本地模块包了,缺点是需要手动打包,然后重新安装依赖,也不灵活

能否绕开手动打包这一步呢?可以通过相对路径直接安装模块,npm install path/to/module,这样也可以安装本地模块,但是当模块发生变化时需要重新安装

本地模块改变时,能不能不要重新安装呢?可以使用npm link

# 在模块目录下执行link命令,链接到全局模块
cd path/to/module1
npm link

# 在项目目录下执行l
cd path/to/project
npm link module1 

# 移除link
npm unlink module1

link本身只是对目录做了一个软链接,如果换了一个开发环境,又需要重新来一次,因此只局限于单人本地调试本地模块。

2.4. 维护每个模块的被依赖项目

npm包的版本遵循SemVer规范,即X.Y.Z的格式,X 是主版本号、Y 是次版本号、而 Z 为修订号。每个元素必须以数值来递增。

  • 主版本号(major):当你做了不兼容的API 修改
  • 次版本号(minor):当你做了向下兼容的功能性新增
  • 修订号(patch):当你做了向下兼容的问题修正。

在查看package.json的已安装库

  • X.Y.Z,直接安装指定的版本
  • ~X.Y.Z,会安装当前minor version(也就是中间的那位数字)中最新的版本,如^3.1.0最后实际安装的版本可能是3.1.10,但不会更新到3.2.x
  • ^X.Y.Z,会安装当前major version(也就是第一位数字)中最新的版本,如^3.1.0最后实际安装的版本可能是3.10.0,但不会更新到4.x.x,目前是npm install默认的插入符号了

这种在不指定特定版本下默认添加插入符号的操作,可能会带来一些意料之外的结果,如果所有的模块都按照标准的SemVer规范来更新版本号当然是最好的,但如果使用了错误的版本,比如安装到完全无法兼容的版本,就会导致代码抛出异常。

为了解决不同环境下安装到不同的包,又出现了package-lock等方案。

如果不信任这个自动更新机制,我们可以对所有的模块都采用完全指定版本的方式进行安装。

这又会带来一个新的问题,公共模块在发布更新之后,如果希望依赖于这个模块的项目进行更新,则需要手动去修改每个依赖项目的中该模块的版本号。

换言之,虽然我们封装了公共模块,却仍旧需要维护依赖这个公共模块的所有项目,试想某个受欢迎的公共模块被100个写死了版本号的项目所依赖,修改起来也是头疼不已。

3. monorepo

如果有一种新的模块引入方式,能够像npm link一样即时感知本地模块的改动,同时在本地模块版本更新后自动同步依赖版本,是不是就能解决前面这两个问题了呢?

想想在单项目中,我们甚至都没有考虑过这些问题。这是因为没有比将模块源码与项目源码放在一起更容易调试的了。

将不同项目的代码放在一起的方式被称为monorepo。假设有两个项目,app-mobileapp-pc,他们共同依赖mod1mod2mod3,monorepo采用的方案就是将这5个项目放在同一个仓库中

因此,大型前端项目走逐渐采用monorepo作为项目代码的管理方式,其主要特点是在单个仓库中管理多个npm包,如ReactVue等项目目前均采用这种结构。

除了开源项目而言,对于本文前面讨论的多项目逻辑复用,monorepo也是十分方便的。

3.1. lerna

参考:

lerna提供了一种快速搭建monorepo仓库的方式,并通过使用git和npm来优化monorepo项目的工作流程。lerna提供了大量的指令,如创建模块、管理模块依赖、发布模块等

新建monorepo项目

# 初始化,会生成lerna.json配置文件
lerna init

# 创建模块1,模块位于packages/test1目录下
lerna create test1

# 创建模块2
lerna create test2

# 将test1添加为test2的依赖
lerna add test1 --scope test2

# 提交代码
git add . 
git commit

# 修改git tag,更新所有模块版本号,发布包到对应的npm仓库,测试的话可以通过verdaccio发布到本地私有仓库
lerna publish

如果是拉取monorepo项目,则需要

# 安装每个包的依赖,然后为相互依赖的包自动建立link,最后执行npm prepublish
lerna bootstrap

下面是在使用中遇见的一些问题。

问题1:当本地仅仅是修改了代码,还没有commit时,lerna updated无法查看到包的变化。需要提交之后,才能看见变化。如果存在某个包的变化之后,再继续修改其他的包,其他的包及时没有提交,也可以查看到对应包的变化。

举个例子:

  • 修改mod1,未提交时,lerna updated 返回空
  • 修改mod1,然后提交,未进行发布,lerna updated 返回mod1
  • 此时再修改磨mod2,不进行提交和发布,此时lerna updated 返回mod1 和 mod2

可以研究一下lerna updated的检测原理。

问题2:无法实现自定义发布某些模块的场景

参考issue1691issue1055,基于lerna的使用git tag的特性,要么全量发布,要么都不发布。

lerna 不会发布private的包,但是在publish时也需要更新对应的version,

问题3:每个包的版本都是一致的,尽管在某次提交中并没有更新

Fixed/Locked(默认)模式下面,所有包的版本号都是一致的,维护在lerna.jsonversion中,可以通过lerna init --independent选择Independent独立模式,每个包可以拥有自己的版本号,由每个包自己的package.json维护

问题4:如果多个模块都依赖于第三方模块,使用leran add 时,会在每个模块的node_modules下都安装一次

下图展示了mod1和mod2都依赖axios,当执行lerna bootstarp时,axios会被重复安装。

这是因为,lerna作为多模块流程管理工具,认为每个模块下的package.json都是独立的,因此在安装的时候会重复调用yarn install多次,导致每个模块会重复安装一些公共的模块。

问题5:在某个依赖于其他局部模块的项目下,单独运行yarn install时,会破坏lerna本身的link,安装到错误的模块

下图展示了lerna add mod2 --scope=mod3之后,cd packages/mod3 && yarn时,会安装到错误的mod2(npm上一个错误的同名包),而不是我们预期的packages/mod2

除了lerna之外,还有Bit、Bazel等多个monorepo管理工具,详情可移步:2021年管理Monorepo代码库的11种出色工具

3.2. yarn workspace

参考

上面提到了的问题4和问题5是lerna的硬伤,lerna本身只管理模块的建立、关联和发布流程,并不像yarnnpm一样参与实际的包安装,重复安装和智能识别link并安装的工作应该交给包管理工具,因此yarn实现了这个功能:yarn workspaces

yarn workspaces主要解决从多个子目录下不同的package.json中安装依赖的问题,下面是一个workspace项目的package.json

{
  "name": "workspace_demo",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "private": true,
  "workspaces": [
    "packages/*"
  ]
}

所有模块都放在workspaces目录下,跟lerna相同。使用实践:由于yarn和lerna在功能上有较多的重叠,我们采用yarn官方推荐的做法,用yarn来处理依赖问题,用lerna来处理发布问题

在一个workspaces的项目中执行lerna init,会自动转换成 lerna workspace模式

{
  "packages": [
    "packages/*"
  ],
  "version": "0.0.0"
}

然后使用lerna来处理模块创建和发布,使用yarn worksapce来管理依赖


# 快速创建模块
lerna create mod1 -y
lerna create mod2 -y

# 会在项目根node_modules下安装axios,这样就避免了上面问题4:重复安装相同依赖
yarn workspace mod1 add axios
yarn workspace mod2 add axios

# 声明依赖,mod2添加依赖mod1,需要注意的是这里需要指定本地的包版本号
yarn workspace mod2 add mod1@0.0.0

# 本地开发一通修改,mod2会即时获取mod1模块的内容,本地开发调试很方便

# 后续lerna publish发布

3.3. 在项目中的实践

在实际开发中,不一定非得将拆分的模块发布到npm或者私有仓库上,我们最初的目的是跨项目的逻辑复用。使用monorepo之后,做个项目集中在同一个git仓库下,这样就可以很方便地进行逻辑复用、开发调试及发布。

假设现在需要重构片头提到的移动端和PC端项目,他们除了UI的差异之外,很多逻辑都是相同的,采用monorepo之后,整个项目的大致结构如下

├── package.json
├── packages
│   ├── mobile # 移动端业务模块
│   ├── pc # PC端业务模块
│   ├── common-util
│   ├── message-box
│   ├── request
│   ├── storage
│   └── track-log
├── tsconfig.json
└── yarn.lock

优点1:通过workspace来拆分模块,在单个workspace中的代码很方便进行移植,这样可以在开发前期不用过分考虑项目的划分,一个monorepo项目中,

  • 多个被复用的npm包,包含公共的组件和逻辑等
  • 业务部署项目,依赖其他npm包

在业务模块中编写代码,遇见多个模块公用的逻辑,就拆分成独立的模块,拆分的模块也可以很方便地迁移到其他公共组件库。我们很难在项目初期就将各个模块的职责给划分好,在monorepo中,这种随时拆分模块的方式可以保证我们在业务的迭代同时比较方便地进行移植和重构

优点2:由于是在同一个项目下,pc端和移动端可以使用同一套开发环境,本地公共模块也可以使用相同的开发环境。

基于这点,每个模块甚至可以发布源码,而无需再为每个模块搭建独立的开发环境,如配置ts、配置babel、配置rollup打包等(除非我们的模块需要发布到其他仓库上供其他不同开发环境的人员使用),这大大提高了开发调试效率。

3.4. 一个演示demo

基于这个思路,编写了一个monorepo的demo,github地址,介绍了在多个react项目中复用常规模块和jsx组件模块。

整个项目只完成了本地多模块开发的部分,并没有补充搭建和发布到verdaccio等私有仓库的部分,目前看起来应该能够满足我最初的需求了。

下面是搭建整个项目的具体步骤。

首先创建目录

mkdir monorepo-demo && cd monorepo-demo

初始化package.json

yarn init -y

修改package.json,配置workspaces字段

{
  "name": "monorepo-demo",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "private": true,
  "workspaces": [
    "packages/*"
  ]
}

初始化 lerna

lerna init

创建第一个公共模块mod1

# 创建的默认版本是0.0.0
lerna create mod1 -y

修改一下mod1的源码packages/mod1/lib/mod1.js

module.exports = mod1;

function mod1() {
    console.log('mod1 ')
}

然后再创建一个应用模块,这里使用create-react-app创建

# 等他安装一会
cd packages && create-react-app react-app

# 启动项目
yarn workspace react-app start

为react-app项目模块安装刚才创建的mod1模块

yarn workspace react-app add mod1@0.0.0

修改代码试试,packages/react-app/index.js

import mod1 from 'mod1'
mod1()

大工告成,我们创建了第一个可用的模块

接下来创建一个react组件模块u-button

lerna create u-button -y

这次我们导出一个button组件,packages/u-button/lub/u-button.jsx(记得修改u-button模块package.json的main入口字段)

import React from 'react'
const Button = ()=>{
    const text = 'hello'
    return (<button>{text}</button>)
}
export default Button

如法炮制,向react-app添加u-button的依赖,

yarn workspace react-app add u-button@0.0.0

然后引入,

import UButton from 'u-button'
const node = <UButton />

不出意外,会看见下面的错误

File was processed with these loaders:
 * ../../node_modules/@pmmmwh/react-refresh-webpack-plugin/loader/index.js
You may need an additional loader to handle the result of these loaders.
| const Button = ()=>{
|     const text = 'hello'
>     return (<button>{text}</button>)
| }
|

这是因为create-react-app默认的babel配置排除了node_modules,,我们需要放开其限制

yarn workspace react-app add customize-cra react-app-rewired -D

关于customize-crareact-app-rewired的具体使用这里不详细展开,大致步骤

  • react-app项目目录下创建config-overrides.js
    const {babelInclude} = require('customize-cra')
    const path = require('path')
    module.exports = (config, env) => {
      // 各个workspace直接输出原始代码,因此需要加入babel
      babelInclude([
          path.resolve('../../packages'),
      ])(config)
      return config
    }
  • 使用react-app-rewired替换react-app的package.json下的相关指令
    "scripts": {
      "start": "react-app-rewired start",
      "build": "react-app-rewired build",
      "test": "react-scripts test",
      "eject": "react-scripts eject"
    }

然后重新启动项目即可

yarn workspace react-app start

修改u-button模块中的内容,也能体验到热更新的开发体验,完美

我们再创建一个新的react项目,取名为react-app2

cd packages && create-react-app react-app2

# 重新处理依赖,将第三方依赖提升至root node_modules
yarn 

react-app与react-app2都可以使用刚才的公共模块mod1、u-button,甚至是customize-cra覆盖的开发环境

yarn workspace react-app2 add mod1@0.0.0

接下来,就愉快地编写代码吧~

3.5. 存在的问题

monorepo解决了多模块的开发调试等问题,但将所有项目都放在同一个仓库下,也会带来新的问题。

问题1:文件数量会根据业务迭代快速增加

新同事初始化拉取项目时可能需要等待漫长的时间~

问题2:项目都放在同一个仓库中进行版本维护,多人协作开发时

  • 可能会污染单个项目的commit历史记录,各种feature、fix甚至revert充斥着整个历史记录里面
  • 项目的权限不容易控制,可能出现同事修改了公共模块导致所有项目都崩掉

问题3:业务模块的发布和部署

基于单个项目的发布和部署是比较简单的,只需要在CI中安装依赖、打包、发布即可;对于monorepo而言,需要区分并部署不同的业务,所涉及到的局部模块依赖安装、增加发布等都需要重新考虑

4. 微前端

既然提到了monorepo,这里感觉可以再提一下微前端。

当单技术栈的项目变得逐渐庞大时,就很难再调整方向了,比如升级基础库、换一个框架、换一门语言等,写也不好写,测也不好测,部署也不好部署。

面对这种问题,后端已经有了微服务之类的实践,只要项目足够小,就很容易升级或替换;前端项目也是同理,举个例子,各种各样的促销弹窗,本不应该由业务项目的代码来维护,而应该由单独的活动模块去处理。

微前端的核心思想是按模块部署项目,主要是为了实现每个模块的代码隔离和团队隔离,每个模块可以使用独立的技术栈,也可以由不同的团队进行维护。

由此可见,微前端必不可少的就是多个业务模块,那么如何管理这些业务模块呢?上面提到的monorepo就有用武之地了。

不过笔者目前在实际项目开发中,并没有遇见需要使用微前端的场景,后续如果有类似的业务场景,可以再补充一下。

5. 小结

本文首先讨论了基于独立npm包在开发调试和更新中遇见的一些问题,然后研究了monorepo在同一个项目中维护多个模块的方案

  • 基于lerna管理模块的创建、发布
  • 基于yarn workspaces管理多个模块之间的依赖

最后,不管是multirepo还是monorepo,归根到底还是模块的管理,与之相比,更重要的还是如何编写可靠、易于扩展的模块,代码才是整个项目的灵魂。

由于笔者也是刚开始正儿八经在项目中使用这种方案,如果文中有理解错误的地方,欢迎指正和探讨。