基于 Lerna 管理 packages 的 Monorepo 项目最佳实践

本文首发于 vivo互联网技术 微信公众号 mp.weixin.qq.com/s/NlOn7er0i…
做者:孔垂亮前端

对于维护过多个package的同窗来讲,都会遇到一个选择题,这些package是放在一个仓库里维护仍是放在多个仓库里单独维护,本文经过一个示例讲述了如何基于Lerna管理多个package,并和其它工具整合,打造高效、完美的工做流,最终造成一个最佳实践node

背景webpack

最近在工做中接触到一个项目,这个项目是维护一套 CLI,发到 npm 上供开发者使用。先看一张图:git

项目仓库中的根目录上就三个子模块的文件夹,分别对应三个 package,在熟悉了构建和发布流程后,有点傻了。工做流程如图中所示:github

  1. 使用webpack、babel和uglifyjs把 pkg-a 的 src 编译到 distweb

  2. 使用webpack、babel和uglifyjs把 pkg-b 的 src 编译到 distnpm

  3. 使用webpack、babel和uglifyjs把 pkg-main 的 src 编译到 distjson

  4. 最后使用拷贝文件的方式,把pkg-main、pkg-a、pkg-b中编译后的文件组装到 pkg-npm 中,最终用于发布到 npm 上去。bootstrap

痛点segmentfault

  1. 很差调试。由于最终的包是经过文件拷贝的方式组装到一块儿的,而且都是压缩过的,没法组建一个自上到下的调试流程(实际工做中只能加log,而后从新把包编译组装一遍看效果)

  2. 包的依赖关系不清晰。pkg-a、pkg-b索性没有版本管理,更像是源码级别的,但逻辑又比较独立。pkg-main中的package.json最终会拷贝到 pkg-npm 中,但又依赖pkg-a、pkg-b中的某些包,因此要把pkg-a、pkg-b中的依赖合并到pkg-main中。pkg-main和pkg-npm的package.json耦合在一块儿,致使一些原本是工程的开发依赖也会发布到 npm 上去,变成pkg-npm 的依赖包。

  3. 依赖的包冗余。能够看到,pkg-a、pkg-b、pkg-main要分别编译,都依赖了babel、webpack等,要分别 cd 到各个目录安装依赖。

  4. 发布须要手动修改版本号。 由于最终只发布了一个包,但实际逻辑要求这个包即要全局安装又要本地安装,业务没有拆开,致使要安装两遍。耦合一块儿,即使使用 npm link 也会致使调试困难,

  5. 发版没有 CHANGELOG.md 由于pkg-a、pkg-b都没有真正管理版本,因此也没有完善的CHANGELOG来记录自上个版本发布已来的变更。

整个项目像是一个没有被管理起来的 Monorepo。那什么又是 Monorepo 呢?

Monorepo vs Multirepo

Monorepo 的全称是 monolithic repository,即单体式仓库,与之对应的是 Multirepo(multiple repository),这里的“单”和“多”是指每一个仓库中所管理的模块数量。

Multirepo 是比较传统的作法,即每个 package 都单独用一个仓库来进行管理。例如:Rollup, ...

Monorep 是把全部相关的 package 都放在一个仓库里进行管理,每一个 package 独立发布。例如:React, Angular, Babel, Jest, Umijs, Vue ...

一图胜千言:

固然到底哪种管理方式更好,仁者见仁,智者见智。前者容许多元化发展(各项目能够有本身的构建工具、依赖管理策略、单元测试方法),后者但愿集中管理,减小项目间的差别带来的沟通成本。

虽然拆分子仓库、拆分子 npm 包是进行项目隔离的自然方案,但当仓库内容出现关联时,没有任何一种调试方式比源码放在一块儿更高效。

结合咱们项目的实际场景和业务须要,自然的 MonoRepo ! 由于工程化的最终目的是让业务开发能够 100% 聚焦在业务逻辑上,那么这不只仅是脚手架、框架须要从自动化、设计上解决的问题,这涉及到仓库管理的设计。

