Skip to content

Vite 插件

VitePress 处于 Vite 环境下,因此天然支持 Vite 插件。

Teek 有过一个想法,那就是将所有功能完全插件化,通过 NPM 下载各个插件来合并成主题:

  • 目录页插件
  • 归档页插件
  • 文章信息插件
  • Footer 插件
  • ...

这完全是可行的,每个插件都是独立的,支持任何 VitePress 项目。

但是目前没有太多精力去实现这个计划,您可以通过 Teek 的按需引入功能(等价于下载插件),来加载自己需要的功能。

在了解 Vite 插件实现之前,建议您先去 Vite 官方文档 了解什么是 Vite。

下面介绍在 VitePress 中自定义 Vite 插件的场景。

Vite 插件基础模板

首先介绍 Vite 插件的基础模板:

ts
import type { Plugin } from "vite";

interface Options {
  // ...
}

export default function VitePluginVitePressTemplate(option: Options = {}): Plugin {
  return {
    name: "vite-plugin-vitepress-template",
    // ...
  };
}

Vite 插件本质是一个函数,需要返回一个对象,对象的各个 Key 就是 Vite 提供的钩子,比如 transformconfig 等,我们需要识别这些钩子分别执行了哪部分逻辑,这样才能针对性的实现自己的功能。

Vite 提供了哪些钩子请看官网 插件 API

扫描项目文件

如果您使用了 Teek 主题,那么在项目启动时,终端会打印:

sh
Injected Sidebar Data Successfully. 注入侧边栏数据成功!
Injected Permalinks Data Successfully. 注入永久链接数据成功!
Injected DocAnalysisInfo Data Successfully. 注入文档分析数据成功!
Injected Catalogues Data Successfully. 注入目录页数据成功!
Injected posts Data Successfully. 注入 posts 数据成功!

每一行都是一个 Vite 插件输出的内容,这些插件都是去扫描项目的 Markdown 文件,然后生成数据并注入到 VitePress 的 themeConfig 中。

扫描项目文件的目的有如下场景:

  • 生成侧边栏:根据 Markdown 文件路径生成侧边栏数据
  • 解析 Markdown 文档的 frontmatter 来生成文章信息,或给 Markdown 文件自动添加 frontmatter
  • 解析 Markdown 文档的内容,生成站点信息功能(总字数、文章字数、阅读时长等)
  • ...

这里需要用到 Vite 提供的 config 钩子,在解析 VitePress 配置前会调用该钩子,因此我们在这个钩子里执行扫描项目文件的逻辑,最后将数据注入到 VitePress 的 themeConfig 中。

ts
import type { Plugin } from "vite";

interface Options {
  // ...
}

export default function VitePluginVitePressDemo(option: Options = {}): Plugin & { name: string } {
  return {
    name: "vitepress-plugin-demo",
    config(config: any) {
      // 获取 themeConfig 配置
      const {
        site: { themeConfig = {} },
        srcDir,
      } = config.vitepress;

      // 使用 node API 扫描项目文件,项目文件的根路径为 srcDir
      const data = scanProjectFiles(srcDir);

      themeConfig.demo = data;
    },
  };
}

const scanProjectFiles = (srcDir: string) => {};

这里就不详细介绍 scanProjectFiles 的逻辑,您可以阅读 Teek 的 Vite 插件源码来了解具体实现。

加载功能组件

开头说的可以将各个功能完全插件化,就是利用插件来往 VitePress 的插槽中插入组件。

Vite 提供的 loadtransformresolveId 等钩子,是在访问某个资源的时候被调用,比如在浏览器访问某个页面时,我们可以通过这些钩子拦截到页面的代码,然后进行内容加工再返回给浏览器渲染。

因此当进入 VitePress 页面时,我们可以拦截 VitePress 的 Layout 组件,然后将自己实现的组件插入到插槽中,最后返回给浏览器渲染。

VitePress 提供了哪些插槽请看 布局插槽

比如自定义一个组件插入到 Layoutlayout-top 插槽中。

