Skip to content

npm 包发布

npm(Node Package Manager)是 JavaScript 生态中最核心的包管理与分发平台。对于前端工程师而言,发布一个高质量的 npm 包不仅仅是 npm publish 一条命令那么简单——它涉及到 registry 机制、模块格式设计、构建工具选型、版本管理、自动化发布流水线等一整套工程体系。

本文将从 发布基础 → 导出设计 → 构建工具 → 发布工作流 → 私有包 → 最佳实践 → 面试高频题 七个维度,全面深入地拆解 npm 包发布。


一、npm 包发布基础

1.1 npm Registry 机制

npm Registry 是一个基于 CouchDB 的巨型 JSON 文档数据库,所有通过 npm publish 发布的包都以 tarball(.tgz)的形式存储在 Registry 中。当用户执行 npm install 时,客户端通过 HTTP API 向 Registry 查询包的元数据(metadata),然后下载对应版本的 tarball 并解压到 node_modules

npm publish / install 流程:

发布流程:
┌──────────┐    npm publish     ┌──────────────────┐
│  开发者   │ ──────────────►   │   npm Registry    │
│  本地项目  │   上传 tarball     │  registry.npmjs.org│
└──────────┘                    │                  │
                                │  ┌────────────┐  │
                                │  │ package.tgz │  │
                                │  │ metadata    │  │
                                │  └────────────┘  │
                                └──────────────────┘

安装流程:
┌──────────┐  npm install foo   ┌──────────────────┐
│  使用者   │ ──────────────►   │   npm Registry    │
│  项目     │   查询元数据       │                  │
│          │ ◄──────────────   │  返回 metadata    │
│          │   下载 tarball     │                  │
│          │ ◄──────────────   │  返回 .tgz 文件   │
└──────────┘                    └──────────────────┘


┌──────────────┐
│ node_modules/ │
│  └── foo/     │
│      ├── dist/│
│      └── ...  │
└──────────────┘

Registry 为每个包维护一份 metadata 文档,核心结构如下:

json
{
  "name": "my-lib",
  "dist-tags": {
    "latest": "2.0.0",
    "next": "3.0.0-beta.1"
  },
  "versions": {
    "1.0.0": { "dist": { "tarball": "https://..." } },
    "2.0.0": { "dist": { "tarball": "https://..." } }
  },
  "time": {
    "1.0.0": "2024-01-01T00:00:00.000Z",
    "2.0.0": "2024-06-01T00:00:00.000Z"
  }
}

npm 客户端解析版本时的优先级:

npm install foo


  有 lockfile?──── 是 ───► 使用 lockfile 中锁定的版本




  查询 Registry metadata


  根据 package.json 中的 semver 范围
  匹配 dist-tags.latest 对应的最新版本


  下载并解压 tarball

1.2 核心命令

npm login

bash
npm login --registry=https://registry.npmjs.org

执行后会在 ~/.npmrc 中写入 authToken:

//registry.npmjs.org/:_authToken=npm_XXXXXXXXXXXX

npm publish

bash
npm publish
npm publish --access public
npm publish --tag beta
npm publish --dry-run

--dry-run 是发布前的重要检查手段,它会模拟整个发布流程,列出将要上传的文件列表和包大小,但不会真正上传到 Registry。

npm unpublish

bash
npm unpublish my-package@1.0.0
npm unpublish my-package --force

npm 的 unpublish 策略:

条件是否可撤销
发布后 72 小时内可以撤销
发布后超过 72 小时不可撤销
包没有其他包依赖可以撤销
包被其他包依赖不可撤销

npm deprecate

bash
npm deprecate my-package@"< 2.0.0" "请升级到 2.x 版本"

相比 unpublish,deprecate 是更推荐的做法——它不会删除包,而是在用户安装时显示警告信息。

1.3 package.json 发布相关字段

package.json 是 npm 包的"身份证",其中与发布直接相关的字段有十余个,理解每个字段的含义和交互关系至关重要。

json
{
  "name": "@myorg/utils",
  "version": "1.2.3",
  "description": "A collection of utility functions",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    },
    "./utils": {
      "import": "./dist/utils.mjs",
      "require": "./dist/utils.cjs"
    }
  },
  "files": ["dist", "README.md"],
  "engines": {
    "node": ">=18.0.0"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/myorg/utils.git"
  },
  "license": "MIT",
  "sideEffects": false,
  "keywords": ["utils", "helpers"],
  "peerDependencies": {
    "react": ">=18.0.0"
  }
}

各字段详解:

name

包名是 npm 生态中的唯一标识符。命名规则:

  • 长度 ≤ 214 个字符
  • 不能以 ._ 开头
  • 不能包含大写字母
  • 不能包含 URL 不安全字符
  • Scoped 包名格式:@scope/package-name

version

遵循 SemVer(语义化版本规范),格式为 MAJOR.MINOR.PATCH。Registry 不允许发布已存在的版本号——一旦发布,该版本号将被永久占用,即使 unpublish 后也无法重新使用。

main

模块解析时 main 字段的作用:

require("my-lib")


  Node.js 在 node_modules 中找到 my-lib


  读取 my-lib/package.json


  使用 "main" 字段作为入口
  main: "./dist/index.cjs"


  加载 ./dist/index.cjs

module

module 字段是社区约定(非 Node.js 官方),用于指向 ESM 格式的入口文件。Webpack、Rollup 等打包工具在解析模块时会优先读取 module 字段。

types / typings

指向 TypeScript 类型声明文件(.d.ts)。TypeScript 编译器在解析第三方包的类型时会读取此字段。

files

files 字段是一个白名单数组,指定哪些文件/目录会被包含在发布的 tarball 中。

files 字段的过滤逻辑:

项目目录                          发布到 npm 的内容
┌──────────────┐                ┌──────────────┐
│ src/          │    files:     │ dist/         │
│ dist/         │   ["dist"]    │   index.mjs   │
│ tests/        │ ──────────►   │   index.cjs   │
│ .eslintrc     │               │   index.d.ts  │
│ tsconfig.json │               │ package.json  │ ← 始终包含
│ package.json  │               │ README.md     │ ← 始终包含
│ README.md     │               │ LICENSE       │ ← 始终包含
│ LICENSE       │               └──────────────┘
└──────────────┘

无论 files 字段如何配置,以下文件始终会被包含:

  • package.json
  • README(任何大小写和扩展名)
  • LICENSE / LICENCE
  • CHANGELOG

以下文件始终会被排除:

  • .git
  • node_modules
  • .npmrc
  • package-lock.json

engines

声明包所需的 Node.js 版本范围。当用户的 Node.js 版本不满足要求时,npm 会发出警告(配合 engine-strict 可以升级为报错)。

sideEffects

告诉打包工具(Webpack/Rollup)该包是否有副作用。设为 false 表示所有模块都是纯净的,可以安全地进行 Tree Shaking。

1.4 .npmignore vs files 字段

两种方式都可以控制发布的文件范围,但思路相反:

特性.npmignorefiles 字段
策略黑名单(排除指定文件)白名单(只包含指定文件)
默认行为不存在时使用 .gitignore不存在时包含所有文件
推荐度⭐⭐⭐⭐⭐⭐⭐
安全性容易遗漏敏感文件只有明确声明的才会发布
维护成本需要随项目文件变化更新通常只需声明 dist 目录

最佳实践:始终使用 files 字段。白名单策略远比黑名单安全——你永远不会意外发布不该发布的文件。

发布前可以通过以下命令检查将被打包的文件:

bash
npm pack --dry-run
bash
npx publint

publint 是一个社区工具,可以自动检测 package.json 中的常见配置错误。


二、现代包的导出设计

2.1 exports 字段详解

exports 字段是 Node.js 12.7.0 引入的「条件导出」机制,它是 main / module / types 字段的现代化替代方案。相比传统字段,exports 提供了更精确的模块入口控制和子路径映射能力。

exports 条件匹配流程:

import { foo } from "my-lib"


  读取 my-lib/package.json 的 exports 字段


  匹配子路径 "."


  ┌─────────────────────────────────────────────┐
  │ 检测调用环境的条件(按优先级从高到低)         │
  │                                             │
  │  1. "import"  ← 当前是 ESM import 语句?     │
  │  2. "require" ← 当前是 CJS require 调用?     │
  │  3. "node"    ← 当前是 Node.js 环境?         │
  │  4. "browser" ← 当前是浏览器环境?             │
  │  5. "default" ← 兜底条件                      │
  └─────────────────────────────────────────────┘


  返回第一个匹配的条件对应的文件路径

完整的 exports 配置示例:

json
{
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    },
    "./utils": {
      "import": "./dist/utils.mjs",
      "require": "./dist/utils.cjs"
    },
    "./styles/*.css": "./dist/styles/*.css",
    "./package.json": "./package.json"
  }
}

条件导出支持的常用条件:

条件含义触发场景
importESM 导入import x from 'pkg'
requireCJS 导入const x = require('pkg')
typesTypeScript 类型TS 编译器解析类型时
nodeNode.js 环境在 Node.js 中运行时
browser浏览器环境打包工具识别的浏览器构建
default兜底其他条件都不匹配时
development开发环境打包工具设定 NODE_ENV=development
production生产环境打包工具设定 NODE_ENV=production

条件的顺序非常重要——Node.js 和打包工具会按照对象中键的顺序从上到下匹配,返回第一个满足的条件。因此 types 必须始终放在最前面。

2.2 main vs module vs exports 的关系与优先级

这三个字段的关系经历了一个演进过程:

模块入口字段的演进:

2012─────────────2017─────────────2020─────────────现在
  │                │                │                │
  │   main         │   main         │   main         │
  │  (CJS入口)     │  (CJS入口)     │  (CJS入口)     │
  │                │                │                │
  │                │   module       │   module       │
  │                │  (ESM入口)     │  (ESM入口)     │
  │                │  (社区约定)     │  (社区约定)     │
  │                │                │                │
  │                │                │   exports      │
  │                │                │  (官方标准)     │
  │                │                │  (条件导出)     │
  │                │                │  (子路径映射)   │
  │                │                │                │
  CommonJS时代     打包工具兴起      Node.js官方支持   三者并存

优先级规则:

Node.js 模块解析优先级:

exports > main

打包工具(Webpack/Vite)模块解析优先级:

exports > module > main

TypeScript 类型解析优先级:

exports.types > types/typings

exports 存在时,mainmodule 会被完全忽略。但为了兼容不支持 exports 的旧版 Node.js(< 12.7.0)和旧版打包工具,建议同时保留三个字段:

json
{
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  }
}

2.3 Dual Package(CJS + ESM 双格式发布)

JavaScript 生态目前同时存在 CommonJS 和 ES Modules 两种模块系统。一个高质量的 npm 包需要同时支持两种格式,这就是 Dual Package 模式。

Dual Package 产物结构:

dist/
├── index.mjs          ← ESM 格式(import/export)
├── index.cjs          ← CJS 格式(require/module.exports)
├── index.d.mts        ← ESM 对应的类型声明
├── index.d.cts        ← CJS 对应的类型声明
└── index.d.ts         ← 通用类型声明(兼容旧版 TS)

Dual Package Hazard(双包风险)

当一个包同时提供 CJS 和 ESM 两种格式时,存在一个潜在风险:同一个包可能在一个应用中被加载两次——一次通过 require,一次通过 import。这会导致:

Dual Package Hazard:

应用代码
├── 模块 A: import { foo } from 'my-lib'  → 加载 dist/index.mjs
│                                            创建实例 Instance_1

└── 模块 B: const { foo } = require('my-lib') → 加载 dist/index.cjs
                                                  创建实例 Instance_2

Instance_1 !== Instance_2  ← 两个不同的模块实例!

问题:
- 单例模式失效
- instanceof 检查失败
- 状态不共享

解决方案一:ESM Wrapper 模式

javascript
import cjsModule from './index.cjs';
export const foo = cjsModule.foo;
export const bar = cjsModule.bar;

ESM 入口只是对 CJS 模块的一个薄封装,确保无论通过哪种方式导入都使用同一份代码。

解决方案二:使用 exports 条件导出做隔离