一个理想的开发环境能够抽象成这样:

“只关心业务代码,能够直接跨业务复用而不关心复用方式,调试时全部代码都在源码中。”

在前端开发环境中,多 Git Repo,多 npm 则是这个理想的阻力,它们致使复用要关心版本号,调试须要 npm link。而这些是 MonoRepo 最大的优点。

上图中提到的利用相关工具就是今天的主角 Lerna ! Lerna是业界知名度最高的 Monorepo 管理工具,功能完整。

Lerna

1、Lerna 是什么

A tool for managing JavaScript projects with multiple packages.

Lerna is a tool that optimizes the workflow around managing multi-package repositories with git and npm.

Lerna 是一个管理多个 npm 模块的工具,是 Babel 本身用来维护本身的 Monorepo 并开源出的一个项目。优化维护多包的工做流,解决多个包互相依赖,且发布须要手动维护多个包的问题。

Lerna 如今已经被不少著名的项目组织使用,如:Babel, React, Vue, Angular, Ember, Meteor, Jest 。

一个基本的 Lerna 管理的仓库结构以下:

安装

推荐全局安装,由于会常常用到 lerna 命令

npm i -g lerna

复制代码

项目构建

1.初始化

lerna init

复制代码

init 命令详情 请参考 lerna init

其中 package.json & lerna.json 以下:

// package.json
{
  "name": "root",
  "private": true, // 私有的,不会被发布,是管理整个项目,与要发布到npm的解耦
  "devDependencies": {
    "lerna": "^3.15.0"
  }
}
 
// lerna.json
{
  "packages": [
    "packages/*"
  ],
  "version": "0.0.0"
}

复制代码

2.增长两个 packages

lerna create @mo-demo/cli
lerna create @mo-demo/cli-shared-utils

复制代码

create 命令详情 请参考 lerna create

3.分别给相应的 package 增长依赖模块

lerna add chalk                                           // 为全部 package 增长 chalk 模块
lerna add semver --scope @mo-demo/cli-shared-utils        // 为 @mo-demo/cli-shared-utils 增长 semver 模块
lerna add @mo-demo/cli-shared-utils --scope @mo-demo/cli  // 增长内部模块之间的依赖

复制代码

add 命令详情 请参考 lerna add

4.发布

lerna publish

复制代码

publish 命令详情 请参考 lerna publish

以下是发布的状况,lerna会让你选择要发布的版本号,我发了@0.0.1-alpha.0 的版本。

发布 npm 包须要登录 npm 帐号

5.安装依赖包 & 清理依赖包

上述1-4步已经包含了 Lerna 整个生命周期的过程了,但当咱们维护这个项目时,新拉下来仓库的代码后,须要为各个 package 安装依赖包。

咱们在第4步 lerna add 时也发现了,为某个 package 安装的包被放到了这个 package 目录下的 node_modules 目录下。这样对于多个 package 都依赖的包,会被多个 package 安装屡次,而且每一个 package 下都维护 node_modules ,也不清爽。因而咱们使用 --hoist 来把每一个 package 下的依赖包都提高到工程根目录,来下降安装以及管理的成本

lerna bootstrap --hoist

复制代码

bootstrap 命令详情 请参考 lerna bootstrap

为了省去每次都输入 --hoist 参数的麻烦,能够在 lerna.json 配置:

{
  "packages": [
    "packages/*"
  ],
  "command": {
    "bootstrap": {
      "hoist": true
    }
  },
  "version": "0.0.1-alpha.0"
}

复制代码

配置好后,对于以前依赖包已经被安装到各个 package 下的状况,咱们只须要清理一下安装的依赖便可:

lerna clean

复制代码

而后执行 lerna bootstrap 便可看到 package 的依赖都被安装到根目录下的 node_modules 中了。

Lerna的最佳实践

lerna不负责构建,测试等任务,它提出了一种集中管理package的目录模式,提供了一套自动化管理程序,让开发者没必要再深耕到具体的组件里维护内容,在项目根目录就能够全局掌控,基于 npm scripts,使用者能够很好地完成组件构建,代码格式化等操做。接下来咱们就来看看,若是基于 Lerna,并结合其它工具来搭建 Monorepo 项目的最佳实践。

