使用 NodeJS 开发一个命令行工具,批量管理多项目依赖

概述

大家好,本篇文章的内容主要分为两部分:

  1. 开发 multi-dependent-management 工具库,解决在业务上遇到的问题
  2. 关于开发这个工具库时的一些总结

multi-dependent-management 是一个基于 NodeJS 开发的,在命令行中使用的工具库,主要用于批量管理基于 Npm 的 package.json 项目依赖。它可以批量对你的项目进行依赖升级、移除、查看差异、执行 shell 命令等操作。

首先我们先来介绍下为什么要开发这样一个工具。

背景

我现在的公司,前端开发只有 3 人,但内部使用的管理系统和 H5 就有 27 个了(大部分都是维护状态),而这些前端应用,都是基于一套组件库去开发的:

使用 NodeJS 开发一个命令行工具,批量管理多项目依赖_第1张图片

现有的 Npm 包会有多个,当我们出现 Bug 或有新功能迭代时,要同时更新多个系统并统一发布上线(因为是对内使用的,而且技术管理比较松,测试没问题就可以在某个时间段上线),这时候就有下面的流程:

使用 NodeJS 开发一个命令行工具,批量管理多项目依赖_第2张图片

不需更新业务代码,只更新依赖版本,就是上面的流程。

如果同时有多个系统要更新,这里的操作就很麻烦。有一次因为用户模块出了问题,所有使用该模块的系统(二十多个)都要更新,那时候非常痛苦。

因为项目维护都集中在一两个人,所以项目都会在我们电脑本地。这时候我就想,有没有工具可以批量更新多个项目的依赖,然后直接 git commit 提交到 gitlab 上?(公共模块要迭代,比如侧边栏、导航栏、页面初始化等操作,都已经封装好了,所以大部分时候我们的组件库更新都是只需更新版本号)

这种操作,有点像 npkill,它会扫描的目标路径,让你选择对应含有 node_modules 的文件夹,进行选择删除。

还有 npm-check-updates 可以帮你进行依赖检查并更新的操作。

我自己搜索了下,没找到可以集成上面我所说的内容的工具库,所以就打算自己搞一个,功能如下:

  1. 批量依赖升级
  2. 批量依赖移除
  3. 批量依赖变更
  4. 批量执行 shell 命令
  5. 查看项目依赖版本差异

这样就可以解决我上面所说的需求,通过执行命令,帮我把重复类似的工作处理掉。

使用

详细的使用教程在 github 仓库 有详细说明了,这里主要分享下如何快速使用,并使用该工具快速解决上面的问题。

这个工具库是用 NodeJS 进行开发的,在命令行执行操作,和我们平时使用的一些命令类似,比如:vue create test

整个工具库的操作流程,基本如下:

使用 NodeJS 开发一个命令行工具,批量管理多项目依赖_第3张图片

按照上面背景所说的需求,我们需要通过命令行批量更新依赖版本,并提交到 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 检查每个项目的依赖版本是否最新,将可以更新的依赖一一展现出来,让你选择哪个依赖需要更新:

使用 NodeJS 开发一个命令行工具,批量管理多项目依赖_第4张图片

当我们选择要更新的依赖后,就会通过 fs 直接修改文件的版本号,而不会安装依赖。

接着就要把修改记录提交到 gitlab,这时候用到的是 shell 命令:

mdm shell -p ./project

会根据你选中的项目,执行相关的脚本命令,该功能自由度比较高,可以搭配不同的操作。

选完要处理的项目后,需要先输入共同执行的命令,没有的话,不输入保存就行了:

使用 NodeJS 开发一个命令行工具,批量管理多项目依赖_第5张图片

我们这里输入了 git 提交的命令。

二次确认后,执行结果:

使用 NodeJS 开发一个命令行工具,批量管理多项目依赖_第6张图片

成功将多个项目提交到 gitlab

解决方式二

方式一分别用了两个命令去操作:upgrade + shell。但其实我们可以直接 shell 一次性完成。

同样假设我们的项目在 ./demo 下,现在需要更新 vue 的版本,并提交到 gitlab

mdm shell -p ./demo

将依赖升级的命令,也放到 shell 去操作:

使用 NodeJS 开发一个命令行工具,批量管理多项目依赖_第7张图片

安装完依赖后,接着就提交代码到 gitlab:

使用 NodeJS 开发一个命令行工具,批量管理多项目依赖_第8张图片

总结

上面两种方式都可以解决我在“背景”所说的问题,具体使用哪种,看你的需求,使用 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 进行相关命令操作。

下图就是我在开发这个工具库时,执行的流程:

使用 NodeJS 开发一个命令行工具,批量管理多项目依赖_第9张图片

下面我们从零开始,实现上面的工作流程配置,如果嫌麻烦,我这里已经按照下面的流程,配好了一个现成的模板

准备工作

整个配置,大概需要 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

> [email protected] start ~/Downloads/test-ddd
> npm run tsc && node ./dist/index.js


> [email protected] 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

> [email protected] 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'

> [email protected] 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 {
  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

你可能感兴趣的:(使用 NodeJS 开发一个命令行工具,批量管理多项目依赖)