Code Monkey home page Code Monkey logo

lerna-learning's Introduction

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

lerna JavaScript Style Guide Commitizen friendly

背景

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

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

  1. 使用webpack、babel和uglifyjs把 pkg-a 的 src 编译到 dist
  2. 使用webpack、babel和uglifyjs把 pkg-b 的 src 编译到 dist
  3. 使用webpack、babel和uglifyjs把 pkg-main 的 src 编译到 dist
  4. 最后使用拷贝文件的方式,把pkg-main、pkg-a、pkg-b中编译后的文件组装到 pkg-npm 中,最终用于发布到 npm 上去。

痛点

  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

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-repo/
    ┣━ packages/
    ┃     ┣━ package-a/
    ┃     ┃      ┣━ ...
    ┃     ┃      ┗━ package.json
    ┃     ┗━ package-b/
    ┃            ┣━ ...
    ┃            ┗━ package.json
    ┣━ ...
    ┣━ lerna.json
    ┗━ package.json

开始使用

安装

推荐全局安装,因为会经常用到 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"
}
  1. 增加两个 packages
lerna create @mo-demo/cli
lerna create @mo-demo/cli-shared-utils

create 命令详情 请参考 lerna create

  1. 分别给相应的 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

  1. 发布
lerna publish

publish 命令详情 请参考 lerna publish

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

发布 npm 包需要登陆 npm 账号

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

上述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. 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 文件时,便会自动修正并校验错误。即保证了代码风格统一,又能提高代码质量。

自动生成日志

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

  1. 调用 lerna version

    1. 找出从上一个版本发布以来有过变更的 package
    2. 提示开发者确定要发布的版本号
    3. 将所有更新过的的 package 中的package.json的version字段更新
    4. 将依赖更新过的 package 的 包中的依赖版本号更新
    5. 更新 lerna.json 中的 version 字段
    6. 提交上述修改,并打一个 tag
    7. 推送到 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 的更多玩法,请参考官网文档: https://github.com/lerna/lerna/blob/master/README.md

编译、压缩、调试

采用 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 为例,请参考 https://code.visualstudio.com/docs/editor/debugging。

增加如下调试配置文件:

// .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 一起使用的工具。构建一套完善的仓库管理机制,可能它的收益不是一些量化的指标可以衡量出来的,也没有直接的价值输出,但它能在日常的工作中极大的提高工作效率,解放生产力,节省大量的人力成本。

 参考文献

手摸手教你玩转 Lerna

精读《Monorepo 的优势》

使用lerna优雅地管理多个package

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

lerna-learning's People

Contributors

morrain avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar

lerna-learning's Issues

对于多人协作而言,monorepo 还是有很多问题

对于单个项目的开发,monorepo 可以轻松应对,而对于团队人数较多,会在同一个 monorepo 项目中用不同的分支进行开发并且发布包版本的场景而言,基本是一场灾难。

  • 初始化新包的时候,版本号应该如何选择。
  • 对于开发分支,包版本的命名规则应该如何,特别是多个开发分支存在,需要发不同分支的包的情况。
  • 在不同的开发分支间进行切换,每次需要重新执行 lerna bootstrap 以完成包之间的链接(因为不同分支的依赖情况及子包数量不同)
  • 同上,在切换到新的开发分支时,子包的编译顺序需要梳理,比如 A 包依赖 B 包,B 是新添加的,全局 build 的情况下,需要先指定编译 B 包输出 lib 目录,A 包编译才能找到依赖并通过编译。
  • 如果使用别名,各个子包之间如何保证别名的引用是当前包范围
  • 新增加一个包,要先发布初始版本至仓库,才能在其他子包找到该包的依赖

诸如此上种种,来源于实际项目实践,如果有好的方案,还望解答。:grinning:

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.