候选标题(5 个)
① 编辑器加载快了 60%:我们是怎么把 1.5MB 的”赘肉”切掉的
② 代码级拆解:一个富文本编辑器的”核心瘦身 + 插件外挂”架构实战
③ 告别”全家桶”式打包——从 B 站编辑器看前端重型模块的按需加载方案
④ 你的编辑器为什么慢?一文讲透 Addon 架构的设计哲学与工程细节
⑤ dynamic import + globalThis + preload:大厂编辑器的三板斧瘦身术
正文
一个富文本编辑器,功能越做越多:画板(Excalidraw,~800KB)、思维导图(MindMap,~200KB)、流程图(Mermaid,~500KB)、AI 助手(~150KB)……当这些功能全部打进一个 bundle,用户还没敲第一个字,浏览器就已经在下载 1.5MB 以上的”死重”——而绝大多数用户可能一辈子都不会点开画板。
这不是功能的问题,是加载策略的问题。
今天我们来拆解一套来自实际生产项目的解决方案:Addon 架构——把重型功能从主 bundle 中物理隔离,按需加载,并且在 ES Module 和 UMD 两种分发模式下都能无缝运行。
一、整体架构:三层分离
先看全貌,整个方案分三层:
┌─────────────────────────────────────────────┐
│ Layer 1 · 运行时 Loader(addon-extensions) │ → 按需加载 + 容错
├─────────────────────────────────────────────┤
│ Layer 2 · 构建时配置(vite.config) │ → 代码分割 / 外部化
├─────────────────────────────────────────────┤
│ Layer 3 · Addon Bundle(独立 UMD 文件) │ → 独立打包 + 全局注册
└─────────────────────────────────────────────┘
打个比方:Layer 1 是餐厅的服务员(按需问客人要什么菜),Layer 2 是厨房的备菜策略(把大菜和小菜分开准备),Layer 3 是独立的外卖档口(每道大菜有自己的独立出餐口)。
下面逐层拆解。
二、Layer 1:运行时按需加载
核心文件是 addon-extensions.ts,它为每个重型功能提供一个异步加载函数,统一返回 AddonResult:
// 伪代码:每个 addon 的加载契约
interface AddonResult {
extension: TiptapExtension // 编辑器扩展本体
slashCommands?: SlashCommand[] // 附带的斜杠命令(如 /excalidraw)
}
设计巧思 ①:一次 import 拿到”扩展 + 命令”。如果把扩展和斜杠命令拆成两次 import(),就是两次网络往返。合成一个 AddonResult 一次搞定。
以 Excalidraw 的加载为例,完整逻辑如下:
async loadExcalidrawExtension(config):
① if config.features.excalidraw === false → return null // 功能开关关闭,直接跳过
② await ensureReact() // 确保 React 全局可用
└→ 失败? → return null + 打印 warn // React 加载失败,优雅降级
③ { ExcalidrawNode, excalidrawCommand } = await import('@bilibili/eva3-extension-excalidraw')
④ return { extension: ExcalidrawNode.configure({...}), slashCommands: [excalidrawCommand()] }
⑤ catch → return null + 打印 error // 任何异常都不影响主编辑器
设计巧思 ②:Feature Flag 前置判断。配置 features.excalidraw = false 时,连 import() 都不会触发,零网络开销。
设计巧思 ③:try-catch 全包裹。每个 addon 的加载都是独立的,某个 CDN 抽风不会拖垮其他功能,更不会导致编辑器白屏。
四个 addon 的加载在主流程中通过 Promise.all 并行触发:
// extensions.ts 中的编排逻辑(伪代码)
async buildExtensions(config):
coreExtensions = [ StarterKit, Bold, Image, Link, Table, ... ] // 同步,立即可用
addonResults = await Promise.all([
loadExcalidrawExtension(config), // ~800KB,需要 React
loadMermaidExtension(config), // ~500KB
loadMindMapExtension(config), // ~200KB
loadAIToolkitExtension(config), // ~150KB,需要 React
])
for result in addonResults:
if result != null → coreExtensions.push(result.extension)
// 从 addon 结果中收集 slash 命令,注入到 Slash 扩展
slashGroups = [基础命令, ...各 addon 返回的 slashCommands]
coreExtensions.push(Slash.configure({ groups: slashGroups }))
return coreExtensions
四个网络请求同时出发、互不阻塞、各自容错。 这是最基本但非常有效的并行化策略。
三、Layer 2:构建时的 Bundle 分离
相同的源代码,要同时支持两种消费模式,构建策略完全不同:
ES Module 模式——利用 Rollup 的 manualChunks,天然支持 code splitting:
// vite.config.mts(伪代码)
manualChunks(moduleId):
if moduleId contains 'excalidraw' → chunk: 'excalidraw'
if moduleId contains 'mermaid' → chunk: 'mermaid'
if moduleId contains 'mind-map' → chunk: 'mind-map'
产出结果:主 bundle index.mjs 不含重型依赖,用到时浏览器自动按 chunk 加载。这是 ES Module 的”原生能力”,没什么魔法。
UMD 模式——难点来了。UMD 必须是单文件(inlineDynamicImports: true),不支持 code splitting。怎么办?
方案是:外部化 + 自定义 Rollup 插件。
先把重型依赖声明为外部化:
HEAVY_EXTERNALS_UMD = [
'mermaid', 'simple-mind-map', 'react', 'react-dom', '@excalidraw/excalidraw'
]
但这带来一个新问题:源代码里有 import("simple-mind-map") 这样的动态导入,被外部化后,Rollup 会原样输出到产物中。而浏览器碰到 import("simple-mind-map") 这种裸模块标识符会直接报错——它不知道去哪找这个包。
这是整个方案中最精巧的一环——externalDynamicImportGlobals 插件:
// 自定义 Rollup 插件(伪代码)
externalDynamicImportGlobals(globals):
对每个外部化模块的 import() 调用:
转换前: import("simple-mind-map")
转换后: globalThis["MindMap"]
? Promise.resolve({ default: globalThis["MindMap"] })
: Promise.reject(new Error("simple-mind-map not available"))
翻译成人话:把”去网上找这个包”变成了”看看全局变量里有没有”。如果消费者已经通过 <script> 标签加载了对应的 addon UMD 文件,全局变量自然就有了;如果没有,reject 会被 addon-extensions.ts 的 try-catch 捕获,走降级逻辑。
这就是为什么同一套 addon-extensions.ts 代码能同时在 ES 和 UMD 下工作:ES 模式下 import() 走网络加载 chunk,UMD 模式下 import() 被编译成 globalThis 查找。
四、Layer 3:独立的 Addon Bundle
每个 addon 有自己的入口文件和构建配置,输出为独立 UMD 文件。
MindMap Addon(mindmap-addon.ts):
import MindMap from 'simple-mind-map'
import Export, Drag, KeyboardNavigation from '插件目录'
MindMap.usePlugin(Export) // 预注册常用插件
MindMap.usePlugin(Drag)
MindMap.usePlugin(KeyboardNavigation)
globalThis.MindMap = MindMap // 注册到全局,供核心包 import() 消费
无外部依赖,simple-mind-map 及插件全部打入,消费者引入一个 <script> 即可。
Excalidraw Addon(excalidraw-addon.ts):
import * as ExcalidrawLib from '@excalidraw/excalidraw'
globalThis.ExcalidrawLib = ExcalidrawLib
注意差异:Excalidraw 把 React 外部化了(external: ['react', 'react-dom']),因为 React 由核心包的 ensureReact() 统一提供。这避免了同一个页面加载两份 React。
构建命令也很简洁:
ADDON_NAME=excalidraw vite build --config vite.addon.config.mts
ADDON_NAME=mindmap vite build --config vite.addon.config.mts
一个配置文件,通过环境变量切换不同 addon 的入口、外部化策略和全局变量映射。
五、隐藏 Boss:React 运行时的”鸡生蛋”问题
Excalidraw 和 AI 助手依赖 React,但编辑器本身是用 Lit 写的,不需要 React。把 React 打进主 bundle?多了 130KB+。不打进去?Excalidraw 加载时没有 React 可用。
解法是 react-runtime.ts 里的 Singleton CDN Loader:
// 初始化阶段(编辑器构造函数)
preloadReact():
if React 已存在 → 跳过
注入 <link rel="preload" as="script" href="react.min.js"> ← 仅预取,不执行
注入 <link rel="preload" as="script" href="react-dom.min.js"> ← 浏览器后台并行下载
// 使用阶段(addon 加载前调用)
ensureReact():
if globalThis.React 存在 → return true(消费者已自行加载)
if 正在加载中 → return 同一个 Promise(去重,不会重复加载)
首次加载:
preloadScript(REACT_DOM_CDN) ← 先发起 ReactDOM 的预取
await loadScript(REACT_CDN) ← 执行 React(设置 window.React)
await loadScript(REACT_DOM_CDN) ← 从 preload 缓存命中,0 额外 RTT
return isReactAvailable()
这里有一个经典的网络并行化技巧:ReactDOM 的 UMD 包在执行时需要 window.React 已存在,所以不能同时以 <script> 加载两者。但 <link rel="preload"> 只下载不执行——在 React 还在执行的时候,ReactDOM 的字节已经在飞了。等 React 设置好全局变量后,ReactDOM 直接从缓存读取,节省一个完整的 RTT。
同时,reactLoadPromise 的 singleton 模式确保:即使 Excalidraw 和 AI 同时调用 ensureReact(),React 也只加载一次。
六、整体初始化时序
把所有层串起来,完整的加载时序如下:
new Eva3AskmeEditor(container, config)
│
├─ preloadReact() ← 立即:注入 preload hint,浏览器后台开始下载
│
└─ await initialize()
├─ createDOMStructure() ← 同步:构建 DOM 骨架
├─ await createTiptapEditor()
│ ├─ await buildExtensions()
│ │ ├─ 同步加载 40+ 核心扩展 ← StarterKit, Bold, Table, Link...
│ │ ├─ await Promise.all([
│ │ │ loadExcalidraw(), ← ensureReact() → import() → 配置
│ │ │ loadMermaid(), ← import() → 配置
│ │ │ loadMindMap(), ← import() → 配置
│ │ │ loadAIToolkit(), ← ensureReact() → import() → 配置
│ │ │ ])
│ │ └─ 收集扩展 + slash 命令 → return extensions[]
│ └─ new Editor({ extensions })
├─ mountToolbar()
└─ requestAnimationFrame → setContent()
核心扩展同步就绪,重型 addon 异步并行加载。如果所有 addon 的 feature flag 都关闭,buildExtensions() 几乎是纯同步的。
七、这套方案的量化收益
| 产物 | 包含内容 | 加载时机 |
|---|---|---|
index.umd.js | 核心编辑器 + 40+ 轻量扩展 | 首屏必须 |
excalidraw.umd.js | @excalidraw/excalidraw(不含 React) | 用画板时 |
mindmap.umd.js | simple-mind-map + 3 个插件 | 用思维导图时 |
| React/ReactDOM CDN | React 18 production UMD | 首次用 React 功能时 |
主 bundle 减少约 1.5MB 的第三方依赖,对于不使用画板/流程图的用户(绝大多数),这就是纯粹的带宽节省。
八、总结:四个值得借鉴的设计决策
1. AddonResult 统一契约——一次 dynamic import 同时返回扩展和命令,避免多次往返。
2. Feature Flag 前置于 import——开关关闭时连网络请求都不发。不是”加载了再判断要不要用”,而是”不用就根本不加载”。
3. externalDynamicImportGlobals 插件——让同一份源码的 import() 在 ES 模式下走 chunk、在 UMD 模式下走 globalThis,消除双模式维护成本。
4. preload + singleton 的 React 加载——利用浏览器 preload 缓存实现网络并行化,加上 Promise singleton 防止重复加载。
最好的性能优化,不是让所有东西都更快——而是让不需要的东西根本不出现在关键路径上。 这个原则适用于编辑器,也适用于任何前端重型应用的架构设计。