基于lerna的monorepo改造现有项目实战总结

·  阅读 1243

温馨提示:本文不是介绍lerna的使用,而是着重基于lerna的monorepo项目改造过程遇到的问题以及总结复盘。

背景

有这样的一个已经迭代了近3年的小程序项目,因为业务的整个交易流程是在不同平台进行复用的,单独抽离了一个业务npm包business-sdk,通过npm包的方式引入到不同平台。

由于该业务是本人所在部门维护的,并且也会部署到我们自己的一个平台上,所以在npm业务包进行需求迭代时,是在我们平台项目上通过npm link进行本地开发调试的。这种multi-repo开发方式,随着频繁的迭代其劣势从开发、npm发版到上线的整个过程中体现的越来越明显:

  • 开发阶段

    这个阶段主要体现以下4个方面痛点:

    • 开发一个需求至少执行一次npm link;开发前需在应用App中使用npm link建立对业务npm包的软链,而安装新npm依赖时会导致软链失效,需要重新执行该操作。
    • 多分支切换易遗漏出错; 业务开发时App和npm包要单独各建分支,在并行2个项目开发时在4个分支间切换,3个以上极容易遗漏出错
    • 代码风格不统一;App应用跟npm包的代码规范不一致,开发比较困惑
  • 包发版

    这个阶段主要的问题是:npm业务包发版,需要开发关注发版细节:

    • 执行npm version patch|minor|major修改npm包版本号并git commit
    • 需要人肉git push
  • 上线

    npm包发版后,这个阶段的问题需要在应用项目中更新最新业务包:

    • npm install business-sdk@xxx
    • 打包编译输出产物
    • 小程序上线

    这个过程会导致之前操作的npm link失效。

总结一下,一个项目从开发到上线的过程是这样的:

graph LR
创建两个分支 --> npm-link
npm-link --> 开发
开发 --> 修改npm的版本号
修改npm的版本号 --> App应用安装并更新npm包依赖
App应用安装并更新npm包依赖 --> 打包编译上线

整个流程开发者都要全程主动参与,流程长,那么能否将流程中需要开发者参与的一些步骤自动化呢,从而将流程简化为:

graph LR
创建一个分支 --> 开发
开发 --> 发版
发版 --> 打包编译上线

这样,开发者可以将精力主要集中在业务开发上,从流程上提升开发效率?

答案是肯定的,monorepo方案是一个不错的选择。

为什么选择monorepo方案

monorepo方案优势正是multi-repo的劣势,它可以让多个模块共享同一个仓库,带来的好处:

  • 可以共享同一套构建流程
  • 统一代码规范
  • 在模块间存在相互依赖的情况下,查看代码、修改bug、开发调试等会更加方便

因此也越来越受到大家的关注,像 BabelReactVue 等主流的开源仓库都采用的 monorepo

实现monorepo的方案很多,我们选择社区比较成熟的lerna方案,它内部完全接管业务npm包的开发、版本依赖、版本发布等维护工作,即:

  • 调试方便,npm模块之间的相互引用,lerna内部会自动进行npm link
  • npm版本管理方便,npm发版后,它会自动维护npm版本以及依赖该npm模块的依赖方版本的更新
  • 易于统一代码风格&提交规范

所以,基于上面的原因选择基于lernamonorepo方案。

改造方案

目录结构

项目新建一个repo用来充当monorepo,其目录结构是这样的:

image.png

其中目录appspackageslerna的packages目录,为了保留原有项目的git commit历史记录,通过lerna import命令来迁移原有项目。其中:

  • apps目录表示的是主应用目录,原应用项目App迁移到到目录下;它不参与npm发版(应用的package.json中的private设置true)
  • packages目录为共享可复用的package存放位置,参与npm发版;原business-sdk npm包代码迁移到该位置。

开发阶段

开发前,首先需要安装各个package的依赖。

项目使用lerna bootstrap --hoist将各个package的依赖提升至根目录下,依赖提升有两个的原则:

  • 单个package独有的依赖会提升至根目录的node_modules下
  • 多个package的相同依赖,若版本相同则提升根目录;不同版本时,则会将最常用的版本提升;

另外,针对main应用依赖business-sdk包的情况,lerna会自动帮我们在main应用建立business-sdk包的link,无需开发者关注。

启动项目时,可以继续使用原先App应用中在package.json中配置的scripts功能,只需要在项目根目录下的package.json新建scripts即可。如启动本地开发模式的脚步如下,它会执行主应用包中的start脚步:

{
"scripts": {
    "start": "lerna run --stream --scope=main start",
    ...
  },
}
复制代码

这样,我们在项目根目录下执行如下命令就可以启动本地开发了。

npm start
复制代码

发版阶段

