1

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

 2 years ago
source link: https://zhuanlan.zhihu.com/p/415361629
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

活多人少有挑战,撸起袖子拼命干。

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

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

有空时可以写个脚本,分析下 Egg 官方自己造的轮子的使用率。


2. 新老交替

突然发现,每次翻 Node.js 文档,都能看到不少变化,某些文档写的真心不错,大部分问题都能找到描述。

## 类型判断

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

参赛选手:

  • 我们封装了个 is-type-of,集成了大部分类型判断的场景。
  • 官方在 10.x 后提供了 util.types

技能演示:

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

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

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

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

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

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

评委点评:

  • util.types 的判断逻辑不少,但类似判断 String 等方法需要用 typeof,记忆起来有点麻烦。
  • 倾向于继续用 is-type-of ,推动它的底层大部分逻辑简化为 util,相当于简单的包了一层。
  • 官方的 util 里面很多好东西,可以挖掘下。

## setTimeout

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

参赛选手:

技能演示:

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

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

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

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

评委点评:

  • 官方对 Promise 的支持越来越多了,很多对官方 API 进行 Promisify 的包,都可以慢慢退役了。
  • sleep 支持 ms 的语义化表达,如 sleep('10m')
  • 注意:如果需要延迟的时间较长(大于 25 天),需要用 safe-timers

## 文件处理

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

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

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

参赛选手:

  • 我们封装了 mzmz-modules 这 2 个模块,前者主要提供了 fs 等价的 Promise 版 API ,后者对 mkdirprimraf 进行了 Promise 封装。
  • 官方在 10.x 后开始重视这块,14.x 后就好用了不少,派出了 fsPromise 这位代表。

技能演示:

// 常规文件操作
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`
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 });

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

评委点评:

  • 虽迟但到,立刻马上现在就使用官方方案,Promise 你值得拥有。
  • 绝大部分情况下,如果你不应该用 **fs.readFileSync** 这些同步方法,否则考虑剁手吧。
  • 由于 rimraf 这名字大家经常记不住,老外还曾在 StackOverflow 讨论考古这是啥缩写,可惜没个结论。

## 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());
});

参赛选手:

  • 脑子不够,类库来凑:社区有 pump 这个库,不过由于 callback 风格的,所以我们封了个 mz-modules/pump
  • 官方在 10.x 后推出的 stream.pipeline,注意不是 stream.pipe 。(贡献者就是 pump 作者)

技能演示:

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'), 
);
// 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'), 
);

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

评委点评:

  • Stream 尽量少碰吧,一些小文件场景,直接用 FS 更稳妥一点。
  • 再次提醒,原来的 **stream.pipe()** 别再用了,这是一个倾向于让用户链式调用,但要么容易遗漏错误处理,要么写起来很别扭的 API。

## HTTP 请求

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

可惜,官方的 http 库太底层太基础了,用起来往往需要大量的封装。譬如 302 后自动跳转、文件上传、响应结果解析等等。 曾经广受社区欢迎的 request 库去年宣布停止维护后,也引起了社区比较大的混乱,虽然提供了替代品建议。 ​

参赛选手:

  • 我们封装的 urllib,并在 Egg 里面内置,是和后端通讯的核心类库,稳定支撑了多年的双十一。
  • 官方刚推出的 undici (但没有内置到 Node.js 中)。
  • PS:对应配套的 Mock 库,如 nocksinon

技能演示:

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
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,需要手动转换下

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

评委点评:

  • urllib 经历了多年的考验,非常的稳定,但内部代码有点旧了,未来底层可以切换掉,对用户无感。
  • undici 是官方出品,体积很小,API 设计也很现代化。
  • undici 还不够成熟,文档比较简陋,本想顺便示例下 Mock 的,结果看了半天文档和翻了源码,还是没看懂。
  • 建议观望,小范围尝试。

## Assert 断言

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

参赛选手:

  • power-assertchai(涵盖了 should 和 expect 这 2 个库)
  • 官方 10.x 后推出了 assert 的 strict 增强模式,还提供了 CallTracker 来对函数调用进行计数。

技能演示:

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

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

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

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

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

评委点评:

  • 萝卜青菜各有所爱。
  • 非单测场景,如在函数内校验入参的场景,可以考虑改为 require('assert/strict')
  • 单测场景,用到 power-assert 的情况下,保持观望,或者考虑下 jest 啥的内置断言能力。

## 代码覆盖率

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

参赛选手:

  • 社区目前的主流方案是通过 nyc 来对代码转译,通过构建期打桩的方式来采集代码执行情况,从而计算出覆盖率。
  • V8 支持了 JS 代码覆盖率采集能力,并被 Node.js 10.x 开始集成,建议用 16.x。需使用 c8 来导出对应的报表。

$ nyc mocha 
$ open coverage/lcov-report/index.html
$ 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

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

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

评委点评:

  • nyc 会需要转译代码,导致执行速度变慢,而且有可能会影响到 TS 等 sourcemap 映射,导致排查问题麻烦。
  • 官方的方案是使用了 V8 内置的代码覆盖率采集能力,因此推荐使用。
  • 官方的方案,在进程退出等场景下不一定采集的到,有一些边缘的 case 暂时还比不过 nyc,不过问题不大。
  • 注意:覆盖率采集 和 覆盖率报告生成 是 2 个阶段,后者是 c8 做的,不过 istanbuljs、nyc、c8 的作者都是同一人。

## 调试日志

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

参赛选手:

  • 官方内置 util.debuglog 的实现。
  • debug 是早期大神 visionmedia 出品的,参考了 Node.js 的实现,增加了对浏览器的支持。

技能演示:

// 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
// 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

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

评委点评:

  • 用法几乎没变化,触发的环境变量名修改下即可,同样的支持通配规则。
  • debug 其实是参考官方 Node.js 的实现,反而大受欢迎,被非常多的 Node.js 基础类库所使用。
  • 如果你有用一些 Logger 库的话,logger.verbose()其实也能覆盖这个场景。

## 过期 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);
};
const util = require('util');
const fn = util.deprecate(originFn, '`app.get` is deprecated, please use `app.router.get` instead.', 'DEP0001');

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

评委点评:

  • 建议直接用内置的 util 方法,自动代理了原函数,用起来更简单。
  • 支持 --no-deprecation--throw-deprecation 等参数来增强控制。
  • 第三个参数定义错误码,相同的 CODE 只会打印一次 WARNING。

## 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)

替换指数:★★★★★ 更待何时 评委点评:

  • 原理都是一样的,覆盖 V8 提供的 Error.prepareStackTrace 把对应的堆栈地址经过 sourcemap 转换即可,当年我们也有过 相关分享
  • Node.js 内置了该功能,喜闻乐见。

## Process 子进程

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

参赛选手:

  • 我们封装的 runscript 模块。
  • sindresorhus 大神写的 execa 模块。

技能演示:

const runscript = require('runscript');

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

console.log(stdout);
console.error(stderr);
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);

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

评委点评:

  • 两者对 child_process 包装了一层,很好的支持了跨平台的兼容性。
  • execa 的功能更全面一点,支持 fork,以及 kill 等操作。
  • 遗憾是,官方选手目前没有计划优化和迎战。

## 命令行测试

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

参赛选手:

  • 我们封装的 coffee 模块,现在 Egg 体系的 CLI 测试全部基于它。
  • 我个人最新封装的 clet,试图解决 coffee 存在的一些问题,也是这篇文章的诱因之一。

技能演示:

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();
  });
});
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
});

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

评委点评:

  • 熟悉 Egg 源码的同学,会对 coffee 非常眼熟,我们之前的 CLI 单测都是基于它的。
  • 但 coffee 对 prompt 以及 web server 等长期运行的应用的测试,不是很在行,之前提供的都是一些比较黑的方式。
  • clet 是我最新实现的一版,有很多不错的特性,欢迎大家尝鲜。(预计最近会发 1.0 版本)

3. 写在最后

本文主要是写给 Egg 团队这些深度参与过早期社区建设的同学,需要翻新下我们的认知了。 ​

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

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

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

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

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

  • 积极参与到 Node.js 官方的 API 讨论中,这是我们可以做到的,也应该做到的。
  • 新开坑时,如果是兼容类型的,三思下,可以先写了救急,但建议同步给 Node.js 提 PR,咱最好只维护三年。
  • 如果是旧的历史库,考虑基于官方的新能力,进行翻新,如 is-type-of 这种。
  • 应该以身作则推动老旧版本的 Node.js 下线,封装的库勇敢发大版本。
  • 我们维护的 https://github.com/node-modules 有 172 个库,需要说再见的只有本文提到的几个么?

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK