从零构建 Notion 风格编辑器:基于 Editor.js 的模块化实践指南

一、Notion 风格编辑器的核心特征

Notion 的成功源于其独特的块级编辑架构:每个内容单元(文本、图片、表格)作为独立模块存在,支持自由拖拽排序、嵌套组合和实时协作。这种设计突破了传统富文本编辑器的线性结构,赋予用户更高的内容组织自由度。

实现此类编辑器需解决三大技术挑战:

  1. 块级内容管理:每个模块需保持独立状态和操作接口
  2. 异步协作支持:多用户操作冲突检测与合并
  3. 响应式渲染:复杂嵌套结构的动态布局计算

Editor.js 的模块化设计恰好契合这些需求。其核心概念”Block”天然支持内容单元化,配合插件系统可快速构建复杂交互。

二、Editor.js 基础架构解析

2.1 核心工作机制

Editor.js 采用”工具链”架构,每个功能通过独立工具(Tool)实现:

  1. const editor = new EditorJS({
  2. tools: {
  3. header: Header,
  4. paragraph: Paragraph,
  5. list: List,
  6. image: {
  7. class: ImageTool,
  8. config: {
  9. endpoints: {
  10. byFile: '/upload-image'
  11. }
  12. }
  13. }
  14. }
  15. });

这种设计带来显著优势:

  • 解耦性:各工具独立维护,不影响核心编辑器
  • 可扩展性:通过注册新工具即可添加功能
  • 状态隔离:每个块保存独立数据结构

2.2 块级数据模型

每个 Block 包含:

  • type:工具类型标识
  • data:结构化内容数据
  • tunedOptions:工具特定配置

示例段落块数据结构:

  1. {
  2. "type": "paragraph",
  3. "data": {
  4. "text": "这是段落内容",
  5. "alignment": "center"
  6. }
  7. }

三、Notion 风格功能实现

3.1 块级操作面板

实现拖拽排序需监听 blockDragStartblockDrop 事件:

  1. editor.on('blockDragStart', (block) => {
  2. // 显示拖拽占位符
  3. document.querySelector('.ce-block__dragger').style.display = 'block';
  4. });
  5. editor.on('blockDrop', (newPosition) => {
  6. // 更新块顺序并保存
  7. saveBlocksOrder(newPosition);
  8. });

嵌套块结构通过 BlockTunnel 插件实现,允许在段落内插入子块:

  1. const tunnel = new BlockTunnel({
  2. selector: '.ce-paragraph',
  3. allowedTypes: ['image', 'list']
  4. });
  5. editor.plugins.add(tunnel);

3.2 实时协作集成

采用 Y.js 库实现操作转换(OT)算法:

  1. import * as Y from 'yjs';
  2. import { WebsocketProvider } from 'y-websocket';
  3. const ydoc = new Y.Doc();
  4. const provider = new WebsocketProvider(
  5. 'wss://your-collab-server',
  6. 'editor-room',
  7. ydoc
  8. );
  9. // 将 Editor.js 状态映射到 Y.Map
  10. const editorState = ydoc.getMap('editorState');
  11. editor.on('save', (output) => {
  12. editorState.set('blocks', output.blocks);
  13. });

3.3 快捷键系统扩展

通过 Shortcut 插件实现 Notion 式快捷键:

  1. const shortcuts = new Shortcut({
  2. commands: {
  3. 'Mod+B': (editor) => {
  4. editor.blocks.insert('paragraph', { text: '**粗体文本**' });
  5. },
  6. 'Mod+K': (editor) => {
  7. // 触发链接插入面板
  8. showLinkModal();
  9. }
  10. }
  11. });

四、性能优化策略

4.1 虚拟滚动实现

对于长文档,实现块级虚拟滚动:

  1. class VirtualScroll {
  2. constructor(editor) {
  3. this.editor = editor;
  4. this.visibleRange = { start: 0, end: 20 };
  5. }
  6. updateVisibleBlocks() {
  7. const blocks = this.editor.blocks.getBlocks();
  8. const scrollTop = window.scrollY;
  9. // 计算可见区域块索引
  10. const blockHeights = blocks.map(b => this.calculateBlockHeight(b));
  11. let cumulativeHeight = 0;
  12. for (let i = 0; i < blockHeights.length; i++) {
  13. if (cumulativeHeight > scrollTop &&
  14. cumulativeHeight < scrollTop + window.innerHeight) {
  15. this.visibleRange = { start: i - 2, end: i + 20 };
  16. break;
  17. }
  18. cumulativeHeight += blockHeights[i];
  19. }
  20. this.renderVisibleBlocks();
  21. }
  22. }