ts
const isESM = () => {
  return typeof __filename === "undefined" || typeof __dirname === "undefined";
};

const getDirname = () => {
  return isESM() ? dirname(fileURLToPath(import.meta.url)) : __dirname;
};

// 插件名
const componentName = "MyComponent";
const componentFile = `${componentName}.vue`;
const aliasComponentFile = `${getDirname()}/components/${componentFile}`;
const virtualModuleId = "virtual:my-component-option";
const resolvedVirtualModuleId = `\0${virtualModuleId}`;

export function VitePluginVitePressMyNotFound(option: NotFoundOption = {}): Plugin & { name: string } {
  return {
    name: "vite-plugin-vitepress-my-not-found",
    config() {
      return {
        resolve: {
          alias: {
            [`./${componentFile}`]: aliasComponentFile,
          },
        },
      };
    },
    resolveId(id: string) {
      if (id === virtualModuleId) return resolvedVirtualModuleId;
    },
    load(id: string) {
      // 使用虚拟模块将 option 传入组件里
      if (id === resolvedVirtualModuleId) return `export default ${JSON.stringify(option)}`;

      // 在 Layout.vue 插槽插入自定义组件
      if (id.endsWith("vitepress/dist/client/theme-default/Layout.vue")) {
        // 读取原始的 Vue 文件内容
        const code = readFileSync(id, "utf-8");

        // 插入自定义组件
        const slotName = "layout-top";
        const slotPosition = `<slot name="${slotName}" />`;
        const setupPosition = '<script setup lang="ts">';

        return code
          .replace(
            slotPosition,
            `<${componentName}><template #${slotName}>${slotPosition}</template></${componentName}>`
          )
          .replace(setupPosition, `${setupPosition}\nimport ${componentName} from './${componentFile}'`);
      }
    },
  };
}
vue
<script setup lang="ts" name="MyComponent">
// @ts-ignore
import option from "virtual:my-component-option";

const { label = "myComponent" } = { ...option };
</script>

<template>
  <div>{{ label }}</div>
</template>

插件通过虚拟模块将 option 配置传入到 virtual:my-component-option 中,因此可以在组件里引入。虚拟模块的内容请看 Vite 官网 虚拟模块相关说明

上面 index.ts 给出的代码模板具有通用性,你只需要:

  • const componentName = "MyComponent"; 改为要插入的组件名
  • const slotName = "layout-top"; 改为要插入的插槽名

为什么不用 transform 钩子?

transform 钩子返回的资源内容已经过 rollup 编译过,不再是源内容,因此无法找到插槽位置,一个解决方案是使用 load 钩子。

unbuild 构建

unbuild 是一个用于构建库和工具的现代构建工具,由 UnJS 团队开发和维护。它旨在简化构建过程,提供高效的打包和构建功能,特别适用于构建 JavaScript 和 TypeScript 项目。

Teek 使用 unbuild 构建 VitePress 插件,这里仅介绍 unbuild 的 entries 配置项,其他 unbuild 的配置项请看 unbuild 文档

警告

如果插件在 node 环境下运行,需要构建为 js 相关文件,如果在 client 环境下运行,则可以保留 tsvue 等文件。

VitePress 的 .vitepress/config.mtsnode 环境运行,因此 config.mts 文件引入的第三方依赖必须是 js 相关文件,在 .vitepress/theme/index.ts 文件则可以引入 tsvue 等不需要构建的文件。

入口文件

如果插件仅只有一个入口文件 index.ts,则 unbuild 的配置文件内容如下所示:

ts
// unbuild.config.ts
import { defineBuildConfig } from "unbuild";

export default defineBuildConfig({
  entries: ["src/index"],
  // ...
});

等于:

ts
// unbuild.config.ts
import { defineBuildConfig } from "unbuild";

export default defineBuildConfig({
  entries: [{ builder: "rollup", input: "src/index", outDir: "dist" }],
  // ...
});

后者比较灵活,可以指定输出的位置 outDir

提示

如果您觉得 inputoutDirsrc/indexdist 不易于阅读,可以改成 ./src/index./dist

