一次真实的头像发布翻车记录:从 npm 包整理、两步验证到 unpkg 主源与 jsDelivr 备用源,顺手整理成可复用的固定图床流程。
我只是想换个头像,怎么最后折腾出了一套 npm 图床?

我原本只想给博客换个头像。
图片已经准备好了,Edge 里也明明登录着 npm。按我的朴素理解,这件事应该和把新头像塞进微信差不多:选图、确认、结束,最多再欣赏两秒自己的审美。
结果我在终端里敲下:
npm whoami
npm 很冷静地回了一个 ENEEDAUTH。
那一刻我才发现,浏览器和命令行虽然住在同一台电脑里,关系却像合租室友:门对门住了半年,谁也不认识谁。更麻烦的是,登录还只是第一道门,真正拦住发布的东西藏在后面。
为什么一个头像要绕到 npm
我之前就有一个放静态资源的 npm 项目,这次只是把站点头像换进去,再发布一个新版本。看起来有点绕,但头像、Logo、友链图标这类文件有几个共同点:
- 文件小,公开也没关系;
- 会被多个页面反复使用;
- 很少更新,但更新后希望旧版本还能找回来;
- 不值得单独搭一套图床后台。
npm 包刚好有版本号,unpkg、jsDelivr 又能直接读取公开包里的文件。对我这种喜欢折腾、又不想为了一个头像再养一套服务的人来说,确实挺合适。
不过先把话说在前面:npm 不是无限容量免费图床。它更适合少量固定资源,不适合相册、用户上传、私密文件和大视频。真把它当网盘猛塞,省下来的那点钱,可能最后会换一种方式找回来。
第一步:先把包收拾干净
如果从零开始,可以先建一个独立目录:
mkdir your-static-assets
cd your-static-assets
npm init -y
我习惯只把最终要公开的文件放进 dist:
your-static-assets/
├─ dist/
│ └─ img/
│ ├─ avatar.jpg
│ └─ logo.webp
└─ package.json
然后把 package.json 控制在够用的范围:
{
"name": "your-unique-static-assets",
"version": "1.0.0",
"description": "Public static assets for a personal website",
"license": "MIT",
"files": [
"dist"
]
}
这里最值得盯住的是 files。它相当于发布白名单,只让 dist 进入 npm 包。
我不太怕发布失败,失败了还能重来;我更怕它成功得过于顺滑,顺手把 .npmrc、环境变量、日志和备份一起送上公网。那种成功,大概属于“手术很顺利,病人走错了”这一类。
正式发布前先跑:
npm pack --dry-run
输出里的 Tarball Contents 就是即将公开的文件。我这次确认里面只有头像、两个原有静态资源和 package.json,才继续往下走。
这一步不要靠感觉。凡是看到下面这些东西,先停:
.npmrc、.env和其他账号配置;- 令牌、Cookie、验证码、两步验证恢复码;
- 不准备公开的原图、设计稿、备份和日志;
- 与这批静态资源没有关系的文件。
图片本身也最好先压缩并清理 EXIF。头像不需要保留相机原始尺寸,更没必要把拍摄位置和设备信息夹带上网。
第二步:浏览器登录,不代表终端登录
回到开头那个 ENEEDAUTH。解决方法并不复杂,使用网页授权重新登录 CLI:
npm login --auth-type=web
终端会给出 npm 官方的一次性地址,按 Enter 后在浏览器里确认。完成后再检查:
npm whoami
能返回自己的用户名,才说明 CLI 真正认识你了。
我当时看到用户名出现,以为这回总算稳了,于是执行:
npm publish --access public
然后 npm 又把门关上了,错误是 E403:发布需要两步验证,或者具备相应权限的细粒度令牌。
这件事后面还有一个小反转。我启用了“授权与发布”两步验证,马上重试,结果还是同一个 E403。从后续重新登录就能正常继续来看,更像是启用两步验证前签发的 CLI 令牌没有同步获得新的发布状态,而不是设置本身没生效。
我最后注销旧会话,再重新走一次网页登录:
npm logout
npm login --auth-type=web
如果只是普通手动发布,我更愿意走网页验证,不在命令、聊天或截图里传令牌。自动化发布可以用最小权限、有限有效期的令牌或可信发布,但那是另一套安全账,不能为了省两次点击就随便欠着。
第三步:网络又来插了一脚
旧令牌处理完,我以为这次终于只剩按按钮。偏偏登录接口连续出现了两次 ECONNRESET。
registry 不是完全不通,npm ping 还能返回,只是慢得像在思考人生。这个时候我没有改代理,也没有乱换 registry,而是给官方源多一点重试和等待时间:
npm login --auth-type=web `
--fetch-retries=5 `
--fetch-retry-mintimeout=1000 `
--fetch-retry-maxtimeout=10000 `
--fetch-timeout=120000
这次网页授权正常回调。重新发布时,npm 又给出一个发布专用验证页;在浏览器里完成两步验证后,终端终于出现了那个朴素的加号:包发布成功。
所以遇到连接重置时,先分清是账号拒绝还是链路抖动:
ENEEDAUTH通常是 CLI 没登录;E403要看权限、两步验证和令牌状态;ECONNRESET更像连接被中途断开,可以先检查npm ping再有限重试。
错误长得都不太友好,但病因不是一回事。把它们混着治,很容易从换头像一路折腾到换系统。
第四步:发布成功还不算结束
发布后先确认 registry 已经看到新版本:
npm view your-unique-static-assets version
然后用固定版本拼出资源地址。假设版本是 1.0.0,文件路径是 dist/img/avatar.jpg,主链接使用 unpkg:
https://unpkg.com/your-unique-static-assets@1.0.0/dist/img/avatar.jpg
备用链接使用 jsDelivr:
https://cdn.jsdelivr.net/npm/your-unique-static-assets@1.0.0/dist/img/avatar.jpg
注意 jsDelivr 的路径里多了一段 /npm/。两个地址都固定到同一版本,不用 latest,也不省略版本号。
我一开始用的是另一个 npm CDN 域名。新版本返回 404 时,我还以为只是同步慢;顺手检查旧版本,居然也是 404。那一刻答案已经很明显了:不是我的新包没传上去,是这条分发链路当前就靠不住。
后来我分别检查 unpkg 和 jsDelivr,两边都返回 200,文件大小和 SHA-256 也一致。于是最终顺序定为:
unpkg 主源 -> jsDelivr 备用源
这里容易漏掉一件事:配置里保存两个 URL,并不等于浏览器会自动回退。真正的备用源还要靠错误监听触发。
第五步:让备用地址真的能接班
HTML 可以这样写:
<img
src="https://unpkg.com/your-unique-static-assets@1.0.0/dist/img/avatar.jpg"
data-fallback-src="https://cdn.jsdelivr.net/npm/your-unique-static-assets@1.0.0/dist/img/avatar.jpg"
width="96"
height="96"
alt="站点头像"
decoding="async"
/>
再在公共布局中统一监听加载失败:
<script type="module">
document.querySelectorAll('img[data-fallback-src]').forEach((image) => {
image.addEventListener('error', () => {
const fallback = image.dataset.fallbackSrc;
if (fallback) image.src = fallback;
}, { once: true });
});
</script>
once: true 是为了避免备用源也失败时反复横跳。这个回退只处理明确的加载错误;如果主源一直卡着不报错,浏览器不会立刻换路。要求严格的场景还得加超时控制,不过对于头像这种非核心资源,我暂时不想把它写成一套小型容灾系统。
Astro 项目里可以把两个地址放到同一份配置:
export const siteAvatar =
'https://unpkg.com/your-unique-static-assets@1.0.0/dist/img/avatar.jpg';
export const siteAvatarFallback =
'https://cdn.jsdelivr.net/npm/your-unique-static-assets@1.0.0/dist/img/avatar.jpg';
<img
src={siteAvatar}
data-fallback-src={siteAvatarFallback}
alt="站点头像"
/>
我把这个回退补到了友链列表、申请友链卡片和首页友链预览。因为只改配置、不检查真实渲染位置,最后往往会得到一种很熟悉的效果:代码看起来支持,页面实际上随缘。
以后换头像,不能覆盖原版本
npm 已发布的版本不能原地覆盖。下一次替换图片,要先增加版本:
npm version patch --no-git-tag-version
npm pack --dry-run
npm publish --access public
例如 1.0.0 会变成 1.0.1。发布完成后,主链接和备用链接必须一起更新:
https://unpkg.com/your-unique-static-assets@1.0.1/dist/img/avatar.jpg
https://cdn.jsdelivr.net/npm/your-unique-static-assets@1.0.1/dist/img/avatar.jpg
只改一个地址,会让故障回退时重新看见旧头像。这个问题平时藏得很好,通常要等主 CDN 真出故障才突然出来打招呼。
固定版本还有一个好处:版本号变化就是新的缓存键,不需要赌各个节点什么时候刷新 latest。新版本有问题时,把站点地址改回上一个版本,也比试图覆盖远端文件简单。
我现在会做的发布检查
折腾完这一轮,我把流程缩成了九步:
- 替换并压缩图片,清理不需要的元数据。
- 使用
npm version patch --no-git-tag-version增加版本。 - 运行
npm pack --dry-run,逐项核对发布内容。 - 用
npm whoami确认 CLI 登录身份。 - 确认两步验证可用,再执行
npm publish --access public。 - 使用
npm view检查 registry 中的新版本。 - 分别请求 unpkg 和 jsDelivr,检查状态码与文件类型。
- 比较本地文件和两个 CDN 文件的 SHA-256。
- 同时更新主、备用地址,完成站点构建后再上线。
如果站点配置了 CSP,还要把 https://unpkg.com 和 https://cdn.jsdelivr.net 加到 img-src。普通 <img> 展示一般不用额外处理 CORS;但如果要把图片画进 Canvas 后读取像素,就得另外确认 CDN 的 CORS 响应。
最后再提醒一次:公共 npm 包里的内容谁都能下载,历史版本也不能当保险箱。原图要单独备份,令牌不要进前端和仓库,包里只放愿意长期公开的文件。
头像换好了,但这事还没完全结束
现在这张头像已经能从 unpkg 加载,失败时也会转去 jsDelivr。回头看,真正花时间的不是上传图片,而是补齐身份验证、版本管理和失败回退这些不起眼的小环节。
手动发布目前挺稳,也让我每次都能看一眼包里到底有什么;但以后资源多了,是继续手动,还是接一套自动发布?自动化当然省事,可一旦引入令牌、权限和 CI,新的坑已经在远处探头了。
这个选择我还没定。如果你也拿 npm 放过头像、字体或博客静态资源,评论区可以说说你最后是继续用,还是某次翻车后搬了家。也许下一次折腾,就从这些答案里开始。
支持与分享
觉得这篇文章有点用,就把它递给下一位读者。
- 作者
- Demius
- 发布于
- 2026年9月14日
- 许可协议
- CC BY-NC-SA 4.0

评论区
分享你的想法,与大家交流讨论。