4.2 增量保存机制

采用差异保存策略减少网络请求:

  1. let lastSavedState = null;
  2. editor.on('change', () => {
  3. const currentState = editor.save();
  4. const diff = calculateDiff(lastSavedState, currentState);
  5. if (diff.length > 0) {
  6. sendToServer(diff);
  7. lastSavedState = currentState;
  8. }
  9. });
  10. function calculateDiff(oldState, newState) {
  11. // 实现深度比较算法
  12. // 返回变更的块索引和修改内容
  13. }

五、完整实现示例

  1. // 初始化配置
  2. const editor = new EditorJS({
  3. holderId: 'editorjs',
  4. tools: {
  5. header: {
  6. class: Header,
  7. inlineToolbar: true
  8. },
  9. paragraph: {
  10. class: Paragraph,
  11. inlineToolbar: ['bold', 'italic', 'link']
  12. },
  13. image: {
  14. class: ImageTool,
  15. config: {
  16. endpoints: {
  17. byFile: '/api/upload-image',
  18. byUrl: '/api/fetch-image'
  19. }
  20. }
  21. },
  22. list: {
  23. class: List,
  24. inlineToolbar: true
  25. },
  26. table: {
  27. class: Table,
  28. inlineToolbar: true
  29. },
  30. embed: {
  31. class: Embed,
  32. config: {
  33. services: {
  34. youtube: true,
  35. twitter: true
  36. }
  37. }
  38. }
  39. },
  40. data: {
  41. blocks: [
  42. {
  43. type: 'header',
  44. data: {
  45. text: 'Notion 风格编辑器',
  46. level: 1
  47. }
  48. },
  49. {
  50. type: 'paragraph',
  51. data: {
  52. text: '使用 Editor.js 构建'
  53. }
  54. }
  55. ]
  56. },
  57. placeholder: '开始输入...',
  58. autofocus: true
  59. });
  60. // 协作功能初始化
  61. const ydoc = new Y.Doc();
  62. const provider = new WebsocketProvider(
  63. 'wss://collab-server.com',
  64. 'editor-room-123',
  65. ydoc
  66. );
  67. const editorState = ydoc.getMap('editorState');
  68. editorState.observe((event) => {
  69. if (event.keys.includes('blocks')) {
  70. const blocks = event.values.blocks;
  71. editor.blocks.render(blocks);
  72. }
  73. });
  74. // 保存按钮处理
  75. document.getElementById('save-btn').addEventListener('click', async () => {
  76. const output = await editor.save();
  77. const response = await fetch('/api/save-document', {
  78. method: 'POST',
  79. body: JSON.stringify(output),
  80. headers: {
  81. 'Content-Type': 'application/json'
  82. }
  83. });
  84. if (response.ok) {
  85. alert('保存成功');
  86. }
  87. });

六、进阶优化方向

  1. Markdown 导入/导出:开发转换插件处理格式映射
  2. 版本历史:集成 Diff.js 实现变更追踪
  3. 移动端适配:优化触摸交互和手势操作
  4. 无障碍支持:完善 ARIA 标签和键盘导航

七、常见问题解决方案

Q1:如何实现块级锁定防止意外修改?

  1. editor.on('blockFocus', (block) => {
  2. if (block.config.locked) {
  3. editor.blocks.deselect();
  4. showLockedNotification();
  5. }
  6. });

Q2:如何优化大量图片块的加载性能?

  • 实现懒加载:仅渲染可视区域图片
  • 使用 CDN 和渐进式 JPEG
  • 添加占位符和加载动画

Q3:如何支持自定义块类型?

  1. 创建继承 Tool 的类
  2. 实现 rendersavevalidate 方法
  3. 在配置中注册新工具

通过系统化的模块设计和渐进式功能扩展,开发者可以基于 Editor.js 构建出功能完备、体验流畅的 Notion 风格编辑器。这种方案既保留了框架的灵活性,又通过标准化接口降低了开发复杂度,特别适合需要快速迭代的中大型项目。