現代前端工程化-基於 Monorepo 的 lerna

語言: CN / TW / HK

本文你能學到什麼?

看完本文後希望可以檢查一下圖中的內容是否都掌握了,文中的例子最好實際操作一下,下面開始正文。

本文是 前端工程化系列 中的一篇,回不斷更新,下篇更新內容可看文末的 下期預告宗旨:工程化的最終目的是讓業務開發可以 100% 聚焦在業務邏輯上

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 是一個用來優化託管在 git\npm 上的多 package 程式碼庫的工作流的一個管理工具,可以讓你在主專案下管理多個子專案,從而解決了多個包互相依賴,且釋出時需要手動維護多個包的問題。

關鍵詞:多倉庫管理,多包管理,自動管理包依賴

lerna 解決了哪些痛點

資源浪費

通常情況下,一個公司的業務專案只有一個主幹,多 git repo 的方式,這樣 node_module 會出現大量的冗餘,比如它們可能都會安裝 ReactReact-dom 等包,浪費了大量儲存空間。

除錯繁瑣

很多公共的包通過 npm 安裝,想要除錯依賴的包時,需要通過 npm link 的方式進行除錯。

資源包升級問題

一個專案依賴了多個 npm 包,當某一個子 npm 包程式碼修改升級時,都要對主幹專案包進行升級修改。(這個問題感覺是最煩的,可能一個版本號就要去更新一下程式碼併發布)

lerna的核心原理

monorepo 和 multrepo 對比

monorepo :是將所有的模組統一的放在一個主幹分支之中管理。 multrepo :將專案分化為多個模組,並針對每一個模組單獨的開闢一個 reporsitory 來進行管理。

image.png

lerna 軟鏈實現(如何動態建立軟鏈)

未使用 lerna 之前,想要除錯一個本地的 npm 模組包,需要使用 npm link 來進行除錯,但是在 lerna 中可以直接進行模組的引入和除錯,這種動態建立軟鏈是如何實現的?

軟鏈是什麼?

Node.js 中如何實現軟鏈

lerna 中也是通過這種方式來實現軟鏈的

fs.symlinkSync(target,path,type)

fs.symlinkSync(target,path,type)
target <string> | <Buffer> | <URL>   // 目標檔案
path <string> | <Buffer> | <URL>  // 建立軟鏈對應的地址
type <string>

它會建立名為 path 的連結,該連結指向 targettype 引數僅在 Windows 上可用,在其他平臺上則會被忽略。它可以被設定為 'dir''file''junction' 。如果未設定 type 引數,則 Node.js 將會自動檢測 target 的型別並使用 'file''dir' 。如果 target 不存在,則將會使用 'file'Windows 上的連線點要求目標路徑是絕對路徑。當使用 'junction' 時, target 引數將會自動地標準化為絕對路徑。

  • 基本使用

const res = fs.symlinkSync('./target/a.js','./b.js');
image.png

這段程式碼的意思是為  建立一個軟連結 b.js 指向了檔案 ./targert/a.js ,當 a.js 中的內容發生變化時, b.js 檔案也會發生相同的改變。

Node.js 文件中, fs.symlinkSync() lerna 的原始碼中動態連結也是通過 symlinkSync 來實現的。原始碼對應地址:軟鏈實現原始碼地址參考1

function createSymbolicLink(src, dest, type) {
  log.silly("createSymbolicLink", [src, dest, type]);

  return fs
    .lstat(dest)
    .then(() => fs.unlink(dest))
    .catch(() => {
      /* nothing exists at destination */
    })
    .then(() => fs.symlink(src, dest, type));
}

更多關於軟鏈的文章,我後面會單獨寫一篇文章介紹軟硬連結,這裡知道 lerna 連結部分 的實現就可以了。Node fs 官網 參考2

lerna 基本使用

lerna 環境配置

lerna 在使用之前需要全域性安裝 lerna 工具。

npm install lerna -g

初始化一個lerna 專案

mkdir lerna-demo ,在當前目錄下建立資料夾 lerna-demo ,然後使用命令 lerna init 執行成功後,目錄下將會生成這樣的目錄結構。,一個 hello world 級別的 lerna 專案就完成了。