1、优雅的提交

1.commitizen && cz-lerna-changelog

commitizen 是用来格式化 git commit message 的工具,它提供了一种问询式的方式去获取所需的提交信息。

cz-lerna-changelog 是专门为 Lerna 项目量身定制的提交规范,在问询的过程,会有相似影响哪些 package 的选择。以下:

咱们使用 commitizen 和 cz-lerna-changelog 来规范提交,为后面自动生成日志做好准备。

由于这是整个工程的开发依赖,因此在根目录安装:

npm i -D commitizen
npm i -D cz-lerna-changelog

复制代码

安装完成后,在 package.json 中增长 config 字段,把 cz-lerna-changelog 配置给 commitizen。同时由于commitizen不是全局安全的,因此须要添加 scripts 脚原本执行 git-cz

{
  "name": "root",
  "private": true,
  "scripts": {
    "c": "git-cz"
  },
  "config": {
    "commitizen": {
      "path": "./node_modules/cz-lerna-changelog"
    }
  },
  "devDependencies": {
    "commitizen": "^3.1.1",
    "cz-lerna-changelog": "^2.0.2",
    "lerna": "^3.15.0"
  }
}

复制代码

以后在常规的开发中就可使用 npm run c 来根据提示一步一步输入,来完成代码的提交。

2.commitlint && husky

上面咱们使用了 commitizen 来规范提交,但这个要靠开发自觉使用 npm run c 。万一忘记了,或者直接使用 git commit 提交怎么办?答案就是在提交时对提交信息进行校验,若是不符合要求就不让提交,并提示。校验的工做由 commitlint 来完成,校验的时机则由 husky 来指定。husky 继承了 Git 下全部的钩子,在触发钩子的时候,husky 能够阻止不合法的 commit,push 等等。

// 安装 commitlint 以及要遵照的规范
npm i -D @commitlint/cli @commitlint/config-conventional

复制代码
// 在工程根目录为 commitlint 增长配置文件 commitlint.config.js 为commitlint 指定相应的规范
module.exports = { extends: ['@commitlint/config-conventional'] }

复制代码
// 安装 husky
npm i -D husky

复制代码
// 在 package.json 中增长以下配置
"husky": {
  "hooks": {
    "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
  }
}

复制代码

"commit-msg"是git提交时校验提交信息的钩子,当触发时便会使用 commitlit 来校验。安装配置完成后,想经过 git commit 或者其它第三方工具提交时,只要提交信息不符合规范就没法提交。从而约束开发者使用 npm run c 来提交。

3.standardjs && lint-staged

除了规范提交信息,代码自己确定也少了靠规范来统一风格。

standardjs就是完整的一套 JavaScript 代码规范,自带 linter & 代码自动修正。它无需配置,自动格式化代码并修正,提早发现风格以及程序问题。

lint-staged staged 是 Git 里的概念,表示暂存区,lint-staged 表示只检查并矫正暂存区中的文件。一来提升校验效率,二来能够为老的项目带去巨大的方便。

// 安装
npm i -D standard lint-staged

复制代码
// package.json
{
  "name": "root",
  "private": true,
  "scripts": {
    "c": "git-cz"
  },
  "config": {
    "commitizen": {
      "path": "./node_modules/cz-lerna-changelog"
    }
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  },
  "lint-staged": {
    "*.js": [
      "standard --fix",
      "git add"
    ]
  },
  "devDependencies": {
    "@commitlint/cli": "^8.1.0",
    "@commitlint/config-conventional": "^8.1.0",
    "commitizen": "^3.1.1",
    "cz-lerna-changelog": "^2.0.2",
    "husky": "^3.0.0",
    "lerna": "^3.15.0",
    "lint-staged": "^9.2.0",
    "standard": "^13.0.2"
  }
}

复制代码

