博客 / 詳情

返回

使用 NodeJS 開發一個命令行工具,批量管理多項目依賴

概述

大家好,本篇文章的內容主要分為兩部分:

  1. 開發 multi-dependent-management 工具庫,解決在業務上遇到的問題
  2. 關於開發這個工具庫時的一些總結

multi-dependent-management 是一個基於 NodeJS 開發的,在命令行中使用的工具庫,主要用於批量管理基於 Npm 的 package.json 項目依賴。它可以批量對你的項目進行依賴升級、移除、查看差異、執行 shell 命令等操作。

首先我們先來介紹下為什麼要開發這樣一個工具。

背景

我現在的公司,前端開發只有 3 人,但內部使用的管理系統和 H5 就有 27 個了(大部分都是維護狀態),而這些前端應用,都是基於一套組件庫去開發的:

image.png

現有的 Npm 包會有多個,當我們出現 Bug 或有新功能迭代時,要同時更新多個系統並統一發布上線(因為是對內使用的,而且技術管理比較鬆,測試沒問題就可以在某個時間段上線),這時候就有下面的流程:

image.png

不需更新業務代碼,只更新依賴版本,就是上面的流程。

如果同時有多個系統要更新,這裏的操作就很麻煩。有一次因為用户模塊出了問題,所有使用該模塊的系統(二十多個)都要更新,那時候非常痛苦。

因為項目維護都集中在一兩個人,所以項目都會在我們電腦本地。這時候我就想,有沒有工具可以批量更新多個項目的依賴,然後直接 git commit 提交到 gitlab 上?(公共模塊要迭代,比如側邊欄、導航欄、頁面初始化等操作,都已經封裝好了,所以大部分時候我們的組件庫更新都是隻需更新版本號)

這種操作,有點像 npkill,它會掃描的目標路徑,讓你選擇對應含有 node_modules 的文件夾,進行選擇刪除。

還有 npm-check-updates 可以幫你進行依賴檢查並更新的操作。

我自己搜索了下,沒找到可以集成上面我所説的內容的工具庫,所以就打算自己搞一個,功能如下:

  1. 批量依賴升級
  2. 批量依賴移除
  3. 批量依賴變更
  4. 批量執行 shell 命令
  5. 查看項目依賴版本差異

這樣就可以解決我上面所説的需求,通過執行命令,幫我把重複類似的工作處理掉。

使用

詳細的使用教程在 github 倉庫 有詳細説明了,這裏主要分享下如何快速使用,並使用該工具快速解決上面的問題。

這個工具庫是用 NodeJS 進行開發的,在命令行執行操作,和我們平時使用的一些命令類似,比如:vue create test

整個工具庫的操作流程,基本如下:

image.png

按照上面背景所説的需求,我們需要通過命令行批量更新依賴版本,並提交到 gitlab。

首先安裝工具庫:

# 全局安裝
npm i multi-dependent-management -g

解決方式一

假設我要修改的項目都在 ./demo 下面。

首先進行依賴更新:

# 全局安裝完依賴後,使用 mdm 簡寫去使用
mdm upgrade -p ./demo
  • mdm 就是 multi-dependent-management 這個庫的簡寫
  • upgrade 是這個庫可以觸發的動作
  • -p ./demo 是一個參數,告訴這個工具庫要從哪個路徑進行查詢

upgrade01.gif

首先會遞歸查詢該路徑下所有的 package.json 文件,然後使用 npm-check-updates 檢查每個項目的依賴版本是否最新,將可以更新的依賴一一展現出來,讓你選擇哪個依賴需要更新:

upgrade02.jpg

當我們選擇要更新的依賴後,就會通過 fs 直接修改文件的版本號,而不會安裝依賴。

接着就要把修改記錄提交到 gitlab,這時候用到的是 shell 命令:

mdm shell -p ./project

會根據你選中的項目,執行相關的腳本命令,該功能自由度比較高,可以搭配不同的操作。

選完要處理的項目後,需要先輸入共同執行的命令,沒有的話,不輸入保存就行了:

Xnip2021-10-19_15-24-03.jpg

我們這裏輸入了 git 提交的命令。

二次確認後,執行結果:

Xnip2021-10-19_15-24-32.jpg

成功將多個項目提交到 gitlab

解決方式二

方式一分別用了兩個命令去操作:upgrade + shell。但其實我們可以直接 shell 一次性完成。

同樣假設我們的項目在 ./demo 下,現在需要更新 vue 的版本,並提交到 gitlab

mdm shell -p ./demo

將依賴升級的命令,也放到 shell 去操作:

Xnip2021-10-19_15-27-06.jpg

安裝完依賴後,接着就提交代碼到 gitlab:

