深入理解 Babel - 項目管理工具 lerna 解析

背景

Babel 是一個比較龐大的項目,其子工程就有至少 140 個 (如 babel/plugins/presets/lerna/babel-loader 等),產出的子工具已經是前端開發的基礎設施,對開發效率、代碼質量等有非常高的要求。

在本文中,我們將瞭解 Babel 是怎樣進行項目管理的。

本文從工程管理、代碼管理、文檔管理、質量管理四個方面對 Babel 項目管理進行拆解分析。

工程管理

Babel 是典型的 monorepo 項目,即單倉庫項目,所有的子模塊在同一個倉庫裏。Babel 目前有 140 + 個子模塊,在工程管理部分,需要解決以下問題:

工程管理部分主要使用 lerna、yarn 等工具。

代碼風格

Babel 是多人協作開發的開源項目,如何保證代碼風格一致,Babel 使用的是社區常見的解決方案。

該模塊主要使用 eslint、prettier 等工具。

文檔

Babel 的迭代速度很快、涉及的模塊很多,該模塊解決版本發佈後如何自動更新相關文檔等問題。

該模塊主要使用 lerna 等工具。

質量控制

Babel 的產品是前端開發的基礎設施,該模塊主要保證 Babel 的產出是高質量的。

該模塊主要使用 jest、git blame 等工具。

monorepo

Babel 使用 monorepo 模式進行工程管理。

什麼是 monorepo

monorepo(monolithic repository),指的是單倉庫代碼,將多個項目代碼存儲在一個倉庫裏。另外有一種模式是 multirepo,指的是多倉庫代碼 (one-repository-per-module),不同的項目分佈在不同的倉庫裏。React、Babel、Jest、Vue、Angular 均採用 monorepo 進行項目管理。

典型的 monorepo 結構是:

├── packages
|   ├── pkg1
|   |   ├── package.json
|   ├── pkg2
|   |   ├── package.json
├── package.json

這是 Babel 源碼的目錄結構:

├─ lerna.json
├─ package.json
└─ packages/ # 這裏將存放所有子項目目錄
    ├─ README.md
    ├─ babel-cli
    ├─ babel-code-frame
    ├─ babel-compat-data
    ├─ babel-core
    ├─ babel-generator
    ├─ babel-helper-annotate-as-pure
    ├─ babel-helper-builder-binary-assignment-operator-visitor
    ├─ babel-helper-builder-react-jsx
    ├─ ...

而 rollup 則採取了 multirepo 的模式:

├─ rollup
    ├─ package.json
    ├─ ...
├─ plugins
    ├─ package.json
    ├─ ...
├─ awesome
    ├─ package.json
    ├─ ...
├─ rollup-starter-lib
    ├─ package.json
    ├─ ...
├─ rollup-plugin-babel
    ├─ package.json
    ├─ ...
├─ rollup-plugin-commonjs
    ├─ package.json
    ├─ ...

monorepo 的優缺點

優點

缺點

選擇

multirepo 和 monorepo 是兩種不同的理念。

multirepo 允許多元化發展,每個模塊獨立實現自己的構建、單元測試、依賴管理等。monorepo 抹平了模塊間的很多差異,通過集中管理和高度集成化的工具,減少開發和溝通時的成本。monorepo 最大的問題可能就是不能管理佔用空間太大的項目了。

所以,還是要根據項目的實際需求出發選擇用哪種項目管理模式。

lerna

lerna 是基於 git/npm/yarn 等的工作流提效工具,用於維護 monorepo。它是 Babel 開發過程中提升開發效率產出的工具。

lerna 本身也是一個 monorepo 的項目,並且,lerna 爲 monorepo 項目提供瞭如下支持:

lerna 命令集

命令行列表

lerna 官網有對各種命令各種用法的詳細介紹,這些命令可以分爲:項目管理、依賴管理、版本管理三大類。

全局配置項

lerna 有一批通用參數,所有子命令均可以使用。

--concurrency

當 lerna 將任務並行執行時,需要使用多少線程 (默認爲邏輯 CPU 內核數)。

lerna publish --concurrency 1

--loglevel<silent|error|warn|success|info|verbose|silly>

