這樣用 lerna 也太爽了吧!

Hi,大家好。我是 yxchan🧑‍💻。不知道大家團隊內的 npm 包代碼是「分開管理」還是「合併管理」的呢?

關於代碼庫管理,一般是兩種常見方案:

multirepo

monorepo

以上兩種方式,孰優孰劣是個 “哲學” 問題。但對於團隊內的「公共組件」而言,我認爲monorepo模式更加合理 (效率不一定會更高)。

爲什麼呢?

因爲同一個團隊內,組件之間避免不了會出現相互依賴的情況。設想一下:

有兩個模塊,module-amodule-bmodule-b依賴於module-a。 這時發現,module-a有個bug,需要發bugfix版本:

沒錯,一些「工具」指的就是lerna

lerna

⚠注意:以下內容並不是lerna的使用教程,👉 「lerna 使用教程 [1]」👈。

是什麼?

The Original Tool forJavaScript Monorepos.

用於 TypeScript/JavaScript monorepo 的原始工具。

怎麼用?

自己去看文檔 [2]。

同學 A:我想發個 npm 包,要怎麼搞?

我:首先clone倉庫下來,然後在packages目錄下創建個文件夾A,然後在Anpm init,然後配置相關的打包工具..... 開發完後就參照lerna文檔進行發佈......

同學 A:比新建個倉庫還麻煩,npm publish不能發佈嗎?

我:......

確實是,這麼一套下來,比新建倉庫發佈更麻煩,還要去查閱lerna的文檔來怎麼進行構建和發佈。再說,程序員🧑‍💻怎麼能容忍手動創建文件夾這種事情發生!所以,得想辦法解決掉這些 “麻煩”。最後的解決方案,就是封裝

封裝

更通俗的說,就是「化繁爲簡」。

封裝的思路主要有 部分:

$ npm run create
複製代碼
$ npm run link
複製代碼
$ npm run test
複製代碼
$ npm run build
複製代碼
$ npm run version
複製代碼
$ npm run release
複製代碼

這樣,只要知道自己當前的步驟,就不用查看文檔,直接運行 ⬇️ :

$ npm run 「步驟」
複製代碼

一步到位!

演示

接來下,將會演示 lernanpm-yxutilslernanpm-yxtest 的「創建」、「安裝」、「開發」、「測試」、「構建」、「發佈」、「依賴連接」、「同步構建發佈」。

創建

運行 ⬇️ ,創建項目:

$ npm run create
複製代碼

可以看到在packages下,新增了yxtestyxutils兩個項目。

安裝

運行 ⬇️ ,安裝項目所需的依賴:

$ npm run link
複製代碼

選擇「默認」安裝方式,這樣所有項目的依賴都會被安裝。

開發

添加say方法到yxutils中,⬇️ :

lerna-npm/packages/yxutils/src/features/index.ts

/**
 * 打印
 * @return {string} val 打印內容
 */
interface Isay {
  (val: string): void
}
export let say: Isay
say = (val: string) ={
  console.log(val)
}
複製代碼

添加printName方法到yxtest中,⬇️ :

lerna-npm/packages/yxtest/src/features/index.ts

/**
 * 打印名字
 * @param { string } sFirstName
 * @param { string } sFirstName
 * @returns { void }
 */
interface IprintName {
  (sFirstName: string, sLastName: string): void
}
export let printName: IprintName
printName = (sFirstName: string, sLastName: string) ={
  console.log(sFirstName + sLastName)
}
複製代碼

測試

編寫測試代碼

lerna-npm/packages/yxutils/test/index.spec.ts

import { say } from '../src/index'

describe('utils '() ={
  test('This is say test'() ={
    const consoleSpy = jest.spyOn(console, 'log')
    say('hello')
    expect(consoleSpy).toHaveBeenCalledWith('hello')
  })
})
複製代碼

lerna-npm/packages/yxtest/test/index.spec.ts

import { printName } from '../src/index'