Xnip2021-10-19_15-27-48.jpg

總結

上面兩種方式都可以解決我在“背景”所説的問題,具體使用哪種,看你的需求,使用 upgrade 命令,會自動幫你查找每個依賴可以升級的版本,而 shell 是純手動模式,讓你完全控制要升級的依賴版本。

除去這兩個功能,multi-dependent-management 工具庫還有其他的功能,具體的使用大家可以去 github 或者 npm 查看。

關於開發

技術棧

該工具的開發,使用的技術棧:

  1. 有關命令行操作的工具

    1. commander
    2. enquirer(命令行交互)
    3. ora
    4. shelljs
    5. npm-check-updates(檢查依賴版本是否需要升級)
  2. 單元測試

    1. jest
    2. memfs(使用內存模擬 fs)
  3. 工具庫

    1. lodash
    2. just-diff
    3. semver
  4. 其他

    1. typescript
    2. commitlint
    3. husky
    4. lint-staged
    5. standard-version
    6. eslint

整個開發,就是上面所展示的庫,像單測、工具庫、husky、commitlint 這些都是很常用的,這裏就不一一展開。

開發工作流

使用 eslint 規範代碼樣式,jest 做單元測試, husky + lint-staged + git hook 進行相關命令操作。

下圖就是我在開發這個工具庫時,執行的流程:

multi-dependent-management.jpg

下面我們從零開始,實現上面的工作流程配置,如果嫌麻煩,我這裏已經按照下面的流程,配好了一個現成的模板。

準備工作

整個配置,大概需要 10 - 15 分鐘左右。

我們首先要建一個項目,使用 typescript 進行開發。

mkdir test && cd test # 新建文件

npm init # npm 初始化

git init # 初始化 git

mkdir lib && mkdir tests # 添加文件夾

npm i typescript -S

# 添加忽略文件:node_modules coverage dist
vim .gitignore

添加文件:

vim lib/a.ts

export function getName() {
  return 'ok'
}
export function getData() {
  return {
    name: getName()
  }
}

vim lib/index.ts

import { getData } from './a'

console.log(getData())

因為我們用的是 typescript,所以需要先編譯才能用 node.js 執行

package.json 添加腳本命令:

{
  "scripts": {
    "tsc": "tsc",
    "start": "npm run tsc && node ./dist/index.js"
  }
}

tsc 命令是用來編譯 .ts 文件,變為 .js。然後使用 node 執行相關文件。

添加 tsconfig.json,告訴 typscript 要如何進行編譯。

vim tsconfig.json

{
  "compilerOptions": {
    "module": "commonjs",
    "noImplicitAny": true,
    "removeComments": true,
    "preserveConstEnums": true,
    "outDir": "./dist",
    "declaration": true,
  },
}

這裏主要説下 outDirdeclaration。當你執行 tsc 時,會將轉譯文件放到指定目錄,而 declaration 會生成 .d.ts 文件。

配置完成後, 我們執行下命令:npm start,會有下面的日誌顯示:

npm start

> test-ddd@1.0.0 start ~/Downloads/test-ddd
> npm run tsc && node ./dist/index.js


> test-ddd@1.0.0 tsc ~/Downloads/test-ddd
> tsc

{ name: 'ok' }

看到日誌成功打印,我們的準備工作完成了,目錄結構是這樣的:

.
├── lib
│   ├── a.ts
│   └── index.ts
├── package-lock.json
├── package.json
├── tests
└── tsconfig.json

下面就開始配置開發工作流。

配置 husky

husky 是按官方教程來的,這裏用的版本是 7.x,要注意版本號,很多以前的教程是在 package.json 配置,那個是要用 4.x 版本才行。

# 先保證當前項目有 .git 文件
# 初始化並安裝
npx husky-init && npm install
# 這時候,項目根目錄會生成一個 .husky 文件,裏面包含了一個鈎子文件:pre-commit

修改 .husky/pre-commit 文件,將裏面的 npm test 改為 npm run lint-staged,後面會用到。

配置 lint-staged

npm i lint-staged -D

package.json 添加相關配置:

{
  "scripts": {
    "lint-staged": "lint-staged",
    "lint": "eslint --fix lib/**",
    "test:unit": "jest"
  },
  "lint-staged": {
    "{lib,tests}/**/*": [
      "npm run lint",
      "npm run test:unit",
      "git add"
    ]
  },
}

這裏我們我們配置了 lint-staged 和 3 個腳本命令。linttest:unit 是執行 eslintjest 用的,下面我們繼續配置這兩個工具。

配置 eslint

安裝:

npm i eslint -D
# 初始化
./node_modules/.bin/eslint --init
# 按着指示進行配置即可
# 這是我選擇的配置:
✔ How would you like to use ESLint? · style
✔ What type of modules does your project use? · esm
✔ Which framework does your project use? · none
✔ Does your project use TypeScript? · No / Yes
✔ Where does your code run? · browser, node
✔ How would you like to define a style for your project? · guide
✔ Which style guide do you want to follow? · standard
✔ What format do you want your config file to be in? · JavaScript
Checking peerDependencies of eslint-config-standard@latest
The config that you've selected requires the following dependencies:

@typescript-eslint/eslint-plugin@latest eslint-config-standard@latest eslint@^7.12.1 eslint-plugin-import@^2.22.1 eslint-plugin-node@^11.1.0 eslint-plugin-promise@^4.2.1 || ^5.0.0 @typescript-eslint/parser@latest
✔ Would you like to install them now with npm? · No / Yes

添加 .eslintignore 忽略不必要的文件,vim .eslintignore

package.json
package-lock.json

配置完成後,我們看下相關命令: "lint": "eslint --fix lib/**”

這裏是指定要 fix 的文件路徑,根據你的項目進行相關變動即可。

最後可以試下執行 npm run lint 看看是否成功。

配置 jest

npm i jest -D
# 初始化配置
./node_modules/.bin/jest --init
# 按照你自身的需求進行配置即可

配置 babeltypescript

npm i babel-jest @babel/core @babel/preset-env @babel/preset-typescript ts-node @types/jest @types/node -D

修改 tsconfig.json

{
  ...,
  // 添加:
  "compilerOptions": {
    ...,
    "types": [
      "jest",
      "node"
    ]
  }
}

添加 babel.config.js 文件:

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {targets: {node: 'current'}}],
    '@babel/preset-typescript',
  ],
};

我們添加一個測試文件,驗證下是否成功:

vim tests/a.spec.ts

import { getName } from '../lib/a'

describe('測試 getName', () => {
  test('執行 getName,返回字符串 "ok" ', () => {
    expect(getName()).toBe('ok')
  })
})

修改在 package.jsonlint 命令:"lint": "eslint --fix lib/** tests/**”,添加對 tests 文件的檢查。

我們執行之前添加的腳本命令:npm run test:unit

npm run test:unit

> test-ddd@1.0.0 test:unit ~/Downloads/test-ddd
> jest

 PASS  tests/a.spec.ts
  測試 getName
    ✓ 執行 getName,返回字符串 "ok"  (2 ms)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |      50 |      100 |      50 |      50 |
 a.ts     |      50 |      100 |      50 |      50 | 5
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.914 s
Ran all test suites.

看到執行成功了,jest 的配置也完成了。

試驗

準備工作都準備好,我們來提交下代碼試試:

git add ./
git commit -m 'test'

這時候會看到觸發鈎子,使 lint-staged 開始工作:

lint-staged

⚠ Skipping backup because there’s no initial commit yet.

⚠ Some of your tasks use `git add` command. Please remove it from the config since all modifications made by tasks will be automatically added to the git commit index.

✔ Preparing...
⚠ Running tasks...
  ❯ Running tasks for {lib,tests}/**/*
    ✖ npm run lint [FAILED]
    ◼ npm run test:unit
    ◼ git add
✔ Applying modifications...

✖ npm run lint:

...

~/Downloads/test-ddd/tests/a.spec.ts
  3:1  error  'describe' is not defined  no-undef
  4:3  error  'test' is not defined      no-undef
  5:5  error  'expect' is not defined    no-undef

有個文件的代碼格式沒通過,所以整個 commit 操作被攔截下來,無法成功 commit。同理,如果 lint 命令通過,但 test:unit 命令沒通過,也是會被攔截下來。

我們來修復下這個問題:

./eslintrc.js 添加下面的配置:

module.exports = {
  env: {
    jest: true,
  },
}

再次 commit 後,提交成功,eslint 和單測都成功。

git commit -m 'test'

> test-ddd@1.0.0 lint-staged ~/Downloads/test-ddd
> lint-staged

⚠ Skipping backup because there’s no initial commit yet.

⚠ Some of your tasks use `git add` command. Please remove it from the config since all modifications made by tasks will be automatically added to the git commit index.

✔ Preparing...
✔ Running tasks...
✔ Applying modifications...
[master (root-commit) 658c2dd] test
 12 files changed, 6339 insertions(+)
 .....

到這裏,已經完成我們的配置。整個工程配置,可以當成一個工具庫模板,後面有新的工具開發,直接使用該模板,快速搭建基礎功能。

單元測試