要報告什麼級別的日誌。如果失敗,所有日誌都寫到當前工作目錄中的 lerna-debug.log 中。

任何高於該設置的日誌都會顯示出來。默認值是 "info"。

--max-buffer

爲每個底層進程調用設置的最大緩衝區長度。例如,當有人希望在運行 lerna import 的同時導入包含大量提交的倉庫時,就是它出場的時候了。在這種情況下,內置的緩衝區長度可能不夠。

--no-progress

禁用進度條。在 CLI 環境中總是這樣。

--no-sort

默認情況下,所有任務都按照拓撲排序的順序在包上執行,以尊重所討論的包的依賴關係。在不保證 lerna 調用一致的情況下,以最努力的方式打破循環。

如果只有少量的包有許多依賴項,或者某些包執行的時間長得不成比例,拓撲排序可能會導致併發瓶頸。--no-sort 配置項禁用排序,而是以最大併發的任意順序執行任務。

如果您運行多個 watch 命令,該配置項也會有所幫助。因爲 lerna run 將按照拓撲排序的順序執行命令,所以在繼續執行之前可能會等待某個命令。當您運行 "watch" 命令時會阻塞執行,因爲他們通常不會結束。

--reject-cycles

如果(在 bootstrap、exec、publish 或 run 中)發現循環,則立即失敗。

lerna bootstrap --reject-cycles

過濾器參數

--scope

只包含名稱與給定通配符匹配的包。

lerna exec --scope my-component -- ls -la     
lerna run --scope toolbar-* test     
lerna run --scope package-1 --scope *-2 lint

--ignore

排除名稱與給定通配符匹配的包。

lerna exec --ignore package-{1,2,5}  -- ls -la     
lerna run --ignore package-1  test     
lerna run --ignore package-@(1|2) --ignore package-3 lint

--no-private

排除私有的包。默認情況下是包含它們的。

--since [ref]

只包含自指定 ref 以來已經改變的包。如果沒有傳遞 ref,它默認爲最近的標記。

    # 列出自最新標記以來發生變化的包的內容
    $ lerna exec --since -- ls -la
    # 爲自“master”以來所有發生更改的包運行測試
    $ lerna run test --since master
    # 列出自“某個分支”以來發生變化的所有包
    $ lerna ls --since some-branch

在 CI 中使用時,如果您可以獲得 PR 將要進入的目標分支,那麼它將特別有用,因爲您可以將其作爲 --since 配置項的 ref。這對於進入 master 和 feature 分支的 PR 來說很有效。

--exclude-dependents

當使用 --since 運行命令時,排除所有傳遞的被依賴項,覆蓋默認的 “changed” 算法。

如果沒有 --since 該參數時無效的,會拋出錯誤。

--include-dependents

在運行命令時包括所有傳遞的被依賴項,無視 --scope、--ignore 或 --since。

--include-dependencies

在運行命令時包括所有傳遞依賴項,無視 --scope、--ignore 或 --since。

與接受 --scope(bootstrap、clean、ls、run、exec) 的任何命令組合使用。確保對任何作用域的包(通過 --scope 或 --ignore)的所有依賴項(和 dev 依賴項)也進行操作。

注意:這將會覆蓋 --scope 和 --ignore。

例如,如果一個匹配了 --ignore 的包被另一個正在引導的包所以來,那麼它仍會照常工作。

當您想要 “設置” 一個依賴於其他正在設置的包其中的一個包時,這是非常有用的。

lerna bootstrap --scope my-component --include-dependencies     
# my-component 及其所有依賴項將被引導
lerna bootstrap --scope "package-*" --ignore "package-util-*" --include-dependencies     
# 所有匹配 "package-util-*" 的包將被忽略,除非它們依賴於名稱匹配 "package-*" 的包

--include-merged-tags

lerna exec --since --include-merged-tags -- ls -la

在使用 --since 命令時,它包含來自合併分支的標記。這隻有在從 feature 分支進行大量發佈時纔有用,通常情況下不推薦這樣做。

lerna 原理解析

文件結構

以下是 lerna 的主要目錄結構(省略了一些文件和文件夾):

