从零构建一个插件系统(二)串行插件系统

#工具相关
9 分钟阅读

书接上文,在第一篇我们讨论了一个插件系统需要包含的重点部分有哪些,下面就来实现一个基本的串行插件系统,在这里我们还是以我的 Nextjs 构建 SSG 的目标为清单。

  1. 拉取所有的 Issues
  2. 对 Issue 内容中的图片进行防盗链处理
  3. 自动提取文章摘要
  4. 提取文章的缩略图
  5. 合并专栏
  6. 实现缓存
  7. 输出最终的 JSON 文件

需求分析🔗

我们先从快速验证开始。一个插件系统,核心的部分为:

  • .use() 注册插件。
  • .run() 启动流程。
  • context 对象来传递数据。

基于上述内容,我们可以快速写出第一个版本的代码。

类型定义🔗

TYPESCRIPT
// 插件生命周期,我们先定义三个核心阶段
export type PluginLifecycle = "load" | "transform" | "generate";

// 插件的类型,它有一个名字,和几个可选的生命周期钩子函数
export type Plugin<Context> = {
  name: string;
  load?: (ctx: Context) => Promise<void> | void;
  transform?: (ctx: Context) => Promise<void> | void;
  generate?: (ctx: Context) => Promise<void> | void;
};

// Issue 的数据结构
export type Issue = {
  id: number;
  content: string;
  // 后续 transform 阶段会添加的字段
  summary?: string;
  thumbnail?: string;
};

// 全局共享的上下文,目前很简单,就是 issues 数组
export type Context = {
  issues: Issue[];
};

插件系统实现🔗

TYPESCRIPT
export class PluginSystem<Ctx extends Context> {
  private plugins: Plugin<Ctx>[] = [];
  // 所有插件共享这一个 context 实例
  private ctx: Ctx;

  constructor(initialContext: Ctx) {
    this.ctx = initialContext;
  }

  use(plugin: Plugin<Ctx>) {
    this.plugins.push(plugin);
    return this; // 支持链式调用
  }

  async run() {
    // 定义生命周期的执行顺序
    const lifecycles: PluginLifecycle[] = ["load", "transform", "generate"];

    for (const lifecycle of lifecycles) {
      console.log(`\n--- Running ${lifecycle} lifecycle ---`);
      for (const plugin of this.plugins) {
        // 检查插件是否实现了当前生命周期的钩子
        const hook = plugin[lifecycle];
        if (typeof hook === "function") {
          console.log(`Executing plugin: ${plugin.name}`);
          // 串行执行,等待上一个插件完成
          await hook.call(null, this.ctx);
        }
      }
    }

    return this.ctx;
  }
}

使用示例🔗

TYPESCRIPT
// 伪造两个插件来测试
const fetchIssuesPlugin: Plugin<Context> = {
  name: "fetch-issues-plugin",
  async load(ctx) {
    console.log("  -> Fetching issues...");
    ctx.issues = [
      { id: 1, content: "第一个 issue,包含图片 https://img.com/a.png" },
      { id: 2, content: "第二个 issue,内容丰富" },
    ];
  },
};

const outputPlugin: Plugin<Context> = {
  name: "output-plugin",
  generate(ctx) {
    console.log("  -> Generating output...");
    // 模拟输出
    console.log("【Final Context】", JSON.stringify(ctx, null, 2));
  },
};

// 运行系统
async function main() {
  const pluginSystem = new PluginSystem({ issues: [] });
  await pluginSystem.use(fetchIssuesPlugin).use(outputPlugin).run();
}

main();

通过上述代码我们实现了最基本的串行流程。但正如我们在上一篇讨论的,它有一个巨大的隐患:所有插件都能在任何阶段修改整个 context,这可能会导致误操作,下面来进行修复。

2. 上下文划分🔗

现在,我们来修复这个问题。我们的目标是:插件在不同的生命周期,只能拿到它该拿到的 API,做它该做的事。

我们将对类型定义和 PluginSystem 的实现进行一次重构。

类型定义 (v2)🔗

TYPESCRIPT
import fs from "fs/promises";
import path from "path";

// --- 基础数据类型 ---
export type Issue = {
  id: number;
  content: string;
};

// 这是在 transform 阶段,所有插件共享和修改的数据载体
export type TransformContext = {
  issues: Issue[];
  summary?: string; // 假设这是从 issue 提取的全局摘要
  thumbnail?: string; // 全局缩略图
};

// --- 受限的上下文 API 定义 ---

// `load` 钩子能拿到的 API,它只能“提交”资源
type LoadContextAPI = {
  emitIssue: (issue: Issue) => void;
};

// `generate` 钩子能拿到的 API,它只能“写”文件
type GenerateContextAPI = {
  writeFile: (filePath: string, content: string) => Promise<void>;
};

// --- 插件与钩子定义 ---