這個工具庫我是有寫單元測試,因為寫得不是很多,只能根據覆蓋率去寫,哪裏沒有覆蓋到,就補用例,一些重點的環節,就儘量測試不同的情況。

Coverage Status

整個單測過程,我想總結下兩個點:

  1. 使用 memfs 這個庫是 mock fs
  2. 使用 JestspyOn 方法,mock 模塊內的某個函數

這兩個 Mock,是我在寫單測中,經常要用到的。

使用 memfs mock fs

因為 fs 是屬於 io 操作,而且工具方法涉及到對文件的操作,如果不 mock fs,需要寫一些重置方法,重置用於單測的文件。

或者還可以直接 mock 使用了 fs 的方法,但這個我覺得非常麻煩,因為有大量的測試用例需要用到,並且用到的場景有些會不同,所以這個方法我也沒采用。

最後我是看到這篇文章 Testing filesystem in Node.js: The easy way 後,知道了 memfs 這個庫,使用內存模式去模擬 fs。個人體驗非常好,只需簡單的配置,就可以解決 fs mock 問題,並且還能自定義文件目錄和內容。

首先添加文件:

tests/__mocks__/fs.ts

文件內容:

import { fs } from 'memfs';

export default fs;

使用:

const packageJson = { ... }
describe('test', () => {
  beforeEach(() => {
    // 每次執行用例錢,重置內容
    vol.reset();
    // 設置路徑、目錄和相關文件的內容
    vol.fromNestedJSON({
      p1: {
        'package.json': JSON.stringify(packageJson),
      },
      p2: {
        'package.json': JSON.stringify(packageJson),
      },
    }, '/abc');
  });

  describe('test...', () => {
    it('獲取內容', async () => {
      // 使用 fs(已經 mock 處理了)獲取對應路徑的文件內容
      const data = JSON.parse(fs.readFileSync('/abc/p1/package.json', { encoding: 'utf-8' }));
      // 判斷獲取的文件內容是否和開始配置的數據一致
      expect(data).toBe(packageJson); // pass
    });
  });

可以看到配置過程非常簡單,而使用效果和 fs 沒什麼區別。

Mock 模塊內的某個函數

我們看下要測試的這個方法:

// 偽代碼
import {
  getConfirmPrompt,
} from './utils';
import * as upgradeUtils from './upgrade';

export async function upgrade(paths: string[]): Promise<void> {
  await upgradeUtils.getMultiSelectProject(paths);
  await.getConfirmPrompt().run();
}

這裏只展示了關鍵點,我們要 mock 上面的兩個函數:

  • getConfirmPrompt 函數是另一個文件引入的
  • getMultiSelectProject 函數是同一個文件的

要 mock getConfirmPrompt 函數很簡單,直接使用 spyOn 就行了:

// 偽代碼
import * as utils from '../lib/utils';

describe('test', () => {
  test('upgrade.js', () => {
    jest.spyOn(utils, 'getConfirmPrompt').mockImplementation(() => ([
      { ... }, { ... }
    ]));
  })
})

另一個要 mock 的函數是 getMultiSelectProject,它是和 upgrade 方法在同一個文件,這裏的解決方法有點繞。

首先在該函數的文件,添加這樣一行代碼:

import * as upgradeUtils from './upgrade';

需要 mock 的函數,要這樣調用:

upgradeUtils.getMultiSelectProject()

接着在測試文件,同樣也是要先引入:

// 偽代碼
import * as upgradeUtils from '../lib/upgrade';

describe('test', () => {
  test('upgrade.js', () => {
    // 使用 jest.spyOn 去 mock 函數
    // 首先傳入該函數的模塊,第二個參數是你要 mock 的方法名
    // 再使用 mockImplementation 返回你要 mock 的值
    jest.spyOn(upgradeUtils, 'getMultiSelectProject').mockImplementation(() => ([
      { ... }, { ... }
    ]));
  })
})

再使用 spyOnmockImplementationgetMultiSelectProject 函數 mock 就行了。

這裏我還沒搞懂為什麼要這樣處理,後面再歸納下不同情況的 mock 方式。

總結

這次分享的內容,主要是如何使用 multi-dependent-management 這工具去解決在開發遇到的問題,並總結在開發這個工具時的功能。 雖然平時有做一些小工具的開發,應用到工作上,但都很少進行這樣的總結,所以這次嘗試下,鍛鍊自己的總結能力和表達能力。

以上就是本文章的全部內容了,如果有不正確的地方,感謝指正~

畫圖工具:miro

錄屏工具:kap

multi-dependent-management 工具倉庫地址

參考鏈接

  1. Testing filesystem in Node.js: The easy way
  2. typescript 快速開始
  3. husky 文檔
  4. eslint
  5. jest
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.