lerna
├─ CHANGELOG.md -- 更新日誌
├─ README.md -- 文檔
├─ commands -- 核心子模塊
├─ core -- 核心子模塊
├─ utils -- 核心子模塊
├─ lerna.json -- lerna 配置文件
├─ package-lock.json -- 依賴聲明
├─ package.json -- 依賴聲明
├─ scripts -- 內置腳本
└─ yarn.lock -- 依賴聲明

有趣的是,lerna 本身也是用 lerna 進行開發管理的。它是一個 monorepo 項目,其各個子項目分佈在 lerna/commands/、core/、utils/* 目錄下。

另外,在源碼中,經常會看到名稱爲 @lerna/command 的子項目,如 lerna/core/lerna/index.js 的內容是:

const cli = require("@lerna/cli");
const addCmd = require("@lerna/add/command");
const bootstrapCmd = require("@lerna/bootstrap/command");
const changedCmd = require("@lerna/changed/command");
const cleanCmd = require("@lerna/clean/command");
const createCmd = require("@lerna/create/command");
const diffCmd = require("@lerna/diff/command");
const execCmd = require("@lerna/exec/command");
const importCmd = require("@lerna/import/command");
const infoCmd = require("@lerna/info/command");
const initCmd = require("@lerna/init/command");
const linkCmd = require("@lerna/link/command");
const listCmd = require("@lerna/list/command");
const publishCmd = require("@lerna/publish/command");
const runCmd = require("@lerna/run/command");
const versionCmd = require("@lerna/version/command");

這些子項目分佈在 lerna/commands/、core/、utils/* 下,截至本文截稿時,lerna 有 61 個子項目。

以下是各子項目分佈:

命令行註冊

lerna 命令註冊工作集中在 lerna/core/lerna/* 路徑下。

lerna/core/lerna/package.json

該文件的 bin 字段定義了 lerna 命令:

"bin": {         
    "lerna": "cli.js"     
}

lerna/core/lerna/cli.js

該文件描述了命令行的執行入口:

   #!/usr/bin/env node
    "use strict";
    /* eslint-disable import/no-dynamic-require, global-require */
    const importLocal = require("import-local");
    if (importLocal(__filename)) {
        require("npmlog").info("cli", "using local version of lerna");
    } else {
        require(".")(process.argv.slice(2));
    }

lerna/core/lerna/index.js

該文件爲命令行引入了所有 lerna 指令:

"use strict";
const cli = require("@lerna/cli");
const addCmd = require("@lerna/add/command");
const bootstrapCmd = require("@lerna/bootstrap/command");
const changedCmd = require("@lerna/changed/command");
const cleanCmd = require("@lerna/clean/command");
const createCmd = require("@lerna/create/command");
const diffCmd = require("@lerna/diff/command");
const execCmd = require("@lerna/exec/command");
const importCmd = require("@lerna/import/command");
const infoCmd = require("@lerna/info/command");
const initCmd = require("@lerna/init/command");
const linkCmd = require("@lerna/link/command");
const listCmd = require("@lerna/list/command");
const publishCmd = require("@lerna/publish/command");
const runCmd = require("@lerna/run/command");
const versionCmd = require("@lerna/version/command");
const pkg = require("./package.json");
module.exports = main;
function main(argv) {
const context = {
    lernaVersion: pkg.version,
};
return cli()
    .command(addCmd)
    .command(bootstrapCmd)
    .command(changedCmd)
    .command(cleanCmd)
    .command(createCmd)
    .command(diffCmd)
    .command(execCmd)
    .command(importCmd)
    .command(infoCmd)
    .command(initCmd)
    .command(linkCmd)
    .command(listCmd)
    .command(publishCmd)
    .command(runCmd)
    .command(versionCmd)
    .parse(argv, context);
}

Commander 類

lerna 的子命令均繼承自 Command 類,比如 lerna init 命令定義爲:

const { Command } = require("@lerna/command");
class InitCommand extends Command {
    ...
}

Command 類定義在 @lerna/command,位於 lerna/core/command 目錄。

有一些寫法值得借鑑,比如個別方法需要 InitCommand 的實例自行定義,否則拋錯,InitCommand 類的定義如下:

