# 你天天 npm install，但知道一个 npm 包是怎么发出来的吗？

> 从空文件夹、`npm publish`，到另一个项目真正安装：亲手跑通第一次 npm 包闭环。

我们几乎每天都在敲 `npm install`。

可我突然发现，自己其实没认真想过：一个普通的 JavaScript 文件夹，究竟从哪一步开始变成了“可以被别人安装的 npm 包”？

所以这次不背概念，也不搭复杂脚手架。我们只做一件事：**从空文件夹开始，发布一个最小工具包，再换一个全新项目把它装回来。**

![从空文件夹到换项目安装：本篇只跑一条 npm 发布主线](images/01-article-guide.png)<!-- display-width:500 -->

**预计用时：** 核心路线约 40 分钟，不含注册账号和等待浏览器验证的时间。

**通关标志：** 新项目可以执行 `npm install <你的包名>`，运行后得到预期输出；修复 Bug 后还能更新到补丁版本。

> 截图中的 npm 用户名是 `ppshux`。你跟做时必须换成自己的用户名和包名，不要原样复制 `@ppshux/tiny-text-utils` 去发布——那个名字已经被占用了。

## 1. 先把工具和发布身份准备好

### 这一步有什么用？

先分清两个问题：电脑能不能运行 npm，以及 npm 知不知道“你是谁”。前者靠 Node.js，后者靠 npm 账户登录。

你需要：

