图片与公开路径:从 public 到构建后 URL
你在文章目录旁边放了一张 diagram.png,开发服务器里图片显示正常,部署到 https://docs.example.test/guide/ 后却变成空白。打开浏览器开发者工具,失败请求通常不是“图片损坏”,而是 URL 被拼成了 https://docs.example.test/diagram.png,服务器实际只提供 https://docs.example.test/guide/diagram.png;也可能是文件在 Git 中叫 Diagram.png,Linux 发布机只认这个大小写,而作者电脑的文件系统没有暴露问题。
解决这类故障,不能把“把 /images/a.png 改成 ./images/a.png”当成万能答案。图片引用至少有四个对象:源文件中的路径、构建器识别到的资源、输出目录里的文件、浏览器最终请求的 URL。public 类目录通常原样复制,Markdown 相对资源通常被构建器接管,组件和 CSS 又有自己的解析规则;base、哈希文件名、CDN 前缀和缓存策略会继续改变最终结果。下面沿着一次图片交付把这些对象接起来,读者可以用同一套证据定位问题。
先建立“文件名不是 URL”的直觉
假设项目源目录是 docs,文章位于 docs/guide/paths.md,有两种图片放法:
docs/
├─ guide/
│ ├─ paths.md
│ └─ images/
│ └─ relative-diagram.png
├─ public/
│ └─ images/
│ └─ shared-diagram.png
└─ .vitepress/
└─ config.mtsguide/images/relative-diagram.png 是一个相对源资源。paths.md 中写 ,构建器可以沿文件系统找到它,生产构建可能把它复制成带哈希的 assets/relative-diagram.x7k2.png。public/images/shared-diagram.png 是公开文件,通常被复制成输出根下的 images/shared-diagram.png,不经过同一套哈希流程。
这意味着三件事。第一,public 不是浏览器可访问的 URL 前缀,源文件里的 public/images/shared-diagram.png 往往应该写成 /images/shared-diagram.png。第二,/images/shared-diagram.png 的第一个 / 表示“当前站点的 URL 根”,不是仓库根;站点部署在 /guide/ 时还要考虑 base。第三,./images/relative-diagram.png 依赖 Markdown 文件位置,文章移动后它可能跟着移动,也可能因为相邻目录不存在而失效。
官方文档的表述可以帮助确认这条心智模型:VuePress 资源处理建议 Markdown 使用相对 URL,.vuepress/public 中的文件复制到生成目录根部;VitePress 资源处理说明相对资源会被 Vite 处理、public 文件原样复制;Docusaurus 静态资源则把 static 文件复制到 build 根部,并允许通过 staticDirectories 增加其他目录。
安装与初始化先固定运行时
图片路径问题常被误诊为 Markdown 问题,实际可能是构建器版本、Node 运行时或模块格式变了。安装时把版本基线写进项目的包管理和 CI,避免作者使用一个工具链、构建机使用另一套工具链。
VuePress 2 的官方入口是本地安装 vuepress、Vite bundler 和主题;其 Getting Started 文档要求 Node.js 20.9.0+,且文档页仍标注为 RC,因此升级时应锁定依赖并阅读变更记录。VitePress 要区分两条发行线:稳定 1.6.4 文档要求 Node.js 18+,npm 默认 vitepress 安装稳定线;官网主线 Getting Started当前面向 2.0.0-alpha,要求 Node.js 22+ 并使用 vitepress@next。Docusaurus 3.x 的安装文档要求 Node.js 20+,用 create-docusaurus 生成站点。版本号和运行时要求会继续变化,安装前应从实际 lockfile、node --version 和对应发行线文档重新确认,不能把三者混成一套“站点工具标准版本”,预发布线也不能无验证替换生产基线。
一个空目录里的 VitePress 最小初始化可以这样做:
node --version
npm --version
npm add -D vitepress@1
npx vitepress init
# 选择 docs 作为 Markdown 根目录,并允许向 package.json 写入 docs:dev/docs:build/docs:preview
npm run docs:build
npm run docs:preview这组命令选择稳定 1.x;若要评估 2.x,显式改用 vitepress@next、Node 22 和独立锁文件,在代表页面通过构建与回滚演练前不要覆盖稳定站点。
如果现有项目不是 ESM,初始化后出现 require() of ES Module 或配置加载失败,先检查最近的 package.json 是否有 "type": "module",或把 .vitepress/config.js 改为 .mjs/.mts。不要为了绕过错误把图片路径写成另一种格式,这两个问题处在不同层:一个是 Node 读取配置失败,一个是构建后 URL 解析失败。
VuePress 与 Docusaurus 的入口分别见 VuePress Getting Started 和 Docusaurus Installation。Docusaurus 的典型安装命令如下,真实项目应使用锁文件和团队批准的版本,而不是在每次构建时无提示地漂移到最新版:
npx create-docusaurus@latest docs-site classic
cd docs-site
node --version
npm run build
npm run serveVuePress 和 Docusaurus 的配置文件也在 Node 中执行,配置里可以改变资源目录、构建目录和插件行为。VitePress 的 Markdown 与主题代码进入 Vite 图构建,资源解析能力强,但它的自定义主题入口与 Vite 插件并不等价于一个跨框架通用的“图片插件 API”。迁移时不要假设某个 VuePress 插件能直接安装到 VitePress 或 Docusaurus;先确认它依赖的 bundler、Markdown 解析器、主题生命周期和输出结构。
public、相对路径与根路径怎样取舍
把资源分成“随页面构建的资源”和“按固定公开 URL 提供的文件”,路径策略就会清楚。
随页面构建的资源适合文章专属图片、组件依赖图片和会随内容一起移动的图。Markdown 用相对路径:

