开源最佳实践

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个(已讲过)

  1. tsup
  2. tsx
  3. tsd
  4. tsdoc

模块安装和monorepo

  1. pnpm

测试

  1. Vitest
  2. c8(已讲过)
  3. supertest
  4. cypress

其他

  1. Size-limit
  2. NX
  3. ESLint
  4. Prettier
  5. Changesets

其中ESLint和Prettier前端同学都熟悉,这里就不细讲了。

  • ESLint是一个用于JavaScript和JSX代码的静态代码分析工具。它可以帮助开发者在编写代码的过程中发现并修复潜在的问题,以确保代码的质量和一致性。
  • Prettier是一个代码格式化工具,用于自动格式化代码以保持一致的代码风格。它支持多种编程语言,包括JavaScript、CSS、HTML、JSON等。Prettier可以帮助开发者快速、准确地格式化代码,提高代码的可读性和可维护性。它可以与其他代码检查工具(如ESLint)配合使用,以确保代码的质量和一致性。

它俩是VSCode非常常见的搭档。

Pnpm

包管理演进历史,参考https://zhuanlan.zhihu.com/p/582229306,各个包管理器对比如下。

pnpm在2018年发布,作为npm的更快速和更高效的替代品。

Untitled

选择pnpm的原因,第一个就是安装速度快,参考官方的https://pnpm.io/benchmarks基准测试数据。

行为缓存lockfilenode_modulesnpmpnpmYarnYarn PnP
install34.3s13.2s22.1s20.2s
install2.5s1.6s695msn/a
install9.5s4.8s8.8s668ms
install15.5s9.3s22.8s15.2s
install19.3s9.8s8.9s670ms
install2.8s3.4s16sn/a
install2.5s1.7s681msn/a
install2.8s8.8s16.6sn/a
updaten/an/an/a9.2s5.8s8.7s16.9s

还有一些其他特性

Untitled

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中的包重复问题!

Untitled

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!

优点:

  1. 基于vite机制,性能极佳,比jest快很多,
  2. 对esm支持极好
  3. 兼容jest api
  4. 很多技术选型也都是非常棒,比如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

Untitled

Untitled

Untitled

Untitled

修改代码,手动执行node run.js启动服务。

describe("template spec", () => { it("passes", () => { cy.visit("http://127.0.0.1:3000"); cy.contains("Hello Koa"); }); });

Untitled

为了简化操作,我们把启动服务放到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 是一个用于控制文件包大小的工具,它可以读取文件的大小,并与设置的文件大小进行比较。

Untitled

使用GitHub操作Size Limit将在拉取请求讨论中作为评论发布捆绑大小更改。

Untitled

也可以结合https://github.com/statoscope/statoscope 进行分析。

 Statoscope: A Course Of Intensive Therapy For Your Bundle.

Untitled

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 执行完成后把构建输出(包括终端输出、构建结果文件)作为缓存内容存储起来。

Untitled

最常用的动作

  • 将所有的子项目统一打包编译,命令: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

Untitled

nx除了本地换成,其实还支持云服务(remote cache)。即nx.app,如果真的对ci/cd有极强的需求,其实也是可以考虑选择的。

Untitled

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 工作流会将开发者分为两类人,一类是项目的维护者,还有一类为项目的开发者,两者的职责可以通过如下流程图很简洁的表示出来:

Untitled

根据上图, changesets 的工作流程是这样:开发者在 Monorepo 项目下进行开发,开发完成后,给对应的子项目添加一个 changesets 文件。项目的维护者后面会通过 changesets 来消耗掉这些文件并自动修改掉对应包的版本以及生成 CHANGELOG 文件,最后将对应的包发布出去。

常用的命令只有add、version、publish

Untitled