class InitCommand extends Command {
    ...
    initialize() {
        throw new ValidationError(this.name, "initialize() needs to be implemented.");
    }
    execute() {
        throw new ValidationError(this.name, "execute() needs to be implemented.");
    }
}

import-local

上文提到,lerna/core/lerna/cli.js 描述了命令行的執行入口:

#!/usr/bin/env node
"use strict";
/* eslint-disable import/no-dynamic-require, global-require */
const importLocal = require("import-local");
if (importLocal(__filename)) {
    require("npmlog").info("cli", "using local version of lerna");
} else {
    require(".")(process.argv.slice(2));
}

其中,import-local 的作用是,實現本地開發版本和生產版本的切換。import-local/index.js 的內容是:

 'use strict';
const path = require('path');
const resolveCwd = require('resolve-cwd');
const pkgDir = require('pkg-dir');
module.exports = filename => {
    const globalDir = pkgDir.sync(path.dirname(filename));
    const relativePath = path.relative(globalDir, filename);
    const pkg = require(path.join(globalDir, 'package.json'));
    const localFile = resolveCwd.silent(path.join(pkg.name, relativePath));
    const localNodeModules = path.join(process.cwd(), 'node_modules');
    const filenameInLocalNodeModules = !path.relative(localNodeModules, filename).startsWith('..');
    // Use path.relative() to detect local package installation,
    // because __filename's case is inconsistent on Windows
    // Can use === when targeting Node.js 8
    // See https://github.com/nodejs/node/issues/6624
    return !filenameInLocalNodeModules && localFile && path.relative(localFile, filename) !== '' && require(localFile);
};

依賴管理

Babel 使用 lerna 進行依賴管理。其中,lerna 自己實現了一套依賴管理機制,也支持基於 yarn 的依賴管理。這裏主要介紹 lerna 的 hoisting。

子模塊相同的依賴可以通過依賴提升(hoisting),將相同的依賴安裝在根目錄下,本地包之間用軟連接實現。

lerna bootstrap

該命令執行時,會在每個子項目下面,各自安裝其中 package.json 聲明的依賴。

這樣會有一個問題,相同的依賴會被重複安裝,除了佔用更多空間外,依賴安裝速度也受影響。

lerna bootstrap --hoist

--hoist 標記時,lerna bootstrap 會識別子項目下名稱和版本號相同的依賴,並將其安裝在根目錄的 node_modules 下,子項目的 node_modules 會生成軟連接。

這樣節省了空間,也減少了依賴安裝的耗時。

yarn install

當在項目中聲明 yarn 作爲依賴安裝的底層依賴,如:

lerna.json

{             
    "npmClient": "yarn",             
    "useWorkspaces": true,         
}

package.json

{             
    "workspaces": [                 
        "packages/*"             
    ]         
}

相對於 lerna,yarn 提供了更強大的依賴分析能力、hoisting 算法。而且,默認情況下,yarn 會開啓 hoist 功能,也可以設置 nohoist 關閉該功能:

{             
    "workspaces": {                 
        "packages": [                     
            "Packages/*",                 
        ],                 
        "nohoist": [                     
            "**"                 
        ]             
     }         
}

lerna 中涉及的 git 命令

lerna 中廣泛使用了 git 命令用於內部工作,這裏列舉了 lerna 中使用的 git 命令。

git init
git rev-parse
git describe
git rev-list
git tag
git log
git config
git diff-index
git --version
git show
git am
git reset
git ls-files
git diff-tree
git commit
git ls-remote
git checkout
git push
git add
git remote
git show-ref

沒有必要逐個介紹 git 命令,我們選取幾個不是很常見的 git 命令介紹,瞭解其作用、lerna 哪些命令用到了它們。

git rev-parse

下面是一個 git rev-parse 命令的執行結果案例:

$ git rev-parse HEAD
f7f6d6f2b6b47eb8c4cf4b8bf5f83e0b8028c031
getCurrentSHA() {
    return this.execSync("git", ["rev-parse", "HEAD"]);
}
getWorkspaceRoot() {
    return this.execSync("git", ["rev-parse", "--show-toplevel"]);
}

