那些你应该说再见的 npm 祖传老库

320次阅读  |  发布于3年以前

源起

不知不觉都 2021 年了,Node.js 的 LTS 已经到了 16.x, 这期间由于 Node.js 发展过程中基础类库的不完善,出现了各种生生不息的类库套娃封装,npm 包的数量扶摇直上,已经突破 170 万,断层式第一。

最近在响应 sindresorhus 大神的号召,陆续把一些类库升级为 ESM,期间重新审视 Egg 团队曾经沉淀下来的各种基础类库,也许需要说再见了。

新老交替

类型判断

使用场景:JavaScript 的类型判断一向被诟病,我们只能面对现实。

参赛选手:

技能演示:

const is = require('is-type-of');

is.regexp(/.*/);
is.asyncFunction(async function foo() {});

is.string(str);

VS

const types = require('util/types'); // 10.x 用 require('util').types

types.isRegExp(/.*/);
types.isAsyncFunction(async function foo() {});

// 一些基础的类型没有支持
typeof str === 'string';

替换指数:★★★★☆ 顺手为之

评委点评:

setTimeout

使用场景:等待一段时间,类似其他语言的 sleep 函数。

参赛选手:

技能演示:

const { sleep } = require('mz-modules);

await sleep('1s');

VS

const { setTimeout } = require('timers/promises');
await setTimeout(1000);

// 旧版本可以自己 promisify
await new Promise(resolve => {
  setTimeout(resolve, 1000);
});

替换指数:★★★★★ 更待何时

评委点评:

文件处理

使用场景:日常的文件处理,如判断是否存在,写入文件,创建及删除目录等等。

虽然 Node.js 从第一个版本开始就有了 fs 模块,但老实说,真的一言难尽。

举个例子,创建和删除目录,这是一个非常典型的场景,谁不喜欢 mkdirp -prm -rf 呢?

但当年文件 API 非常不好用,跨平台兼容性也不咋滴。相信大家都遇到过在 Windows 下时删除文件夹时,经常会遇到:文件正在使用中 or 请先清空子目录文件。在那个 npm 依赖还是马里亚纳海沟的年代,深一点的依赖连文件管理器都没法删除,因此经常需要祭出 rimraf 这些利器。

参赛选手:

技能演示:

// 常规文件操作
const { fs } = require('mz');
await fs.exists('/path/to/file');
await fs.readFile('/path/to/file');
await fs.writeFile('/path/to/file', 'some text');

// 目录操作
const { mkdirp, rimraf } = require('mz-modules');
await rimraf('/path/to/dir'); // 递归删除文件在 Windows 真的曾经很难很难。
await mkdirp('/path/to/dir'); // 早期的 Node.js 不支持递归创建和忽略已创建,即人民群众盼望着的 `mkdir -p`

VS

const fsPromise = require('fs/promises'); 

// 常规文件操作
await fsPromise.access('/path/to/file').then(() => true, () => false); // 这个比较恶心,exists 被 deprecate 了,只能判断 access,不存在会抛错。
await fsPromise.readFile('/path/to/file');
await fsPromise.writeFile('/path/to/file', 'some text');

// 目录操作
await fsPromise.rm('/path/to/dir', { force: true, recursive: true, maxRetries: 5 });
await fsPromise.mkdir('/path/to/dir', { recursive: true });

替换指数:★★★★★ 更待何时

评委点评:

Stream 流处理

使用场景:Stream 是 Node.js 新手最容易出问题的地方,尤其经常用到的 pipe:

如下这段代码,读取一个 zip 文件,然后解压,再写入文件,最后来个错误处理,很完美了是不?

await new Promise((resolve, reject) => {
  fs.createReadStream('/path/to/src.zip')
    .pipe(zlib.createGunzip())
    .pipe(fs.createWriteStream('/path/to/target.js'))
    .on('error', reject)
    .on('finish', resolve);
});

然而,如果你实际使用的时候,一旦源文件不存在 or 不是 zip 格式等等情况下,进程立即 crash 掉。

是的,国内绝大部分的教程,都不会告诉你 pipe 时还需对每一个 stream 进行错误处理,丑到爆了,有没有!而且即使是老手,也经常容易踩坑。

await new Promise((resolve, reject) => {
  fs.createReadStream('/path/to/src.zip')
    .on('error', reject) // 如果需要不同阶段打印不同的错误信息,会更丑。

    .pipe(zlib.createGunzip())
    .on('error', reject)

    .pipe(fs.createWriteStream('/path/to/target.js'))
    .on('error', reject)

    .on('finish', () => resolve());
});

参赛选手:

技能演示:

const { pump } = require('mz-modules');
const fs = require('fs');

await pump(
  fs.createReadStream('/path/to/src.zip'), 
  zlib.createGunzip(),
  fs.createWriteStream('/path/to/target.js'), 
);

VS

// 16.x 才支持,10.x 需要自己 promisify 下。
const { pipeline } = require('stream/promises'); 
const fs = require('fs/promises');

await pipeline(
  fs.createReadStream('/path/to/src.zip'), 
  zlib.createGunzip(),
  fs.createWriteStream('/path/to/target.js'), 
);

替换指数:★★★★★ 更待何时

评委点评:

HTTP 请求

使用场景:发起一个 HTTP 请求,这是非常核心的能力之一。

可惜,官方的 http 库太底层太基础了,用起来往往需要大量的封装。譬如 302 后自动跳转、文件上传、响应结果解析等等。

曾经广受社区欢迎的 request 库去年宣布停止维护后,也引起了社区比较大的混乱,虽然提供了替代品建议。

参赛选手:

技能演示:

const httpclient = require('urllib');

const result = await httpclient.request(url, {
  method: 'post',
  contentType: 'json', // 请求参数处理
  dataType: 'json', // 响应结果处理
  data: {
    hello: 'world',
    now: Date.now(),
  },
});

console.log(result.status);
console.log(result.headers);
console.log(result.data); // 打印响应的 JSON

VS

const { request } = require('undici');

const result = await request(url, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    hello: 'world',
    now: Date.now(),
  }),
});