- [Node.js 官方下载页](https://nodejs.org/en/download)：安装 Node.js 时会一起得到 npm。
- 一个终端：Windows 可以用 PowerShell、Windows Terminal 或 [Git Bash](https://git-scm.com/downloads)。本文截图来自 Git Bash，命令在 PowerShell 里同样可用。
- 一个编辑器：[VS Code](https://code.visualstudio.com/Download) 足够；用其他能编辑纯文本的工具也可以。
- [npm 账号](https://www.npmjs.com/signup)：用户名稍后会成为包名里的 scope。

先执行：

```bash
node -v
npm -v
```

只要两条命令都能返回版本号，就可以继续。截图里的版本是 Node.js `v22.23.1` 和 npm `10.9.8`，你的数字不同很正常。

![Node.js 和 npm 都能返回版本号，说明基础环境可用](images/02-environment-check.png)<!-- display-width:500 -->

如果提示“找不到命令”，先安装 Node.js，再**重新打开**终端；旧终端可能还没有读到新的环境变量。

接着登录 [npm 网站](https://www.npmjs.com/)，按 [npm 官方 2FA 指南](https://docs.npmjs.com/configuring-two-factor-authentication) 为账户启用双重验证。

为什么一篇入门教程开局就提 2FA？因为你马上要获得“向公共仓库发布代码”的权限。别人一旦依赖你的包，发布权限就不再只是自己的小事。

![npm 账户已经为授权与发布启用双重验证](images/03-two-factor-enabled.png)<!-- display-width:360 -->

然后回到终端：

```bash
npm login
```

浏览器会打开 npm 的授权页面。完成验证后回到终端，再执行：

```bash
npm whoami
```

预期看到自己的 npm 用户名。实验中得到的是：

```text
ppshux
```

![npm login 完成后，终端确认已经登录 Registry](images/04-login-success.png)<!-- display-width:580 -->

**成功标准：** `npm whoami` 不再报 `ENEEDAUTH`，而是返回你的用户名。

## 2. 从空文件夹做出第一个 package

### 这一步有什么用？

`package.json` 不是某种神秘配置，它只是用一份结构化信息告诉 npm：**“我是谁、版本多少、入口在哪、准备发布哪些文件。”**

创建并进入目录：

```bash
mkdir tiny-text-utils
cd tiny-text-utils
```

![新建 tiny-text-utils 并进入空目录](images/05-empty-folder.png)<!-- display-width:480 -->

用自己的 npm 用户名初始化 scoped package。下面仍以截图中的 `ppshux` 为例：

```bash
npm init --scope=@ppshux
```

npm 会依次询问包名、版本、描述、入口、作者和许可证。第一次跟做可以这样填：

```text
package name: @ppshux/tiny-text-utils
version: 1.0.0
description: A tiny JavaScript text utility package
entry point: index.js
author: ppshux
license: MIT
```

再告诉 Node.js：这个包里的 `.js` 文件使用 ES Module，也就是后面要写的 `import` / `export` 语法。

```bash
npm pkg set type=module
```

此时 `package.json` 的关键部分应该类似这样：

```json
{
  "name": "@ppshux/tiny-text-utils",
  "version": "1.0.0",
  "description": "A tiny JavaScript text utility package",
  "main": "index.js",
  "type": "module",
  "author": "ppshux",
  "license": "MIT"
}
```

`@ppshux` 是 scope，`tiny-text-utils` 才是 scope 里的包名。npm 官方的 [Scope 文档](https://docs.npmjs.com/using-npm/scope.html) 把这种完整名字写成 `@scope/package`。

**成功标准：** 当前目录出现 `package.json`，其中的 `name`、`version`、`main` 和 `type` 与上面一致。

## 3. 写三个小函数，先在本地跑通

### 这一步有什么用？

发布只是搬运。代码在本地都跑不通，传到 Registry 也不会突然变聪明。

新建 `index.js`，先照着写下面的 1.0.0 版本：

```js
export function capitalize(text) {
  if (text.length === 0) return "";
  return text[0].toUpperCase() + text.slice(1);
}

export function countWords(text) {
  const trimmed = text.trim();
  if (trimmed === "") return 0;
  return trimmed.split(/\s+/).length;
}

export function truncate(text, maxLength) {
  if (text.length <= maxLength) return text;
  return text.slice(0, maxLength - 3) + "...";
}
```

这段代码里保留了实验中真实出现的一个边界问题。先不用猜在哪里，后面我们会像真正维护一个包那样复现它、修复它并发布补丁。

再新建 `demo.js`：

```js
import { capitalize, countWords, truncate } from "./index.js";

console.log(capitalize("hello"));
console.log(countWords("hello npm world"));
console.log(truncate("hello npm world", 8));
```

运行：

```bash
node demo.js
```

预期得到：

```text
Hello
3
hello...
```

![三个文本函数在本地得到预期输出](images/06-local-result.png)<!-- display-width:360 -->

**成功标准：** 三行输出完全一致。若出现 `Cannot use import statement outside a module`，检查 `package.json` 是否有 `"type": "module"`。

## 4. 发布前先让 npm “假装打包”

### 这一步有什么用？

`npm publish` 之前先执行 dry-run，就像寄快递前打开箱子看一眼：别把本地测试文件、密钥或无关大文件顺手寄出去了。

```bash
npm pack --dry-run
```

第一次预演里，`demo.js`、`index.js` 和 `package.json` 都准备进入 tarball。

![第一次 dry-run 发现本地演示文件 demo.js 也会进入包](images/07-pack-before.png)<!-- display-width:540 -->

`demo.js` 只用于本地验证，使用者不需要它。给 `package.json` 加一份发布白名单：

```bash
npm pkg set "files[0]=index.js"
```

再次执行：

```bash
npm pack --dry-run
```

这次应该只剩 `index.js` 和 `package.json`。

![加入 files 白名单后，待发布内容只剩 index.js 和 package.json](images/08-pack-after.png)<!-- display-width:540 -->

**成功标准：** `Tarball Contents` 中没有 `.env`、账号凭证和本地演示文件。

> 真正公开维护的包还应该补上 `README.md` 和与 `license` 一致的许可证文件。本篇先把发布主线跑通，但不要把“最小能发”误当成“生产级完成”。npm 的 [公开包发布指南](https://docs.npmjs.com/creating-and-publishing-scoped-public-packages/) 也建议准备 README。

## 5. 第一次真正 npm publish

### 这一步有什么用？

这一步会把当前版本真实写入公共 npm Registry。它不是演示命令，所以先确认包名属于自己的 scope、内容检查无误、账户 2FA 可用。

对于 scoped public package，执行：

```bash
npm publish --access public
```

浏览器可能再次要求授权。成功时终端最后会出现：

```text
+ @ppshux/tiny-text-utils@1.0.0
```

![npm Registry 接收 @ppshux/tiny-text-utils 1.0.0](images/09-first-publish.png)<!-- display-width:560 -->

**成功标准：** 末尾出现 `+ <你的完整包名>@1.0.0`，并且能在 npm 网站的 Packages 页面找到它。

如果报 `EPUBLISHCONFLICT`，通常是这个包名和版本已经存在。**已经发布的版本号不能拿来覆盖重发**；先确认包名是否属于你，再决定发布新版本。具体行为以 [`npm publish` 官方文档](https://docs.npmjs.com/cli/v11/commands/npm-publish) 为准。

## 6. 换一个全新项目，像普通用户一样安装

### 这一步有什么用？

“发布成功”只证明 Registry 收到了文件。真正的闭环是：离开原目录，在另一个项目里也能安装和调用。

回到上一级目录，新建使用者项目：

```bash
cd ..
mkdir try-tiny-text-utils
cd try-tiny-text-utils
npm init -y
npm pkg set type=module
```

安装刚发布的包：

```bash
npm install @ppshux/tiny-text-utils
```

![在全新项目安装刚刚发布的包，npm 报告 added 1 package](images/10-consumer-install.png)<!-- display-width:480 -->

新建 `app.js`：

```js
import { capitalize, countWords, truncate } from "@ppshux/tiny-text-utils";

console.log(capitalize("hello npm"));
console.log(countWords("hello npm world"));
console.log(truncate("hello npm world", 8));
```

运行：

```bash
node app.js
```

预期输出：

```text
Hello npm
3
hello...
```

这时目录里会多出三个熟面孔：

- `node_modules/`：实际安装下来的包代码。
- `package.json`：当前使用者项目是谁，以及它依赖什么。
- `package-lock.json`：把这次实际解析到的依赖版本记录得更具体。

![npm 包从开发者电脑经过 Registry 到达使用者项目](images/11-package-lifecycle.png)<!-- display-width:500 -->

**成功标准：** `app.js` 能通过完整包名导入函数，而不是通过 `./index.js` 读取原项目文件。

## 7. 故意碰一下边界，看看 1.0.0 的 Bug

### 这一步有什么用？

版本号不是装饰。一个已经有人能安装的包，发现问题后不能悄悄覆盖旧文件，只能发布一个可追踪的新版本。

在使用者项目的 `app.js` 末尾加一行：

```js
console.log(truncate("hello", 2));
```

再次运行：

```bash
node app.js
```

我们希望最多保留两个字符，结果却得到：

```text
hell...
```

![边界输入 truncate("hello", 2) 暴露了 1.0.0 的真实 Bug](images/12-bug-reproduction.png)<!-- display-width:360 -->

原因在这里：

```js
text.slice(0, maxLength - 3)
```

当 `maxLength` 是 `2` 时，结束位置变成 `-1`，JavaScript 会从末尾倒着计算，于是切出了 `hell`，再拼上 `...`。

回到**发布者项目**，把 `truncate` 改成：

```js
export function truncate(text, maxLength) {
  if (text.length <= maxLength) return text;
  if (maxLength <= 3) return text.slice(0, Math.max(0, maxLength));
  return text.slice(0, maxLength - 3) + "...";
}
```

现在 `truncate("hello", 2)` 会得到 `he`。这才与函数名表达的“截断到最大长度”一致。

## 8. 发布 1.0.1，再让使用者更新

### 这一步有什么用？

这次修复没有破坏旧用法，也没有新增大功能，适合升级补丁版本，也就是 `PATCH`。

在发布者项目执行：

```bash
npm version patch
```

版本会从 `1.0.0` 变成 `1.0.1`。确认代码和 dry-run 后再次发布：

```bash
npm pack --dry-run
npm publish --access public
```

![版本升级为 1.0.1，并成功发布补丁](images/13-patch-publish.png)<!-- display-width:560 -->

再回到使用者项目：

```bash
npm update @ppshux/tiny-text-utils
```

![使用者项目更新到补丁版本](images/14-consumer-update.png)<!-- display-width:500 -->

最后重新运行：

```bash
node app.js
```

现在第四行应该是：

```text
he
```

![更新后重新运行，边界输入正确得到 he](images/15-final-result.png)<!-- display-width:360 -->

版本号常写成 `主版本.次版本.补丁版本`：

- `PATCH`：兼容性的 Bug 修复，例如 `1.0.0 → 1.0.1`。
- `MINOR`：向后兼容的新功能，例如 `1.0.1 → 1.1.0`。
- `MAJOR`：可能破坏旧用法的变化，例如 `1.1.0 → 2.0.0`。

可以继续阅读 npm 的 [Semantic Versioning 说明](https://docs.npmjs.com/about-semantic-versioning)。先有这次真实升级的手感，再看规则会轻松很多。

## 9. 回头看：一个 npm 包到底是什么？

现在可以把整条路线串起来了：

```text
空文件夹
  ↓ npm init
package.json + JavaScript 模块
  ↓ npm pack --dry-run
待发布的 tarball
  ↓ npm publish
npm Registry 中的不可覆盖版本
  ↓ npm install
另一个项目的 node_modules
  ↓ npm update
使用者获得新的补丁版本
```

`npm install` 不再只是一个黑盒命令。你已经亲手做过它的另一端：创建包、描述包、检查包、发布包，再作为使用者把它装回来。

![制作、发布、安装和补丁升级：第一次 npm 闭环完成](images/16-article-summary.png)<!-- display-width:500 -->

本篇示例源码位于仓库的 `examples/npm-package-from-zero/`。其中 `index-v1.0.0.js` 保留实验中的边界 Bug，`index.js` 是修复后的版本，方便逐行对照。

最后再提醒三件真实发布时不能省的事：

1. 包名、scope、作者和许可证换成自己的真实信息。
2. 发布前反复检查 dry-run，绝不把密钥和内部文件打进包。
3. 为真正给别人使用的包补 README、测试、变更记录和长期维护计划。

做到这里，你不只是“会用 npm”了。至少下一次看到 `npm install`，脑子里已经能浮出它完整的旅行路线。