发版就涉及到npm包的版本维护更新问题,lerna有两种模式来维护版本:

  • 独立模式:项目每个package的版本各自维护,在其旧版本的基础上进行累加
  • 固定模式:基于lerna.json中的version值来升级所有变更包的版本

针对固定模式,举例来说,初始项目lerna.json中的version的值默认为0.0.0, packageA改动并发版后,lerna.json中的version值会递增为0.0.1,同时packageA的最新版本变为0.0.1;后面packageB改动发版时,其最新的版本号是在lerna.json中version值的基础上累加,变更为0.0.2lerna.json的版本也更新为该值。

可以看出固定模式存在一个问题是packageB的历史版本号缺少连续性,比如上面就缺少0.0.1版本。

项目使用lerna独立模式进行版本管理。lerna是通过执行lerna publish来进行某个package发版,它会自动帮我们完成如下操作:

  • 修改package的版本号,并在依赖它的其他package中更新该依赖的版本号
  • git commit & git push
  • npm publish

使用lerna publish来进行发版时,需要注意以下几点:

  • 发版到私有npm源

    可以用lerna publish --registry url来指定私有源地址,也可以在lerna.json中配置publish命令的参数:

    {
    "command": {
        "publish": {
          "registry": "http://xx.npm.registry.com/"
        }
    }
    复制代码
  • 更细粒度的版本号管理

    lerna publish发版时,默认是按照npm version patch来生成新的版本号,若想细粒度的控制package发版的版本号,可以为lerna publish指定npm version的参数,如下所示:

    lerna publish [major | minor | patch | premajor | preminor | prepatch | prerelease]
    复制代码

    若想先发布预发版本,在测试正常在发正式版本,可以这样使用:

    # 发预发版
    lerna publish --conventional-commits --conventional-prerelease
    复制代码

    使用上面命令可以多次发布预发测试版,在最终没有问题了,可以将最新无问题的预发版发布为最新上线版本。

    # 将预发版发布为正式版
    lerna publish --conventional-commits --conventional-graduate
    复制代码

统一代码规范 & 自动生成changelog

1、eslint和prettier统一代码规范

二者的区别是,eslint即可代码格式化,又可进行代码质量检查;而prettier只能进行代码风格检查。项目中使用eslint来完成代码质量检查,而prettier负责代码格式化,二者在格式化方面有冲突的地方,如何保证在vscode协调工作,可以参考这篇彻底搞懂ESLint与Prettier在vscode中的代码自动格式化

配合husky在提交代码时强制代码检查,提交代码前处理。检查存在不符合代码风格的则不允许提交代码,从而达到代码风格的统一和质量检查。其中package.json有关husky的配置如下:

{
 "scripts": {
    "lint": "eslint $(git diff HEAD --name-only | grep -E '\\.(js|ts|wss|mpx)$' | xargs)"
 },
 "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "{packages,apps}/**/*.{js,ts,wss,mpx}": [
      "npm run lint --fix"
    ]
  },
}
复制代码

为提前进行代码格式化,可以在项目根目录新建.vscode/setting.json文件,配置文件保存时就进行格式化:

{
  // 关闭代码保存自动格式化,防止使用prettier格式化,因为eslint会使用prettier进行格式化
  "editor.formatOnSave": false, 
  // 代码保存时,自动使用eslint进行格式化
  "editor.codeActionsOnSave": { 
    "source.fixAll.eslint": true
  }
}
复制代码

2、自动生成changelog

为package生成changelog的好处:项目迭代频繁,changelog可以用来记录每个版本的迭代功能、bugfix等情况,对项目的迭代有一个清晰的时间脉络,对项目回溯有一定的必要性。

那怎么自动生成changelog? 很简单只需执行如下命令:

lerna version --conventional-commits
# or 
lerna publish --conventional-commits
复制代码

lerna会根据传统的提交规范来自动生成changelog。默认情况下,lerna自动生成changelog 的预设值是angular,其提供的提交类型有如下几种:

image.png

所以,git的commit message必须要按照以上指定的规范来提交, 否则无法生成changelog。但是约定归约定,开发者不一定遵守或者新人接手时不熟悉未必按照这个规范来提交,这就需要强制约束了。社区提供的commitlint就是对开发者的commit message进行规范检查的,项目中需要安装有关commitlint的两个npm包:

npm install @commitlint/cli @commitlint/config-conventional -D
复制代码

提交规范的检查时间点可以跟eslint检查代码风格一样,在代码提交前进行检查,检查失败则不允许提交。

package.json中的其配置如下:

{
 ...
 "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  },
  "lint-staged": {
    "{packages,apps}/**/*.{js,ts,wss,mpx}": [
      "npm run lint --fix"
    ]
  },
  ...
}
复制代码