安装完成后,在 package.json 增长 lint-staged 配置,如上所示表示对暂存区中的 js 文件执行 standard --fix 校验并自动修复。那何时去校验呢,就又用到了上面安装的 husky ,husky的配置中增长'pre-commit'的钩子用来执行 lint-staged 的校验操做,如上所示。

此时提交 js 文件时,便会自动修正并校验错误。即保证了代码风格统一,又能提升代码质量。

2、自动生成日志

有了以前的规范提交,自动生成日志便水到渠成了。再详细看下 lerna publish 时作了哪些事情:

1.调用 lerna version

  • 找出从上一个版本发布以来有过变动的 package

  • 提示开发者肯定要发布的版本号

  • 将全部更新过的的 package 中的package.json的version字段更新

  • 将依赖更新过的 package 的 包中的依赖版本号更新

  • 更新 lerna.json 中的 version 字段

  • 提交上述修改,并打一个 tag

  • 推送到 git 仓库

2.使用 npm publish 将新版本推送到 npm

CHANGELOG 很明显是和 version 一一对应的,因此须要在 lerna version 中想办法,查看 lerna version 命令的详细说明后,会看到一个配置参数 --conventional-commits。没错,只要咱们按规范提交后,在 lerna version 的过程当中会便会自动生成当前这个版本的 CHANGELOG。为了方便,不用每次输入参数,能够配置在 lerna.json中,以下:

{
  "packages": [
    "packages/*"
  ],
  "command": {
    "bootstrap": {
      "hoist": true
    },
    "version": {
      "conventionalCommits": true
    }
  },
  "ignoreChanges": [
    "**/*.md"
  ],
  "version": "0.0.1-alpha.1"
}

复制代码

lerna version 会检测从上一个版本发布以来的变更,但有一些文件的提交,咱们不但愿触发版本的变更,譬如 .md 文件的修改,并无实际引发 package 逻辑的变化,不该该触发版本的变动。能够经过 ignoreChanges 配置排除。如上。

实际 lerna version 不多直接使用,由于它包含在 lerna publish 中了,直接使用 lerna publish就行了。

Lerna 在管理 package 的版本号上,提供了两种模式供选择 Fixed or Independent。默认是 Fixed,更多细节,以及 Lerna 的更多玩法,请参考官网文档:

3、编译、压缩、调试

采用 Monorepo 结构的项目,各个 package 的结构最好保持统一。

根据目前的项目情况,设计以下:

  1. 各 package 入口统一为 index.js

  2. 各 package 源码入口统一为 src/index.js

  3. 各 package 编译入口统一为 dist/index.js

  4. 各 package 统一使用 ES6 语法、使用 Babel 编译、压缩并输出到 dist

  5. 各 package 发布时只发布 dist 目录,不发布 src 目录

  6. 各 package 注入 LOCAL_DEBUG 环境变量, 在index.js 中区分是调试仍是发布环境,调试环境 ruquire(./src/index.js) 保证全部源码可调试。发布环境 ruquire(./dist/index.js) 保证全部源码不被发布。

由于 dist 是 Babel 编译后的目录,咱们在搜索时不但愿搜索它的内容,因此在工程的设置中把 dist 目录排除在搜索的范围以外。

接下来,咱们按上面的规范,搭建 package 的结构。

首先安装依赖

npm i -D @babel/cli @babel/core @babel/preset-env  // 使用 Babel 必备 详见官网用法
npm i -D @babel/node                               // 用于调试 由于用了 import&export 等 ES6 的语法
npm i -D babel-preset-minify                       // 用于压缩代码

复制代码

因为各 package 的结构统一,因此相似 Babel 这样的工具,只在根目录安装就行了,不须要在各 package 中安装,简直是清爽的要死了。

增长 Babel 配置

// 根目录新建 babel.config.js
module.exports = function (api) {
  api.cache(true)
 
  const presets = [
    [
      '@babel/env',
      {
        targets: {
          node: '8.9'
        }
      }
    ]
  ]
 
  // 非本地调试模式才压缩代码,否则调试看不到实际变量名
  if (!process.env['LOCAL_DEBUG']) {
    presets.push([
      'minify'
    ])
  }
 
  const plugins = []
 
  return {
    presets,
    plugins,
    ignore: ['node_modules']
  }
}

