Vue 的 `app.use()`、Figma 的快捷键、Vite 的插件——为什么它们底层是同一种架构?

🚀 省流助手

  • 现象 :你天天写 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

注意三件事

  1. 内核 Core 一行业务代码都没有------它只管"维护钩子注册表 + 按事件分发"
  2. 三个插件互不知道对方存在,删任何一个其他都能跑
  3. 上下文 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 秒问自己:

  1. 这是核心还是边缘? 如果一年后还会改,那是边缘,应该走插件
  2. 它需要哪些时机? 把时机翻译成钩子名字
  3. 它需要什么数据? 把数据翻译成上下文

哪怕你现在的工具里没有内核,只有一个 class,也可以先开个简单的 EventEmitter + 在关键流程里 emit,让新功能挂上去而不是塞进流程内。这一步是从 monolith 走向插件化最划算的入门成本

工作流上的小习惯:

  • 每周读一个开源工具的「核心入口」(不是文档,而是源码 src/index.tssrc/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 视角望它们;当你从插件化视角望,它们就是同一种架构的不同尺寸。

相关推荐
洛卡卡了1 小时前
grill-me、Trellis、Superpowers:不同场景下怎么用?
chatgpt·ai编程·claude
koping_wu1 小时前
【Claude Code】Mac安装Claude Code、通过阿里云百炼接入Claude Code
macos·阿里云·云计算·ai编程
墨者阳明2 小时前
[AI纪元]RAG真的过时了吗?初步窥探传统RAG、grep MD、llms wiki方案的优劣势
aigc·ai编程
财经资讯数据_灵砚智能2 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(日间)2026年5月9日
人工智能·python·信息可视化·自然语言处理·ai编程
TDengine (老段)2 小时前
TDengine 整体架构全景 — 深度解析
大数据·数据库·物联网·架构·时序数据库·tdengine·涛思数据
SamDeepThinking2 小时前
你认为从0-1开发一个项目最难的地方是什么?
java·后端·架构
DogDaoDao2 小时前
【GitHub】SuperClaude Framework深度解析:将Claude Code打造为专业开发平台的元编程配置框架
人工智能·深度学习·程序员·大模型·github·ai编程·claude
程序员鱼皮2 小时前
有人靠 API 中转站赚了上亿?我花 2 块钱做了一个。。
计算机·ai·程序员·编程·ai编程
星辰_mya2 小时前
Docker “超级大厨”
运维·docker·容器·面试·架构