Excalidraw 很适合嵌入 React:一个组件就能得到完整的手绘风编辑器。但 Next.js 上线后最常见的两个问题往往不是画图,而是编辑器在服务端被执行,或者字体、语言包与数据 chunk 从错误地址加载。
因此,可靠封装要从“只在客户端加载”和“确定性托管静态资源”开始。做好这两点后,应用再决定开放哪些原生操作、如何映射语言、场景是否自动保存,以及原生全屏不可用时如何降级。
先确认 Excalidraw 是否适合产品场景
Excalidraw 很适合架构草图、粗略流程、会议共创和希望保持非正式感的示意图。它已经提供选择、连线、文字、图片、场景加载和图片导出,产品不必自己实现图形引擎。
手绘风格也是产品取舍。如果用户需要多页面、深度定制编辑器 UI、像素级设计或大量业务专用图形,应先比较编辑器 API,而不是只被默认视觉效果吸引。
| 需求 | Excalidraw 适配度 |
|---|---|
| 手绘风流程图与架构草图 | 很适合 |
| 场景导入和图片导出 | 原生支持 |
| 简单的本地单用户画板 | 很适合 |
| 自建多人协作后端 | 可以,但属于独立架构 |
| 像素级 UI 设计编辑器 | 通常不是目标 |
让编辑器离开服务端渲染路径
封装组件使用 client component,并在该工具 route layout 引入 @excalidraw/excalidraw/index.css。在 effect 中先配置资源根路径,再动态 import 包。这样既不会在服务端求值依赖 DOM 的代码,也不会让无关页面加载编辑器 chunk。
运行时解析字体、语言包或数据 chunk 之前,必须已经写入资源路径。可以封装一个带 typeof window 判断的函数,在客户端模块初始化时执行一次,import 前再执行一次。
const ASSET_PATH = '/excalidraw/';
function configureAssets() {
if (typeof window !== 'undefined') {
window.EXCALIDRAW_ASSET_PATH = ASSET_PATH;
}
}
useEffect(() => {
let active = true;
configureAssets();
import('@excalidraw/excalidraw').then(({ Excalidraw }) => {
if (active) setEditor(() => Excalidraw);
});
return () => { active = false; };
}, []);复制并锁定 Excalidraw 静态资源
EXCALIDRAW_ASSET_PATH 应指向部署站点提供的目录,例如 /excalidraw/。把与依赖版本匹配的包资源复制到 public/excalidraw,并保留末尾斜杠。一定要测试干净的生产构建,因为开发环境的解析方式可能掩盖缺失文件。
打开 Network 面板,重点检查字体、locale 模块和数据 chunk 是否 404。严格 CSP 场景只为实际使用的功能开放必要 worker 与 blob 来源。
- 依赖包版本与复制出来的资源必须一起升级。
- 运行时不要依赖 node_modules 路径。
- 分别验证中文和英文语言资源。
- 部署后测试图片插入和导出,而不只是画一个矩形。
优先开放原生操作,不要重复造 UI
UIOptions 可以控制显示哪些画布操作和工具。通用本地画板可以开放清空、加载场景、保存文件、图片导出、背景设置、主题切换和图片插入。这样不必在产品外壳里重写弹窗、文件选择与序列化。
initialData 适合设置可预测的首次画布状态,但它不是受控状态。它只负责初始化;持续持久化应通过变化回调或 Excalidraw imperative API 单独设计。
<Excalidraw
langCode={locale === 'zh' ? 'zh-CN' : 'en'}
name="product-sketch"
theme="light"
initialData={{
appState: {
viewBackgroundColor: '#ffffff',
currentItemStrokeColor: '#111827',
},
}}
UIOptions={{
canvasActions: {
clearCanvas: true,
loadScene: true,
saveAsImage: true,
toggleTheme: true,
},
tools: { image: true },
}}
/>在编辑器边界做国际化映射
把站点 locale 到 Excalidraw langCode 的映射集中处理,例如 zh 对应 zh-CN,英文使用 en。页面标题、隐私提示、加载文案、重试按钮和全屏按钮仍然进入网站自己的消息字典。
这样编辑器内部文案跟随依赖包翻译,产品文案则继续遵循网站 i18n 流程。不同语言下工具栏宽度可能变化,必须用最长文案测试换行和遮挡。
明确场景是临时、本地保存还是云端同步
最小封装可以只使用 Excalidraw 原生加载和保存功能,不在 React state 中保留场景副本。对于强调隐私的工具页,这是很好的默认值:应用不收集画布,用户需要携带时主动保存文件。
如果需要自动本地恢复,应通过 onChange 谨慎序列化 elements、appState 和 files,做防抖并处理浏览器配额。如果需要账号同步或协作,文件 Blob、文档权限、加密、迁移与冲突解决都会变成后端问题。
- 不要在每次指针移动时无防抖写存储。
- 除了场景元素还要保存引用文件,否则图片会消失。
- 长期存储前过滤临时 appState 字段。
- 明确告诉用户画板是未保存、本地保存还是已经同步。
把全屏当成产品功能,而不是偶然 CSS
嵌入状态下,编辑器容器必须有明确高度。进入全屏时在外层 wrapper 调用 requestFullscreen;如果浏览器拒绝,则降级为 fixed 覆盖整个视口。CSS 全屏时锁住 body 滚动,并在 cleanup 恢复。
进入或退出两种全屏模式后,都应触发 resize,让 Excalidraw 重新计算视口。flex 祖先还要设置 min-h-0,否则画布可能溢出,而不是在全屏列布局中收缩。
认真处理加载、清理与隐私边界
动态 import 较慢时,纯白矩形看起来像坏掉了。应该显示中性的加载面,import 失败后切换为本地化错误和重试操作,组件卸载后也不能继续 setState。全屏和键盘监听器必须全部移除,body 样式也要在 effect cleanup 中恢复。
除非经过明确隐私评审,不要在 scene change 回调里做统计。通常只记录页面访问和按钮事件就足够,不需要收集图形、文字、文件名或嵌入图片。
总结
稳定的 Excalidraw 集成并不复杂,但边界必须严格:client component、动态加载前设置资源路径、复制与版本匹配的公共资源、组合原生 UI、明确持久化语义,并让容器正确适应全屏变化。优先设计这些边界,大部分集成问题都会消失。