复制代码

修改各 package 的代码

// @mo-demo/cli/index.js
if (process.env.LOCAL_DEBUG) {
  require('./src/index')                        // 若是是调试模式,加载src中的源码
} else {
  require('./dist/index')                       // dist会发到npm
}
 
// @mo-demo/cli/src/index.js
import { log } from '@mo-demo/cli-shared-utils'  // 从 utils 模块引入依赖并使用 log 函数
log('cli/index.js as cli entry exec!')
 
// @mo-demo/cli/package.json
{
  "main": "index.js",
  "files": [
    "dist"                                       // 发布 dist
  ]
}
 
 
// @mo-demo/cli-shared-utils/index.js
if (process.env.LOCAL_DEBUG) {
  module.exports = require('./src/index')        // 若是是调试模式,加载src中的源码
} else {
  module.exports = require('./dist/index')       // dist会发到npm
}
 
// @mo-demo/cli-shared-utils/src/index.js
const log = function (str) {
  console.log(str)
}
export {                                         //导出 log 接口
  log
}
 
// @mo-demo/cli-shared-utils/package.json
{
  "main": "index.js",
  "files": [
    "dist"
  ]
}

复制代码

修改发布的脚本

npm run b 用来对各 pacakge 执行 babel 的编译,从 src 目录输出出 dist 目录,使用根目录的配置文件 babel.config.js。

npm run p 用来取代 lerna publish,在 publish 前先执行 npm run b来编译。

其它经常使用的 lerna 命令也添加到 scripts 中来,方便使用。

// 工程根目录 package.json
 "scripts": {
   "c": "git-cz",
   "i": "lerna bootstrap",
   "u": "lerna clean",
   "p": "npm run b && lerna publish",
   "b": "lerna exec -- babel src -d dist --config-file ../../babel.config.js"
 }

复制代码

调试

咱们使用vscode自带的调试功能调试,也可使用 Node + Chrome 调试,看开发者习惯。

咱们就 vscode 为例,请参考

增长以下调试配置文件:

// .vscode/launch.json
{
    // 使用 IntelliSense 了解相关属性。
    // 悬停以查看现有属性的描述。
    // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "debug cli",
            "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/babel-node",
            "runtimeArgs": [
                "${workspaceRoot}/packages/cli/src/index.js"
            ],
            "env": {
                "LOCAL_DEBUG": "true"
            },
            "console": "integratedTerminal"
        }
    ]
}

复制代码

由于 src 的代码是 ES6 的,因此要使用 babel-node去跑调试,@babel/node 已经在前面安装过了。

**最棒的是,能够直接使用单步调试,调到依赖的模块中去,**如上图,咱们要执行 @mo-demo/cli-shared-utils 模块中的 log 方法,单步进入,会直接跳到 @mo-demo/cli-shared-utils src 源码中去执行。以下图

结语

到这里,基本上已经构建了基于 Lerna 管理 packages 的 Monorepo 项目的最佳实践了,该有的功能都有:

  • 完善的工做流

  • 流畅的调试体验

  • 风格统一的编码

  • 一键式的发布机制

  • 完美的更新日志

  • ……

固然,Lerna 还有更多的功能等待着你去发掘,还有不少能够结合 Lerna 一块儿使用的工具。构建一套完善的仓库管理机制,可能它的收益不是一些量化的指标能够衡量出来的,也没有直接的价值输出,但它能在平常的工做中极大的提升工做效率,解放生产力,节省大量的人力成本。

——— 参考文献 ———

  1. 手摸手教你玩转 Lerna

  2. 精读《Monorepo 的优点》

  3. 使用lerna优雅地管理多个package

  4. 用 husky 和 lint-staged 构建超溜的代码检查工做流

更多内容敬请关注 vivo 互联网技术 微信公众号

注:转载文章请先与微信号:labs2020 联系。

相关文章
相关标签/搜索