🚀 省流助手
- 现象 :你天天写
app.use(router)、vite.config.ts里加 plugin、用 Excalidraw 装协作光标,但自己写小工具时还是 monolith → 改源码 → 3 个月烂掉 - 真相:你早就在消费「核心 + 插件」这个范式了,只是没意识到它有一个统一名字
- 带走:50 行 Node 实现一个最简内核(文末有可运行代码),从此能 think in plugin
一、症状:你的工具为什么越扩越烂
写过这种代码吗?
ts
// editor.ts
class Editor {
handleKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'c') this.copy();
if (e.ctrlKey && e.key === 'v') this.paste();
// 3 个月后
if (e.ctrlKey && e.key === 'z') this.undo();
if (e.key === 'Delete') this.delete();
// 用户:能加个命令面板吗?
if (e.ctrlKey && e.key === 'k') this.openCommandPalette();
// 用户:能加吸附线吗?
this.drawSnapLines();
// 用户:协作光标?
this.drawRemoteCursor();
// ...继续往下加
}
}
每加一个功能,handleKeyDown 就胖一圈。半年后这文件 3000 行,圈复杂度爆表,谁都不敢动。
这不是你的能力问题,是 monolithic 架构的物理极限。
但奇怪的是------你每天用的工具,从没出现这种困境。Vue、Vite、Excalidraw、Figma、Express 都在不停加功能,越加越好用。它们怎么做到的?
二、三种容易想到的方案,三种崩坏
接到「让 Editor 可扩展」需求,开发者通常会想到这三种方案。每一种都崩坏在不同的地方。
方案 1:全塞 if-else(你正在做的事)
ts
handleKeyDown(e) {
if (feature1) { /* ... */ }
if (feature2) { /* ... */ }
if (feature3) { /* ... */ }
}
崩坏:单文件越来越胖;新人加功能必须读完全部 if-else;删除功能怕牵连其他分支。
方案 2:配置开关(看似优雅)
ts
new Editor({
enableCopy: true,
enableSnapLines: true,
enableCollab: false,
});
崩坏 :每加一个功能,Editor 内核就要加一个 if 判断 if (config.enableSnapLines)。功能在外面声明,逻辑在里面藏,等于把 monolith 拆成了 monolith + 配置文件。
方案 3:fork 改源码
「我们项目情况特殊,干脆 fork 一份改」。
崩坏 :上游每发一个版本,你都要 rebase 自己的 patch;半年后没人能合,永远停在某个老版本。这是你逃离方案 1/2 的代价。
三种方案的共同问题是:功能与内核深度耦合。要么内核里塞功能(1 / 2),要么改内核加功能(3)。
三、真相:你天天用的工具早就解决了
打开你电脑随便看几个工具的入口代码:
ts
// Vue 应用
const app = createApp(App);
app.use(router); // ← 插件
app.use(pinia);
app.use(i18n);
// Vite 配置
export default defineConfig({
plugins: [
vue(), // ← 插件
unocss(),
visualizer(),
],
});
// Excalidraw / 可扩展画板
<Excalidraw
renderTopRightUI={...} // ← 钩子点
onChange={...}
onPointerDown={...}
/>
// Express 后端
app.use(cors()); // ← 中间件 = 插件
app.use(helmet());
app.use('/api', apiRouter);
// Fastify 后端
fastify.register(authPlugin); // ← 显式叫 register,本质同前
形状高度一致:一个核心实例 + 一个 use / register / 类似形状的方法 + 一组互不相关的插件函数。
更重要的是这些插件都遵循同一个隐含契约:
| 工具 | 插件能干什么 | 怎么知道何时干 |
|---|---|---|
| Vue | 注册组件 / 指令 / 全局属性 | app 实例传给插件,插件主动调用 |
| Vite | 改 build 配置 / 转换代码 / 注入虚拟模块 | rollup 钩子(transform / load / resolveId) |
| Express | 处理请求 / 改响应 | (req, res, next) => {} 形状的中间件链 |
| Excalidraw | 渲染 UI / 响应交互 | React props 形式的回调钩子 |
它们都在做同一件事:把「插件能介入的时机」明确暴露出来,让插件函数挂上去;运行到那个时机就调用所有挂上去的函数。
这就是「核心 + 插件」架构。
四、根因:「核心 + 插件」的三件套
任何「核心 + 插件」框架都由三件套组成:
css
┌─────────────────────────────────────────────────┐
│ 1. 钩子点(hooks) │
│ 核心暴露的「时机」清单 │
│ 例:Vue 的 install 时机;Vite 的 transform; │
│ 编辑器的 onKeyDown / onMouseMove │
├─────────────────────────────────────────────────┤
│ 2. 上下文(context) │
│ 钩子触发时传给插件的数据 + 能调用的 API │
│ 例:Vue 传 app 实例;Vite 传 source code; │
│ 编辑器传 event + canvas 实例 │
├─────────────────────────────────────────────────┤
│ 3. 注册表(registry) │
│ 谁挂在哪个钩子点上 │
│ 例:Vue 的 plugins 数组;Vite 的 plugin 配置; │
│ 编辑器的 hooks Map │
└─────────────────────────────────────────────────┘
理解这三件套,再看任何「插件化」工具,你都能 5 分钟看懂它的架构。
更厉害的是反过来------自己写工具时主动套这三件套,monolith 的痛苦会消失大半。
五、50 行 Node 实现一个最简内核
口说无凭。下面是一个能跑的最简版本(TypeScript,可直接 ts-node 运行):
ts
// core.ts ------ 核心 + 插件 微内核(约 30 行)
type Hook<T> = (ctx: T) => void | Promise<void>;
type Plugin<T> = (api: PluginAPI<T>) => void;
interface PluginAPI<T> {
on(event: string, hook: Hook<T>): void;
emit(event: string, ctx: T): Promise<void>;
}
export class Core<T extends Record<string, any> = {}> {
private hooks = new Map<string, Hook<T>[]>();
use(plugin: Plugin<T>): this {
plugin({
on: (event, hook) => {
const list = this.hooks.get(event) ?? [];
list.push(hook);
this.hooks.set(event, list);
},
emit: this.emit.bind(this),
});
return this;
}
async emit(event: string, ctx: T): Promise<void> {
for (const hook of this.hooks.get(event) ?? []) {
await hook(ctx);
}
}
}
ts
// editor.ts ------ 用核心做一个迷你编辑器
import { Core } from './core';
type EditorCtx = { event: KeyboardEvent; selection: string[] };
const editor = new Core<EditorCtx>();
// 插件 1:复制粘贴
editor.use((api) => {
api.on('keydown', ({ event, selection }) => {
if (event.ctrlKey && event.key === 'c') {
console.log('[copy]', selection);
}
if (event.ctrlKey && event.key === 'v') {
console.log('[paste]');
}
});
});
// 插件 2:吸附线(按住 Shift 时启用)
editor.use((api) => {
api.on('keydown', ({ event }) => {
if (event.shiftKey) console.log('[snap-line] enabled');
});
});
// 插件 3:协作光标(独立模块,零侵入)
editor.use((api) => {
api.on('keydown', () => {
console.log('[remote-cursor] sync');
});
});
// 模拟一次按键事件
editor.emit('keydown', {
event: { ctrlKey: true, key: 'c' } as KeyboardEvent,
selection: ['shape-1', 'shape-2'],
});
输出:
css
[copy] [ 'shape-1', 'shape-2' ]
[remote-cursor] sync
注意三件事:
- 内核
Core一行业务代码都没有------它只管"维护钩子注册表 + 按事件分发" - 三个插件互不知道对方存在,删任何一个其他都能跑
- 上下文
EditorCtx通过泛型约束,所有插件都拿同一份类型------这是好工具和烂工具的分水岭
把这 50 行扩展一下(加上 off() 解绑、加上 priority 控制顺序、加上 async 错误隔离),就是 Vite 内核的核心思路。Vite 的 Plugin 类型本质就是这个再加 rollup 的 hook 名字清单。
六、什么场景该用 / 什么场景别滥用
「核心 + 插件」不是银弹。以下是判断边界。
适合
- 可扩展的产品级工具:编辑器、构建工具、API 网关、规则引擎
- 多团队共建:不同团队开发独立插件互不干扰
- 生命周期长:3 年以上的项目,新需求会持续产生
- 核心稳定 / 边缘多变:核心逻辑(如 canvas 渲染)稳定,业务功能(快捷键、吸附线、协作)多变
不适合
- 一次性脚本:写完就扔的爬虫、迁移脚本------加抽象就是负担
- 强耦合业务:电商订单流水线,状态机本身是核心,硬拆插件反而割裂
- 3 个钩子以下:只有 1-2 个介入点直接 callback 参数就够,搞插件系统是炫技
- 新手主导:钩子点一旦定错,后期改动牵连所有插件------架构债会拖垮项目
一个判断公式
| 信号 | 倾向 |
|---|---|
| 已经在写第 3 个 if-else 分支了 | → 上插件 |
| 不同团队往同一文件里塞功能 | → 上插件 |
| 项目预计活 3 年+ | → 上插件 |
| 写完就扔 | → 别上 |
| 钩子点想不清楚 | → 先别上,再写两版迭代后再说 |
七、预防建议:从今天起 think in plugin
下次接到「能不能加 X 功能」这种需求时,停 30 秒问自己:
- 这是核心还是边缘? 如果一年后还会改,那是边缘,应该走插件
- 它需要哪些时机? 把时机翻译成钩子名字
- 它需要什么数据? 把数据翻译成上下文
哪怕你现在的工具里没有内核,只有一个 class,也可以先开个简单的 EventEmitter + 在关键流程里 emit,让新功能挂上去而不是塞进流程内。这一步是从 monolith 走向插件化最划算的入门成本。
工作流上的小习惯:
- 每周读一个开源工具的「核心入口」(不是文档,而是源码
src/index.ts或src/core.ts),看它们怎么定义钩子 - 写自己的工具时,先画一张「钩子时机表」,再写代码
- 给团队 review 别人的工具时,问一句「如果有人想加 X,他要改哪几个文件?」------改超过 1 个就有插件化空间
八、知识点提炼
写这篇时把核心架构思想压缩成 3 个可带走的知识点:
1. 钩子模式(Hook Pattern)
钩子就是「内核留给外部的预约时机」。设计钩子时遵守:
- 名字用动词时态 :
onResolve/beforeBuild/afterMount,让插件作者一眼看出"什么时候触发" - 数量保守:先少后多------钩子定多了改起来要联动所有插件
- 顺序明确:触发顺序文档要写明,否则插件之间会因为顺序假设打架
2. 上下文不可变 + 显式传递
插件之间通过上下文通信,但 [MUST] 显式声明谁能改谁。Vite plugin 的 transform 钩子返回新代码而不是直接 mutate 入参------这是好工具的共同特征。滥用 mutate = 噩梦。
3. 注册顺序与依赖图
用户写 app.use(A); app.use(B); 时,A 和 B 可能有依赖(B 用 A 注册的资源)。框架要么强约束顺序(先来后到),要么显式声明依赖(如 Fastify 的 dependencies 字段)。永远别让用户去猜插件加载顺序。
写在最后:你不需要造下一个 Vite。但只要写过一次「核心+插件」最简内核,再看 Vite / Vue / Express 源码会非常顺。它们之所以看起来神,是因为你曾经从 monolithic 视角望它们;当你从插件化视角望,它们就是同一种架构的不同尺寸。