describe('test '() ={
  test('This is printName test'() ={
    const consoleSpy = jest.spyOn(console, 'log')
    printName('yx''chan')
    expect(consoleSpy).toHaveBeenCalledWith('yxchan')
  })
})
複製代碼

運行 ⬇️ ,進行jest測試:

$ npm run test
複製代碼

構建

運行 ⬇️ ,進行項目構建:

$ npm run build
複製代碼

選擇「默認」構建方式,這樣所有「未被構建過」 or 「有代碼變動」的項目都會被構建(也可以選擇「自定義」模式,選擇構建「一個」or「多個」指定項目)。

可以看到的yxutilsyxtest目錄下都多了個代碼打包後生成的dist目錄。

版本

緊接着,來到「更新版本號」。在進行這一步之前,要先確保「提交代碼」。

此時,會自動打上tag,並且推送到「遠程倉庫」:

$ git tag
// lernanpm-yxtest@0.0.1
// lernanpm-yxutils@0.0.1
複製代碼

發佈

運行 ⬇️ ,一鍵發佈。

$ npm run release
複製代碼

至此,已經完成發佈一個獨立的「npm 包」的完整流程。

依賴連接

yxtest中的printName方法內其實可以調用yxutils已經封裝好了say方法。

因此,可以在yxtest中連接yxutils,然後使用say方法。

運行 ⬇️ :

$ npm run link
複製代碼

選擇「自定義」模式,選擇 yxtest 爲「目標 Module」,lernanpm-yxutils爲「依賴名稱」,dependencies爲「依賴位置」。

此時,可以在yxtestpackage.json中的dependencies看到,lernanpm-yxutils已添加成功。

修改代碼:

lerna-npm/packages/yxtest/src/features/index.ts

import { say } from 'lernanpm-yxutils'
/**
 * 打印名字
 * @param { string } sFirstName
 * @param { string } sFirstName
 * @returns { void }
 */
interface IprintName {
  (sFirstName: string, sLastName: string): void
}
export let printName: IprintName
printName = (sFirstName: string, sLastName: string) ={
  say(sFirstName + sLastName)
}
複製代碼

然後,再跑一遍「測試」、「構建」、「版本」、「發佈」流程。

同步構建發佈

在上一步驟,已經讓yxtestyxutils建立了連接,接下來嘗試修改yxutils

lerna-npm/packages/yxutils/src/features/index.ts

/**
 * 打印
 * @return {string} val 打印內容
 */
interface Isay {
  (val: string): void
}
export let say: Isay
say = (val: string) ={
  console.log(val.split('').join(''))
}
複製代碼

可以看到,因爲yxtest依賴了yxutils,即使yxtest沒有代碼改動,也被同步「打包」、「構建」、「發佈」了。

實現

項目地址:lerna-npm[3]

創建

主要是爲了解決「packages目錄下創建個文件夾A,然後在Anpm init,然後配置相關的打包工具」這個繁瑣的過程。

lerna-npm/scripts/create/index.ts

entry

/**
 * 程序入口
 * @param {object} payload sModule(模塊名)、sDescription(模塊描述)、sName(作者名稱)
 * @returns {void}
 */
interface Ientry {
  (payload: { sModule: string; sDescription: string; sName: string }): void
}
let entry: Ientry
entry = ({ sModule, sDescription, sName }) ={
  if (!sModule) {
    console.log(chalk.red(`[ERROR] The package name can not be empty!`))
    return
  }
  console.log(chalk.blue(`[INFO] Start creating "${sModule}"...`))
  const foldPath = createFold(sModule)
  if (!foldPath) return
  pullLocalTemp(foldPath, sModule, sDescription, sName)
    .then(() ={
      console.log(
        chalk.green(
          `[SUCCESS] Congratulations! The "${sModule}" create successfully!`
        )
      )
    })
    .catch(() ={
      console.log(chalk.red(`[ERROR] Sorry! The "${sModule}" create failed!`))
      // 刪除創建失敗的項目
      rimraf(foldPath, () ={
        console.log(chalk.blue(`[INFO] Delete "${sModule}" package fold!`))
      })
    })
}
複製代碼
  1. 通過 inquirer[4] 工具,拿到命令行交互後的數據sModulesDescriptionsName

  2. 把相關參數傳入entry函數;

  3. 創建名爲sModule的文件夾;

  4. 通過pullLocalTemp函數寫入模板內容;

  5. 如果項目創建失敗就刪除已創建的文件夾;