git describe

下面是一個 git describe 命令的執行結果案例:

$ git describe
polaris-release-1.0.0-c12345
export function getLastCommit(execOpts?: ExecOptions) {
  if (hasTags(execOpts)) {
    log.silly("getLastTagInBranch", "");
    return childProcess.execSync("git", ["describe", "--tags", "--abbrev=0"], execOpts);
  }
  log.silly("getFirstCommit", "");
  return childProcess.execSync("git", ["rev-list", "--max-parents=0", "HEAD"], execOpts);
}

git rev-list

下面是一個 git rev-list 命令的執行結果案例:

$ git rev-list HEAD
f7f6d6f2b6b47eb8c4cf4b8bf5f83e0b8028c031
a3d8b4e1c2e1d0a9b8c6e5f7d6a4b3e8a1b2c3d4

這裏 git rev-list HEAD 將列出當前 HEAD 指向的提交及其之前的所有提交的 SHA-1 哈希值。

export function getLastCommit(execOpts?: ExecOptions) {
  if (hasTags(execOpts)) {
    log.silly("getLastTagInBranch", "");
    return childProcess.execSync("git", ["describe", "--tags", "--abbrev=0"], execOpts);
  }
  log.silly("getFirstCommit", "");
  return childProcess.execSync("git", ["rev-list", "--max-parents=0", "HEAD"], execOpts);
}

git diff-index

下面是一個 git diff-index 命令的執行結果案例:

$ git diff-index HEAD
:100644 100644 bcd1234... 0123456... M        file.txt

這裏 git diff-index HEAD 將顯示當前提交(HEAD)和工作目錄之間的差異。

export class ImportCommand extends Command<ImportCommandOptions> {
    ...
    if (this.execSync("git", ["diff-index", "HEAD"])) {
      throw new ValidationError("ECHANGES", "Local repository has un-committed changes");
    }
    ...
}

git diff-tree

下面是一個 git diff-tree 命令的執行結果案例:

$ git diff-tree HEAD~2 HEAD
100644 blob a3d8b4e1c2e1d0a9b8c6e5f7d6a4b3e8a1b2c3d4        file.txt
export async function getProjectsWithTaggedPackages(
  projectNodes: ProjectGraphProjectNodeWithPackage[],
  projectFileMap: ProjectFileMap,
  execOpts: ExecOptions
): Promise<ProjectGraphProjectNodeWithPackage[]> {
  log.silly("getTaggedPackages", "");
  // @see https://stackoverflow.com/a/424142/5707
  // FIXME: --root is only necessary for tests :P
  const result = await childProcess.exec(
    "git",
    ["diff-tree", "--name-only", "--no-commit-id", "--root", "-r", "-c", "HEAD"],
    execOpts
  );
  const stdout: string = result.stdout;
  const files = new Set(stdout.split("\n"));
  return projectNodes.filter((node) => projectFileMap[node.name]?.some((file) => files.has(file.file)));
}

git show-ref

下面是一個 git show-ref 命令的執行結果案例:

$ git show-ref
a3d8b4e1c2e1d0a9b8c6e5f7d6a4b3e8a1b2c3d4 HEAD
a3d8b4e1c2e1d0a9b8c6e5f7d6a4b3e8a1b2c3d4 refs/heads/main
f7f6d6f2b6b47eb8c4cf4b8bf5f83e0b8028c031 refs/tags/v1.0.0
export function remoteBranchExists(gitRemote: string, branch: string, opts: ExecOptions) {
  log.silly("remoteBranchExists", "");
  const remoteBranch = `${gitRemote}/${branch}`;
  try {
    childProcess.execSync("git", ["show-ref", "--verify", `refs/remotes/${remoteBranch}`], opts);
    return true;
  } catch (e) {
    return false;
  }
}

總結

可以看到,lerna 的內容還是挺多的,如果細緻研究,裏面有大量的方向可以探索,比如文末的 git 命令集,這些值得新開一篇文章詳細介紹,敬請期待!

文 / 效率前端小丙

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/TA-o4acrpQmaX_ej38Yhew