Skip to content

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

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

(1)读取 VitePress 配置,包括 srcDircleanUrlslocales

(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指定扫描的根目录stringvitepresssrcDir 配置项

四、使用示例

在 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 效果