接下來看看pullLocalTemp幹了什麼:

pullLocalTemp

/**
 * 拉取模板,生成目標項目
 * @param {string} sDestpath 文件夾路徑
 * @param {string} sModule 模塊名
 * @param {string} sDescription 模塊描述
 * @param {string} sName 作者名稱
 * @returns {Promise<boolean>}
 */
interface IpullLocalTemp {
  (
    sDestpath: string,
    sModule: string,
    sDescription: string,
    sName: string
  ): Promise<boolean>
}
let pullLocalTemp: IpullLocalTemp
pullLocalTemp = (
  sDestpath: string,
  sModule: string,
  sDescription: string,
  sName: string
) ={
  return new Promise((resolve, reject) ={
    const metadata = {
      pkgName: sModule,
      pkgCamelName: toCamel(sModule),
      description: sDescription,
      name: sName
    }
    // 把文件轉換爲js對象
    Metalsmith(__dirname)
      .metadata(metadata) // 需要替換的數據
      .source(sTempPath) // 模板位置
      .destination(sDestpath) // 目標位置
      .use((files, metalsmith, done) ={
        // 遍歷需要替換模板
        Object.keys(files).forEach(fileName ={
          // 需先轉換爲字符串
          const fileContentsString = files[fileName].contents.toString()
          // 重寫文件內容
          files[fileName].contents = Buffer.from(
            // 使用定義的metaData取代模板變量
            Handlebars.compile(fileContentsString)(metalsmith.metadata())
          )
        })
        done(null, files, metalsmith)
      })
      .build(function (err) {
        if (err) {
          console.log(chalk.red(`[ERROR] Metalsmith build error!`))
          reject(false)
          throw err
        }
        resolve(true)
      })
  })
}
複製代碼

這個函數的功能很簡單,就是使用 metalsmith[5] 把相關參數傳入template中,替換掉對應坑位中的內容,然後輸出模板。

目前支持的模板:

安裝

同學 A: npm i 不能安裝依賴?

該封裝主要是解決依賴「安裝位置」問題以及「鏈接依賴」問題。

設想一下,假如packages下有兩個模塊,module-amodule-b

  1. 該模塊都引用了第三方模塊lodash。如果正常install,則在module-amodule-bnode_modules下都包含lodash,這樣就會造成空間的浪費。針對這種情況,應該把多次引用的第三方模塊提升至頂層的node_modules

  2. module-b依賴了module-a,在module-b的代碼中引用module-a暴露的方法。這種情況就比較麻煩了 (npm link),雖然有解決方案,但並不完美;

很幸運,lerna提供了lerna run build,能完美解決以上兩種情況。

所以,要做的就是對lerna run build命令的封裝。很簡單,通過「問答」,拿到「安裝模式」、「依賴名稱」、「項目名稱」、「安裝位置」,再構造lerna命令。

scripts/link/link.ts

/**
 * 安裝依賴
 * @param {Object} payload sInstallType(安裝模式)、sInstallModule(依賴名稱)、sTargetModule(項目名稱)、sOption(安裝位置)
 * @returns {void}
 */
interface Iinstall {
  (payload: {
    sInstallType: string
    sInstallModule?: string
    sTargetModule?: string
    sOption?: string
  }): void
}
let install: Iinstall
install = ({ sInstallType, sInstallModule, sTargetModule, sOption }) ={
  // 一鍵安裝
  if (sInstallType === 'all') {
    run('lerna'['bootstrap''--hoist'])
    // 自定義安裝
  } else {
    run(
      'lerna',
      ['add', sInstallModule || ''`--scope=lernanpm-${sTargetModule}`].concat(
        sOption === 'normal' ? [] : [`--${sOption}`]
      )
    )
  }
}
複製代碼

