うしろのこの本ください

なんでもかきます

lerna link convertを理解する

最近案件でモノレポ化が盛んになっていて、自分はフロントエンド周りを少しずつ進めています。

構成はlerna + yarn workspaceの基本的なものですが社内ライブラリをパブリッシュしない方向で進めることになり、モノレポ内ですべて完結させる方針です。

で、今までバラバラだったライブラリが一括管理できるので依存関係とかも全部合わせようとなりました。いわゆるFixed modeです。

イメージとしてはこんな感じ

frontend/*
packages
        /a
        /b
        /c
node_modules/*
lerna.json
package.json
yarn.lock

packagesの配下に社内ライブラリが複数入る感じ。workspaceのスコープですがfrontendの直下すべてとpackagesの直下すべてになっていて、lerna run lintとかやると全てのディレクトリでlintが実行されます。

yarn workspaceを使うと各ディレクトリにはnode_modulesが作られず、ルートに巻き上げられます。yarn.lockを見てみるとこのようになっています。(例です)

"a-lib@file:packages/a":
  version "1.0.0"
  dependencies:
    cross-env "^5.2.0"
    vue "^2.6.0"

dependenciesはこんな感じでルートのlockファイルにまとめて記述されます。ではdevDependenciesはどうかというと、普通に記述されます。要するにdevDependenciesに関してはモジュールの実体がルートのnode_modulesに入るだけで個別のスコープに紐づくわけではないということです。共通化されます。

devDependencies自体が共通化されることは良いんですが、共通化するならそれぞれのpackage.jsonに書かれているやつもルートにまとめたいですよね。ということで使うのが lerna link convert です。実行するとワークスペース内のすべてのdevDependenciesがルートへ集約されます。

これでdependencies(パッケージ固有の依存)もdevDependencies(モノレポで使う開発用依存)も良い感じになりました。

lerna link convert、どう動いてるの?

ドキュメントが薄くて裏で何が起こっているか全くわからなかったのでコード読んだ。コマンド自体は @lerna/link のもので、convertはオプションになります。

github.com

READMEには特に書かれていませんね。

コードを読むとなんとなく何をやっているかは分かります。lernaはCommandというクラスを継承してオーバーライドしています。

  execute() {
    if (this.options._.pop() === "convert") {
      return this.convertLinksToFileSpecs();
    }

    return symlinkDependencies(this.allPackages, this.targetGraph, this.logger.newItem("link dependencies"));
  }

オプションが convert なら convertLinksToFileSpecs を呼び出すようになってますね。そうでなければシムリンクを貼るような挙動になっています。つまり lerna link です。

convertLinksToFileSpecs() {
    const rootPkg = this.project.manifest;
    const rootDependencies = {};
    const hoisted = {};
    const changed = new Set();
    const savePrefix = "file:";

    for (const targetNode of this.targetGraph.values()) {
      const resolved = { name: targetNode.name, type: "directory" };

      // install root file: specifiers to avoid bootstrap
      rootDependencies[targetNode.name] = targetNode.pkg.resolved.saveSpec;

      for (const depNode of targetNode.localDependents.values()) {
        const depVersion = slash(path.relative(depNode.pkg.location, targetNode.pkg.location));
        // console.log("\n%s\n  %j: %j", depNode.name, name, `${savePrefix}${depVersion}`);

        depNode.pkg.updateLocalDependency(resolved, depVersion, savePrefix);
        changed.add(depNode);
      }

      if (targetNode.pkg.devDependencies) {
        // hoist _all_ devDependencies to the root
        Object.assign(hoisted, targetNode.pkg.devDependencies);
        targetNode.pkg.set("devDependencies", {});
        changed.add(targetNode);
      }
    }

    // mutate project manifest, completely overwriting existing dependencies
    rootPkg.set("dependencies", rootDependencies);
    rootPkg.set("devDependencies", Object.assign(rootPkg.get("devDependencies") || {}, hoisted));

    return pMap(changed, node => node.pkg.serialize()).then(() => rootPkg.serialize());
  }

まあざっくりとした理解ですが単にdevDependenciesをルートにマージして上書きしているようです。元からは消す。"巻き上げ"の正体は本当に記述を移動しているだけっぽいですね。あとは各パッケージのdependenciesへの参照を file:path/パッケージ名の形で追記しています。

おわり

lernaは基本複数のパッケージに対して同じコマンドを実行するためのツールとして使うのが良いと思いますが、依存の巻き上げなどでも使えて楽で良いです。 ただしルートにすでに同じパッケージが存在する場合バージョンが低い方に上書きされるため注意です。(直近babel周りで死にまくっている)