一张图片为什么需要两套地址:让 Hexo 同时兼容本地预览和稳定 URL
刚把一篇视频编码的长文写完时,我先在 Hexo 生成的页面里从头看了一遍。正文里的五张 SVG 都能正常显示,公开地址也没有问题。随后切回 Markdown 预览,文字、表格和代码块都在,图片却一张也看不到。
第一反应当然是图片路径写错了。但如果路径真的错了,为什么构建后的网页又能正常显示?反过来,如果网页能找到图片,Markdown 预览为什么找不到?
当时正文里写的是这样的地址:
/files/... 对网站来说是一条很自然的根路径。Hexo 构建后,浏览器会去站点根目录请求它。但 Markdown 预览器并没有运行在这个 Hexo 站点里,它既不知道 https://shengsheng.fun,也不知道 Hexo 内部有哪些 route。它只能按照本地文件或预览器自己的 base URL 理解这条地址。
这说明同一段 Markdown 实际会被两个完全不同的运行环境读取,路径含义也随之变化。
一张图片其实经过三个地址空间
这个博客原本已经把本地文件名和公开 URL 分开管理。
文章源码放在:
source/_posts/2026-09-21 - 从 MacBook 的 AV1 开始:一部视频编码编年史.md图片资源放在同名目录:
source/files/2026-09-21 - 从 MacBook 的 AV1 开始:一部视频编码编年史/images/...本地目录用日期和中文标题,方便找文章;公开地址则继续使用 frontmatter 里的稳定英文 url_title:
/files/video-codec-history-from-macbook-av1/images/...这套分层解决了「本地好找」和「外链稳定」之间的冲突,但 Markdown 预览把第三个参与者带了进来。最终需要同时照顾三种地址:
| 地址空间 | 谁在读取 | 它需要什么 |
|---|---|---|
| 本地源码路径 | Markdown 预览器 | 能从 .md 文件位置找到真实图片 |
| 文章 HTML 引用 | 浏览器 | 指向稳定的 /files/<url_title>/... |
| Hexo 资源 route | 生成器与静态服务器 | 在公开路径上确实存在对应文件 |
只处理其中两层,第三层就会露出问题。
原先的 /files/<url_title>/... 同时满足了 HTML 引用和公开 route,却没有本地文件可供 Markdown 预览器解析。把它直接换成 ../files/中文目录/...,预览会恢复,生成后的 HTML 却会保留这个相对地址;浏览器再从文章的 /2026/09/21/slug/ 页面位置解析它,最终会请求错误的目录。
要兼顾两边,需要先明确每种地址服务谁,再在构建过程中完成转换。
几个看起来更简单的办法为什么没有采用
最省事的写法似乎是直接使用完整线上地址:
它在网页和大多数 Markdown 预览器里都能显示,但有两个问题。新文章没有发布前,线上资源根本不存在;已经发布以后,本地预览又依赖网络和远端缓存,看到的不一定是刚刚修改的图片。写作阶段仍然没有真正使用本地源文件。
第二种办法是只写本地相对路径,然后接受网页里也是相对路径。这会把资源 URL 绑定到文章 permalink 的层级。只要文章日期、pretty_urls 或部署前缀变化,浏览器解析出的地址也会变化,原本稳定的 /files/<url_title>/... 就失去了意义。
第三种办法是在 _posts 旁边复制一份图片,或者为英文 slug 再维护一个资源目录。这样确实不需要转换,但同一张图会出现两个副本。修改、压缩和发布时都要确认哪一份才是源文件,时间久了很容易漂移。
Hexo 的 post_asset_folder 也能把文章与资源放在一起,不过这个仓库已经有独立的 source/files/ 资源、长期使用的 /files/<url_title>/... 外链,以及 playground、源码包等其他内容。为了修复 Markdown 预览而整体切换资产模型,影响范围远大于问题本身。
最后采用的方案仍然只有一份图片,也不改变公开 URL:Markdown 源码写本地相对路径,Hexo 构建时把引用地址和资源 route 分别转换到同一个稳定地址。
源码先写成预览器真正认识的路径
文章位于 source/_posts/,资源位于同级的 source/files/,所以 Markdown 里可以这样写:
从文章文件所在目录出发,../files/ 正好落到 source/files/。Markdown 预览器不需要知道 Hexo,也不需要访问网络,只要按文件系统解析就能读到图片。
路径外面的 <...> 是 Markdown 链接目标的尖括号写法。这里的目录含有空格,如果直接写在普通圆括号里,不同解析器可能会在第一个空格处截断;用尖括号包住以后,整个字符串会被当成一个链接目标。
这一步只解决了写作时的预览。接下来还要阻止这条中文相对路径进入最终 HTML。
第一层转换:在 Marked token 上改写引用
sheng-blog 使用 hexo-renderer-marked。这个渲染器会把 Markdown 先解析成 token,再调用 renderer.image、renderer.link 等方法输出 HTML。它同时提供 marked:renderer 过滤器,允许站点脚本包装这些方法。
因此没有去正则替换整篇 Markdown,而是在图片和链接已经被识别为 token 之后,只改写它们的 href:
hexo.extend.filter.register('marked:renderer', function(renderer) {
if (renderer[RENDERER_PATCHED]) return;
for (const methodName of ['image', 'link']) {
const originalRenderer = renderer[methodName];
renderer[methodName] = function(token) {
const href = rewriteLocalFilesHref(this.options.hexo, token.href);
const nextToken = href === token.href ? token : { ...token, href };
return originalRenderer.call(this, nextToken);
};
}
Object.defineProperty(renderer, RENDERER_PATCHED, { value: true });
});token 里的原始地址大致是:
../files/2026-09-21 - 中文标题/images/example.svg转换函数取出资源目录名,在 Hexo 的文章集合里找到同名文章,再读取它的 url_title,最终得到:
/files/stable-english-slug/images/example.svg这里选择 token 级改写,有一个很实际的好处。文章里可能正好有代码块在讲 ../files/...,也可能有普通文本拿它举例。整篇字符串替换会把示例代码一起改掉;renderer 只会接触已经被 Markdown 解析成图片或链接的内容,修改范围更准确。
包装时还加了 RENDERER_PATCHED 标记。Marked 的 renderer 对象会在多次文章渲染之间复用,而 marked:renderer 过滤器也会反复执行。如果每次都重新包一层,image() 和 link() 会套上越来越多代理函数。用 Symbol 标记已经处理过的对象后,后续执行会直接返回,同一个 renderer 只包装一次。
地址转换里几个不起眼的边界
真正的转换逻辑不长,但有几处不能只按当前五张图写死。
首先要统一路径分隔符:
const normalizedHref = href.replace(/\\/g, '/');Hexo route 和网页 URL 使用 /,Windows 本地工具却可能带入 \。进入匹配逻辑前先归一化,后面的规则才不会因平台不同而失效。
查询参数和 hash 不参与目录匹配,因此先从 pathname 中分离:
const suffixIndex = normalizedHref.search(/[?#]/);
const pathname = suffixIndex === -1 ? normalizedHref : normalizedHref.slice(0, suffixIndex);
const suffix = suffixIndex === -1 ? '' : normalizedHref.slice(suffixIndex);这样以后给图片加 ?v=hash 或 #fragment 时,目录匹配只处理 pathname,转换完再把后缀接回去。
本地目录名还可能被 Markdown 解析器编码成 %20 或其他百分号形式,所以匹配到目录段后会尝试 decodeURIComponent。解码失败时保留原值,而不是让一条异常链接中断整站生成。
最后,转换只匹配一个非常窄的前缀:一个或多个 ../,随后必须是 files/。HTTP 地址、站点根路径、锚点和其他相对链接都原样交回默认 renderer。找不到对应文章或 url_title 时也保持原地址,让错误继续以可观察的方式暴露,而不是猜一个不存在的公开 URL。
第二层转换:把资源本体发布到稳定 route
正文已经会输出:
<img src="/files/stable-english-slug/images/example.svg" alt="示意图">但这并不意味着文件已经出现在这个地址上。Hexo 默认仍然按照源码目录生成资源 route:
files/2026-09-21 - 中文标题/images/example.svg因此还需要原先的 after_generate 逻辑,把资源本体从中文源码目录映射到 url_title:
hexo.extend.filter.register('after_generate', async function() {
const routeMap = buildFilesRouteMap(this);
const routePaths = this.route.list().filter(routePath => routePath.startsWith('files/'));
const moves = [];
for (const routePath of routePaths) {
const targetPath = getMappedRoute(routePath, routeMap);
if (!targetPath || targetPath === routePath) continue;
const stream = this.route.get(routePath);
if (!stream) continue;
moves.push({
sourcePath: routePath,
targetPath,
modified: this.route.isModified(routePath),
data: await streamToBuffer(stream)
});
}
for (const move of moves) {
this.route.set(move.targetPath, {
data: move.data,
modified: move.modified
});
this.route.remove(move.sourcePath);
}
});这里修改的是 Hexo route,不是生成后的 public/ 文件。after_generate 发生在 route 已经建立、静态文件尚未最终写完的阶段;在这一层移动资源,后面的本地 server 和 hexo generate 都能拿到同一套结果。如果直接提前修改 public/,下一轮生成仍可能把它覆盖掉。
route 内容先读成 Buffer,因为 source/files/ 里不只有文本,还可能有 PNG、字体、压缩包和其他二进制资源。统一按字节搬运,才能避免隐式编码破坏文件。
移动过程也分成「先收集,后写入」两段。遍历 this.route.list() 时直接增删 route,容易让当前集合发生变化,后续项目被跳过;先把所有 move 计划存下来,再统一 set 和 remove,行为会稳定得多。
为什么两层转换缺一不可
marked:renderer 和 after_generate 看起来都在改路径,职责其实完全不同:
| 转换层 | 输入 | 输出 | 负责对象 |
|---|---|---|---|
marked:renderer |
Markdown 里的本地相对地址 | HTML 里的稳定公开地址 | 引用 |
after_generate |
Hexo 根据源码目录建立的 route | /files/<url_title>/... route |
资源本体 |
只做第一层,页面会请求正确的公开地址,但那里没有文件。只做第二层,文件已经发布到稳定地址,文章 HTML 却仍然指向错误的相对目录。只有两条链路最终汇合,浏览器的一次请求才能真正命中资源。
这也是这次问题最值得记录的地方:页面里的 URL 和磁盘上的文件不是同一个对象。一个是引用,一个是被引用的资源;它们可以通过约定关联,却不能指望改动其中一边后,另一边自动跟上。
验证不能只看一次 hexo generate
这类兼容改动至少要从两个方向验证。
本地预览方向先检查 Markdown 中的五条相对路径。以文章所在的 source/_posts/ 为基准,把 ../files/... 解析成绝对路径,每一个结果都必须是实际存在的文件。这样即使不启动 Hexo,预览器也有资源可读。
构建方向再做一次干净生成:
pnpm exec hexo clean
pnpm exec hexo generate生成后检查四件事:
- 文章 HTML 里的图片都变成
/files/<url_title>/...。 - HTML 中没有残留
../files/,也没有百分号编码后的中文源码目录。 public/files/<url_title>/...下的图片真实存在。public/files/YYYY-MM-DD - 中文标题/不存在,避免公开两套地址。
还要重新打开 Markdown 预览和文章页面各看一次。文件存在、HTML 字符串正确,只能说明静态条件满足;预览器是否支持含空格的链接写法、主题是否会处理图片懒加载、浏览器请求是否返回 200,仍然要在真实展示环境里确认。
这次实际验证中,五条本地路径都能解析,生成后的 HTML 也只有五条稳定英文图片地址,中文 route 被移除,原来的网页图片没有受到影响。
最后看这次兼容真正解决了什么
表面上,这次只是在图片链接前面加了 ../,再补了几十行转换代码。真正解决的却是资源身份混在一起的问题。
本地资源目录属于写作者,应该跟文章文件名保持一致,方便查找和预览;公开 URL 属于读者和外链,应该跟稳定的 url_title 保持一致,不因中文标题调整而变化;Markdown renderer 负责把写作地址翻译成公开引用,Hexo route 过滤器负责把资源本体送到公开地址。
这套做法也不只适用于图片。只要 Markdown 里引用的是 source/files/ 下的下载文件、示例数据或其他文章资源,同一层 renderer 包装都可以处理;公开资源仍由 route 映射统一发布。
以后再遇到「源码里能找到、预览里找不到、构建后却正常」这类问题,可以先列出文件系统、内容渲染器、站点路由和浏览器,逐个确认它们正在解释哪一种地址,再决定改哪里。许多看似偶发的路径问题,只是同一个字符串进入了不同的地址空间。