有「安裝』,當然也有「卸載」。同理也是通過「問答」的模式,拿到「項目名稱」(需要卸載依賴的項目)、「依賴名稱」,再構造lerna命令。

scripts/link/unlink.ts

/**
 * 卸載依賴
 * @param {Object} payload sTargetModule(目標項目)、sDelModule(卸載依賴名稱)
 * @returns {void}
 */
interface Iuninstall {
  (payload: { sTargetModule: string; sDelModule: string }): void
}
let uninstall: Iuninstall
uninstall = ({ sTargetModule, sDelModule }) ={
  run('lerna'[
    'exec',
    `--scope=lernanpm-${sTargetModule}`,
    `npm uninstall ${sDelModule}`
  ])
}
複製代碼

測試

同理。

有兩種測試模式:

  1. 全量:運行「全部」項目的測試

  2. 自定義:運行「特定」項目的測試

scripts/test/index.ts

/**
 * 測試項目
 * @param {Object} payload sTestType(測試模式)、sTargetModule(目標項目)
 * @returns {void}
 */
interface Itest {
  (payload: { sTestType: string; sTargetModule?: string }): void
}
let test: Itest
test = ({ sTestType, sTargetModule }) ={
  // 默認測試方式
  if (sTestType === 'all') {
    run('lerna'['run''test''--no-sort'])
    // 自定義測試方式
  } else {
    run('lerna'['run''test'`--scope=lernanpm-${sTargetModule}`])
  }
}
複製代碼

構建

同理。

有兩種構建模式:

  1. 全量:構建「所有」項目

  2. 自定義:構建「特定」項目

scripts/build/index.ts

/**
 * 構建項目
 * @param {Object} payload sBuildType(構建模式)、vPackages(項目名稱)
 * @returns {void}
 */
interface Ibuild {
  (payload: { sBuildType: string; vPackages?: Array<string> }): void
}
let build: Ibuild
build = ({ sBuildType, vPackages }) ={
  // 默認構建方式
  if (sBuildType === 'all') {
    run('lerna'['run''build'])
    // 自定義構建方式
  } else {
    vPackages &&
      vPackages.forEach(async pkg ={
        await run('lerna'['run''build'`--scope=lernanpm-${pkg}`])
      })
  }
}
複製代碼

版本

lerna提供的版本號構建命令,可供選擇的參數不多,而且自帶「問答」模式,固毋需對命令再封裝。

"version""lerna version"
複製代碼

⚠️注意:得益於 nx[6],「有代碼改動」& 與其有「依賴關係」的項目都會被重新構建新的版本號。

發佈

lerna提供的發佈命令,可供的選擇也是不多,也是自帶「問答」模式,亦毋需對命令再封裝。

"release""lerna publish from-package"
複製代碼

⚠️注意:所有被「新構建過版本號」的項目,都會被髮布。

注意

  1. 使用npm run link連接packages的其它模塊時,要確保該模塊與當前模塊是高度耦合的,並且穩定、可靠;

  2. 確保在開發某個模塊時,只改動「當前模塊」的代碼!因爲,任何模塊的代碼改動都會被識別,即使被改動模塊沒有被重新構建,版本亦會被更新發布;

  3. 若要卸載某個模塊的依賴,可以運行npm run unlink

  4. 單獨執行某個模塊的測試,可以運行npm run test

  5. npm run version失敗的情況一般分兩種:

  1. 刪除某個模塊的特定版本,可以運行npm unpublish moduleName@version(不建議,直接發個新版本覆蓋更合理),然後刪除本地和遠程相關的tag

  2. 查看packages下模塊之間的引用關係,可以npx nx graph

最後

由於時間關係,項目還有很多可以優化的地方,好比如:支持多模板、豐富命令參數等等。目前只是對lerna最基礎的參數進行封裝,基於「簡單」的原則,很多參數比較少用到,所以並沒有封裝在裏面。但可以直接使用lerna的命令運行。

關於本文

作者:JustCarryOn

https://juejin.cn/post/7134646424083365924

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