// 将所有钩子组合成一个类型
export type PluginHooks = {
  load?: (ctx: LoadContextAPI) => Promise<void> | void;
  // transform 钩子直接接收可变的数据上下文
  transform?: (ctx: TransformContext) => Promise<void> | void;
  // generate 钩子接收最终数据和专用的 API
  generate?: (
    ctx: Readonly<TransformContext>, // generate 阶段不应再修改数据,设为只读
    api: GenerateContextAPI
  ) => Promise<void> | void;
};

// 插件的最终类型
export type Plugin = {
  name: string;
} & PluginHooks;

/**
 * 这是一个辅助函数,它什么也不做,但能提供完美的 TypeScript 类型推断。
 * 开发者用它来包裹插件定义,就能在编写钩子时获得正确的上下文类型提示。
 * @param plugin 插件对象
 */
export function definePlugin(plugin: Plugin): Plugin {
  return plugin;
}

插件系统实现 (V2)🔗

TYPESCRIPT
export class PluginSystem {
  // 将钩子按生命周期分类存储,这样结构更清晰
  private hooks: {
    load: Required<PluginHooks>["load"][];
    transform: Required<PluginHooks>["transform"][];
    generate: Required<PluginHooks>["generate"][];
  };

  constructor() {
    this.hooks = {
      load: [],
      transform: [],
      generate: [],
    };
  }

  use(plugin: Plugin) {
    // 注册时,将插件的钩子函数放入对应的数组
    if (plugin.load) this.hooks.load.push(plugin.load);
    if (plugin.transform) this.hooks.transform.push(plugin.transform);
    if (plugin.generate) this.hooks.generate.push(plugin.generate);
    return this;
  }

  async run() {
    // --- 1. load 生命周期 ---
    console.log(`\n--- Running load lifecycle ---`);
    const loadedIssues: Issue[] = [];
    const loadContextAPI: LoadContextAPI = {
      emitIssue: (issue) => {
        console.log(`  [emitIssue] Loaded issue: ${issue.id}`);
        loadedIssues.push(issue);
      },
    };
    for (const hook of this.hooks.load) {
      await hook(loadContextAPI);
    }

    // --- 2. transform 生命周期 ---
    console.log(`\n--- Running transform lifecycle ---`);
    // 创建一个在 transform 阶段共享的可变数据上下文
    const transformContext: TransformContext = { issues: loadedIssues };
    for (const hook of this.hooks.transform) {
      // 钩子函数直接修改 transformContext 对象
      await hook(transformContext);
    }

    // --- 3. generate 生命周期 ---
    console.log(`\n--- Running generate lifecycle ---`);
    const generateContextAPI: GenerateContextAPI = {
      writeFile: async (filePath: string, content: string) => {
        const dir = path.dirname(filePath);
        await fs.mkdir(dir, { recursive: true });
        await fs.writeFile(filePath, content, "utf-8");
        console.log(`  [writeFile] Output generated: ${filePath}`);
      },
    };
    for (const hook of this.hooks.generate) {
      // 传入最终的数据(设为只读)和专用的 API
      await hook(Object.freeze(transformContext), generateContextAPI);
    }

    // 返回最终处理好的数据,方便调试
    return transformContext;
  }
}

使用示例 (v2)🔗

因为上述对类型进行了重构,所以插件的编写基于 definePlugin 就可以提供完美的类型提示。

TYPESCRIPT
// 插件定义
import { definePlugin } from "./core";

export const fetchIssuesPlugin = definePlugin({
  name: "fetch-issues-plugin",
  async load(ctx) {
    // ctx 上只有 emitIssue 方法,无法做其他事情
    ctx.emitIssue({
      id: 1,
      content: "第一个 issue,包含图片 https://img.com/a.png",
    });
    ctx.emitIssue({ id: 2, content: "第二个 issue,内容丰富" });
  },
});

export const outputPlugin = definePlugin({
  name: "output-plugin",
  async generate(ctx, api) {
    // ctx 是只读的,api 上只有 writeFile 方法
    const outputData = {
      issues: ctx.issues,
      summary: ctx.summary,
      thumbnail: ctx.thumbnail,
    };
    await api.writeFile(
      "./dist/blog-data.json",
      JSON.stringify(outputData, null, 2)
    );
  },
});

// 运行
const pluginSystem = new PluginSystem();
pluginSystem
  .use(fetchIssuesPlugin)
  .use(outputPlugin) // 之后可以加入更多的 transform 插件
  .run();

最后🔗

通过这次重构,我们不仅实现了一个可用的串行插件系统,更重要的是,我们通过精细化的类型设计和受限的上下文 API,建立了一套更健壮、更安全的“游戏规则”。这为我们后续引入更复杂的并发、缓存等功能打下了坚实的基础。

下一篇会重点讨论如何升级当前的插件系统,让其支持并发构建来大大加速整个插件流程。此外如果文章有错误或者不清晰地方或者指出。

文章目录