toolgarden.xyz
EN
浏览器工具开发ExcalidrawNext.js在线画板前端工程

怎么封装 Excalidraw:解决 Next.js SSR、资源路径与全屏问题

在 Next.js 中稳定集成 Excalidraw,解决动态加载、静态资源路径、工具配置、国际化、全屏降级、导出与持久化取舍。

ToolGarden 推荐的工具优先在浏览器本地运行,文件和文本不必上传到服务器,适合更注重安全隐私的日常处理。

发布于 2026年7月22日约 10 分钟阅读作者 ToolGarden

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、明确持久化语义,并让容器正确适应全屏变化。优先设计这些边界,大部分集成问题都会消失。

常见问题

Q.为什么 Next.js 集成 Excalidraw 会报 window is not defined?

依赖包在服务端渲染上下文中被执行了。把编辑器放进 client component,并在挂载后动态 import,让依赖 DOM 的代码只在浏览器运行。

Q.EXCALIDRAW_ASSET_PATH 控制什么?

它告诉 Excalidraw 运行时从哪里加载字体、语言 chunk 等配套资源。应指向与依赖版本匹配的 public 目录,并在 import 编辑器之前完成设置。

Q.嵌入 Excalidraw 后会自动保存场景吗?

不要默认它会自动保存。原生场景加载和保存操作可以让用户管理文件;自动恢复需要显式设计变化数据、文件、存储上限和迁移。

Q.为什么恢复场景后,插入的图片不见了?

场景元素引用的二进制文件可能单独保存。只持久化 elements 和 appState 会丢失文件 Blob,必须把 files 集合当作同一文档的一部分保存与恢复。

Q.应该选择 Excalidraw 还是 tldraw?

取决于产品需求。Excalidraw 适合鲜明的手绘图风格和原生场景工作流;tldraw 提供另一套通用白板模型、多页面与丰富的编辑器组合能力。应使用真实的持久化、资源和移动端需求分别做原型。