console.log(result.statusCode);
console.log(result.headers);
console.log(await result.body.json()); // 打印响应的 JSON,需要手动转换下

替换指数:★★★☆☆ 观望,未来可期

评委点评:

Assert 断言

使用场景:Assert 断言是一个常用的功能。

参赛选手:

技能演示:

主办方弃权,代码比较简单,但对比写出来挺麻烦的。

我们这边一直都用 power-assert,参考阅读:《No API is the best API》。

最近试了下 assert/strict 感觉还可以,但某些场景还是无法覆盖,如 assert(arr[1] === 10) 这种取值后的。

值得关注的是 assert.rejectsassert.match 等 API,还有个 CallTracker 是新出来的,有点类似 sinon 等 stub 库里面,验证某个函数有没有被调用。

替换指数:★★☆☆☆ 保持关注

评委点评:

代码覆盖率

使用场景:单元测试很重要,我们也会通过 测试覆盖率 来看单测的覆盖程度。

参赛选手:

$ nyc mocha 
$ open coverage/lcov-report/index.html

VS

$ c8 mocha
$ open coverage/index.html

后者还可以用来支持运行期的覆盖率采集:

# 代码里面定时触发覆盖率导出
const v8 = require('v8');
v8.takeCoverage();

# 启动应用,使用环境变量来通知 V8 启动采集
$ NODE_V8_COVERAGE=./coverage/tmp npm run dev

# 调用接口
$ curl http://localhost:7001

# 生成报告
$ c8 report -r html --all

之前在内网写过一篇:《代码覆盖率的运行期采集 - 论如何有理有据地怼测试同学验证不充分》回头有空放出来。

替换指数:★★★★☆ 推荐使用

评委点评:

调试日志

使用场景:调试日志的打印,通过环境变量来开启。

参赛选手:

技能演示:

// https://www.npmjs.com/package/debug
const debug = require('debug')('egg-bin:test');

debug('launch application at %s', host);

// 通过环境变量 DEBUG 激活,支持通配。
$ DEBUG=egg-core,egg-bin:* node index.js

VS

// https://nodejs.org/api/util.html#util_util_debuglog_section_callback
const util = require('util');
const debug = util.debuglog('egg-bin:test');

debug('launch application at %s', host);

// 通过环境变量 NODE_DEBUG 激活,支持通配。
$ NODE_DEBUG=egg-core,egg-bin:* node index.js