另外,需要注意一点,在通过lerna publish发布包时,它会自动修改package的版本号,并自动commit & push到git仓库,这时需要对commit的message按规范指定,否则会导致leran publish失败,一般在leran.json中publish命令中进行如下指定:

{
    "command": {
        "publish": {
           "message": "chore(release): publish"
        }
    }
复制代码

改造过程遇到的问题

1、现有git仓库迁移monorepo,要保留git commit的提交记录

既然是重新新建一个repo对现有项目进行monorepo改造,那么将已有的两个repo项目迁移至新建repo,若copy整个项目的话,则在提交时会丢失原有项目的git commit历史记录,若想保留原有项目的git提交记录怎么办呢?

lerna想到了这方面的使用场景,提供了lerna import命令,其作用:

将一个带有git提交历史记录的模块导入到monoreopo的packages中

这样,通过该命令将基于git commit的package引入到当前 lerna 下面的 packages ,成为当前项目目录的一个包,然后统一管理。

具体的来说,在当前项目执行两次lerna import命令,即:

# 导入主app应用包至当前项目app目录
lerna import AppPath --preserve-commit --dest=apps --flatten

# 导入业务包至当前项目packages目录,默认导入到packages目录
lerna import businessSdkPath --preserve-commit --flatten
复制代码

温馨提示lerna import需要注意的3个地方:

  • 执行该命令导入外部模块前,工作区应没有变动未提交的内容

  • 该命令的--dest参数指定导入的目录必须已经存在,否则会报错。

    例如,将sdk外部模块导入到当前项目的packages目录下,执行如下命令:

    lerna import ~/../sdk --preserve-commit --flatten --dest=packages/sdk
    复制代码

    packages目录下没有sdk目录,则该命令会报该错误:lerna ERR! EDESTDIR --dest does not match with the package directories: packages

  • 若要获取外部模块最新的git commit内容,该命令需要使用--flatten参数,否则导入带有冲突合并提交的外部包时,导入的代码不是最新的,会丢失冲突合并后的代码。

  • lerna import导入的外部git仓库比较简单:一是导入本地git仓库,并且导入的仓库只有git log,没有分支和tag信息;二是导入时不能修改git仓库名称;若有这方面的使用场景,可以使用tomono进行迁移。

2、lerna bootstrap --hoist并没有安装package的依赖

lerna提供bootstrap命令完成2个主要功能:

  • npm install各个包的依赖
  • 所有相互依赖的package建立符号链接

其中,在安装各个包的依赖时,lerna提供2种方式:

  • 每个包各自安装各自的依赖
  • 将指定或者所有package的依赖进行提升到根目录

这正是lerna bootstrap命令的参数--hoist的功能;顾名思义,指定该参数时则在安装项目依赖时会选择上面的第二种方式。但是在使用lerna init的项目中,执行命令:

lerna bootstrap --hoist
复制代码

lerna提示:lerna info bootstrap root only。给出的提示说只安装项目根目录的依赖,而各个package的依赖却没有安装。这种结果让人摸不着头绪,无奈去查看bootstrap命令的源码到底干啥了,发现这么一段逻辑:

if (this.options.useWorkspaces || this.rootHasLocalFileDependencies()) {
      return this.installRootPackageOnly();
    }
复制代码

useWorkspaces配置项为true就会只安装root的依赖,为什么会配置为true呢?它是干什么用的呢?

带着这两个疑问去调研发现:

  • 为什么会配置为true

    最新版本的lerna在执行lerna init命令后,其生成的lerna.json配置文件中会将useWorkspaces设置为true

  • 它是干什么用的呢

    该配置项是为了配合yarn的workspace来使用的:

    • 值为true,相当于使用lerna bootstrap --use-workspaces,表示lerna.jsonpackages将被package.json/workspaces的值覆盖,源码如下所示;
      get packageConfigs() { 
          if (this.config.useWorkspaces) { 
              const workspaces = this.manifest.get("workspaces"); 
              ... 
              return workspaces.packages || workspaces; 
           } 
          return this.config.packages || [Project.PACKAGE_GLOB]; 
      }
      复制代码
    • 值为false,则lernayarn会分别管理各自的packages路径。

至此真相大白,lerna初始化时npmClient会默认使用npm,但却配置的了yarn的workspace的相关配置("useWorkspaces": true),从而导致npm无法安装package的依赖。

温馨提示:lerna bootstrap命令有无--hoist参数其表现尤其需要关注一下:

没有--hoist选项时,是不会安装root的依赖,只会安装各个package的依赖,并且会生成各个包的package-lock.json;

有该选项时则包括root目录依赖都会安装,但是安装各个package时不会根据每个包的package-lock.json的情况来安装

3、lerna运行package的script脚本时会丢弃脚本的输出信息

在通过lerna run xxx命令执行各个package的package.json中的scripts配置脚本时,若script脚本有输出信息,例如webpack构建信息, lerna默认是不会在标准输出中输出这些信息的。

为了解决这一问题,可以执行命令时添加--stream参数即可。

lerna run xxx --stream
复制代码

4、webpack构建配置调整

项目在multi-repo方式下,业务npm包是不会单独通过webpack构建的,其发版内容就是开发的源码;它是作为App应用的一个依赖模块参与到App的webpack构建打包流程,所以在App主应用webpack配置中需要将该业务包的内容包含进来,如下面是webpack的js配置部分:

function resolve(dir) {
  return path.join(__dirname, '..', dir);
}

// webpack的配置如下:
...
module: {
    rules: [
       ...
       {
          test: /\.js$/,
          loader: 'babel-loader',
          include: [resolve('src'), resolve('node_modules/business-sdk')]
        },
        ...
    ]
}
...
复制代码

经过monorepo改造后,因为目录结构的调整,所以App中的webpack配置也发生变更,同理webpack引入业务npm包的地址也发生变更,

...
module: {
    rules: [
       ...
       {
          test: /\.js$/,
          loader: 'babel-loader',
          include: [resolve('src'), resolve('../../node_modules/business-sdk')]
        },
        ...
    ]
}
...
复制代码

同样的,对于执行lerna bootstrap --hoist命令后,package的依赖提至根目录,那么webpack若有这些依赖包的配置,一样需要手动变更为根目录的node_modules,否则会导致webpack构建时报错。

总结一句话:依赖安装提升后,原有的webpack构建涉及到配置业务npm包的node_modules的情况都需要变更为根目录的node_modules。

5、自动生成changelog输出Version bump only for package的问题

某些情况下,package自动生成的changelog没有任何内容,除了提升Version bump only for package,其表明当前package因其依赖版本的更新导致其版本的升级,除此之外该package没有任何变动。

例如模块A依赖了模块B,B版本更新了,导致A升级B的最新版本而生成A的changelog就会只有前面提到的版本提示信息。

这种情况暂时还无法屏蔽生成,有人已经在github上提到该issue # support ignore "Note: Version bump only for package" when generate changelog.md in independent mode

既然无法屏蔽,换一种思路,能否指定为具体某些package自动生成changelog呢? 答案是lerna也是不支持的

6、lerna固定模式与独立模式生成changelog的区别

  • 固定模式,会为每个改动的package以及依赖它的package都自动生成changelog,包括根目录
  • 独立模式,除了不会为根目录生成changelog之外,其他同固定模式

7、如何修改自动生成changelog中的提交信息

自动生成的changelog文件内容一般如下图所示:

image.png

默认情况下,lerna只有featfixperf三种类型的消息会生成的changelog的内容。若package有变动但提交message不是这三种提交类型时,则自动生成的changelog内容为Version bump only for package xxx

若要修改生成changelog的类型,可以在lerna.json配置command.version.changelogPreset进行自定义changelog的预设设置

"version": {
      "changelogPreset": {
        "name": "conventionalcommits",
        "types": [
            { "type": "feat", "section": "  Features" },
            { "type": "fix", "section": "  Bug Fixes" },
            { "type": "perf", "section": "⚡️ Performance Improvements" },
            { "type": "revert", "section": ":rewind: Reverts" },
            { "type": "style", "section": "Styles"},
            { "type": "docs", "section": "Documentation", "hidden": true },
            {
            "type": "chore",
            "section": "Miscellaneous Chores",
            "hidden": true
            },
            {
            "type": "refactor",
            "section": "  Code Refactoring",
            "hidden": true
            },
            { "type": "test", "section": "Tests", "hidden": true },
            { "type": "build", "section": "Build System", "hidden": true },
            { "type": "ci", "section": "Continuous Integration", "hidden": true }
        ]
      }
    }
复制代码

如上配置的结果是featfixperfstylerevert 类型会形成 changlog,其他的不会生成changelog,也就是将对应类型设置hidden:true生成changelog就会忽略该类型的信息。

另外,通过上面方式设置lerna changelog的自定义预设,也可以自定义提交类型,如新增新类型或者删除已有类型。

monorepo改造收益

项目的monorepo改造,收益是比较明显的,主要体现在:

  • 正如我们要达到的目标,简化了需求开发到上线的流程,平均每个需求减少10min+,开发者只关注业务开发
  • 统一代码规范和git的commit message规范,减少bug的发生率
  • 自动生成changelog,可以对项目的迭代回溯有一个清晰的时间脉络

参考文献:

分类:
前端
收藏成功!
已添加到「」, 点击更改