Yihui’s Blog

编辑器加载快了 60%:我们是怎么把 1.5MB 的"赘肉"切掉的

候选标题(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.jssimple-mind-map + 3 个插件用思维导图时
React/ReactDOM CDNReact 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 防止重复加载。

最好的性能优化,不是让所有东西都更快——而是让不需要的东西根本不出现在关键路径上。 这个原则适用于编辑器,也适用于任何前端重型应用的架构设计。

维护与整理 · Yihui在 GitHub 上编辑

继续阅读

浏览全部文章

Tiptap 深度教程(四):终极定制 - 从零创建你的专属扩展

引言 欢迎来到《Tiptap 深度教程》系列的第四篇,也是最具深度的一章。在前几篇教程中,我们探索了如何利用 Tiptap 强大的开箱即用功能和丰富的扩展生态来快速构建编辑器。然而,Tiptap 的真正威力并不仅限于此,它最核心的优势在于其近乎无限的可扩展性。当标准功能无法满足你独特的产品需求时,你需要的能力,是…

阅读全文

Tiptap 深度教程(三):掌握内容 - 核心扩展全面指南

前言回顾 在 第二章 中,我们成功使用 StarterKit 快速搭建了一个功能完备且经过性能优化的富文本编辑器。StarterKit 确实是一个极佳的起点,它为我们提供了开箱即用的基础功能。 然而,Tiptap 的真正威力在于其精细的模块化设计和"按需引入"的架构哲学。就像搭建乐高积木一样,你可以精确选择需要的…

阅读全文