从一个空文件夹开始,制作并发布自己的第一个公开 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。
先执行:
node -v
npm -v只要两条命令都能返回版本号,就可以继续。截图里的版本是 Node.js v22.23.1 和 npm 10.9.8,你的数字不同很正常。
如果提示“找不到命令”,先安装 Node.js,再重新打开终端;旧终端可能还没有读到新的环境变量。
接着登录 npm 网站,按 npm 官方 2FA 指南 为账户启用双重验证。
为什么一篇入门教程开局就提 2FA?因为你马上要获得“向公共仓库发布代码”的权限。别人一旦依赖你的包,发布权限就不再只是自己的小事。
然后回到终端:
npm login浏览器会打开 npm 的授权页面。完成验证后回到终端,再执行:
npm whoami预期看到自己的 npm 用户名。实验中得到的是:
ppshux成功标准: npm whoami 不再报 ENEEDAUTH,而是返回你的用户名。
2. 从空文件夹做出第一个 package
这一步有什么用?
package.json 不是某种神秘配置,它只是用一份结构化信息告诉 npm:“我是谁、版本多少、入口在哪、准备发布哪些文件。”
创建并进入目录:
mkdir tiny-text-utils
cd tiny-text-utils用自己的 npm 用户名初始化 scoped package。下面仍以截图中的 ppshux 为例:
npm init --scope=@ppshuxnpm 会依次询问包名、版本、描述、入口、作者和许可证。第一次跟做可以这样填:
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 语法。
npm pkg set type=module此时 package.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,其中的 name、version、main 和 type 与上面一致。
3. 写三个小函数,先在本地跑通
这一步有什么用?
发布只是搬运。代码在本地都跑不通,传到 Registry 也不会突然变聪明。
新建 index.js,先照着写下面的 1.0.0 版本:
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:
import { capitalize, countWords, truncate } from "./index.js";
console.log(capitalize("hello"));
console.log(countWords("hello npm world"));
console.log(truncate("hello npm world", 8));运行:
node demo.js预期得到:
Hello
3
hello...成功标准: 三行输出完全一致。若出现 Cannot use import statement outside a module,检查 package.json 是否有 "type": "module"。
4. 发布前先让 npm “假装打包”
这一步有什么用?
npm publish 之前先执行 dry-run,就像寄快递前打开箱子看一眼:别把本地测试文件、密钥或无关大文件顺手寄出去了。
npm pack --dry-run第一次预演里,demo.js、index.js 和 package.json 都准备进入 tarball。
demo.js 只用于本地验证,使用者不需要它。给 package.json 加一份发布白名单:
npm pkg set "files[0]=index.js"再次执行:
npm pack --dry-run这次应该只剩 index.js 和 package.json。
成功标准: Tarball Contents 中没有 .env、账号凭证和本地演示文件。
真正公开维护的包还应该补上README.md和与license一致的许可证文件。本篇先把发布主线跑通,但不要把“最小能发”误当成“生产级完成”。npm 的 公开包发布指南 也建议准备 README。
5. 第一次真正 npm publish
这一步有什么用?
这一步会把当前版本真实写入公共 npm Registry。它不是演示命令,所以先确认包名属于自己的 scope、内容检查无误、账户 2FA 可用。
对于 scoped public package,执行:
npm publish --access public浏览器可能再次要求授权。成功时终端最后会出现:
+ @ppshux/tiny-text-utils@1.0.0成功标准: 末尾出现 + <你的完整包名>@1.0.0,并且能在 npm 网站的 Packages 页面找到它。
如果报 EPUBLISHCONFLICT,通常是这个包名和版本已经存在。已经发布的版本号不能拿来覆盖重发;先确认包名是否属于你,再决定发布新版本。具体行为以 `npm publish` 官方文档 为准。
6. 换一个全新项目,像普通用户一样安装
这一步有什么用?
“发布成功”只证明 Registry 收到了文件。真正的闭环是:离开原目录,在另一个项目里也能安装和调用。
回到上一级目录,新建使用者项目:
cd ..
mkdir try-tiny-text-utils
cd try-tiny-text-utils
npm init -y
npm pkg set type=module安装刚发布的包:
npm install @ppshux/tiny-text-utils新建 app.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));运行:
node app.js预期输出:
Hello npm
3
hello...这时目录里会多出三个熟面孔:
node_modules/:实际安装下来的包代码。package.json:当前使用者项目是谁,以及它依赖什么。package-lock.json:把这次实际解析到的依赖版本记录得更具体。
成功标准: app.js 能通过完整包名导入函数,而不是通过 ./index.js 读取原项目文件。
7. 故意碰一下边界,看看 1.0.0 的 Bug
这一步有什么用?
版本号不是装饰。一个已经有人能安装的包,发现问题后不能悄悄覆盖旧文件,只能发布一个可追踪的新版本。
在使用者项目的 app.js 末尾加一行:
console.log(truncate("hello", 2));再次运行:
node app.js我们希望最多保留两个字符,结果却得到:
hell...原因在这里:
text.slice(0, maxLength - 3)当 maxLength 是 2 时,结束位置变成 -1,JavaScript 会从末尾倒着计算,于是切出了 hell,再拼上 ...。
回到发布者项目,把 truncate 改成:
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。
在发布者项目执行:
npm version patch版本会从 1.0.0 变成 1.0.1。确认代码和 dry-run 后再次发布:
npm pack --dry-run
npm publish --access public再回到使用者项目:
npm update @ppshux/tiny-text-utils最后重新运行:
node app.js现在第四行应该是:
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 包到底是什么?
现在可以把整条路线串起来了:
空文件夹
↓ npm init
package.json + JavaScript 模块
↓ npm pack --dry-run
待发布的 tarball
↓ npm publish
npm Registry 中的不可覆盖版本
↓ npm install
另一个项目的 node_modules
↓ npm update
使用者获得新的补丁版本npm install 不再只是一个黑盒命令。你已经亲手做过它的另一端:创建包、描述包、检查包、发布包,再作为使用者把它装回来。
本篇示例源码位于仓库的 examples/npm-package-from-zero/。其中 index-v1.0.0.js 保留实验中的边界 Bug,index.js 是修复后的版本,方便逐行对照。
最后再提醒三件真实发布时不能省的事:
- 包名、scope、作者和许可证换成自己的真实信息。
- 发布前反复检查 dry-run,绝不把密钥和内部文件打进包。
- 为真正给别人使用的包补 README、测试、变更记录和长期维护计划。
做到这里,你不只是“会用 npm”了。至少下一次看到 npm install,脑子里已经能浮出它完整的旅行路线。