今天的唯一目标

从一个空文件夹开始,制作并发布自己的第一个公开 npm 包,再换一个全新项目安装、复现 Bug、发布补丁并完成最终验收。

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

我们几乎每天都在敲 npm install

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

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

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

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

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

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

这一步有什么用?

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

你需要:

  • Node.js 官方下载页:安装 Node.js 时会一起得到 npm。
  • 一个终端:Windows 可以用 PowerShell、Windows Terminal 或 Git Bash。本文截图来自 Git Bash,命令在 PowerShell 里同样可用。
  • 一个编辑器:VS Code 足够;用其他能编辑纯文本的工具也可以。
  • npm 账号:用户名稍后会成为包名里的 scope。

先执行:

bash可复制后修改
node -v
npm -v

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

Node.js 和 npm 都能返回版本号,说明基础环境可用

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

接着登录 npm 网站,按 npm 官方 2FA 指南 为账户启用双重验证。

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

npm 账户已经为授权与发布启用双重验证

然后回到终端:

bash可复制后修改
npm login

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

bash可复制后修改
npm whoami

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

text可复制后修改
ppshux
npm login 完成后,终端确认已经登录 Registry

成功标准: npm whoami 不再报 ENEEDAUTH,而是返回你的用户名。

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

这一步有什么用?

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

创建并进入目录:

bash可复制后修改
mkdir tiny-text-utils
cd tiny-text-utils
新建 tiny-text-utils 并进入空目录

用自己的 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 文档 把这种完整名字写成 @scope/package

成功标准: 当前目录出现 package.json,其中的 nameversionmaintype 与上面一致。

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...
三个文本函数在本地得到预期输出

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

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

这一步有什么用?

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

bash可复制后修改
npm pack --dry-run

第一次预演里,demo.jsindex.jspackage.json 都准备进入 tarball。

第一次 dry-run 发现本地演示文件 demo.js 也会进入包

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

bash可复制后修改
npm pkg set "files[0]=index.js"

再次执行:

bash可复制后修改
npm pack --dry-run

这次应该只剩 index.jspackage.json

加入 files 白名单后,待发布内容只剩 index.js 和 package.json

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

真正公开维护的包还应该补上 README.md 和与 license 一致的许可证文件。本篇先把发布主线跑通,但不要把“最小能发”误当成“生产级完成”。npm 的 公开包发布指南 也建议准备 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

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

如果报 EPUBLISHCONFLICT,通常是这个包名和版本已经存在。已经发布的版本号不能拿来覆盖重发;先确认包名是否属于你,再决定发布新版本。具体行为以 `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

新建 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 到达使用者项目

成功标准: 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

原因在这里:

js可复制后修改
text.slice(0, maxLength - 3)

maxLength2 时,结束位置变成 -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,并成功发布补丁

再回到使用者项目:

bash可复制后修改
npm update @ppshux/tiny-text-utils
使用者项目更新到补丁版本

最后重新运行:

bash可复制后修改
node app.js

现在第四行应该是:

text可复制后修改
he
更新后重新运行,边界输入正确得到 he

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

  • 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 说明。先有这次真实升级的手感,再看规则会轻松很多。

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 闭环完成

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

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

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

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