Skip to content

LV070-永久链接 ​

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 函数

在 VitePluginVitePressAutoPermalink 插件的 config 钩子中,插件会:

(1)读取 VitePress 配置,包括 srcDir、cleanUrls、locales 等

(2)调用 createPermalinks 函数扫描 Markdown 文件并生成 permalink 映射关系

(3)处理国际化,给 permalink 添加语言前缀

(4)将 permalink 映射关系注入到 themeConfig.permalinks 中

核心代码逻辑:

typescript
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

核心代码逻辑:

typescript
// 将 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 结构更清晰

在 src/rewrites.ts 中,createRewrites 函数会:

(1)调用 createPermalinks 函数扫描 Markdown 文件并生成 permalink 映射关系

(2)将 permalink 映射关系处理成 .md 结尾的格式

(3)处理国际化,给 permalink 添加语言前缀

(4)返回一个映射对象,将文件路径映射到 permalink

核心代码逻辑:

typescript
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 字段并生成映射关系

核心代码逻辑:

typescript
/**
 * 生成永久链接
 * @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 文件:

typescript
/**
 * 扫描指定根目录下的 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 添加多语言前缀:

typescript
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;
};

插件提供了 usePermalink 函数来初始化 permalinks 功能,并处理路由变化。在 src/usePermalink.ts 中,usePermalink 函数会:

(1)在组件挂载前调用 replaceUrlWhenPermalinkExist 函数,将当前 URL 替换为 permalink

(2)提供 startWatch 函数来监听路由变化

(3)在路由变化时,根据 permalink 映射关系替换 URL

核心代码逻辑:

typescript
/**
 * 判断路由是否为文档路由,
 * 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);
};

三、配置选项 ​

namedescriptiontypedefault
ignoreList忽略的文件/文件夹列表,支持正则表达式string[][]
path指定扫描的根目录stringvitepress 的 srcDir 配置项

四、使用示例 ​

在 Markdown 文件的 frontmatter 中添加如下内容:

yaml
---
permalink: /guide/quickstart
---
  • 当为 Proxy 方式时,该文件除了通过 文件路径 访问,也可以通过 permalink 访问。
  • 当为 Rewrites 方式时,该文件需要通过 permalink 访问,而 文件路径 访问将会失效。

通过以上方式,vitepress-plugin-permalink 插件实现了永久链接功能,使得 Markdown 文档的访问地址不再因为文档路径的移动而发生变化。

五、具体示例分析 ​

以 docs/01.指南/01.简介/01.简介.md 这篇文档为例,详细说明两种方式的工作机制。该文档的 frontmatter 设置了:

yaml
permalink: /guide/intro

1. 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.简介.mddocs/01.指南/01.简介/01.简介.md
构建后文件路径dist/01.指南/01.简介/01.简介.htmldist/guide/intro.html
访问方式1/01.指南/01.简介/01.简介.html (有效)/01.指南/01.简介/01.简介.html (404)
访问方式2/guide/intro (有效)/guide/intro (有效)
运行时开销有重定向开销无运行时开销
SEO 影响可能有重定向影响更好的 SEO 效果