<!-- VitePress / VuePress 的主题组件中,动态路径需要经过 base helper -->
<img :src="withBase('/images/shared-diagram.png')" alt="共享流程图" />固定公开 URL 适合 favicon.svg、robots.txt、共享 logo、下载入口和被多个页面引用的海报。它们放在框架认可的公开目录,源码按公开根路径引用:
VuePress: docs/.vuepress/public/images/shared-diagram.png
VitePress: docs/public/images/shared-diagram.png
Docusaurus: website/static/images/shared-diagram.pngVuePress 和 VitePress 的 public 目录归属源目录,不一定等于仓库根目录;VitePress 默认源目录为 docs 时,公开目录就是 docs/public。VuePress 默认是源目录下的 .vuepress/public,并可以通过 public 配置项改变。Docusaurus 默认目录叫 static,只有在配置 staticDirectories: ['public', 'static'] 后,public 才会成为它的静态输入目录。
相对路径的优点是构建器能发现缺失文件、生成哈希并让文章携带自己的依赖;代价是移动 Markdown、改变文档层级或把引用搬进 CSS 后,解析基准会变化。根路径的优点是稳定、容易给 CDN 和外部链接使用;代价是它可能绕过资源图,缺失文件直到浏览器发出 404 才暴露。固定资源如果没有对应的静态扫描,就必须用构建后 URL 检查补上这个盲区。
base 是部署前缀,不是图片目录
站点根部署和子路径部署的差异,集中体现在 base 或 baseUrl。例如站点地址是 https://docs.example.test/handbook/,则:
站点 base: /handbook/
公开文件源: docs/public/images/shared-diagram.png
期望 URL: https://docs.example.test/handbook/images/shared-diagram.pngVuePress 配置 base: '/handbook/';VitePress 在 .vitepress/config.mts 中配置同名 base;Docusaurus 使用 url: 'https://docs.example.test' 与 baseUrl: '/handbook/'。这三个值表达的是“站点挂载在哪个 URL 前缀”,不是把图片复制到名为 handbook 的目录。路径通常必须以 / 开始和结束;少一个斜杠就可能产生拼接错误或路由与资源不一致。
// VuePress: docs/.vuepress/config.js
import { defineUserConfig } from 'vuepress'
export default defineUserConfig({
base: '/handbook/',
})这个 VuePress 示例保留默认公开目录 ${sourceDir}/.vuepress/public。public 配置项接收的是文件系统目录;若确实要改目录,应传入从配置环境可稳定解析的目标路径并检查构建输出,不能把 public: 'public' 误解成“公开 URL 前缀”。
// VitePress: docs/.vitepress/config.mts
import { defineConfig } from 'vitepress'
export default defineConfig({
base: '/handbook/',
vite: {
build: {
assetsInlineLimit: 4096,
},
},
})// Docusaurus: website/docusaurus.config.js
export default {
url: 'https://docs.example.test',
baseUrl: '/handbook/',
staticDirectories: ['static'],
}VitePress 官方文档说明 public 文件在输出根部原样复制,同时 Markdown 中 /icon.png 会随 base 调整;主题配置里动态生成的 src 则应使用 withBase。VuePress 也提供 withBase,并特别提醒使用 Webpack bundler 时需要检查 markdown.assets.absolutePathPrependBase。Docusaurus 的 baseUrl 行为对 Markdown 资源和 JSX 不完全相同:Markdown 语法会被当作文件路径处理,JSX 中硬编码 /img/a.png 可能跳过 base,官方建议使用 import 或 useBaseUrl。因此,组件里从配置字符串动态拼 src 是最容易制造子路径 404 的地方。
Markdown、Vue 与 CSS 的解析基准不同
同一张图在三种写法中的处理责任不同。Markdown 图片通常交给站点的 Markdown 编译器;Vue 模板中的静态 src 可能进入 Vite 或 Webpack 资源图;CSS url() 由 CSS bundler 解析,位于 public 的文件又可能走根路径。不要把三种写法机械替换。
在 VitePress 中,文章内推荐:

在 Vue 组件中,静态 import 让构建器知道依赖:
<script setup lang="ts">
import diagramUrl from './images/relative-diagram.png'
import { withBase } from 'vitepress'
const publicDiagramUrl = withBase('/images/shared-diagram.png')
</script>
<template>
<img :src="diagramUrl" alt="文章专属图" width="960" height="600" />
<img :src="publicDiagramUrl" alt="共享图" width="960" height="600" />
</template>在 Docusaurus 的 MDX 中,相邻资源用 Markdown 或 import/require;静态目录中的组件资源用 @site/static 和 useBaseUrl。官方说明 Markdown 链接会被解析为 require,但 JSX 的 <img src="/img/a.png"> 不会自动获得同样的处理。Docusaurus 的普通 CSS 则允许用 url('/font/site.woff2') 指向 static/font/site.woff2,构建器会接管这类 CSS 引用;这与 JSX 的裸根路径不是同一条处理链,详见官方静态资源说明。
/* docs/.vitepress/theme/custom.css 或 Docusaurus 的 CSS 入口 */
.article-diagram {
background-image: url('/images/shared-diagram.png');
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}这段 CSS 在 VitePress 的受支持样式文件和 Docusaurus 的 CSS 构建链里会被处理,但运行时拼出的 style="background-image:url(...)"、第三方插件注入的字符串和最终 HTML 中的裸 URL 未必会经过同一转换。判断依据不是“有没有以 / 开头”,而是该引用是否真的进入当前框架的资源处理链。如果资源是组件依赖,优先 import;如果必须保留稳定文件名,放入 public/static,动态组件通过 withBase 或 useBaseUrl 生成 URL,最后检查构建产物和 HTTP 请求。
构建后 URL、哈希与缓存要一起验证
开发服务器往往把缺失的资源请求转给模块图或提供宽松的 fallback,生产构建则会把文件复制、重命名并交给静态服务器。VitePress 对进入资源图的相对资源生成哈希文件名;未被引用的相对资源不会因为躺在源码目录里就自动进入产物,小图片还可能按阈值内联为 Base64。public 中的文件走另一条规则,会原样复制而不需要被 import。Docusaurus 默认静态目录中的文件不会被哈希或压缩,但通过 import/require 进入 bundler 的资源可以获得处理。VuePress 是否哈希以及文件落点取决于版本和 bundler,不能只根据旧项目的 .vuepress/dist 目录推断。
正向实验应同时检查输出文件、HTML/CSS 中的引用和 HTTP 响应:
# 以 VitePress 为例,dist 路径按项目配置调整
npm run docs:build
Get-ChildItem -Recurse docs/.vitepress/dist | Where-Object { $_.Name -match 'diagram|shared|assets' }
rg -n "diagram|shared-diagram|handbook" docs/.vitepress/dist
# 启动生产预览后,检查状态、内容类型和缓存头
npm run docs:preview -- --host 127.0.0.1 --port 4173
curl.exe -sS -D shared.headers -o shared.bin http://127.0.0.1:4173/handbook/images/shared-diagram.png期望证据是:文章 HTML 或生成的 CSS 指向真实输出文件;公开文件以原文件名出现在输出根部;响应状态为 200,Content-Type 是 image/png 或 image/svg+xml,不是 HTML fallback;浏览器 Network 面板中的请求 URL 包含 /handbook/;哈希资源每次内容变更生成新 URL。只看到构建退出码为 0 不够,因为硬编码的静态路径缺失通常不会阻止 Docusaurus 编译,最终表现只是 404。
哈希资源适合长缓存:内容变化意味着 URL 变化,可以返回 Cache-Control: public, max-age=31536000, immutable。HTML、搜索清单以及会原地更新的 favicon.svg、robots.txt、共享海报等固定 URL 更适合 no-cache,它允许存储但要求复用前重新验证;no-cache 不等于 no-store。MDN 的 Cache-Control 指南把“哈希 URL 长缓存、入口文档重新验证”作为一组配套策略。查询参数只有在 CDN 缓存键确实包含它时才能充当版本,不能想当然地用 ?v=2 清旧图。
CDN 规则还要按状态码收口。不要把图片路径的长期缓存规则无条件套给 404、鉴权页和 SPA fallback;例如 Cloudflare 的状态码缓存说明明确列出了未提供缓存头时对 404 的默认缓存,修复源站后边缘仍可能继续返回旧错误。发布前检查 200 与错误响应各自的 Cache-Control,回滚时按完整 URL 或缓存标签清理,随后同时请求源站和边缘节点,比较 Age、ETag、Via 与响应体。
缓存反证要故意制造“URL 不变、内容已替换”和“资源不存在、404 被缓存”两种状态。前一种在固定 URL 上替换图片后,同时请求源站与边缘,若 ETag、响应体哈希或像素内容仍不同,说明重新验证或失效没有收敛;后一种先请求不存在的 URL,再发布同名文件,若源站已返回图片而边缘仍返回旧 404,就证明错误响应进入了缓存。只有浏览器强制刷新后看起来正常,不能证明其他访客、其他边缘节点或离线缓存已经更新。
文件名大小写与 CDN 让隐患离开本机
开发机上的大小写不一致可能被文件系统宽容地隐藏。文章写 ,仓库实际文件却是 diagram.png,在大小写敏感的 Linux 构建机或对象存储上就会失败。空格、非 ASCII 文件名、#、?、反斜杠和末尾句点也会在 URL 编码、Windows 文件系统、CDN 规则之间产生不同解释。
建议把公开资源的命名限制为小写 ASCII、数字、连字符和单一扩展名,例如 site-writing-image-path-22.svg。不要靠人工记忆验证,直接扫描引用和文件系统:
$assetRoots = @('docs', 'website', 'static', 'public') | Where-Object { Test-Path $_ }
Get-ChildItem -Recurse -File $assetRoots -Include *.png,*.jpg,*.jpeg,*.webp,*.gif,*.svg |
ForEach-Object {
if ($_.Name -cnotmatch '^[a-z0-9][a-z0-9._-]*$') {
Write-Output "BAD_NAME $($_.FullName)"
}
}
rg -n --glob '*.md' --glob '*.mdx' --glob '*.vue' --glob '*.css' `
'(/|\./|\.\./)[^ )"'']+\.(png|jpe?g|webp|gif|svg|woff2?)' docs website这个扫描只负责发现可疑命名和引用,不能证明 URL 一定有效。还要检查 URL 编码、CDN 的路径前缀、对象存储的 key、反向代理是否去掉了 /handbook/,以及 CDN 是否把 404 错误页缓存下来。一个常见事故是源站已经修正了大小写,边缘节点仍缓存旧的 404;清理缓存或更换哈希 URL 后才会恢复,不能用“浏览器强制刷新”当生产修复。
响应式图像要保持内容、尺寸和加载策略一致
文章图片不只是 src。如果没有宽高约束,图片加载后会把正文向下推,移动端会出现布局跳动;如果原图很宽,浏览器虽然能缩放,但下载成本仍按原始字节数支付。稳定的最小写法是给图片提供真实比例的 width/height,CSS 让它不溢出容器:
<img
src="/images/shared-diagram.png"
alt="从源文件到公开 URL 的链路"
width="1920"
height="1200"
loading="lazy"
decoding="async"
style="display:block;max-width:100%;height:auto"
>高分辨率屏幕和移动网络可以使用 srcset 与 sizes,但每个候选 URL 都要经过同样的 base 和 CDN 检查:
<img
src="/images/diagram-960.webp"
srcset="/images/diagram-960.webp 960w, /images/diagram-1920.webp 1920w"
sizes="(max-width: 720px) 100vw, 960px"
width="1920"
height="1200"
alt="图片路径链路"
loading="lazy"
decoding="async"
>如果站点部署在 /handbook/,裸写上述根路径是否被框架改写,要以生成 HTML 和实际请求为准。对于 Docusaurus JSX,使用 useBaseUrl('/images/diagram-960.webp') 或 import;对于 VitePress/VuePress 主题组件,使用 withBase;对于 Markdown,使用框架文档规定的静态资源语法。不要在 CDN 前缀、base、srcset 三处各自拼一遍,否则移动端候选图很容易漏掉其中一层。
响应式图也有成本边界。srcset 候选越多,构建产物和 CDN 对象越多,清理越难;图片压缩质量过高会浪费传输,过低会损害阅读。容量验收关注趋势而不是一个万能阈值:同一文章的候选文件是否全部被引用,构建后是否出现未使用的大文件,移动端实际下载的字节数是否明显低于桌面端,以及 CDN 命中率是否随版本稳定。演示站可以先保留两档,生产再根据真实访问分布决定是否增加更多宽度。
SVG 能显示不代表可以任意信任
SVG 是文本格式,既可以是静态图,也可以包含脚本、事件属性、外部引用或 foreignObject。作为 <img> 或 CSS 图片时,浏览器会施加额外限制;把 SVG 内联进 HTML、通过 <object>/<iframe> 展示,或导入成 Vue/React 组件时,内容会进入更有能力的上下文。MDN 对 SVG 图片上下文的说明可以解释为什么两种显示方式不能混为一谈。
因此,仓库内可信且经过审查的文章海报和图标优先用 <img src="...svg"> 或 Markdown 图片,不要为了改颜色就把用户上传的 SVG 直接注入页面。Docusaurus 支持把 SVG import 转成 React 组件,这种便利只适用于可信构建输入,不能当作上传文件预览器。外部 SVG 在进入公开目录前必须经过能解析 SVG/DOM 的允许列表清洗;若业务不需要矢量交互,直接在隔离环境转成 PNG/WebP 更容易收紧边界。DOMPurify支持清洗 SVG,但服务端使用时还要固定受支持的 DOM 实现并跟进安全更新,不能把一条正则当作 sanitizer。
可以先做静态门禁,门禁不替代专用 SVG sanitizer,但能阻止明显危险内容进入提交:
$svgFiles = Get-ChildItem -Recurse -File docs,website,static,public -ErrorAction SilentlyContinue |
Where-Object Extension -ieq '.svg'
foreach ($file in $svgFiles) {
$text = Get-Content -Raw -Encoding utf8 $file.FullName
if ($text -match '(?is)<script\b|<foreignObject\b|on[a-z][a-z0-9:_-]*\s*=|(?:xlink:)?href\s*=\s*["''](?!#)|<!ENTITY|<\?xml-stylesheet') {
Write-Error "SVG needs review: $($file.FullName)"
}
}这项检查只负责分流人工复核:它会误报合法动画,也会漏掉编码、命名空间和解析差异造成的绕过,不能给文件盖“安全”结论。上传链仍要校验文件大小、解析后的元素与属性、外部引用和输出格式,并对清洗后的结果重新解析。发布端返回 Content-Type: image/svg+xml 与 X-Content-Type-Options: nosniff,CSP 至少收紧 img-src 并在不使用对象嵌入时设置 object-src 'none';具体策略要与站点现有脚本、样式和 CDN 域名合并测试。
正向实验与反向实验要看同一组证据
可以在临时站点中准备一张公开图、一张相邻图和一个故意写错大小写的引用。正向实验使用 base: '/handbook/',把站点构建后放到本地静态服务器的 /handbook/ 入口;依次观察输出目录、生成 HTML、Network 请求和响应头。预期是相邻图可能带哈希并从资产目录取出,公开图保留公开目录层级,两个 URL 都含正确的部署前缀。
反向实验只改一处:把 src="/images/shared-diagram.png" 放进一个不会自动改写路径的 JSX 或自定义主题组件,或者把引用改成 Diagram.png。预期结果是构建仍可能成功,但 Network 出现没有 /handbook/ 的 404,或在 Linux/CI 上出现构建器的找不到文件错误。这个结果很重要:构建成功证明语法和渲染链可启动,不证明每个根路径都能从部署入口访问。
# 生产预览入口;把 /handbook/ 和具体文件换成构建结果中的真实路径
npm run docs:preview -- --host 127.0.0.1 --port 4173
curl.exe -sS -D good.headers -o good.bin http://127.0.0.1:4173/handbook/images/shared-diagram.png
curl.exe -sS -D no-base.headers -o no-base.bin http://127.0.0.1:4173/images/shared-diagram.png
curl.exe -sS -D wrong-case.headers -o wrong-case.bin http://127.0.0.1:4173/handbook/images/Diagram.png三条 GET 请求分别用来证明:正确 base 的公开 URL 可访问、未带 base 的根 URL 是否错误、大小写错误是否被环境暴露。不要只发 HEAD 请求,有些托管层对 HEAD 与 GET 使用不同规则;这里保留响应头和响应体,才能识别“状态 200、内容却是 HTML fallback”。实际项目还应把生成 HTML 中的 src、srcset、CSS url() 和站内链接抽取出来,逐个对照输出目录或 HTTP 响应。不要只扫源 Markdown,因为 Vue/MDX 组件中拼出的动态 URL 可能根本不出现在静态文本规则里。
同一套结论不能从一个开发服务器外推到另一种框架或 CDN。每个目标环境都要独立留下源引用、输出文件、生成 URL、GET 响应、缓存头和回滚结果;跨框架迁移还要加入移动端候选图、大小写敏感文件系统以及 SVG 清洗后的重新解析。这样迁移决策依赖的是可重复证据,而不是某次本地预览的印象。
从错误响应反推故障层
看到图片空白后,先看 Network 的请求 URL、状态码、响应头和响应体开头,再回到源文件。404 说明请求没有命中目标对象,响应类型可能是图片也可能是 HTML 错误页;200 text/html 常见于 SPA fallback 把不存在的图片请求返回成站点首页,浏览器拿到 HTML 后仍无法解码;200 image/png 但画面是旧版本,优先检查浏览器和 CDN 的缓存年龄、ETag 与 Age;301 或 302 反复跳转,则要检查末尾斜杠、反向代理前缀和重定向规则有没有互相追加 /handbook/。
下面的检查比只打开图片更有辨识力:
curl.exe -sS -D - -o response.bin http://127.0.0.1:4173/handbook/images/shared-diagram.png
Get-Item response.bin | Select-Object Length
Get-Content -LiteralPath response.bin -Encoding Byte -TotalCount 16 | Format-HexPNG 文件开头通常是固定的二进制签名,SVG 则应以 XML/SVG 文本开头;如果响应体开头是 <!doctype html>,问题在静态服务器 fallback 或路由,而不是图片编码。304 Not Modified 也不是失败,它表示浏览器沿用缓存副本;此时要对比 ETag 和内容是否对应当前构建,而不是马上删除浏览器缓存。CDN 若把错误响应缓存太久,会让修复后的源站继续看起来像坏的,排障时应同时请求源站和边缘 URL,并记录两边的 Cache-Control、Age、ETag 与 Via。
这组证据还能区分三类责任:构建目录没有文件,属于源引用或 bundler;输出有文件但 HTTP 404,属于托管根目录、base 或重写;HTTP 返回正确图片但页面不显示,才继续检查 HTML 属性、CSS 覆盖、图片尺寸、CSP 和浏览器解码。把故障先分层,能避免反复修改 Markdown 却没有触及真正的发布入口。
接入项目时先建立一条可回滚路径
把图片接入现有站点,先选一个不影响导航的文章和一张可公开的测试图,形成四步变更:新增资源、更新引用、构建并预览、再决定是否删除旧资源。旧路径不要在同一个提交里直接消失,尤其当搜索索引、外部文章或 CDN 还保留旧 URL 时。
旧文件迁移到新目录时,优先保留 HTTP 重定向,而不是复制两份长期相同的图片。静态托管平台如果支持重定向文件或配置,就把 /old/images/diagram.png 指向 /handbook/images/diagram.png;如果平台不支持服务器重写,可以暂时保留一个同内容文件,设置清理期限和引用扫描,等旧访问量降到可接受水平后再删除。重定向目标本身也要带正确的 base,否则访问者会被送到域名根下的错误路径。
回滚路径要可执行:恢复上一版配置中的 base/baseUrl,恢复旧文件或旧构建产物,重新发布静态目录,清理错误的 CDN 缓存,最后用旧 URL 和新 URL 各发一次请求。仅仅回滚 Markdown 不一定足够,因为旧版本生成的哈希文件、静态目录和重定向规则可能已经被单独发布。发布记录至少保留构建版本、公开目录清单、重定向清单和缓存清理动作。
项目中的插件会改变这条链。VuePress 插件可以通过 extendsMarkdown、onPrepared、onGenerated、bundler 扩展等钩子参与准备和构建;Docusaurus 插件能挂接内容加载、路由、Webpack 配置和主题组件;VitePress 更接近 Vite 加 Vue 主题的组合,资源能力主要来自 Vite 的资源处理和主题扩展。官方入口分别见 VuePress Plugin API、Docusaurus Plugins 和 VitePress 扩展默认主题。安装图片优化、搜索或 MDX 插件后,要重新确认它是否重写 URL、改变输出目录、内联资源或引入客户端脚本。
三套框架的选型边界
如果团队主要写 Vue 组件和 Markdown,已有 VuePress 2 项目通常先沿用它的相对资源、public 和 withBase 语义,减少迁移变量;但 VuePress 2 文档仍处于 RC 口径,插件和 bundler 升级需要锁版本和回归构建。若项目需要 Vite 资源图、快速开发和 Vue 主题扩展,VitePress 的 public、相对资源、哈希输出与 withBase 组合更直接;生产可采用 Node 18+ 的稳定 1.x,评估 Node 22+ 的 2.x 预发布线时则必须隔离锁文件和回滚产物,两条线不能混写成一个兼容承诺。
Docusaurus 适合以 React/MDX、版本化文档、国际化和插件生态为中心的站点。它的 static 目录非常适合固定公开文件,但 JSX 中必须警惕根路径绕过 baseUrl;相邻 MDX 资源更适合 Markdown 语法或 import。Docusaurus 官方介绍已将 v1 标记为弃用,迁移到 v2+ 不能只搬 Markdown,还要迁移 static/baseUrl、MDX 组件、主题、重定向和搜索资源。
选型时不要问“谁的图片语法最简单”,而要问四个问题:部署是否经常从域名根切换到子路径,图片是否需要固定公开 URL,构建器是否必须为资源生成哈希和缺失检查,团队是否愿意维护插件和主题生命周期。若 CDN、对象存储或多语言站点已经独立管理公开资源,站点框架只负责生成正确的前缀和引用,不能把 CDN key 当成本地文件路径直接写进 Markdown。
权限、敏感数据、容量与成本
公开目录会进入静态构建和 CDN,目录权限不是隐私边界。不要把生产截图、带 token 的请求图、内部域名、真实账号、用户头像原图或带 EXIF 位置信息的照片放入 public/static。提交前做字符串和文件类型扫描,发布前抽查构建产物;删除源文件也不能立即从 CDN、浏览器缓存、搜索索引和旧制品中消失。
资源治理可以落成四个可操作的不变量:公开目录里的文件都有 owner 和用途;每个固定 URL 都能在构建后得到 200 或明确的重定向;哈希资源的旧版本有保留期限而不是无限累积;旧路径清理前有访问日志或引用扫描证据。容量成本至少拆成仓库存储、CI 构建时间、制品保存、对象存储、CDN 回源和带宽六项。大量未引用图片会增加仓库克隆、构建扫描和 CDN 清理成本,即使用户永远看不到它们。
清理应按依赖关系执行:先扫描源码和生成产物的引用,再标记候选文件,观察旧 URL 访问,添加重定向或保留窗口,最后删除源文件和制品。回滚时反向恢复文件、配置和重定向,并验证 CDN 不再返回旧错误页。不要把 rm -rf public/images 当清理方案,也不要在没有快照或发布制品的情况下批量重命名图片。
发布门禁与长期维护
一次可靠的图片发布至少有三层检查。源层检查路径存在、大小写一致、文件名可部署、SVG 不含明显危险节点;构建层检查 base、输出文件、哈希引用和固定资源;运行层检查关键页面、移动端宽度、旧路径重定向、响应状态和 Content-Type。三层缺一时,问题可能在本地、CI、CDN 或浏览器之间来回漂移。
可以把以下命令作为轻量门禁的起点,实际脚本名按项目已有任务调整:
# 1. 文章和站点源码中的资源引用检查
rg -n --glob '*.md' --glob '*.mdx' --glob '*.vue' --glob '*.css' `
'(/|\./|\.\./)[^ )"'']+\.(png|jpe?g|webp|gif|svg|woff2?)' docs website
# 2. 构建并检查产物中是否仍有明显错误路径
npm run docs:build
rg -n "src=\"/|href=\"/|url\('/" docs/.vitepress/dist website/build
# 3. 只在本地预览端口启动后执行 HTTP 检查
curl.exe -fsS http://127.0.0.1:4173/handbook/ | Out-Null
curl.exe -fsS -D - -o NUL http://127.0.0.1:4173/handbook/images/shared-diagram.png这不是一条跨框架通用脚本:VuePress 输出目录、Docusaurus build 目录、VitePress .vitepress/dist 都可能被配置改变,站点应把实际输出目录和部署前缀作为参数传入。更成熟的门禁还会解析 HTML、读取 srcset、模拟大小写敏感文件系统、检查 SVG、对关键页面截图,并在 CDN 发布后重新取样。真正的完成信号不是“所有图片都放进了 public”,而是从文章源文件到浏览器请求的每一段都能被解释、被验证,并能在路径策略改变时撤回。