json
{
  "exports": {
    ".": {
      "import": "./dist/esm-wrapper.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

2.4 Type Declarations(类型声明文件)

TypeScript 用户需要类型声明文件来获得类型提示和类型检查。类型声明的解析路径取决于 tsconfig.json 中的 moduleResolution 配置:

TypeScript moduleResolution 与类型解析:

┌─────────────────┬────────────────────────────────────┐
│ moduleResolution │  类型声明查找路径                    │
├─────────────────┼────────────────────────────────────┤
│ "node"          │  types 字段 → @types/pkg            │
│ "node16"        │  exports.types → types → @types/pkg │
│ "bundler"       │  exports.types → types → @types/pkg │
│ "nodenext"      │  exports.types → types → @types/pkg │
└─────────────────┴────────────────────────────────────┘

为了确保类型声明在所有 moduleResolution 模式下都能正确解析,最完整的配置是:

json
{
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  }
}

关键规则:在 exports 中,types 条件必须放在 default 之前,因为条件匹配是按顺序进行的。


三、构建工具

npm 包的源码通常使用 TypeScript 编写,发布前需要构建为 JavaScript 产物。目前主流的库级别构建工具有三个:tsup、unbuild、Rollup。

3.1 tsup

tsup 是基于 esbuild 的零配置打包工具,专门为 TypeScript 库设计。它的核心优势是极快的构建速度和简洁的配置。

tsup 架构:

源代码 (TypeScript)

       ├──────────────────────┐
       │                      │
       ▼                      ▼
┌─────────────┐      ┌──────────────┐
│   esbuild    │      │ TypeScript   │
│  (代码编译)   │      │ Compiler     │
│  极快!       │      │ (DTS 生成)    │
└─────────────┘      └──────────────┘
       │                      │
       ▼                      ▼
  index.mjs              index.d.ts
  index.cjs              index.d.mts
                         index.d.cts

基本使用:

bash
npm install tsup -D
typescript
import { defineConfig } from 'tsup'

export default defineConfig({
  entry: ['src/index.ts', 'src/utils.ts'],
  format: ['cjs', 'esm'],
  dts: true,
  splitting: true,
  clean: true,
  minify: true,
  sourcemap: true,
  target: 'es2020',
  outDir: 'dist',
})
json
{
  "scripts": {
    "build": "tsup"
  }
}

tsup 的多入口配置:

typescript
import { defineConfig } from 'tsup'

export default defineConfig({
  entry: {
    index: 'src/index.ts',
    utils: 'src/utils.ts',
    'react/index': 'src/react/index.ts',
  },
  format: ['cjs', 'esm'],
  dts: true,
  external: ['react', 'react-dom'],
})

3.2 unbuild

unbuild 是由 unjs 团队维护的构建工具,底层基于 Rollup 和 mkdist。它的特色是"自动推断"——可以从 package.json 的 main / module / exports 字段自动推断构建配置。

unbuild 架构:

package.json (main / module / exports)


  自动推断入口和输出格式

       ├──────────────────────┐
       │                      │
       ▼                      ▼
┌─────────────┐      ┌──────────────┐
│   Rollup     │      │   mkdist     │
│  (打包模式)   │      │ (文件转译模式) │
│  bundled     │      │ passthrough  │
└─────────────┘      └──────────────┘
       │                      │
       ▼                      ▼
  单文件 bundle          保持目录结构
  index.mjs              src/a.ts → dist/a.mjs
  index.cjs              src/b.ts → dist/b.mjs

基本使用:

bash
npm install unbuild -D
typescript
import { defineBuildConfig } from 'unbuild'

export default defineBuildConfig({
  entries: ['src/index'],
  declaration: true,
  clean: true,
  rollup: {
    emitCJS: true,
    inlineDependencies: true,
  },
})

unbuild 的 Stub 模式是一个独特特性,它在开发时生成一个指向源文件的代理模块,无需每次修改后重新构建:

bash
unbuild --stub

生成的 stub 文件(dist/index.mjs)内容类似:

javascript
import jiti from "jiti"
export default jiti(null, { interopDefault: true })("/path/to/src/index.ts")

3.3 Rollup

Rollup 是 JavaScript 模块打包器的先驱,也是 Vite 生产构建的底层引擎。它对 ESM 有原生支持,产出的代码干净整洁,是库打包的经典选择。

javascript
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import typescript from '@rollup/plugin-typescript'
import terser from '@rollup/plugin-terser'

export default {
  input: 'src/index.ts',
  output: [
    {
      file: 'dist/index.mjs',
      format: 'es',
      sourcemap: true,
    },
    {
      file: 'dist/index.cjs',
      format: 'cjs',
      sourcemap: true,
    },
  ],
  plugins: [
    resolve(),
    commonjs(),
    typescript({ tsconfig: './tsconfig.json' }),
    terser(),
  ],
  external: ['react', 'react-dom'],
}

3.4 三者对比与选型建议

特性tsupunbuildRollup
底层引擎esbuildRollup + mkdistRollup
构建速度⚡ 极快🔥 快🐢 一般
零配置✅ 支持✅ 支持(自动推断)❌ 需手动配置
DTS 生成✅ 内置✅ 内置🔌 需插件
多入口
多格式输出✅ CJS/ESM/IIFE✅ CJS/ESM✅ CJS/ESM/UMD/IIFE
Stub 模式✅ 独有
插件生态esbuild 插件Rollup 插件Rollup 插件(最丰富)
代码分割
CSS 处理✅ 内置🔌 需配置🔌 需插件
配置灵活度⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
适用场景中小型 TS 库unjs 生态/Nuxt 模块大型库/需要极致控制

选型建议:

你在写什么?

     ├── 中小型 TypeScript 工具库 → tsup(最省心)

     ├── Nuxt 模块 / unjs 生态 → unbuild(生态契合)

     ├── 大型库 / 需要 UMD 格式 → Rollup(最灵活)

     └── 对构建速度极致要求 → tsup(esbuild 加持)

四、发布工作流

4.1 语义化版本(SemVer)

SemVer(Semantic Versioning)是 npm 生态的版本规范基石,格式为 MAJOR.MINOR.PATCH

版本号格式:MAJOR.MINOR.PATCH

   2  .  3  .  1
   │     │     │
   │     │     └── PATCH:向后兼容的 Bug 修复
   │     │
   │     └──── MINOR:向后兼容的功能新增

   └────── MAJOR:不兼容的 API 变更(Breaking Change)

npm 中常见的版本范围语法:

语法含义示例
^1.2.3兼容 MAJOR>=1.2.3 <2.0.0
~1.2.3兼容 MINOR>=1.2.3 <1.3.0
1.2.3精确版本只匹配 1.2.3
*任意版本匹配所有版本
>=1.2.3大于等于>=1.2.3
1.2.x匹配 PATCH>=1.2.0 <1.3.0

npm version 命令

bash
npm version patch
npm version minor
npm version major
npm version prepatch --preid=alpha
npm version prerelease --preid=beta

npm version 会自动完成三件事:

  1. 修改 package.json 中的 version 字段
  2. 执行 git commit(提交版本变更)
  3. 执行 git tag(创建版本标签)
npm version patch 的执行流程:

package.json: "version": "1.2.3"

       ▼  npm version patch

  修改 package.json: "version": "1.2.4"


  git add package.json


  git commit -m "1.2.4"


  git tag v1.2.4


  完成!可以执行 npm publish

4.2 预发布版本

预发布版本用于在正式发布前进行测试和收集反馈:

版本发布生命周期:

alpha → beta → rc → stable

  α 内部测试       β 公开测试      RC 候选发布      正式发布
  功能不完整       功能基本完整     功能冻结          稳定可用
  Bug 较多         修复主要 Bug    最终验证          生产环境

示例版本线:
1.0.0-alpha.1
1.0.0-alpha.2
1.0.0-beta.1
1.0.0-beta.2
1.0.0-rc.1
1.0.0-rc.2
1.0.0        ← 正式版

发布预发布版本的命令:

bash
npm version 1.0.0-alpha.1
npm publish --tag alpha

npm version 1.0.0-beta.1
npm publish --tag beta

npm version 1.0.0-rc.1
npm publish --tag rc

4.3 Tag 管理

npm 的 dist-tag 机制允许你为不同的发布渠道打标签:

dist-tag 与 npm install 的关系:

npm install my-lib           → 安装 latest 标签对应的版本
npm install my-lib@next      → 安装 next 标签对应的版本
npm install my-lib@canary    → 安装 canary 标签对应的版本
npm install my-lib@1.0.0     → 安装精确版本
Tag用途示例
latest默认标签,稳定版npm publish(默认)
next下一个大版本预览npm publish --tag next
canary每日/每次提交自动构建CI 自动发布
alpha / beta / rc预发布测试npm publish --tag beta

管理 dist-tag 的命令:

bash
npm dist-tag ls my-lib

npm dist-tag add my-lib@2.0.0-beta.1 next

npm dist-tag rm my-lib next

重要:如果你发布预发布版本时忘了加 --tag,它会被标记为 latest,所有用户执行 npm install 时都会安装到这个不稳定版本!这是非常常见的发布事故。

4.4 发布检查清单

一个规范的发布流程应包含以下步骤:

发布检查清单流程:

 ① 代码检查
    │  npm run lint
    │  npm run typecheck


 ② 运行测试
    │  npm run test
    │  确保所有用例通过


 ③ 构建产物
    │  npm run build
    │  检查 dist/ 目录


 ④ 检查包内容
    │  npm pack --dry-run
    │  npx publint
    │  npx are-the-types-wrong


 ⑤ 更新版本
    │  npm version patch/minor/major


 ⑥ 更新 CHANGELOG
    │  记录变更内容


 ⑦ 发布
    │  npm publish


 ⑧ 推送 Git
    │  git push --follow-tags


 ⑨ 验证发布
    npm info my-lib
    npm install my-lib@latest (在新项目中测试)

可以编写一个发布脚本来自动化这个流程:

json
{
  "scripts": {
    "prepublishOnly": "npm run lint && npm run test && npm run build",
    "preversion": "npm run lint && npm run test",
    "version": "npm run build && git add -A",
    "postversion": "git push --follow-tags"
  }
}

4.5 自动化发布

方案一:GitHub Actions + npm publish

yaml
name: Publish
on:
  push:
    tags:
      - 'v*'

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'

      - run: npm ci
      - run: npm run lint
      - run: npm run test
      - run: npm run build
      - run: npm publish --provenance --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GitHub Actions 自动发布流程:

开发者本地                     GitHub                      npm Registry
   │                            │                             │
   │  git tag v1.2.3            │                             │
   │  git push --tags           │                             │
   │ ─────────────────────►     │                             │
   │                     触发 Actions                          │
   │                            │                             │
   │                     ┌──────┴──────┐                      │
   │                     │  npm ci      │                      │
   │                     │  npm test    │                      │
   │                     │  npm build   │                      │
   │                     │  npm publish │ ──────────────────►  │
   │                     └──────┬──────┘                      │
   │                            │                        包发布成功
   │                     Actions 完成                          │
   │                            │                             │

方案二:Changesets + CI

Changesets 是由 Atlassian 维护的版本管理和发布工具,特别适合 monorepo 项目。

bash
npm install @changesets/cli -D
npx changeset init

Changesets 的工作流:

Changesets 工作流:

 ① 开发者在 PR 中添加 changeset
    │  npx changeset
    │  选择要发布的包
    │  选择版本类型(patch/minor/major)
    │  填写变更描述

    ▼  生成 .changeset/xxx.md

 ② PR 合并到 main

 ③ CI 自动执行 changeset version
    │  消费所有 changeset 文件
    │  更新 package.json 版本号
    │  更新 CHANGELOG.md

    ▼  创建 Release PR

 ④ 合并 Release PR

 ⑤ CI 自动执行 changeset publish
    │  npm publish
    │  创建 Git Tag

    ▼  发布完成

GitHub Actions 配置 Changesets:

yaml
name: Release
on:
  push:
    branches:
      - main

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'

      - run: npm ci
      - run: npm run build

      - uses: changesets/action@v1
        with:
          publish: npx changeset publish
          version: npx changeset version
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

changeset 文件示例(.changeset/happy-lion.md):

markdown
---
"@myorg/utils": minor
"@myorg/core": patch
---

Added new date formatting utilities and fixed a bug in string helpers.

五、私有包与 Scope

5.1 @scope/package-name 命名规范

Scoped 包是 npm 的命名空间机制,通过 @scope/ 前缀将包组织在同一个命名空间下:

Scoped 包 vs 非 Scoped 包:

非 Scoped 包:
  lodash              ← 全局唯一名称
  react               ← 先到先得
  utils               ← 常见名称难以获取

Scoped 包:
  @babel/core          ← 属于 babel 组织
  @vue/compiler-sfc    ← 属于 vue 组织
  @mycompany/utils     ← 属于 mycompany 组织

Scoped 包的特性:

特性非 Scoped 包Scoped 包
默认可见性publicprivate(需付费或 --access public
命名冲突容易冲突仅在 scope 内唯一
安装方式npm i lodashnpm i @scope/pkg
发布命令npm publishnpm publish --access public
目录结构node_modules/lodashnode_modules/@scope/pkg

5.2 私有 npm Registry

在企业环境中,通常需要搭建私有 Registry 来托管内部包。

Verdaccio

Verdaccio 是最流行的开源私有 npm Registry,支持缓存公共 npm 包:

Verdaccio 代理架构:

┌────────────┐    npm install    ┌──────────────┐
│  开发者      │ ──────────────► │  Verdaccio   │
│  npm client │                  │  私有 Registry │
└────────────┘                   └──────┬───────┘

                    ┌───────────────────┤
                    │                   │
                    ▼                   ▼
           ┌──────────────┐    ┌──────────────┐
           │ 私有包存储     │    │ npm 公共      │
           │ @company/xxx │    │ Registry 代理  │
           │ 本地磁盘/数据库 │    │ 缓存公共包    │
           └──────────────┘    └──────────────┘
bash
npm install -g verdaccio
verdaccio
yaml
uplinks:
  npmjs:
    url: https://registry.npmjs.org/

packages:
  '@company/*':
    access: $authenticated
    publish: $authenticated
    unpublish: $authenticated

  '**':
    access: $all
    proxy: npmjs

GitHub Packages

json
{
  "name": "@myorg/my-package",
  "publishConfig": {
    "registry": "https://npm.pkg.github.com"
  }
}

npm Organizations

npm 官方提供的 Organization 功能支持团队级别的私有包管理:

npm Organizations 权限模型:

Organization: @mycompany

     ├── Team: developers
     │    ├── read: @mycompany/*
     │    └── write: @mycompany/utils, @mycompany/core

     ├── Team: admins
     │    └── admin: @mycompany/*

     └── Team: readonly
          └── read: @mycompany/shared-types

5.3 .npmrc 配置私有源

.npmrc 是 npm 的运行时配置文件,可以配置在不同层级:

.npmrc 配置层级(优先级从高到低):

项目级别:   /project/.npmrc          ← 最高优先级
用户级别:   ~/.npmrc
全局级别:   $PREFIX/etc/npmrc
内置级别:   /path/to/npm/npmrc       ← 最低优先级

常见的 .npmrc 配置:

ini
registry=https://registry.npmmirror.com/

@mycompany:registry=https://npm.mycompany.com/
//npm.mycompany.com/:_authToken=${NPM_TOKEN}

@myorg:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
Scoped Registry 路由:

npm install lodash
  → 使用默认 registry → https://registry.npmmirror.com/

npm install @mycompany/utils
  → 匹配 @mycompany scope → https://npm.mycompany.com/

npm install @myorg/core
  → 匹配 @myorg scope → https://npm.pkg.github.com/

六、npm 包开发最佳实践

6.1 README 规范

一个优秀的 npm 包 README 应包含以下部分:

README 结构模板:

# 包名

一句话描述这个包做什么。

## 特性
- 特性 1
- 特性 2

## 安装
npm install / yarn add / pnpm add

## 快速开始
最简单的使用示例

## API 文档
详细的 API 说明

## 配置选项
可配置参数说明

## 常见问题
FAQ

## 贡献指南
如何参与开发

## License
开源协议

6.2 CHANGELOG 自动生成

手动维护 CHANGELOG 容易遗漏且效率低下,推荐使用工具自动生成。

Conventional Commits 规范

提交信息格式:

<type>(<scope>): <description>

[optional body]

[optional footer(s)]

type 类型:
  feat:     新功能
  fix:      Bug 修复
  docs:     文档变更
  style:    代码格式(不影响逻辑)
  refactor: 重构
  perf:     性能优化
  test:     测试
  chore:    构建/工具变更

示例:
  feat(auth): add OAuth2 login support
  fix(api): handle null response correctly
  feat!: remove deprecated v1 API    ← ! 表示 Breaking Change

conventional-changelog

bash
npm install conventional-changelog-cli -D
json
{
  "scripts": {
    "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s"
  }
}

Changesets

Changesets 在版本管理的同时自动生成 CHANGELOG,特别适合 monorepo:

bash
npx changeset
npx changeset version

执行 changeset version 后,会自动在每个包目录下生成或更新 CHANGELOG.md

6.3 包体积优化

npm 包的体积直接影响用户的安装速度和应用的打包大小。

Tree Shaking 支持

Tree Shaking 是指打包工具在构建时移除未使用的代码。要让你的包支持 Tree Shaking,需要满足以下条件:

Tree Shaking 生效条件:

 ① 提供 ESM 格式产物
    │  module: "./dist/index.mjs"
    │  或 exports.import

 ② 声明 sideEffects
    │  "sideEffects": false
    │  或指定有副作用的文件
    │  "sideEffects": ["./dist/styles.css"]

 ③ 避免副作用代码
    │  不要在模块顶层执行有副作用的操作
    │  例如:修改全局变量、发起请求等

 ④ 使用命名导出
    │  export { foo, bar }
    │  而不是 export default { foo, bar }
sideEffects 对 Tree Shaking 的影响:

用户代码:
  import { Button } from 'my-ui-lib'

有 sideEffects: false 时:
┌────────────────────┐
│ my-ui-lib          │
│ ├── Button ✅ 保留  │
│ ├── Input  ❌ 移除  │  ← 未使用,被 tree-shake
│ ├── Modal  ❌ 移除  │  ← 未使用,被 tree-shake
│ └── Table  ❌ 移除  │  ← 未使用,被 tree-shake
└────────────────────┘

没有 sideEffects 声明时:
┌────────────────────┐
│ my-ui-lib          │
│ ├── Button ✅ 保留  │
│ ├── Input  ⚠️ 保留  │  ← 打包工具不确定是否有副作用
│ ├── Modal  ⚠️ 保留  │  ← 为安全起见全部保留
│ └── Table  ⚠️ 保留  │  ← 导致包体积膨胀
└────────────────────┘

按需导入设计

对于组件库等大型包,推荐通过子路径导出实现按需导入:

json
{
  "exports": {
    ".": {
      "import": "./dist/index.mjs"
    },
    "./button": {
      "import": "./dist/button.mjs"
    },
    "./input": {
      "import": "./dist/input.mjs"
    }
  }
}
typescript
import { Button } from 'my-ui-lib/button'

6.4 测试:Vitest 单元测试

发布前的测试是保证包质量的关键环节。Vitest 是当前最推荐的测试框架,与 Vite 生态无缝集成。

bash
npm install vitest -D
typescript
import { describe, it, expect } from 'vitest'
import { formatDate, parseDate } from '../src/index'

describe('formatDate', () => {
  it('should format date with default pattern', () => {
    const date = new Date('2024-01-15')
    expect(formatDate(date)).toBe('2024-01-15')
  })

  it('should format date with custom pattern', () => {
    const date = new Date('2024-01-15')
    expect(formatDate(date, 'MM/DD/YYYY')).toBe('01/15/2024')
  })

  it('should throw on invalid date', () => {
    expect(() => formatDate(new Date('invalid'))).toThrow()
  })
})

describe('parseDate', () => {
  it('should parse ISO string', () => {
    const result = parseDate('2024-01-15')
    expect(result.getFullYear()).toBe(2024)
    expect(result.getMonth()).toBe(0)
    expect(result.getDate()).toBe(15)
  })
})
json
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}

发布前建议配合 CI 运行测试覆盖率检查,确保核心功能都有测试覆盖。

6.5 文档:API 文档生成

TypeDoc

TypeDoc 可以从 TypeScript 源码中的 JSDoc 注释和类型信息自动生成 API 文档:

bash
npm install typedoc -D
json
{
  "scripts": {
    "docs": "typedoc src/index.ts --out docs"
  }
}

TypeDoc 配置文件(typedoc.json):

json
{
  "entryPoints": ["src/index.ts"],
  "out": "docs",
  "name": "My Library",
  "includeVersion": true,
  "excludePrivate": true,
  "excludeInternal": true
}

七、面试高频问题

7.1 npm publish 发布一个包的完整流程是什么?

回答思路:从准备阶段到发布后验证,展示完整的工程化意识。

完整流程包括:

  1. 开发阶段:编写源码(TypeScript),配置 tsconfig.json,编写测试用例
  2. 配置 package.json:设置 name、version、main、module、types、exports、files、sideEffects 等字段
  3. 构建:使用 tsup/unbuild/Rollup 构建出 CJS + ESM 双格式产物以及类型声明文件
  4. 检查npm pack --dry-run 确认打包内容,npx publint 检查 package.json 配置,npx are-the-types-wrong 检查类型声明
  5. 测试:运行完整测试套件确保所有用例通过
  6. 更新版本npm version patch/minor/major
  7. 发布npm publish(或 npm publish --access public 对于 scoped 包)
  8. 推送git push --follow-tags
  9. 验证:在一个干净的项目中 npm install 安装发布的包,验证功能正常

7.2 package.json 中 main、module、exports 的区别和优先级是什么?

回答思路:从历史演进角度说明三者的关系,并明确优先级规则。

  • main:最早的模块入口字段,Node.js 原生支持。最初只用于指向 CJS 入口。
  • module:社区约定(由 Rollup 提出),用于指向 ESM 入口。Node.js 不识别此字段,仅打包工具(Webpack/Rollup/Vite)使用。
  • exports:Node.js 12.7.0 正式引入的官方标准,支持条件导出(import/require/types)和子路径映射。

优先级:当 exports 存在时,mainmodule 被完全忽略。Node.js 解析优先级为 exports > main,打包工具解析优先级为 exports > module > main

建议三者都配置以兼容不同的消费环境。

7.3 什么是 Dual Package Hazard?如何解决?

回答思路:解释问题本质,给出具体的解决方案。

Dual Package Hazard 是指当一个包同时提供 CJS 和 ESM 两种格式时,可能在同一个应用中被加载两次(一次通过 require,一次通过 import),产生两个独立的模块实例。这会导致单例模式失效、instanceof 检查失败、状态不共享等问题。

解决方案:

  1. ESM Wrapper 模式:ESM 入口文件不包含逻辑,只是对 CJS 模块的重导出,确保底层只有一份代码。
  2. 状态隔离:将共享状态放在 CJS 模块中,ESM 只是引用它。
  3. 只发布 ESM:如果目标用户群体都支持 ESM,可以只发布 ESM 格式。

7.4 如何让你的 npm 包支持 Tree Shaking?

回答思路:从打包工具的视角解释 Tree Shaking 的前提条件。

四个关键条件:

  1. 提供 ESM 格式产物:Tree Shaking 依赖于 ESM 的静态分析特性(import/export 在编译时确定),CJS 的 require 是动态的,无法静态分析。
  2. 声明 sideEffects 字段:在 package.json 中设置 "sideEffects": false,告诉打包工具所有模块都是纯净的、可以安全移除。如果有 CSS 等副作用文件,使用数组指定。
  3. 使用命名导出export { foo, bar }export default obj 更容易被 Tree Shaking,因为打包工具可以精确追踪哪些命名导出被使用。
  4. 避免模块顶层副作用:不要在模块顶层执行改变全局状态的操作。

7.5 npm 的 dist-tag 是什么?为什么预发布版本一定要指定 tag?

回答思路:从事故场景出发,说明 tag 的作用和重要性。

dist-tag 是 npm 的版本标签系统,默认标签是 latest。当用户执行 npm install pkg 时,实际安装的是 latest 标签对应的版本。

如果发布 2.0.0-beta.1 时忘记指定 --tag beta,那么 latest 标签会指向这个 beta 版本,所有执行 npm install pkg 的用户都会安装到不稳定的 beta 版本——这是一个典型的发布事故。

因此预发布版本必须使用 npm publish --tag beta(或 alpha/rc/next),确保 latest 标签始终指向稳定版本。

7.6 Changesets 和 npm version 有什么区别?Changesets 适合什么场景?

回答思路:对比两者的工作模式,突出 Changesets 在 monorepo 中的优势。

npm version 是 npm 内置的版本管理命令,适合单包项目。它直接修改 version 字段、创建 git commit 和 tag,是一步到位的操作。

Changesets 是一个独立的版本管理工具,核心理念是"intent-based versioning"(基于意图的版本管理)。开发者在 PR 中通过 npx changeset 声明"这个 PR 对哪些包做了什么级别的变更",这些意图被记录为 .changeset/*.md 文件。在发布时,Changesets 会聚合所有 changeset 文件,自动计算最终版本号、更新 CHANGELOG、发布到 npm。

Changesets 特别适合 monorepo 场景——当一个 PR 同时修改了多个包时,Changesets 可以精确地管理每个包的版本变更和依赖关系。

7.7 如何搭建企业内部的私有 npm Registry?

回答思路:给出方案选型和核心配置。

常用方案:

  1. Verdaccio:开源、轻量、部署简单,支持代理公共 npm Registry(miss cache 时自动从上游拉取),适合中小团队。
  2. GitHub Packages:与 GitHub 深度集成,适合使用 GitHub 作为代码仓库的团队。
  3. npm Organizations:npm 官方付费服务,提供团队管理和私有包功能。
  4. Artifactory / Nexus:企业级制品管理平台,支持 npm 在内的多种包格式。

配置方式:在项目的 .npmrc 中使用 scope 路由,将私有 scope 指向私有 Registry,其他包走公共 Registry。

7.8 npx publint 和 npx are-the-types-wrong 分别检查什么?

回答思路:说明两个工具的检查维度和使用场景。

publint 检查 package.json 的发布配置是否正确,包括:

  • exports 字段的条件顺序是否正确
  • main/module/types 指向的文件是否存在
  • ESM/CJS 格式是否正确
  • 文件扩展名是否与格式匹配

are-the-types-wrong(简称 attw)专门检查类型声明文件在不同 moduleResolution 模式下是否能正确解析,包括:

  • nodenode16bundler 模式下类型是否可找到
  • CJS 和 ESM 入口是否都有对应的类型声明
  • 类型声明的导出是否与运行时导出一致

两者配合使用可以在发布前发现绝大部分配置错误。


八、延伸阅读

用心学习,用代码说话 💻