Vue 组件

如果插件有 vue 组件,则 unbuild 的配置文件内容如下所示:

ts
// unbuild.config.ts
import { defineBuildConfig } from "unbuild";

export default defineBuildConfig({
  entries: [
    { builder: "mkdist", input: "src/components", outDir: "dist/components", pattern: ["**/*.vue"], loaders: ["vue"] },
  ],
  // ...
});

mkdist 是一个用于构建 Vue 组件的 unbuild 插件,它将 Vue 组件转换为 CommonJS 和 ESM 格式,并支持 TypeScript,它会保留源目录解构。

因此可以不使用 outDir 选项,outDir 默认为 dist,因此它会自动将 components 目录下的文件复制到 dist 目录下。

mkdist 构建多个类型文件

如果插件需要构建多个类型文件,则 unbuild 的配置文件内容如下所示:

ts
// unbuild.config.ts
import { defineBuildConfig } from "unbuild";

export default defineBuildConfig({
  entries: [
    { builder: "mkdist", input: "src", outDir: "dist", pattern: ["**/*.ts"], format: "cjs", loaders: ["js"] },
    { builder: "mkdist", input: "src", outDir: "dist", pattern: ["**/*.ts"], format: "esm", loaders: ["js"] },
    { builder: "mkdist", input: "src", outDir: "dist", pattern: ["**/*.css"], loaders: ["postcss"] },
  ],
  // ...
});

静态目录

如果插件有一个静态文件目录 assets 需要复制到输出目录下,则 unbuild 的配置文件内容如下所示:

ts
// unbuild.config.ts
import { defineBuildConfig } from "unbuild";

export default defineBuildConfig({
  entries: [{ builder: "copy", input: "src/assets", outDir: "./dist/assets" }],
  // ...
});

使用 copy 功能,input 只能是目录。

使用 rollup 插件

如果你需要一些额外的 rollup 插件打包,则 unbuild 的配置文件内容如下所示:

ts
// unbuild.config.ts
import { defineBuildConfig } from "unbuild";
import RollupPlugin from "rollup-plugin";

export default defineBuildConfig({
  entries: [{ builder: "rollup", input: "src", outDir: "dist" }],
  hooks: {
    "rollup:options": (_, options) => {
      if (Array.isArray(options.plugins)) options.plugins.push(RollupPlugin);
    },
  },
  // ...
});

hooks 是 unbuild 的一个高级配置项,unbuild 会在指定的阶段调用 hooks 中的钩子,和 Vite 插件的钩子函数一样。

比如你希望在构建成功后,将一些文件 copy 到输出目录中,则可以使用 hooksbuildEnd 钩子,并安装 fs-extra 工具实现 copy

ts
import { defineBuildConfig } from "unbuild";
import { copy } from "fs-extra";

export default defineBuildConfig({
  entries: ["src/index"],
  hooks: {
    "build:done": async () => {
      await copy("src/xx.d.ts", "dist/xx.d.ts");
    },
  },
});

externals

externals 是一个数组,用于指定不需要构建的依赖包,它将直接从外部引入,而不是构建到输出目录中。

当你使用了第三方依赖 如 vue、vite 等,需要将这些依赖添加到 externals 中,否则它们将被构建到输出目录中,导致依赖非常大。

ts
// unbuild.config.ts
import { defineBuildConfig } from "unbuild";
import RollupPlugin from "rollup-plugin";

export default defineBuildConfig({
  externals: ["vue", "vite"],
  // ...
});

其他项目使用您的插件时,如何确保这些在 externals 被排除的依赖正确安装呢?毕竟没有这些依赖,插件将无法运行。

您可以在 package.jsondependencies 中添加这些依赖,这些第三方依赖就会跟随你的插件一起安装到项目里。

提示

devDependencies 是开发依赖,不会随着插件一起安装到项目里,因此需要您斟酌哪些第三方依赖是运行必须的,则放到 dependencies 里,哪些是开发时必须的,则放到 devDependencies 里。

最近更新