替换指数:★★★★☆ 推荐使用

评委点评:

过期 API

使用场景:对某个过时的 API 保持兼容,同时打印 WARNING 信息提示用户升级。

参赛选手:deprecate vs util.deprecate

技能演示:

const deprecate = require('deprecate');
const fn = (...args) => {
  deprecate('`app.get` is deprecated, please use `app.router.get` instead.');
  return originFn(...args);
};

VS

const util = require('util');
const fn = util.deprecate(originFn, '`app.get` is deprecated, please use `app.router.get` instead.', 'DEP0001');

替换指数:★★★★★ 更待何时

评委点评:

SourceMap

使用场景:经过 TS、Webpack 编译后的代码,执行时的错误堆栈,往往需要通过 sourcemap 还原为源文件对应的坐标,才能方便的定位问题。

参赛选手:

技能演示:

初始化代码如下:

interface User {
  name: string;
}

function sayHi(user?: User) {
  if (!user) throw new Error('user is required');
  console.log(`Hello ${user.name}`);
}

sayHi();

分别执行对应的命令:

# 编译测试代码,内嵌 sourcemap 方式
$ tsc --inlineSourceMap test.ts

# 执行运行,可以观察到报错行数是编译后的位置,而不是 TS 源码的位置。
$ node test.js

  Error: user is required
      at sayHi (./test.js:3:15)

# 引入 source-map-support 包进行解析
$ node -r source-map-support/register test.js

  Error: user is required
      at sayHi (./test.ts:6:20)

# Node.js 内置命令行参数
$ node --enable-source-maps test.js

  Error: user is required
      at sayHi (./test.ts:6:20)

替换指数:★★★★★ 更待何时

评委点评:

Process 子进程

使用场景:fork 一个子进程也是常见的操作,但 child_process 太底层了,需要开发者自行处理跨平台问题,还需要自行处理执行输出。

参赛选手:

技能演示:

const runscript = require('runscript');

const { stdout, stderr } = await runscript('node index.js', { stdio: 'pipe' });

console.log(stdout);
console.error(stderr);

VS

const execa = require('execa');

const proc = execa.node('index.js', opts);

console.log(proc.pid);

// proc.kill();
// proc.cancel();

const { stdout, stderr, isCanceled, killed, exitCode } = await proc;
console.log(stdout);
console.error(stderr);

替换指数:★★★★★ 更待何时

评委点评:

命令行测试

使用场景:Node.js 的最初以及最大的使用场景,就是写命令行工具,因此它对应的测试很重要。

参赛选手:

技能演示:

const coffee = require('coffee');

describe('cli', () => {
  it('should fork node cli', async () => {
    return coffee.fork('/path/to/file.js')
      .expect('stdout', '12\n')
      .expect('stderr', /34/)
      .expect('code', 0)
      .end();
  });
});

VS

import { runner, KEYS } from 'clet';

it('should works with boilerplate', async () => {
  await runner()
    .cwd(tmpDir, { init: true })
    .spawn('npm init')
    .stdin(/name:/, 'example') // wait for stdout, then respond
    .stdin(/version:/, new Array(9).fill(KEYS.ENTER))
    .stdout(/"name": "example"/) // validate stdout
    .notStderr(/npm ERR/)
    .file('package.json', { name: 'example', version: '1.0.0' }) // validate file
});

替换指数:★★★★☆ 推荐使用

评委点评:

写在最后

简单说,我们曾经维护的一些轮子,如 mz、mz-modules 等库,终于可以解甲归田了,大胆的说再见吧。

从总的趋势上来看,Node.js 官方在不断的听取和吸收社区的反馈,尤其是在 Promise 相关部分,对很多基础类库都进行了翻新。

过往我们遇到兼容性问题时,第一时间想起的是封装一个类库来屏蔽差异,因为给 Node.js 提 PR 的时效性不高,但时代变了,我们应该多考虑下沉。

同时也不要太顾忌那些已经过了 LTS 的老旧 Node.js 版本了,都什么年代了,那些还用着 yield 的库,果断重构发大版本吧。

是时候翻新下我们的认知了:

除了上面 Node.js 官方引入的新能力外,ECMA 等底层也可以关注下,像最近我们压测时就发现 Collator#compareString#localeCompare 快 100 倍。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8