一张图片为什么需要两套地址:让 Hexo 同时兼容本地预览和稳定 URL

刚把一篇视频编码的长文写完时,我先在 Hexo 生成的页面里从头看了一遍。正文里的五张 SVG 都能正常显示,公开地址也没有问题。随后切回 Markdown 预览,文字、表格和代码块都在,图片却一张也看不到。

第一反应当然是图片路径写错了。但如果路径真的错了,为什么构建后的网页又能正常显示?反过来,如果网页能找到图片,Markdown 预览为什么找不到?

当时正文里写的是这样的地址:

![视频编码流水线](/files/video-codec-history-from-macbook-av1/images/04-encoder-pipeline.svg)

/files/... 对网站来说是一条很自然的根路径。Hexo 构建后,浏览器会去站点根目录请求它。但 Markdown 预览器并没有运行在这个 Hexo 站点里,它既不知道 https://shengsheng.fun,也不知道 Hexo 内部有哪些 route。它只能按照本地文件或预览器自己的 base URL 理解这条地址。

这说明同一段 Markdown 实际会被两个完全不同的运行环境读取,路径含义也随之变化。

同一条根路径在 Hexo 页面和 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/ 页面位置解析它,最终会请求错误的目录。

要兼顾两边,需要先明确每种地址服务谁,再在构建过程中完成转换。

几个看起来更简单的办法为什么没有采用

最省事的写法似乎是直接使用完整线上地址:

![示意图](https://shengsheng.fun/files/stable-slug/images/example.svg)

它在网页和大多数 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 分别转换到同一个稳定地址

Markdown 引用与图片资源经过两条链路汇合到同一公开地址

源码先写成预览器真正认识的路径

文章位于 source/_posts/,资源位于同级的 source/files/,所以 Markdown 里可以这样写:

![视频编码流水线](<../files/2026-09-21 - 从 MacBook 的 AV1 开始:一部视频编码编年史/images/04-encoder-pipeline.svg>)

从文章文件所在目录出发,../files/ 正好落到 source/files/。Markdown 预览器不需要知道 Hexo,也不需要访问网络,只要按文件系统解析就能读到图片。

路径外面的 <...> 是 Markdown 链接目标的尖括号写法。这里的目录含有空格,如果直接写在普通圆括号里,不同解析器可能会在第一个空格处截断;用尖括号包住以后,整个字符串会被当成一个链接目标。

这一步只解决了写作时的预览。接下来还要阻止这条中文相对路径进入最终 HTML。

第一层转换:在 Marked token 上改写引用

sheng-blog 使用 hexo-renderer-marked。这个渲染器会把 Markdown 先解析成 token,再调用 renderer.imagerenderer.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 计划存下来,再统一 setremove,行为会稳定得多。

为什么两层转换缺一不可

marked:rendererafter_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

生成后检查四件事:

  1. 文章 HTML 里的图片都变成 /files/<url_title>/...
  2. HTML 中没有残留 ../files/,也没有百分号编码后的中文源码目录。
  3. public/files/<url_title>/... 下的图片真实存在。
  4. public/files/YYYY-MM-DD - 中文标题/ 不存在,避免公开两套地址。

还要重新打开 Markdown 预览和文章页面各看一次。文件存在、HTML 字符串正确,只能说明静态条件满足;预览器是否支持含空格的链接写法、主题是否会处理图片懒加载、浏览器请求是否返回 200,仍然要在真实展示环境里确认。

这次实际验证中,五条本地路径都能解析,生成后的 HTML 也只有五条稳定英文图片地址,中文 route 被移除,原来的网页图片没有受到影响。

最后看这次兼容真正解决了什么

表面上,这次只是在图片链接前面加了 ../,再补了几十行转换代码。真正解决的却是资源身份混在一起的问题。

本地资源目录属于写作者,应该跟文章文件名保持一致,方便查找和预览;公开 URL 属于读者和外链,应该跟稳定的 url_title 保持一致,不因中文标题调整而变化;Markdown renderer 负责把写作地址翻译成公开引用,Hexo route 过滤器负责把资源本体送到公开地址。

这套做法也不只适用于图片。只要 Markdown 里引用的是 source/files/ 下的下载文件、示例数据或其他文章资源,同一层 renderer 包装都可以处理;公开资源仍由 route 映射统一发布。

以后再遇到「源码里能找到、预览里找不到、构建后却正常」这类问题,可以先列出文件系统、内容渲染器、站点路由和浏览器,逐个确认它们正在解释哪一种地址,再决定改哪里。许多看似偶发的路径问题,只是同一个字符串进入了不同的地址空间。