LV03-永久链接
vitepress-plugin-permalink 是一个适用于 VitePress 的 Vite 插件,用于实现永久链接(Permalink)功能。它允许用户为 Markdown 文档设置唯一的访问链接,即使文档路径发生移动,访问地址也不会发生变化。
一、工作原理
插件提供两种方式实现永久链接:
1. Proxy 方式
这种方式不会影响文件路径,而是在访问文件路径时,通过代理(拦截)将其转换为 Permalink。
Proxy 方式类似于一个"中间人",它在开发服务器层面拦截请求,当用户访问 permalink 时,插件会在后台将请求转发到实际的文件路径,但浏览器地址栏显示的仍然是 permalink。这种方式不会改变实际的文件结构,只是修改了访问方式。
假设有一个文件 docs/guide/api.md,其 frontmatter 设置了 permalink: /guide-api
- 传统访问方式:
/guide/api.html - Proxy 方式访问:
/guide-api(浏览器显示),但是实际访问:/guide/api.html(服务器内部重定向)
有以下优势:
- 文件结构保持不变
- 可以通过两种方式访问同一文档
- 开发阶段调试方便
1.1 插件初始化
在 src/index.ts 中,插件通过 VitePluginVitePressPermalink 函数导出两个子插件:
VitePluginVitePressAutoPermalink:负责扫描 Markdown 文件并生成 permalink 映射关系VitePluginVitePressUsePermalink:负责在客户端注入usePermalink函数
1.2 permalink 映射关系生成
在 VitePluginVitePressAutoPermalink 插件的 config 钩子中,插件会:
(1)读取 VitePress 配置,包括 srcDir、cleanUrls、locales 等
(2)调用 createPermalinks 函数扫描 Markdown 文件并生成 permalink 映射关系
(3)处理国际化,给 permalink 添加语言前缀
(4)将 permalink 映射关系注入到 themeConfig.permalinks 中
核心代码逻辑:
const permalinks = createPermalinks({ ...option, path: baseDir }, cleanUrls);
// Key 为 path,Value 为 permalink
const pathToPermalink: Record<string, string> = {};
// Key 为 permalink,Value 为 path
const permalinkToPath: Record<string, string> = {};
// 国际化多语言 key 数组
const localesKeys = Object.keys(locales || {});
for (const [key, value] of Object.entries(permalinks)) {
// 如果设置了 rewrites,则取 rewrites 后的文件路径
const rewriteFilePath = rewrites.map[`${key}.md`]?.replace(/\.md/, "") || key;
// 如果设置了多语言,则 permalink 添加语言前缀
const newValue = getLocalePermalink(localesKeys, key, value);
if (permalinkToPath[newValue]) {
logger.warn(`永久链接 '${newValue}' 已存在,其对应的 '${permalinkToPath[newValue]}' 将会被 '${key}' 覆盖`);
}
pathToPermalink[rewriteFilePath] = newValue;
permalinkToPath[newValue] = rewriteFilePath;
}
themeConfig.permalinks = { map: pathToPermalink, inv: permalinkToPath } as Permalink;1.3 开发服务器中间件
在 VitePluginVitePressAutoPermalink 插件的 configureServer 钩子中,插件会注册一个中间件来拦截请求:
(1)当请求 URL 包含 .md 时,插件会解析 URL 获取路径
(2)检查路径是否为 permalink,如果是,则将其重写为实际文件路径
(3)如果文档路由存在,则替换 URL 实现跳转,防止页面 404
核心代码逻辑:
// 将 permalink 重写实际文件路径
server.middlewares.use((req, _res, next) => {
if (req.url && req.url.includes(".md")) {
const reqUrl = decodeURI(req.url)
.replace(/[?#].*$/, "")
.replace(/\.md$/, "")
.slice(base.length);
// 确保 path 以 / 开头
const path = "/" + reqUrl.replace(/^\//, "");
// 如果访问链接 reqUrl 为 permalink,则找到对应的文档路由。当开启 cleanUrls 后,permalinks 内容都是 .html 结尾
const filePath = permalinks.inv[path] || permalinks.inv[`${path}.html`];
// 如果设置了 rewrites,则取没有 rewrites 前的实际文件地址
const realFilePath = rewrites.inv[`${filePath}.md`]?.replace(/\.md/, "") || filePath;
// 如果文档路由 realFilePath 存在,则替换 URL 实现跳转,防止页面 404
if (realFilePath) req.url = req.url.replace(encodeURI(reqUrl), encodeURI(realFilePath));
}
next();
});1.4 客户端路由处理
在 VitePluginVitePressUsePermalink 插件中,插件会:
(1)通过虚拟模块将配置项传递给客户端组件
(2)在 VitePress 的 Layout.vue 中注入 usePermalink 函数
(3)usePermalink 函数会监听路由变化,并根据 permalink 映射关系替换 URL
2. Rewrites 方式
这种方式在项目运行或者构建时,通过改变文件路径达到永久链接功能。
Rewrites 方式是在构建阶段直接修改文件的实际路径和名称。当构建完成后,生成的文件结构会按照 permalink 的配置进行组织,而不是按照原始的 Markdown 文件结构。
假设有一个文件 docs/guide/api.md,其 frontmatter 设置了 permalink: /guide-api
- 传统构建结果:
dist/guide/api.html - Rewrites 方式构建结果:
dist/guide-api.html(实际文件路径改变)
有以下优势:
- 无需运行时重定向
- 文件结构更加简洁直观
- 更好的 SEO 效果,因为 URL 结构更清晰
2.1 permalink 映射关系生成
在 src/rewrites.ts 中,createRewrites 函数会:
(1)调用 createPermalinks 函数扫描 Markdown 文件并生成 permalink 映射关系
(2)将 permalink 映射关系处理成 .md 结尾的格式
(3)处理国际化,给 permalink 添加语言前缀
(4)返回一个映射对象,将文件路径映射到 permalink
核心代码逻辑:
export const createRewrites = (
options: PermalinkOption & { srcDir?: string; locales?: Record<string, any> } = {}
): Record<string, string> => {
const { path, srcDir = ".", locales = {} } = options;
const baseDir = path ? join(srcDir, path) : srcDir;
const permalinks = createPermalinks({ ...options, path: baseDir }, true);
// 将 permalinks 的 key 和 value 都处理成 .md 结尾
const normalizedPermalinks: Record<string, string> = {};
for (const [key, value] of Object.entries(permalinks)) {
const normalizedKey = key + ".md";
// value: /a/b.html 或 /a/b -> a/b.md(去除原有扩展名,统一加 .md)
const normalizedValue = value.replace(/^\//, "").replace(/\.[^/.]+$/, "") + ".md";
normalizedPermalinks[normalizedKey] = normalizedValue;
}
// Key 为 path,Value 为 permalink
const pathToPermalink: Record<string, string> = {};
// Key 为 permalink,Value 为 path
const permalinkToPath: Record<string, string> = {};
// 国际化多语言 key 数组
const localesKeys = Object.keys(locales || {});
for (const [key, value] of Object.entries(normalizedPermalinks)) {
// 如果设置了多语言,则 permalink 添加语言前缀
const newValue = getLocalePermalink(localesKeys, key, value);
if (permalinkToPath[newValue]) {
logger.warn(`永久链接 '${newValue}' 已存在,其对应的 '${permalinkToPath[newValue]}' 将会被 '${key}' 覆盖`);
}
pathToPermalink[key] = newValue;
permalinkToPath[newValue] = key;
}
logger.info("Injected Permalinks Rewrites Data Successfully. 注入永久链接 Rewrites 数据成功!");
return { __create__: "vitepress-plugin-permalink", ...pathToPermalink };
};2.2 与 VitePress 集成
当使用 createRewrites 函数生成的 rewrites 配置时,VitePress 会在构建时根据映射关系重命名文件,从而实现永久链接功能。
3. 两种方式对比
| 特性 | Proxy 方式 | Rewrites 方式 |
|---|---|---|
| 文件结构 | 保持不变 | 根据 permalink 重新组织 |
| 访问方式 | 两种方式都可访问 | 只能通过 permalink 访问 |
| 性能 | 运行时重定向,略有开销 | 构建时处理,运行时无开销 |
| 适用场景 | 开发环境、需要灵活访问 | 生产环境、追求性能 |
| SEO | 可能会有重定向影响 | 更好的 SEO 效果 |
二、实现永久链接
1. 读取 Markdown 文件的 frontmatter
插件通过读取 Markdown 文件的 frontmatter 中的 permalink 字段来生成永久链接。在 src/helper.ts 中,createPermalinks 函数会:
(1)递归扫描指定目录下的所有 Markdown 文件
(2)使用 gray-matter 解析 Markdown 文件的 frontmatter
(3)提取 permalink 字段并生成映射关系
核心代码逻辑:
/**
* 生成永久链接
* @param option 插件配置项
* @param cleanUrls 是否清除 .html 后缀
*/
export default (option: PermalinkOption = {}, cleanUrls = false): Record<string, string> => {
const { path, ignoreList = [] } = option;
if (!path) return {};
// 获取指定根目录下的所有目录绝对路径
const dirPaths = readDirPaths(path, ignoreList);
// 只扫描根目录的 md 文件
scannerMdFile(path, option, "", cleanUrls, true);
// 遍历根目录下的每个子目录
dirPaths.forEach(dirPath => scannerMdFile(dirPath, option, basename(dirPath), cleanUrls));
return permalinks;
};scannerMdFile 函数会递归扫描目录下的 Markdown 文件:
/**
* 扫描指定根目录下的 md 文件,并生成永久链接
*
* @param root 根目录
* @param option 配置项
* @param prefix 前缀
* @param cleanUrls 是否清除 .html 后缀
* @param onlyScannerRootMd 是否只扫描根目录下的 md 文件
*/
const scannerMdFile = (
root: string,
option: PermalinkOption,
prefix = "",
cleanUrls = false,
onlyScannerRootMd = false
) => {
const { ignoreList = [] } = option;
const ignoreListAll = [...DEFAULT_IGNORE_DIR, ...ignoreList];
// 读取目录名(文件和文件夹)
const secondDirOrFilenames = readdirSync(root);
secondDirOrFilenames.forEach(dirOrFilename => {
if (isSome(ignoreListAll, dirOrFilename)) return;
const filePath = resolve(root, dirOrFilename);
if (!onlyScannerRootMd && statSync(filePath).isDirectory()) {
// 是文件夹目录
scannerMdFile(filePath, option, `${prefix}/${dirOrFilename}`, cleanUrls);
} else {
// 是文件
if (!isMarkdownFile(dirOrFilename)) return;
const content = readFileSync(filePath, "utf-8");
// 解析出 frontmatter 数据
const { data: { permalink = "" } = {} } = matter(content, {});
// 判断 permalink 开头是否为 /,是的话截取掉 /,否则为 permalink
if (permalink) {
// 如果 cleanUrls 为 false,则访问路径必须带有 .html
const filename = basename(dirOrFilename, extname(dirOrFilename));
const finalPermalink = standardLink(permalink);
permalinks[`${prefix ? `${prefix}/` : ""}${filename}`] = cleanUrls ? finalPermalink : `${finalPermalink}.html`;
}
}
});
};2. 支持国际化
插件支持国际化,自动给永久链接添加语言前缀。在 src/rewrites.ts 中,getLocalePermalink 函数会给 permalink 添加多语言前缀:
export const getLocalePermalink = (localesKeys: string[] = [], path = "", permalink = "") => {
// 过滤掉 root 根目录
const localesKey = localesKeys.filter(key => key !== "root").find(key => path.startsWith(key));
if (localesKey) return `/${localesKey}${permalink.startsWith("/") ? permalink : `/${permalink}`}`;
return permalink;
};3. usePermalink 函数
插件提供了 usePermalink 函数来初始化 permalinks 功能,并处理路由变化。在 src/usePermalink.ts 中,usePermalink 函数会:
(1)在组件挂载前调用 replaceUrlWhenPermalinkExist 函数,将当前 URL 替换为 permalink
(2)提供 startWatch 函数来监听路由变化
(3)在路由变化时,根据 permalink 映射关系替换 URL
核心代码逻辑:
/**
* 判断路由是否为文档路由,
* 1、如果为文档路由,则替换为 permalink
* 2、如果为 permalink,则跳转到文档路由,然后重新触发该方法的第 1 点,即将文档路由替换为 permalink(先加载 404 页面再瞬间跳转文档路由)
*
* @param href 访问的文档地址或 permalink
*/
const replaceUrlWhenPermalinkExist = async (href: string) => {
if (!permalinkKeys.length) return;
const { pathname, search, hash } = new URL(href, fakeHost);
// 解码,支持中文
const decodePath = decodeURIComponent(pathname.slice(base.length));
const permalink = permalinks.map[decodePath.replace(/\.html/, "")];
// 如果当前 pathname 和 permalink 相同,则不需要处理
if (permalink === "/" + decodePath) return;
if (permalink) {
// 存在 permalink 则在 URL 替换
return nextTick(async () => {
const to = base.replace(/\/$/, "") + permalink + search + hash;
history.replaceState(history.state || null, "", to);
await router.onAfterUrlLoad?.(to);
});
}
// 不存在 permalink 则获取文档地址来跳转(router.onBeforeRouteChange 在跳转前已经执行了该逻辑,因此只要在 onBeforeMount 触发,用于兜底
const filePath = teyGetFilePathByPermalink(pathname);
if (filePath) {
const targetUrl = base + filePath + search + hash;
// router.go 前清除当前历史记录,防止 router.go 后浏览器返回时回到当前历史记录时,又重定向过去,如此反复循环
history.replaceState(history.state || null, "", targetUrl);
await router.go(targetUrl);
} else await router.onAfterUrlLoad?.(href);
};三、配置选项
| name | description | type | default |
|---|---|---|---|
| ignoreList | 忽略的文件/文件夹列表,支持正则表达式 | string[] | [] |
| path | 指定扫描的根目录 | string | vitepress 的 srcDir 配置项 |
四、使用示例
在 Markdown 文件的 frontmatter 中添加如下内容:
---
permalink: /guide/quickstart
---- 当为
Proxy方式时,该文件除了通过文件路径访问,也可以通过permalink访问。 - 当为
Rewrites方式时,该文件需要通过permalink访问,而文件路径访问将会失效。
通过以上方式,vitepress-plugin-permalink 插件实现了永久链接功能,使得 Markdown 文档的访问地址不再因为文档路径的移动而发生变化。
五、具体示例分析
以 docs/01.指南/01.简介/01.简介.md 这篇文档为例,详细说明两种方式的工作机制。该文档的 frontmatter 设置了:
permalink: /guide/intro1. Proxy 方式工作流程
(1)文件路径:docs/01.指南/01.简介/01.简介.md
(2)传统访问方式:/01.指南/01.简介/01.简介.html
(3)永久链接访问:/guide/intro
工作过程如下:
(1)当用户访问 /guide/intro 时,Vite 开发服务器的中间件会拦截请求
(2)插件检查 permalink 映射关系,发现 /guide/intro 对应文件 01.指南/01.简介/01.简介
(3)服务器内部将请求重定向到实际文件路径 /01.指南/01.简介/01.简介.html
(4)浏览器地址栏仍然显示 /guide/intro,用户感知不到重定向过程
(5)页面正常渲染,导航栏会根据映射关系正确高亮
这样的话,docs/01.指南/01.简介/01.简介.md 文件位置不变,而且两种访问方式都可用:
/01.指南/01.简介/01.简介.html(传统方式)/guide/intro(永久链接方式)
开发调试方便,可以看到实际文件路径和永久链接的对应关系。
2. Rewrites 方式工作流程
(1)原始文件路径:docs/01.指南/01.简介/01.简介.md
(2)构建时处理:根据 permalink 配置重命名文件
(3)构建结果:生成 dist/guide/intro.html 文件
工作过程如下:
(1)在构建阶段,插件扫描所有 Markdown 文件的 frontmatter
(2)发现 docs/01.指南/01.简介/01.简介.md 设置了 permalink: /guide/intro
(3)构建系统根据 rewrites 配置,将文件重命名为 guide/intro.md
(4)最终生成的文件路径为 dist/guide/intro.html
(5)用户只能通过 /guide/intro 访问,传统路径 /01.指南/01.简介/01.简介.html 将返回 404
这种方式的生产环境性能更好,无需运行时重定向,直接访问目标文件。文件结构更加简洁,构建后的目录结构按照 permalink 组织,更加清晰。因为URL 结构简洁,没有重定向链,也就具有更好的 SEO 效果。
3. 两种方式对比
对于 docs/01.指南/01.简介/01.简介.md 文件:
| 特性 | Proxy 方式 | Rewrites 方式 |
|---|---|---|
| 开发阶段文件路径 | docs/01.指南/01.简介/01.简介.md | docs/01.指南/01.简介/01.简介.md |
| 构建后文件路径 | dist/01.指南/01.简介/01.简介.html | dist/guide/intro.html |
| 访问方式1 | /01.指南/01.简介/01.简介.html (有效) | /01.指南/01.简介/01.简介.html (404) |
| 访问方式2 | /guide/intro (有效) | /guide/intro (有效) |
| 运行时开销 | 有重定向开销 | 无运行时开销 |
| SEO 影响 | 可能有重定向影响 | 更好的 SEO 效果 |