发布 TypeScript 包

¥Publishing a TypeScript package

本文专门介绍与 TypeScript 发布相关的内容。发布意味着通过 npm(或其他包管理器)作为包分发;这与编译要在生产中运行的应用/服务器(例如 PWA 和/或端点服务器)无关。

¥This article covers items regarding TypeScript publishing specifically. Publishing means distributed as a package via npm (or other package manager); this is not about compiling an app / server to be run in production (such as a PWA and/or endpoint server).

需要注意的一些重要事项:

¥Some important things to note:

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

    ¥Everything from Publishing a package applies here.

    • main 这样的字段对已发布的内容进行操作,因此当 TypeScript 源代码被转换为 JavaScript 时,JavaScript 就是已发布的内容,而 main 将指向具有 JavaScript 文件扩展名的 JavaScript 文件(例如 main.ts"main": "main.js")。

      ¥Fields like main operate on published content, so when TypeScript source-code is transpiled to JavaScript, JavaScript is the published content and main would point to a JavaScript file with a JavaScript file extension (ex main.ts"main": "main.js").

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

      ¥Fields like scripts.test operate on source-code, so they would use the file extensions of the source code (ex "test": "node --test './src/**/*.test.ts').

  • Node 通过一个名为“类型剥离”的过程运行 TypeScript 代码,其中 node(通过 Amaro)删除 TypeScript 特定的语法,留下原始 JavaScript(node 已经理解)。从 Node 版本 23.6.0 开始,此行为默认启用。

    ¥Node runs TypeScript code via a process called "type stripping", wherein node (via Amaro) removes TypeScript-specific syntax, leaving behind vanilla JavaScript (which node already understands). This behaviour is enabled by default as of node version 23.6.0.

    • Node 不会剥离 node_modules 中的类型,因为这会导致官方 TypeScript 编译器 (tsc) 和 VS Code 的部分出现严重的性能问题,因此 TypeScript 维护者希望阻止人们发布原始 TypeScript,至少目前是这样。

      ¥Node does not strip types in node_modules because it can cause significant performance issues for the official TypeScript compiler (tsc) and parts of VS Code, so the TypeScript maintainers would like to discourage people publishing raw TypeScript, at least for now.

  • 在节点中使用 TypeScript 特定功能(如 enum)仍然需要标志(--experimental-transform-types)。无论如何,这些通常都有更好的替代方案。

    ¥Consuming TypeScript-specific features like enum in node still requires a flag (--experimental-transform-types). There are often better alternatives for these anyway.

    • 为确保不存在特定于 TypeScript 的功能(以便你的代码可以在 Node 中运行),请在 TypeScript 版本 5.8+ 中设置 erasableSyntaxOnly 配置选项。

      ¥To ensure TypeScript-specific features are not present (so your code can just run in node), set the erasableSyntaxOnly config option in TypeScript version 5.8+.

  • 使用 dependabot 保持你的依赖(包括 github 操作中的依赖)为最新。这是一个非常简单的设置和忘记配置。

    ¥Use dependabot to keep your dependencies current, including those in github actions. It's a very easy set-and-forget configuration.

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

    ¥.nvmrc comes from nvm, a multi-version manager for node. It allows you to specify the version of node the project should generally use.

存储库的目录概览如下所示:

¥A directory overview of a repository would look something like:

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

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

¥And a directory overview of its published package would look something like:

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/("'src' 和 'test' 完全隔离")的 test/ 兄弟,具有镜像结构或 "垃圾抽屉"。

¥A note about directory organisation: There are a few common practices for placing tests. Principle of least knowledge says to co-locate them (put them adjacent to implementation). Sometimes, that's in the same directory, or within a drawer like a __test__ (also adjacent to the implementation, "Files co-located but segregated"). Alternatively, some opt to create a test/ sibling to src/ ("'src' and 'test' fully segregated"), either with a mirrored structure or a "junk drawer".

如何处理你的类型

¥What to do with your types

将类型视为测试

¥Treat types like a test

类型的目的是警告实现不起作用:

¥The purpose of types is to warn an implementation will not work:

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

TypeScript 已警告上述代码不会按预期运行,就像单元测试警告代码不会按预期运行一样。它们是互补的,可以验证不同的东西 - 你应该同时拥有两者。

¥TypeScript has warned that the above code will not behave as intended, just like a unit test warns that code does not behave as intended. They are complementary and verify different things—you should have both.

你的编辑器(例如 VS Code)可能内置了对 TypeScript 的支持,可以在你工作时显示错误。如果没有,和/或你遗漏了这些,CI 将为你提供支持。

¥Your editor (eg VS Code) likely has built-in support for TypeScript, displaying errors as you work. If not, and/or you missed those, CI will have your back.

以下 GitHub 操作 设置 CI 任务以自动检查(并要求)类型通过 PR 进入 main 分支的检查。

¥The following GitHub Action sets up a CI task to automatically check (and require) types pass inspection for a PR into the main branch.

# 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(因此在上面的示例中将它们排除在外)。

¥Note that test files may well have a different tsconfig.json applied (hence why they are excluded in the above sample).

生成类型声明

¥Generate type declarations

类型声明(.d.ts 和朋友)将类型信息作为附加文件提供,允许执行代码是原始 JavaScript,同时仍具有类型。

¥Type declarations (.d.ts and friends) provide type information as a sidecar file, allowing the execution code to be vanilla JavaScript whilst still having types.

由于这些是基于源代码生成的,因此可以将它们作为发布过程的一部分进行构建,而不需要签入存储库。

¥Since these are generated based on source code, they can be built as part of your publication process and do not need to be checked into your repository.

以下示例为类型声明生成于发布到 npm 注册表之前。

¥Take the following example, where the type declarations are generated just before publishing to the npm registry.

# 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 及更高版本。

¥You'll want to publish a package compiled to support all Node.js LTS versions since you don't know which version the consumer will be running; the tsconfigs in this article support node 18.x and later.

npm publish 将自动运行 prepack 事先npm 还将在 npm pack --dry-run 之前自动运行 prepack(因此你可以轻松查看已发布的包是什么,而无需实际发布它)。当心,node --run 不这样做。你不能在此步骤中使用 node --run,因此该警告不适用于此处,但它可以用于其他步骤。

¥npm publish will automatically run prepack beforehand. npm will also run prepack automatically before npm pack --dry-run (so you can easily see what your published package will be without actually publishing it). Beware, node --run does not do that. You can't use node --run for this step, so that caveat does not apply here, but it can for other steps.

实际发布到 npm 的步骤将包含在单独的文章中(有几个优点和缺点超出了本文的范围)。

¥The steps to actually publish to npm will be included in a separate article (there are several pros and cons beyond the scope of this article).

分解

¥Breaking this down

生成类型声明是确定性的:每次,你都会从相同的输入获得相同的输出。因此无需将它们提交到 git。

¥Generating type declarations is deterministic: you'll get the same output from the same input, every time. So there is no need to commit these to git.

npm publish 在运行命令时抓取所有适用且可用的内容;因此,在之前立即生成类型声明意味着这些声明可用并且将被拾取。

¥npm publish grabs everything applicable and available at the moment the command is run; so generating type declarations immediately before means those are available and will get picked up.

默认情况下,npm publish 抓取(几乎)所有内容(参见 包中包含的文件)。为了保持已发布的包最小化(请参阅有关 node_modules 的 "宇宙中最重的对象" meme),你需要从打包中排除某些文件(如测试和测试装置)。将这些添加到 .npmignore 中指定的退出列表中;确保列出 !*.d.ts 异常,否则生成的类型声明将不会发布!或者,你可以使用 package.json "files" 创建选择加入(如果错误地遗漏了文件,你的软件包可能会被下游用户破坏,因此这是一个不太安全的选择)。

¥By default, npm publish grabs (almost) everything (see Files included in package). In order to keep your published package minimal (see the "Heaviest Objects in the Universe" meme about node_modules), you want to exclude certain files (like tests and test fixtures) from from packaging. Add these to the opt-out list specified in .npmignore; ensure the !*.d.ts exception is listed, or the generated type declartions will not be published! Alternatively, you can use package.json "files" to create an opt-in (if a mistake is made accidentally omitting a file, your package may be broken for downstream users, so this is a less safe option).