image.png
 - packages(目錄)
 - lerna.json(配置檔案)
 - package.json(工程描述檔案)

lerna 常用命令

介紹一些 lerna 常用的命令,常用命令這部分可以簡單過一遍,當作一個工具集收藏就行,需要的時候來找下,用著用著就熟練了,主要可以實操下下面的實戰小練習,這個過程會遇到一些坑的。

  1. 初始化 lerna 專案
lerna init 
  1. 建立一個新的由 lerna 管理的包。
lerna create <name>
  1. 安裝所有·依賴項並連線所有的交叉依賴

lerna bootstrap
  1. 增加模組包到最外層的公共 node_modules
lerna add axios
  1. packages
    ui-web
    example-web
    
lerna add ui-web --scope=example-web
  1. packages
    packages
    example-web
    yarn start
    package.json
    
lerna exec --scope example-web -- yarn start

如果命令中不增加 --scope example-web 直接使用下面的命令,這會在 packages 下所有包執行命令 rm -rf ./node_modules

lerna exec -- rm -rf ./node_modules
  1. 顯示所有的安裝的包

lerna list // 等同於 lerna ls

這裡再提一個命令也比較常用,可以通過 json 的方式檢視 lerna 安裝了哪些包, json 中還包括包的 路徑 ,有時候可以用於查詢包是否生效。

lerna list --json
  1. 從所有包中刪除 node_modules 目錄
lerna clean

:warning:注意下 lerna clean 不會刪除專案最外層的根 node_modules

  1. 在當前專案中釋出包

lerna publish

這個命令可以結合 lerna.json 中的   "version": "independent" 配置一起使用,可以完成統一發布版本號和 packages 下每個模版釋出的效果,具體會在下面的實戰講解。

