发布 TypeScript 包

本文专门介绍有关 TypeScript 发布的事项。“发布”是指通过 npm(或其他包管理器)以包的形式分发;这与编译要在生产环境中运行的应用/服务器(例如 PWA 和/或端点服务器)无关。

一些需要注意的重要事项

  • 发布包中的所有内容都适用于此。

    • main 这样的字段作用于*已发布*的内容,因此当 TypeScript 源代码转译为 JavaScript 时,JavaScript 是已发布的内容,而 main 会指向一个带有 JavaScript 文件扩展名的 JavaScript 文件(例如 main.ts"main": "main.js")。

    • scripts.test 这样的字段作用于源代码,因此它们会使用源代码的文件扩展名(例如 "test": "node --test './src/**/*.test.ts')。

  • Node 通过一个名为“类型剥离”的过程运行 TypeScript 代码,其中 Node(通过 Amaro)移除 TypeScript 特定的语法,留下原生 JavaScript(Node 已经理解)。此行为在 Node 22.18.0 及更高版本中默认启用。

    • Node **不会**剥离 node_modules 中的类型,因为这可能对官方 TypeScript 编译器(tsc)和 VS Code 的部分功能造成严重的性能问题,所以 TypeScript 维护者希望阻止人们发布原始的 TypeScript,至少目前是这样。
  • 在 Node 中使用 TypeScript 特定的功能(如 enum)仍然需要一个标志(--experimental-transform-types)。无论如何,对于这些功能通常有更好的替代方案。

    • 为确保不包含 TypeScript 特定的功能(这样你的代码就可以直接在 Node 中运行),请在 TypeScript 5.8 及更高版本中设置 erasableSyntaxOnly 配置选项。
  • 使用 Dependabot 来保持你的依赖项最新,包括 GitHub Actions 中的依赖。这是一个非常容易“设置后即忘”的配置。

  • .nvmrc 来自 nvm,一个用于 Node 的多版本管理器。它允许你指定项目通常应使用的 Node 版本。

一个代码仓库的目录概览可能如下所示:

example-ts-pkg/
├ .github/
│ ├ workflows/
│ │ ├ ci.yml
│ │ └ publish.yml
│ └ dependabot.yml
├ src/
│ ├ foo.fixture.js
│ ├ main.ts
│ ├ main.test.ts
│ ├ some-util.ts
│ └ some-util.test.ts
├ LICENSE
├ package.json
├ README.md
└ tsconfig.json

其已发布包的目录概览可能如下所示:

example-ts-pkg/
├ LICENSE
├ main.d.ts
├ main.d.ts.map
├ main.js
├ package.json
├ README.md
├ some-util.d.ts
├ some-util.d.ts.map
└ some-util.js

关于目录组织的说明:放置测试有几种常见的做法。最小知识原则建议将它们共置(放在实现文件的旁边)。有时,这在同一个目录中,或在一个像 __test__ 这样的抽屉目录中(也与实现文件相邻,“文件共置但隔离”)。或者,一些人选择创建一个与 src/ 平级的 test/ 目录(“‘src’和‘test’完全隔离”),其结构可以是镜像的,也可以是一个“杂物抽屉”。

如何处理你的类型

像对待测试一样对待类型

类型的目的是警告某个实现将无法正常工作

const  = 'a';
const bar: number = 1 + ;
Type 'string' is not assignable to type 'number'.

TypeScript 已经警告说上面的代码不会按预期运行,就像单元测试警告代码不会按预期运行一样。它们是互补的,验证不同的东西——你应该两者都有。

你的编辑器(例如 VS Code)可能内置了对 TypeScript 的支持,会在你工作时显示错误。如果没有,或者你错过了这些错误,CI 会为你提供保障。

下面的 GitHub Action 设置了一个 CI 任务,自动检查(并要求)合并到 main 分支的 PR 的类型检查通过。

# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json

name: Tests

on:
  pull_request:
    branches: ['*']

jobs:
  check-types:
    # Separate these from tests because
    # they are platform and node-version independent
    # and need be run only once.

    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: '.nvmrc'
          cache: 'npm'
      - name: npm clean install
        run: npm ci
      # You may want to run a lint check here too
      - run: node --run types:check

  get-matrix:
    # Automatically pick active LTS versions
    runs-on: ubuntu-latest
    outputs:
      latest: ${{ steps.set-matrix.outputs.requireds }}
    steps:
      - uses: ljharb/actions/node/matrix@main
        id: set-matrix
        with:
          versionsAsRoot: true
          type: majors
          preset: '>= 22' # glob is not backported below 22.x

  test:
    needs: [get-matrix]
    runs-on: ${{ matrix.os }}

    strategy:
      fail-fast: false
      matrix:
        node-version: ${{ fromJson(needs.get-matrix.outputs.latest) }}
        os:
          - macos-latest
          - ubuntu-latest
          - windows-latest

    steps:
      - uses: actions/checkout@v4
      - name: Use node ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - name: npm clean install
        run: npm ci
      - run: node --run test

请注意,测试文件很可能应用了不同的 tsconfig.json(因此在上面的示例中它们被排除了)。

生成类型声明

类型声明(.d.ts 及相关文件)以伴随文件的形式提供类型信息,允许执行代码是原生 JavaScript 的同时仍然拥有类型。

由于这些是根据源代码生成的,它们可以作为发布过程的一部分构建,无需检入到你的代码仓库中。

以下面的例子为例,类型声明在发布到 npm 注册表之前生成。

# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json

# This is mostly boilerplate.

name: Publish to npm
on:
  push:
    tags:
      - '**@*'

jobs:
  build:
    runs-on: ubuntu-latest

    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: '.nvmrc'
          registry-url: 'https://registry.npmjs.org'
      - run: npm ci

      # - name: Publish to npm
      #   run: … npm publish …

你需要发布一个编译后支持所有 Node.js LTS 版本的包,因为你不知道消费者将运行哪个版本;本文中的 tsconfig 支持 Node 18.x 及更高版本。

npm publish在此之前自动运行 prepacknpmnpm pack --dry-run 之前也会自动运行 prepack(这样你就可以轻松查看你将发布的包会是什么样子,而无需实际发布)。**注意**,node --run 不会这样做。你不能在此步骤中使用 node --run,所以这个警告在这里不适用,但可能适用于其他步骤。

实际发布到 npm 的步骤将包含在另一篇文章中(这涉及到本文范围之外的几个利弊)。

分解说明

生成类型声明是确定性的:对于相同的输入,你每次都会得到相同的输出。所以没有必要将这些提交到 Git。

npm publish 会抓取命令运行时所有适用且可用的内容;因此在发布前立即生成类型声明意味着这些文件是可用的,并且会被包含进去。

默认情况下,npm publish 会抓取(几乎)所有东西(参见 包中包含的文件)。为了让你的已发布包保持最小(参见关于 node_modules 的“宇宙中最重的物体”的梗),你需要从打包中排除某些文件(如测试和测试固件)。将这些添加到 .npmignore 中指定的排除列表中;确保列出了 !*.d.ts 这个例外,否则生成的类型声明将不会被发布!或者,你可以使用 package.json 的 "files" 字段来创建一个包含列表(如果意外遗漏了一个文件,你的包可能会对下游用户造成破坏,所以这是一个不太安全的选择)。