插件生命周期:内容如何变成路由
前置知识
- ›了解 Docusaurus monorepo 结构及服务端/客户端拆分(第 1 篇)
- ›TypeScript 泛型与接口层级
- ›Webpack plugin 基础概念
插件生命周期:内容如何变成路由
在第 1 篇中,我们看到 loadSite() 会调用 loadPlugins() 来执行插件生命周期。正是在这个函数里,原始内容——markdown 文件、博客文章、页面——被转化为可以在浏览器中访问的 React 路由。它是 Docusaurus 的核心所在。
插件生命周期分为四个阶段,按严格顺序执行:初始化、loadContent()、contentLoaded() 和 allContentLoaded()。每个阶段都会为插件注入更多数据,类型系统则忠实记录着这一演进过程。本文将完整追踪从插件配置到路由生成的整个旅程。
插件解析与初始化(第 1 阶段)
插件配置的写法多种多样。用户可以在 docusaurus.config.js 中使用以下任意形式:
plugins: [
'@docusaurus/plugin-content-docs', // string
['@docusaurus/plugin-content-blog', {path: 'blog'}], // [string, options]
function myPlugin(context, options) { ... }, // function
[myPlugin, {someOption: true}], // [function, options]
false, // disabled
]
configs.ts#L55-L114 中的规范化逻辑处理了所有这些格式,将它们统一转换为 NormalizedPluginConfig 结构,包含 plugin 函数、options 对象,以及用于解析相对路径的 entryPath。
flowchart TD
STR["'@docusaurus/plugin-docs'"] --> NORM[normalizePluginConfig]
TUPLE["['plugin', options]"] --> NORM
FN["function() {...}"] --> NORM
FALSE["false / null"] -->|filtered out| SKIP[Skipped]
NORM --> NPC[NormalizedPluginConfig]
NPC --> INIT[initializePlugin]
INIT -->|plugin returns object| IP[InitializedPlugin]
INIT -->|plugin returns null| SELF_DISABLE[Self-disabled, filtered out]
configs.ts#L157-L163 中的插件排序至关重要:预设插件优先,其次是预设主题,然后是独立插件,最后是独立主题。这个顺序直接影响主题别名的覆盖逻辑——排在后面的条目具有更高优先级。
init.ts#L124-L177 中的初始化逻辑会将 LoadContext 和经过验证的 options 传入插件构造函数。插件可以通过返回 null 来自我禁用——这是插件根据上下文决定是否激活的官方模式(例如,仅在生产环境下启用的 sitemap 插件)。返回 undefined 则会抛出错误,以此区分有意禁用与程序缺陷。
初始化完成后,插件会被附加 options、version 和 path 字段。init.ts#L79-L89 中的版本检测逻辑会判断插件来源:npm 包、项目文件、没有 package.json 的本地文件,还是合成内部插件。
提示: 如果插件定义了
validateOptions函数,它会在构造函数之前执行。这里使用 Joi schema 来校验 options 的正确性——docs、blog 和 pages 插件都大量采用了这一模式。
内容加载(第 2 阶段:loadContent)
所有插件初始化完成后,plugins.ts#L139-L151 中的 executeAllPluginsContentLoading() 会并行执行所有插件的 loadContent() 方法:
sequenceDiagram
participant LP as loadPlugins()
participant DOCS as docs plugin
participant BLOG as blog plugin
participant PAGES as pages plugin
LP->>DOCS: loadContent()
LP->>BLOG: loadContent()
LP->>PAGES: loadContent()
Note over LP: All run in parallel (Promise.all)
DOCS-->>LP: docs metadata + sidebar data
BLOG-->>LP: blog posts + tags
PAGES-->>LP: page metadata
LP->>LP: Translate content (if locale requires it)
每个插件在 loadContent() 阶段从文件系统读取数据。以 docs 插件为例,它会扫描所有已配置版本下的 markdown 文件,解析 front matter,并构建文档元数据。返回值的类型为 Content——可以是插件希望传递给 contentLoaded() 阶段的任意内容。
内容加载完成后,如果当前 locale 需要翻译,系统会调用 plugins.ts#L35-L71 中的 translatePluginContent()。它从 i18n/<locale>/ 加载翻译文件,通过 plugin.translateContent() 处理后,同时翻译该插件所管理的主题配置片段。值得注意的是,翻译后的主题配置会通过 Object.assign 直接合并到 context.siteConfig.themeConfig 中,这是一个有副作用的操作。
路由创建与 actions 系统(第 3 阶段:contentLoaded)
内容加载完成后,每个插件的 contentLoaded() 会收到一个 actions 对象,其中包含三个方法。这是插件影响最终输出的唯一途径。具体实现位于 actions.ts#L28-L103:
addRoute(config) — 注册一个 React 路由。路由配置包含 path、component(主题组件别名,如 @theme/DocItem)以及 modules(与组件一同加载的数据模块)。尾部斜杠规范化会根据站点配置自动处理。
createData(name, data) — 将 JSON 或字符串文件写入 .docusaurus/<pluginName>/<pluginId>/,并返回文件的绝对路径,该路径可在 addRoute() 中作为模块引用使用。数据以插件名称和 ID 为命名空间,避免冲突。
setGlobalData(data) — 设置可通过 useGlobalData() 或 usePluginData() 在任意组件中访问的数据。与路由级别的数据不同,全局数据在每个页面上都可以获取。
flowchart TD
CL[contentLoaded] --> AR[addRoute]
CL --> CD[createData]
CL --> SGD[setGlobalData]
AR --> |route config| ROUTES[Routes array]
CD --> |JSON file| DOT[.docusaurus/pluginName/pluginId/]
SGD --> |data| GD[globalData]
DOT --> |module path used in| AR
每个路由还会在 actions.ts#L78-L83 处被隐式注入一个 context 模块。这个 __plugin.json 文件包含 {name, id},主题组件可通过 useRouteContext() 访问——这就是组件用来识别当前路由由哪个插件创建的机制。
跨插件通信(第 4 阶段:allContentLoaded)
所有插件完成内容加载和路由创建后,allContentLoaded() 才会执行。这一阶段允许插件通过 allContent 参数读取其他插件的内容。
plugins.ts#L191-L228 中的编排逻辑会将所有内容按 pluginName 和 pluginId 聚合,然后为每个插件提供一个全新的 actions 对象。本阶段生成的路由和全局数据会与 contentLoaded() 阶段的结果合并。
sequenceDiagram
participant LP as loadPlugins
participant AGG as aggregateAllContent
participant P1 as Plugin A
participant P2 as Plugin B
LP->>AGG: Collect all plugins' content
AGG-->>LP: allContent map
LP->>P1: allContentLoaded({allContent, actions})
LP->>P2: allContentLoaded({allContent, actions})
Note over P1,P2: Plugins can read each other's content
P1-->>LP: Additional routes/globalData
P2-->>LP: Additional routes/globalData
LP->>LP: mergeResults()
这一阶段的使用场景相对较少,但能实现强大的集成能力——例如,插件可以在 docs 和 blog 文章之间生成交叉引用,或者跨所有内容类型构建统一的搜索索引。
预设:插件集合包
大多数 Docusaurus 站点使用 preset-classic,它将核心插件和主题打包成单一配置入口。preset-classic/src/index.ts#L26-L115 中的预设函数逻辑简洁:根据预设 options 返回 {themes, plugins} 数组。
graph TD
PC[preset-classic] --> TC[theme-classic]
PC -->|if algolia configured| TSA[theme-search-algolia]
PC -->|if docs !== false| DOCS[plugin-content-docs]
PC -->|if blog !== false| BLOG[plugin-content-blog]
PC -->|if pages !== false| PAGES[plugin-content-pages]
PC -->|debug mode| DEBUG[plugin-debug]
PC -->|if sitemap !== false| SM[plugin-sitemap]
PC -->|if svgr !== false| SVGR[plugin-svgr]
PC -->|if gtag option| GTAG[plugin-google-gtag]
这里的条件包含模式值得关注:传入 docs: false 可以完全禁用 docs 插件。纯博客站点正是通过这种方式实现的——禁用 docs,仅依赖 blog 插件运行。
预设展开逻辑位于 presets.ts#L25-L66。预设函数以 (context, presetOptions) 调用,其返回的 plugins 和 themes 会被扁平化合并到主插件列表中——位置在独立插件和主题之前,因此在主题别名解析时优先级较低。
合成插件与引导层
所有用户插件初始化完成后,两个硬编码的"合成"插件会被追加到列表末尾,位于 plugins.ts#L274-L277:
docusaurus-bootstrap-plugin(synthetic.ts#L22-L70)负责注入 docusaurus.config.js 中声明的站点级客户端模块、脚本和样式表。它通过 injectHtmlTags() 将 stylesheets 和 scripts 配置数组转换为 HTML 标签。
docusaurus-mdx-fallback-plugin(synthetic.ts#L78-L130)为不在任何内容插件目录下的 .md 和 .mdx 文件添加 webpack MDX loader。这使得将独立 markdown 文件(如仓库根目录的 README.md)作为 React 组件导入成为可能。它会检查现有 webpack 规则,排除已由内容插件处理的路径——通过检查 rule.include 数组实现,是一种行之有效的实用技巧。
提示: 两个合成插件都将
version: {type: 'synthetic'}设置为特殊标记,以便在站点元数据中与用户插件加以区分。
插件类型层级
类型系统通过 plugin.d.ts 中的三个接口来建模生命周期的演进过程:
graph TD
P["Plugin<Content><br/>name, loadContent, contentLoaded,<br/>allContentLoaded, configureWebpack, ..."] --> IP["InitializedPlugin<br/>+ options, version, path"]
IP --> LP["LoadedPlugin<br/>+ content, globalData,<br/>routes, defaultCodeTranslations"]
Plugin<Content>(第 125 行)——构造函数返回的原始插件接口,包含 name、生命周期方法和可选的钩子。
InitializedPlugin(第 196 行)——初始化后追加 options(已验证)、version(已检测)和 path(已解析的目录)。
LoadedPlugin(第 203 行)——内容加载后追加 content、globalData、routes 和 defaultCodeTranslations。
这种渐进式扩展意味着,只需看类型定义,就能判断当前处于哪个生命周期阶段。接收 LoadedPlugin 的函数可以保证 content 和 routes 都已就绪。
完整流程总览
plugins.ts#L264-L297 中完整的 loadPlugins() 编排逻辑将四个阶段串联在一起:初始化插件 → 追加合成插件 → 并行执行所有插件的 loadContent() + contentLoaded() → 执行 allContentLoaded() 处理跨插件数据 → 合并路由和全局数据。
最终结果返回给 loadSite(),后者将其交给代码生成流程处理。路由变为 @generated/routes,全局数据变为 @generated/globalData.json,客户端 React 应用就此获得渲染所需的一切。
在下一篇文章中,我们将跟随这些路由进入构建流水线——webpack(或 Rspack)在这里编译客户端和服务端 bundle,SSG 引擎则将每个页面渲染为静态 HTML。