lerna publish 永遠不會發布標記為 private 的包( package.json中的”private“: true

以上命令基本夠日常開發使用了,如果需要更詳細內命令內容,可以檢視下面的詳細文件 lerna 命令詳細文件參考3

lerna 應用(適用場景)

從零搭建一個 平臺基礎元件庫專案

lerna 比較適合的場景:基礎框架,基礎工具類, ui-component 中會存在 h5 元件庫, web 元件庫, mobile 元件庫,以及對應的 doc 專案,三個專案通用的 common 程式碼。為了方便多個專案的聯調,以及分別打包,這裡採用了 lerna 的管理方式。

接下來會講解使用 leran 搭建 ui-component 基礎元件庫的過程。

1. 專案初始化

建立一個資料夾 ui-component ,

切換到目錄 ui-component 目錄下。執行 lerna init

image.png

lerna 會自動建立一個 packages 目錄夾,我們以後的專案都新建在這裡面。同時還會在根目錄新建一個 lerna.json 配置檔案

{
  "packages": [
    "packages/*"
  ],
  "version": "0.0.0" // 共用的版本,由lerna管理
}

注意``lerna 預設使用的是集中版本,所有的 package 共用一個 version ,如果需要 packages 下不同的模組 使用不同的版本號,需要配置 Independent 模式。命令列介紹時有提到這裡 在 json` 中增加屬性配置

  "version": "independent"

package.json 中有一點需要注意,他的 private 必須設定為 true ,因為 mono-repo 本身的這個 Git 倉庫並不是一個專案,他是多個專案,所以一般不進行直接釋出,釋出的應該是 packages/ 下面的各個子專案。

子專案建立

現在 package 目錄下是空的,我們需要建立一下元件庫內部相關內容。使用 leran create 命令建立子 package 專案。

lerna create ui-common

lerna create ui-common 會在 packages 中建立 ui-common 專案,另外建立兩個基於 TypeScriptreact 專案 ui-webexample-web , 在 package 目錄下執行

npx create-react-app ui-web --typescript
npx create-react-app example-web --typescript

這裡補充一個小插曲吧,初始化 typescript 專案後如何進行配置,可以直接用 typescript 編寫元件? 安裝 typescript需要的模組包

$ npm install --save typescript @types/node @types/react @types/react-dom @types/jest
$ # 或者
$ yarn add typescript @types/node @types/react @types/react-dom @types/jest

然後在專案根目錄建立 tsconfig.jsonwebpack.config.js 檔案:

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "lib": ["dom","es2015"],
    "jsx": "react",
    "sourceMap": true,
    "strict": true,
    "noImplicitAny": true,
    "baseUrl": "src",
    "paths": {
      "@/*": ["./*"],
    },
    "esModuleInterop": true,
    "experimentalDecorators": true,
  },
  "include": [
    "./src/**/*"
  ]
}
  • jsx 選擇 react
  • lib
    dom
    es2015
    
  • include 選擇我們建立的 src 目錄
var fs = require('fs')
var path = require('path')
var webpack = require('webpack')
const { CheckerPlugin } = require('awesome-typescript-loader');
var ROOT = path.resolve(__dirname);

var entry = './src/index.tsx';
const MODE = process.env.MODE;
const plugins = [];
const config = {
  entry: entry,
  output: {
    path: ROOT + '/dist',
    filename: '[name].bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.ts[x]?$/,
        loader: [
          'awesome-typescript-loader'
        ]
      },
      {
        enforce: 'pre',
        test: /\.ts[x]$/,
        loader: 'source-map-loader'
      }
    ]
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.json'],
    alias: {
      '@': ROOT + '/src'
    }
  },
}

if (MODE === 'production') {
  config.plugins = [
    new CheckerPlugin(),
    ...plugins
  ];
}

if (MODE === 'development') {
  config.devtool = 'inline-source-map';
  config.plugins = [
    new CheckerPlugin(),
    ...plugins
  ];
}
module.exports = config;

建立完兩個專案後, ui-webexample-web 中同時出現 node_modules ,二者會有很多重複部分,並且會佔用大量的硬碟空間

lerna bootstrap

lerna 提供了可以 將子專案的依賴包提升到最頂層 的方式 ,我們可以執行 lerna clean 先刪除每個子專案的 node_modules , 然後執行命令   lerna bootstrop --hoist

lerna bootstrop --hoist 會將 packages 目錄下的公共模組包抽離到最頂層,但是這種方式會有一個問題, 不同版本號只會保留使用最多的版本 ,這種配置不太好,當專案中有些功能需要依賴老版本時,就會出現問題。

yarn workspaces

有沒有更優雅的方式?再介紹一個命令 yarn workspaces ,可以解決前面說的當不同的專案依賴不同的版本號問題, yarn workspaces 會檢查每個子專案裡面依賴及其版本,如果版本不一致都會保留到自己的 node_modules 中,只有依賴版本號一致的時候才會提升到頂層。注意:這種需要在 lerna.json 中增加配置。

"npmClient": "yarn",  // 指定 npmClent 為 yarn
  "useWorkspaces": true // 將 useWorkspaces 設定為 true

並且在 頂層package.json 中增加配置

// 頂層的 package.json
{
    "workspaces":[
        "packages/*"
    ]
}

增加了這個配置後 不再需要 lerna bootstrap 來安裝依賴了,可以直接使用 yarn install 進行依賴的安裝。注意: yarn install 無論在頂層執行還是在任意一個子專案執行效果都是可以。

啟動子專案

配置完成後,我們啟動 packages 目錄下的子專案 example-web ,原有情況下我們可能需要頻繁切換到 example-web 資料夾,在這個目錄執行 yarn start

使用了 lerna 進行專案管理之後,可以在頂層的 package.json 檔案中進行配置,在 scripts 中增加配置。

  "scripts": {
        "web": "lerna exec --scope example-web -- yarn start",
  }

lerna exec --scope example-web 命令是在 example-web 包下執行 yarn start

並且在頂層 lerna.json 中增加配置

{
"npmClient": "true"
}

然後在頂層執行 yarn web 就可以執行 example-web 專案了。

配置完成後嘗試一下,專案正常啟動。

image.png

example-web 模組中 引用 ui-common 中的函式

我們在 ui-common 中定義一個網路請求公共函式,在 ui-webexample-web 專案中都會用到。在專案 example-web 中增加 ui-common 模組依賴,執行命令

lerna add ui-common --scope=example-web

執行命令後,在 example-webpackage.josn 中會出現

image.png

ui-common 已經成功被 example-web 中引用,然後在 example-web 專案中引用 request 函式並使用,例子中只是簡單使用下 ui-common 中的函式。

import React from "react";
import request from "ui-common";

interface IProps {}
interface IState {
  conents: Array<string>;
}
class CommentList extends React.Component<IProps, IState> {
  constructor(props: IProps) {
    super(props);
    this.state = {
      conents: ["我是列表第一條"],
    };
  }
  componentDidMount() {
    request({
      url: "www.baidu.com",
      method: "get",
    });
  }
  render() {
    return (
      <>
        <ul>
          {this.state.conents.map((item, index) => {
            return <li key={index}> {item} </li>;
          })}
        </ul>
      </>
    );
  }
}
export default CommentList;

釋出

專案結構已基本搭建完成,我們嘗試釋出一下 ,使用命令

lerna publish

由於之前我們在 lerna.json 中配置了

{
  "packages": [
    "packages/*"
  ],
  "version": "independent",// 不同模組不同版本
  "npmClient": "yarn", 
  "useWorkspaces": true 
}

執行命令後在會出現如下內容,針對 packages 中的每個模組單獨選擇版本進行釋出。

如果想要釋出的模組統一,使用相同的版本號,需要修改 lerna.json ,將 "version": "independent" , 改為固定版本號,修改後嘗試重新使用 lerna publish 進行釋出,

注意:warning::這裡再次宣告一下,如果使用了 independent 方式進行版本控制,在 packages 內部的包進行互相依賴時,每次釋出之後記得修改下發布後的版本號,否則在本地除錯時會出現剛釋出的程式碼不生效問題(這個問題本人親自遇到過,單獨說下)

框架類專案

公司元件庫專案

元件庫專案類似上面實戰的目錄結構,但是會在 packages 包下新增很多其他的模組,比如 ui-h5 , example-h5

工具類專案

舉例一些開源專案。

  • babel 使用的就是 lerna 進行管理
  • facebook/jest 使用的是 lerna 進行管理
  • alibaba/rax 使用的是 lerna 進行管理

lerna 弊端

和傳統的 git submodules 多倉庫方式對比,我覺得 lerna 優勢很明顯的,個人認為唯一不足的是: 由於原始碼在一起,倉庫變更非常常見,儲存空間也變得很大,甚至幾 GCI 測試執行時間也會變長,雖然如此也是可以接受的。

下期預告

本文主要講解了 lerna 的基本使用,並且用它搭建了一個基礎目錄結構(我會補充一些基礎的配置 eslintprettier 等,本文不多寫之前有寫過),這種搭建我們沒有必要每次都配置一遍,嘗試一遍就好了, 工程化的最終目的是讓業務開發可以 100% 聚焦在業務邏輯上 ,下一篇文章會講解 輪子 create-mono-repo cli 腳手架的完整實現過程,如何快速建立 mono-repo 專案

導圖插入後不是很清晰,有需要的公眾號回覆 lerna 可獲取原圖。

參考文章

  • [1] https://github.com/lerna/lerna/tree/main/utils/create-symlink

  • [2] http://nodejs.cn/api/fs.html#fs_fs_unlink_path_callback

  • [3] http://www.febeacon.com/lerna-docs-zh-cn

  • [4] https://juejin.cn/post/6844903885312622606

  • [5] https://github.com/dkypooh/front-end-develop-demo/tree/master/base/lerna

  • [6] http://www.febeacon.com/lerna-docs-zh-cn/routes/commands/bootstrap.html

  • [7] https://github.com/lerna/lerna/tree/main/utils/create-symlink

關於奇舞團

奇舞團是 360 集團最大的大前端團隊,代表集團參與 W3C 和 ECMA 會員(TC39)工作。奇舞團非常重視人才培養,有工程師、講師、翻譯官、業務介面人、團隊 Leader 等多種發展方向供員工選擇,並輔以提供相應的技術力、專業力、通用力、領導力等培訓課程。奇舞團以開放和求賢的心態歡迎各種優秀人才關注和加入奇舞團。