从零构建一个插件系统(四)插件的缓存

休息了几天,我们继续。今天来聊一聊插件的缓存。

在一个系统中,合理的缓存策略是必不可少的。它像我们大脑的记忆,一方面可以提高执行效率,避免重复的、昂贵的计算;另一方面,在我们的场景里,它还能减少不必要的网络请求,在提升性能的同时,也避免了被上游 API 频繁骚扰。

实现插件缓存,通常有两种思路:

  1. 插件内自行实现:每个插件自己负责读写缓存文件,管理自己的缓存逻辑。
  2. 系统级统一提供:由插件系统提供一套统一的、易于使用的缓存机制,供所有插件调用。

方案一很灵活,但它把缓存管理的复杂性完全甩给了插件开发者。在我们的系列中,我们追求的是构建一个体验良好、易于使用的插件框架,所以,方案二------由系统统一支持缓存,无疑是更优的选择。

系统级缓存的设计与集成

我们先回顾一下上一章最终确定的插件形态:

ts 复制代码
export class MyPlugin extends Plugin {
  apply(hooks: PluginHooksRegister) {
    hooks.onSomeHook(async (ctx) => {
      // ... 插件逻辑 ...
    });
  }
}

apply 方法是我们插件的入口,它接收一个 hooks 对象用来注册生命周期回调。这给了我们一个完美的扩展点:我们只需要在 apply 方法的参数中,再给它传递一个由系统创建好的 cache 实例,插件不就可以直接使用了吗?

plugin.apply(hooks, cache);

思路有了,我们来动手实现这个 cache 模块。

Cache 模块的实现

这里我参考了经典库 lowdb 的设计思想,它非常简洁且强大。同时,为了让每个插件的缓存互不干扰,我们会基于插件的名称来做命名空间的隔离。并且,利用 TypeScript 的泛型,我们可以为每个插件的缓存提供完美的类型支持。

我们先看看理想中的使用方式:

ts 复制代码
// 插件中期望的用法
// 1. 读取缓存
if (cache.data) {
  /* ... */
}

// 2. 写入/更新缓存
await cache.update((currentData) => {
  // 返回全新的数据对象
  return { ...currentData, newKey: "newValue" };
});

下面是 cache.ts 的具体实现:

ts 复制代码
// cache.ts
import fs from "fs/promises";
import path from "path";

export class Cache<T> {
  // 构造函数设为私有,强制开发者使用异步的 create 工厂方法
  private constructor(private filePath: string, private dataCache: T) {}

  /**
   * 异步工厂方法:创建并初始化一个缓存实例。
   * 它会优雅地处理文件不存在或内容非法的情况。
   * @param filePath 缓存文件的绝对路径
   * @param initialData 当缓存文件无法加载时使用的默认数据
   */
  static async create<T>(filePath: string, initialData: T): Promise<Cache<T>> {
    try {
      const content = await fs.readFile(filePath, "utf-8");
      // 成功读取并解析,用文件内容初始化
      return new Cache(filePath, JSON.parse(content));
    } catch {
      // 文件不存在或解析失败,使用初始数据
      // 同时确保缓存目录存在,避免写入时出错
      await fs.mkdir(path.dirname(filePath), { recursive: true });
      return new Cache(filePath, initialData);
    }
  }

  /**
   * 获取当前缓存的数据快照。
   */
  get data(): T {
    return this.dataCache;
  }

  /**
   * 接收一个更新函数,用其返回值更新缓存,并异步地将结果写入文件。
   * @param updater 一个接收当前数据并返回新数据的纯函数。
   */
  update(updater: (data: T) => T): Promise<void> {
    this.dataCache = updater(this.dataCache);
    // 格式化 JSON 输出,方便调试
    return fs.writeFile(this.filePath, JSON.stringify(this.dataCache, null, 2));
  }
}

在插件系统中集成 Cache

现在,我们在 PluginSystemcollectHooks 方法中,为每个插件创建并注入一个专属的 Cache 实例。

ts 复制代码
// core.ts (部分代码)
export class PluginSystem {
  private cacheDir = path.resolve(process.cwd(), ".cache");

  private async collectHooks() {
    for (const plugin of this.plugins) {
      const register: PluginHooksRegister = {
        /* ... */
      };

      // 使用插件的类名作为缓存文件的唯一标识,确保隔离
      const cacheKey = plugin.constructor.name;
      const cachePath = path.join(this.cacheDir, `${cacheKey}.json`);

      // 注意:初始数据设为 null,让插件自己决定缓存的结构和初始状态
      const cache = await Cache.create(cachePath, null);

      // 将 cache 实例作为第二个参数传递给 apply 方法
      plugin.apply(register, cache);
    }
  }
}

同时,我们也需要更新一下 Plugin 的基类定义,让它可以接收泛型和 cache 参数。

ts 复制代码
// types.ts (部分代码)
export abstract class Plugin<TCache = unknown> {
  // 允许插件定义自己的缓存类型
  // ...
  abstract apply(
    hooks: PluginHooksRegister,
    cache: Cache<TCache | null> // cache 是可选的,且初始数据可能为 null
  ): void;
}

插件的全新使用方式

有了系统级的缓存支持,我们的 FetchIssues 插件现在可以变得非常智能。

ts 复制代码
// plugins/FetchIssues.ts

// 1. 定义插件,并传入它的缓存数据类型
interface IssuesCache {
  issues: Issue[];
}

export class FetchIssues extends Plugin<IssuesCache> {
  // 2. 在 apply 方法中接收 cache 实例
  apply(hooks: PluginHooksRegister, cache: Cache<IssuesCache | null>) {
    hooks.onLoad(async (ctx) => {
      // 3. 优先从缓存读取
      if (cache?.data?.issues) {
        console.log(`[${this.name}] -> 命中缓存,从本地加载 issues...`);
        cache.data.issues.forEach((issue) => ctx.emitIssue(issue));
        return; // 命中缓存,直接结束
      }

      // 4. 缓存未命中,执行原始的、昂贵的拉取逻辑
      console.log(`[${this.name}] -> 缓存未命中,开始从远程拉取 issues...`);
      // ... (省略 sleep 和 fetch 逻辑)
      const fetchedIssues = [
        /* ... */
      ];

      fetchedIssues.forEach((issue) => ctx.emitIssue(issue));

      // 5. 将新拉取的数据写入缓存,以便下次使用
      await cache?.update(() => ({ issues: fetchedIssues }));

      console.log(`[${this.name}] -> issues 拉取完成并已写入缓存。`);
    });
  }
}

update 的性能优化思考

目前我们的 update 方法每次调用都会立即执行一次磁盘写入。如果在一个钩子中,某个插件非常频繁地调用 update,就可能造成大量的 I/O 操作。

这里完全可以借鉴 Vue 响应式系统的思想:批量异步更新。我们可以维护一个写入队列(writeQueue)和一个"脏"标记(isDirty)。

  1. update 被调用时,它只更新内存中的 dataCache,并将 isDirty 设为 true
  2. 同时,如果当前没有正在等待的写入任务,就用 process.nextTickPromise.resolve().then() 注册一个微任务。
  3. 在这个微任务中,才真正地执行 fs.writeFile,将内存中最新的数据一次性写入磁盘,并重置"脏"标记。

这样,无论插件在一次事件循环中调用了多少次 update,最终都只会触发一次实际的磁盘写入。

ts 复制代码
import fs from "fs/promises";
import path from "path";

export class Cache<T> {
  private isDirty = false; // 是否有待写入的更新
  private writeScheduled = false; // 是否已调度写入任务

  private constructor(private filePath: string, private cache: T) {}

  static async create<T>(filePath: string, initialData: T): Promise<Cache<T>> {
    try {
      const content = await fs.readFile(filePath, "utf-8");
      return new Cache(filePath, JSON.parse(content));
    } catch {
      await fs.mkdir(path.dirname(filePath), { recursive: true });
      return new Cache(filePath, initialData);
    }
  }

  get data(): T {
    return this.cache;
  }

  update(fn: (data: T) => T): void {
    this.cache = fn(this.cache);
    this.isDirty = true;
    this.scheduleWrite();
  }

  private scheduleWrite() {
    if (this.writeScheduled) return;
    this.writeScheduled = true;

    // 用微任务合并当前事件循环内的多次更新
    Promise.resolve().then(() => this.flush());
  }

  async flush(): Promise<void> {
    if (!this.isDirty) {
      this.writeScheduled = false;
      return;
    }
    this.isDirty = false;
    this.writeScheduled = false;

    await fs.writeFile(
      this.filePath,
      JSON.stringify(this.cache, null, 2),
      "utf-8"
    );
  }
}

最后

这篇我们实现了一个简单但设计思想完备的系统级缓存机制。将复杂性(如文件 I/O、错误处理、性能优化)封装在框架内部,而为开发者提供一个极其简洁的 API,这正是优秀框架设计的核心。合理的缓存,能让我们的插件系统在二次运行时,速度得到质的飞跃。

下一篇,我们将暂时跳出自己的实现,放眼业界,去探讨和借鉴其他领域的插件系统(如 Koa、Vue、VS Code)的设计思想,看看能从中吸收哪些宝贵的经验。

相关推荐
仰望星空的凡人41 分钟前
【JS逆向基础】数据库之MongoDB
javascript·数据库·python·mongodb
樱花开了几轉2 小时前
React中为甚么强调props的不可变性
前端·javascript·react.js
Mr...Gan2 小时前
VUE3(四)、组件通信
前端·javascript·vue.js
你的人类朋友3 小时前
❤️‍🔥BFF架构版的hello world
前端·后端·架构
楚轩努力变强4 小时前
前端工程化常见问题总结
开发语言·前端·javascript·vue.js·visual studio code
前端开发爱好者5 小时前
只有 7 KB!前端圈疯传的 Vue3 转场动效神库!效果炸裂!
前端·javascript·vue.js
Fly-ping5 小时前
【前端】JavaScript文件压缩指南
开发语言·前端·javascript
接口写好了吗6 小时前
【el-table滚动事件】el-table表格滚动时,获取可视窗口内的行数据
javascript·vue.js·elementui·可视窗口滚动
未来之窗软件服务6 小时前
免费版酒店押金原路退回系统之【房费押金计算器】实践——仙盟创梦IDE
前端·javascript·css·仙盟创梦ide·东方仙盟·酒店押金系统
云边散步7 小时前
《校园生活平台从 0 到 1 的搭建》第四篇:微信授权登录前端
前端·javascript·后端