开源最佳实践
在 https://github.com/npmstudy/your-node-v20-monorepo-project 里,我们用到的技术栈如下。
- Tsup as a TypeScript universal package.
- Tsx as a Node.js enhanced with esbuild to run TypeScript & ESM
- Tsd as type test runner
- Tsdoc as document
- PNPM as workspace manager and package manager.
- Vitest as a test runner.
- Size Limit as a size limit plugin.
- Prettier as a code formatter.
- ESLint as a code linter.
- NX as cacheable operations.
- Changesets as a way to manage changes and releases.
- c8 as coverage
- supertest as server test
- cypress as e2e test
运行TS的4个(已讲过)
- tsup
- tsx
- tsd
- tsdoc
模块安装和monorepo
- pnpm
测试
- Vitest
- c8(已讲过)
- supertest
- cypress
其他
- Size-limit
- NX
- ESLint
- Prettier
- Changesets
其中ESLint和Prettier前端同学都熟悉,这里就不细讲了。
- ESLint是一个用于JavaScript和JSX代码的静态代码分析工具。它可以帮助开发者在编写代码的过程中发现并修复潜在的问题,以确保代码的质量和一致性。
- Prettier是一个代码格式化工具,用于自动格式化代码以保持一致的代码风格。它支持多种编程语言,包括JavaScript、CSS、HTML、JSON等。Prettier可以帮助开发者快速、准确地格式化代码,提高代码的可读性和可维护性。它可以与其他代码检查工具(如ESLint)配合使用,以确保代码的质量和一致性。
它俩是VSCode非常常见的搭档。
Pnpm
包管理演进历史,参考https://zhuanlan.zhihu.com/p/582229306,各个包管理器对比如下。
pnpm在2018年发布,作为npm的更快速和更高效的替代品。
选择pnpm的原因,第一个就是安装速度快,参考官方的https://pnpm.io/benchmarks基准测试数据。
行为 | 缓存 | lockfile | node_modules | npm | pnpm | Yarn | Yarn PnP |
---|---|---|---|---|---|---|---|
install | 34.3s | 13.2s | 22.1s | 20.2s | |||
install | ✔ | ✔ | ✔ | 2.5s | 1.6s | 695ms | n/a |
install | ✔ | ✔ | 9.5s | 4.8s | 8.8s | 668ms | |
install | ✔ | 15.5s | 9.3s | 22.8s | 15.2s | ||
install | ✔ | 19.3s | 9.8s | 8.9s | 670ms | ||
install | ✔ | ✔ | 2.8s | 3.4s | 16s | n/a | |
install | ✔ | ✔ | 2.5s | 1.7s | 681ms | n/a | |
install | ✔ | 2.8s | 8.8s | 16.6s | n/a | ||
update | n/a | n/a | n/a | 9.2s | 5.8s | 8.7s | 16.9s |
还有一些其他特性
pnpm使用的是npm version 2.x类似的树形结构,同时使用.pnpm 以平铺的形式储存着所有的包。这里的.pnpm为虚拟存储目录,该目录通过<package-name>@<version>
来实现相同模块不同版本之间隔离和复用,由于它只会根据项目中的依赖生成,并不存在提升,所以它不会存在之前提到的Phantom dependencies问题!
然后使用Store + Links和文件资源进行关联。简单说pnpm会把包下载到一个公共目录,如果某个依赖在 store 目录中存在了话,那么就会直接从 store 目录里面去 hard-link,避免了二次安装带来的时间消耗,如果依赖在 store 目录里面不存在的话,就会去下载一次。
通过Store + hard link的方式,不仅解决了项目中的NPM doppelgangers问题,项目之间也不存在该问题,从而完美解决了npm3+和yarn中的包重复问题!
pnpm除了安装速度快,节省磁盘空间,避免幽灵依赖等优化,也内置了对monorepo的支持。使用起来比较简单,在项目根目录中新建pnpm-workspace.yaml文件,并声明对应的工作区就好。
$ cat pnpm-workspace.yaml
packages:
- 'example'
- 'packages/*'
剩下的就是运行时处理。之前nx run-many解决了monorepo子模块运行的问题。很多时候我们需要在根目录执行某个项目 的构建脚本,需要通过—filter或-F过滤之后,再执行,示例如下。
$ pnpm -F example dev
以上都是pnpm的优点,其实pnpm的演进还是比较快的,比如pnpm v7和pnpm v8版本差异还是比较大的,且不完全兼容。但整个社区是比较活跃的,
Vitest
-
A Vite-native unit test framework.
It's fast!
优点:
- 基于vite机制,性能极佳,比jest快很多,
- 对esm支持极好
- 兼容jest api
- 很多技术选型也都是非常棒,比如tinypool,都是很经典的。
如果我们想要从请求开始来测试node服务接口返回的数据是否正常,也就是说进行一个整体性测试,那么 superTest 就是一个非常好的选择。superTest可以帮助我们去请求本地 koa 或者 express这类web框架所编写的路由接口,而且对接口返回的状态码、数据等进行断言校验。
它本身不依赖任何测试框架,所以我们可以直接把它丢到mocha的测试用例中执行:
先写一个简单的Koa的Hello world。见app.js
import Koa from "koa";
const app = new Koa();
// response
app.use((ctx) => {
ctx.body = "Hello Koa";
});
export default app;
再写一个测试脚本,run.js
import app from "./index.js";
app.listen(3000);
此时,执行node run.js就可以启动服务了。然后我们看一下测试如何编写。
import { expect, test } from "vitest";
import supertest from "supertest";
import app from "./index.js";
test("koa app", async () => {
const res = await supertest(app.callback()).get("/");
expect(res.type).toEqual("text/plain");
expect(res.status).toEqual(200);
expect(res.text).toEqual("Hello Koa");
});
这个代码和我们在Node.js v20的test runner里几乎是一模一样的。通过下面命令即可运行测试。
$ npx vitest run
RUN v0.34.6 /Users/alfred/workspace/npmstudy/vitest-with-supertest
✓ index.test.js (1)
✓ koa app
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 11:56:53
Duration 232ms (transform 17ms, setup 0ms, collect 69ms, tests 8ms, environment 0ms, prepare 44ms)
说明。
- vitest不带run参数,是watch运行模式,和jest类似
- vitest带run参数,是单次运行模式
Cypress
安装
$ npm install cypress --save-dev --registry=https://registry.npmmirror.com
通过npx cypress open打开。
$ npx cypress open
It looks like this is your first time using Cypress: 13.3.0
✔ Verified Cypress! /Users/alfred/Library/Caches/Cypress/13.3.0/Cypress.app
Opening Cypress...
DevTools listening on ws://127.0.0.1:54097/devtools/browser/dc560ea2-0aad-47a4-9461-1e160b19a5a3
修改代码,手动执行node run.js启动服务。
describe("template spec", () => {
it("passes", () => {
cy.visit("http://127.0.0.1:3000");
cy.contains("Hello Koa");
});
});
为了简化操作,我们把启动服务放到cypress.config.js里。
import { defineConfig } from "cypress";
import app from "./index.js";
export default defineConfig({
e2e: {
setupNodeEvents(on, config) {
// implement node event listeners here
on("before:run", async (details) => {
/* ... */
await app.listen(3000);
});
},
},
});
此时,执行结果如下。
$ npx cypress run
DevTools listening on ws://127.0.0.1:61755/devtools/browser/0a2f2716-10cb-49b9-a2c9-5163469574e9
====================================================================================================
(Run Starting)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Cypress: 13.3.0 │
│ Browser: Electron 114 (headless) │
│ Node Version: v20.6.1 (/Users/alfred/.nvm/versions/node/v20.6.1/bin/node) │
│ Specs: 1 found (spec.cy.js) │
│ Searched: cypress/e2e/**/*.cy.{js,jsx,ts,tsx} │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: spec.cy.js (1 of 1)
template spec
✓ passes (27ms)
1 passing (47ms)
(Results)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Tests: 1 │
│ Passing: 1 │
│ Failing: 0 │
│ Pending: 0 │
│ Skipped: 0 │
│ Screenshots: 0 │
│ Video: false │
│ Duration: 0 seconds │
│ Spec Ran: spec.cy.js │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
====================================================================================================
(Run Finished)
Spec Tests Passing Failing Pending Skipped
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ✔ spec.cy.js 42ms 1 1 - - - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
✔ All specs passed! 42ms 1 1 - - -
Cypress写e2e测试还是非常简单的,它也支持测试覆盖率,也可以生产reporter,和我们在上一章节学到的c8类似,大家如果感兴趣也可以自己实现一下。
除此之外,Cypress还支持组件级别测试,可以说又是填补了一块空白,意义非凡,对于开发组件库来说,也是必不可少的功能。
Size-limit
size-limit 是一个防止 JavaScript 库膨胀的工具,用于控制文件包的大小。如果不小心添加了大量的依赖关系,size-limit 会引发错误。
limit-size 是一个用于控制文件包大小的工具,它可以读取文件的大小,并与设置的文件大小进行比较。
使用GitHub操作Size Limit将在拉取请求讨论中作为评论发布捆绑大小更改。
也可以结合https://github.com/statoscope/statoscope 进行分析。
Statoscope: A Course Of Intensive Therapy For Your Bundle.
Nx
在使用Monorepo时,你一定听说过 Nx。简而言之,Nx 可以加快和简化与Monorepo的工作,并提供有用的实用程序。
官方介绍:
Nx是一个智能、快速和可扩展的构建系统,具有一流的Monorepo支持和强大的集成。
Nx的目标是:
- 加快你的前端项目工程化
- 提供一流的开发体验
大部分情况需要了解的就是下面6个指点就够了。
- nx run-many
- dependency graph + nx affected
- nx-enforce-module-boundaries es-linting
- computation cache
- nx cloud
- buildable libs
nx会自动引入上一次build的cache缓存,从而加快编译速度.在How Caching Works这篇文章中非常详细地说明了 nx 缓存构建结果的机制:
nx 会计算当前执行的 target 的 Hash 作为 cache key,在 target 执行完成后把构建输出(包括终端输出、构建结果文件)作为缓存内容存储起来。
最常用的动作
- 将所有的子项目统一打包编译,命令:
npx nx run-many --target=build
- 如果不需要使用cache缓存,可以使用命令:
npx nx run-many --target=build --skip-nx-cache
- 如果需要查看当前子项目依赖的项目是否被修改,可以使用命令:
npx nx affected --target=build
- 如果需要查看当前子项目依赖的图,可以使用命令:
npx nx graph
比如依赖如下。
$ pnpm project-graph
> your-node-v20-monorepo-project@ project-graph /Users/alfred/workspace/npmstudy/your-node-v20-monorepo-project
> nx graph
> NX Project graph started at http://127.0.0.1:4211/projects
nx除了本地换成,其实还支持云服务(remote cache)。即nx.app,如果真的对ci/cd有极强的需求,其实也是可以考虑选择的。
在https://github.com/npmstudy/your-node-v20-monoreopo-project 项目里,我们其实只用了非常简单的npm nx run-many
{
"scripts": {
"build": "nx run-many -t build",
"build:fast": "nx run-many -t build:fast",
"dev": "nx run-many -t dev",
"test": "nx run-many -t test",
}
}
虽然多了一个nx模块,但执行的时候能够利用本地缓存,加快打包、测试速度,已经是非常好的事儿。
Changesets
Changesets 是一个用于 Monorepo 项目下版本以及 Changelog 文件管理的工具。目前一些比较火的 Monorepo 仓库都在使用该工具进行项目的发包例如 pnpm、mobx 等。
changesets 主要关心 monorepo 项目下子项目版本的更新、changelog 文件生成、包的发布。一个 changeset 是个包含了在某个分支或者 commit 上改动信息的 md 文件,它会包含这样一些信息:
- 需要发布的包
- 包版本的更新层级(遵循 semver 规范)
- CHANGELOG 信息
在 changesets 工作流会将开发者分为两类人,一类是项目的维护者,还有一类为项目的开发者,两者的职责可以通过如下流程图很简洁的表示出来:
根据上图, changesets 的工作流程是这样:开发者在 Monorepo 项目下进行开发,开发完成后,给对应的子项目添加一个 changesets 文件。项目的维护者后面会通过 changesets 来消耗掉这些文件并自动修改掉对应包的版本以及生成 CHANGELOG 文件,最后将对应的包发布出去。
常用的命令只有add、version、publish