概述
大家好,本篇文章的內容主要分為兩部分:
- 開發 multi-dependent-management 工具庫,解決在業務上遇到的問題
- 關於開發這個工具庫時的一些總結
而 multi-dependent-management 是一個基於 NodeJS 開發的,在命令行中使用的工具庫,主要用於批量管理基於 Npm 的 package.json 項目依賴。它可以批量對你的項目進行依賴升級、移除、查看差異、執行 shell 命令等操作。
首先我們先來介紹下為什麼要開發這樣一個工具。
背景
我現在的公司,前端開發只有 3 人,但內部使用的管理系統和 H5 就有 27 個了(大部分都是維護狀態),而這些前端應用,都是基於一套組件庫去開發的:
現有的 Npm 包會有多個,當我們出現 Bug 或有新功能迭代時,要同時更新多個系統並統一發布上線(因為是對內使用的,而且技術管理比較鬆,測試沒問題就可以在某個時間段上線),這時候就有下面的流程:
不需更新業務代碼,只更新依賴版本,就是上面的流程。
如果同時有多個系統要更新,這裏的操作就很麻煩。有一次因為用户模塊出了問題,所有使用該模塊的系統(二十多個)都要更新,那時候非常痛苦。
因為項目維護都集中在一兩個人,所以項目都會在我們電腦本地。這時候我就想,有沒有工具可以批量更新多個項目的依賴,然後直接 git commit 提交到 gitlab 上?(公共模塊要迭代,比如側邊欄、導航欄、頁面初始化等操作,都已經封裝好了,所以大部分時候我們的組件庫更新都是隻需更新版本號)
這種操作,有點像 npkill,它會掃描的目標路徑,讓你選擇對應含有 node_modules 的文件夾,進行選擇刪除。
還有 npm-check-updates 可以幫你進行依賴檢查並更新的操作。
我自己搜索了下,沒找到可以集成上面我所説的內容的工具庫,所以就打算自己搞一個,功能如下:
- 批量依賴升級
- 批量依賴移除
- 批量依賴變更
- 批量執行 shell 命令
- 查看項目依賴版本差異
這樣就可以解決我上面所説的需求,通過執行命令,幫我把重複類似的工作處理掉。
使用
詳細的使用教程在 github 倉庫 有詳細説明了,這裏主要分享下如何快速使用,並使用該工具快速解決上面的問題。
這個工具庫是用 NodeJS 進行開發的,在命令行執行操作,和我們平時使用的一些命令類似,比如:vue create test。
整個工具庫的操作流程,基本如下:
按照上面背景所説的需求,我們需要通過命令行批量更新依賴版本,並提交到 gitlab。
首先安裝工具庫:
# 全局安裝
npm i multi-dependent-management -g
解決方式一
假設我要修改的項目都在 ./demo 下面。
首先進行依賴更新:
# 全局安裝完依賴後,使用 mdm 簡寫去使用
mdm upgrade -p ./demo
mdm就是multi-dependent-management這個庫的簡寫upgrade是這個庫可以觸發的動作-p ./demo是一個參數,告訴這個工具庫要從哪個路徑進行查詢
首先會遞歸查詢該路徑下所有的 package.json 文件,然後使用 npm-check-updates 檢查每個項目的依賴版本是否最新,將可以更新的依賴一一展現出來,讓你選擇哪個依賴需要更新:
當我們選擇要更新的依賴後,就會通過 fs 直接修改文件的版本號,而不會安裝依賴。
接着就要把修改記錄提交到 gitlab,這時候用到的是 shell 命令:
mdm shell -p ./project
會根據你選中的項目,執行相關的腳本命令,該功能自由度比較高,可以搭配不同的操作。
選完要處理的項目後,需要先輸入共同執行的命令,沒有的話,不輸入保存就行了:
我們這裏輸入了 git 提交的命令。
二次確認後,執行結果:
成功將多個項目提交到 gitlab。
解決方式二
方式一分別用了兩個命令去操作:upgrade + shell。但其實我們可以直接 shell 一次性完成。
同樣假設我們的項目在 ./demo 下,現在需要更新 vue 的版本,並提交到 gitlab。
mdm shell -p ./demo
將依賴升級的命令,也放到 shell 去操作:
安裝完依賴後,接着就提交代碼到 gitlab:
總結
上面兩種方式都可以解決我在“背景”所説的問題,具體使用哪種,看你的需求,使用 upgrade 命令,會自動幫你查找每個依賴可以升級的版本,而 shell 是純手動模式,讓你完全控制要升級的依賴版本。
除去這兩個功能,multi-dependent-management 工具庫還有其他的功能,具體的使用大家可以去 github 或者 npm 查看。
關於開發
技術棧
該工具的開發,使用的技術棧:
-
有關命令行操作的工具
- commander
- enquirer(命令行交互)
- ora
- shelljs
- npm-check-updates(檢查依賴版本是否需要升級)
-
單元測試
- jest
- memfs(使用內存模擬 fs)
-
工具庫
- lodash
- just-diff
- semver
-
其他
- typescript
- commitlint
- husky
- lint-staged
- standard-version
- eslint
整個開發,就是上面所展示的庫,像單測、工具庫、husky、commitlint 這些都是很常用的,這裏就不一一展開。
開發工作流
使用 eslint 規範代碼樣式,jest 做單元測試, husky + lint-staged + git hook 進行相關命令操作。
下圖就是我在開發這個工具庫時,執行的流程:
下面我們從零開始,實現上面的工作流程配置,如果嫌麻煩,我這裏已經按照下面的流程,配好了一個現成的模板。
準備工作
整個配置,大概需要 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,
},
}
這裏主要説下 outDir 和 declaration。當你執行 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 個腳本命令。lint 和 test:unit 是執行 eslint 和 jest 用的,下面我們繼續配置這兩個工具。
配置 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
# 按照你自身的需求進行配置即可
配置 babel 和 typescript:
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.json 的 lint 命令:"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(+)
.....
到這裏,已經完成我們的配置。整個工程配置,可以當成一個工具庫模板,後面有新的工具開發,直接使用該模板,快速搭建基礎功能。
單元測試
這個工具庫我是有寫單元測試,因為寫得不是很多,只能根據覆蓋率去寫,哪裏沒有覆蓋到,就補用例,一些重點的環節,就儘量測試不同的情況。
整個單測過程,我想總結下兩個點:
- 使用
memfs這個庫是 mockfs - 使用
Jest的spyOn方法,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(() => ([
{ ... }, { ... }
]));
})
})
再使用 spyOn 和 mockImplementation 對 getMultiSelectProject 函數 mock 就行了。
這裏我還沒搞懂為什麼要這樣處理,後面再歸納下不同情況的 mock 方式。
總結
這次分享的內容,主要是如何使用 multi-dependent-management 這工具去解決在開發遇到的問題,並總結在開發這個工具時的功能。 雖然平時有做一些小工具的開發,應用到工作上,但都很少進行這樣的總結,所以這次嘗試下,鍛鍊自己的總結能力和表達能力。
以上就是本文章的全部內容了,如果有不正確的地方,感謝指正~
畫圖工具:miro
錄屏工具:kap
multi-dependent-management 工具倉庫地址
參考鏈接
- Testing filesystem in Node.js: The easy way
- typescript 快速開始
- husky 文檔
- eslint
- jest