休息了几天,我们继续。今天来聊一聊插件的缓存。
在一个系统中,合理的缓存策略是必不可少的。它像我们大脑的记忆,一方面可以提高执行效率,避免重复的、昂贵的计算;另一方面,在我们的场景里,它还能减少不必要的网络请求,在提升性能的同时,也避免了被上游 API 频繁骚扰。
实现插件缓存,通常有两种思路:
- 插件内自行实现:每个插件自己负责读写缓存文件,管理自己的缓存逻辑。
- 系统级统一提供:由插件系统提供一套统一的、易于使用的缓存机制,供所有插件调用。
方案一很灵活,但它把缓存管理的复杂性完全甩给了插件开发者。在我们的系列中,我们追求的是构建一个体验良好、易于使用的插件框架,所以,方案二------由系统统一支持缓存,无疑是更优的选择。
系统级缓存的设计与集成
我们先回顾一下上一章最终确定的插件形态:
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
现在,我们在 PluginSystem
的 collectHooks
方法中,为每个插件创建并注入一个专属的 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
)。
- 当
update
被调用时,它只更新内存中的dataCache
,并将isDirty
设为true
。 - 同时,如果当前没有正在等待的写入任务,就用
process.nextTick
或Promise.resolve().then()
注册一个微任务。 - 在这个微任务中,才真正地执行
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)的设计思想,看看能从中吸收哪些宝贵的经验。