Volto自定义区块编辑器开发实战:从静态文本到可配置表单

发布时间:2026/7/5 5:04:58
Volto自定义区块编辑器开发实战:从静态文本到可配置表单 1. 项目概述Volto 自定义区块编辑体验的实质性跃迁在 Plone 生态里Volto 不是简单的前端套壳而是一套真正把内容编辑权交还给内容创作者的现代化工作流引擎。我从 2019 年 Volto 3.x 时代就开始用它重构客户的老版 Plone 站点当时最头疼的不是功能实现而是“编辑体验”——用户点开一个自定义区块看到的是一行静态文字“Im the Block edit component!”然后茫然地问我“老师我到底该点哪儿改” 这种挫败感比写十个复杂视图还让人焦虑。今天要讲的就是如何把这行“装饰性文字”变成一个真正能干活、能存数据、能联动、能扩展的编辑界面。核心关键词是Plone但请注意我们谈的不是后端 Zope/Python 层的改造而是 Volto 前端 React 组件层的深度介入。它解决的是“内容编辑者在浏览器里能否直观、安全、高效地完成一次区块配置”的问题。适合三类人一是刚学完《Volto 自定义区块入门》、正卡在“编辑不了”这一步的开发者二是负责交付 Plone 项目的前端工程师需要向客户证明“这个后台真的好用”三是 Plone 管理员想理解为什么自己装的某个第三方区块编辑面板里总缺个字段。整个过程不碰 Python 代码不改数据库 Schema所有改动都在src/components/Blocks/MyCustomBlock/这个目录下完成。它不是炫技而是把 Volto 的设计哲学——“编辑即所见配置即表达”——真正落地的一次实操。你不需要是 React 专家但得愿意跟着敲几行import和解构赋值你也不需要懂 Plone 的 ZODB但得明白 Volto 编辑器里的“数据”最终会序列化成一个 JSON 对象再由后端存进 Plone。2. 整体设计思路与架构选型解析2.1 为什么必须拆分 Edit.jsx 与 Data.jsx——职责分离不是教条是工程现实初学者常犯一个错误把所有逻辑都塞进Edit.jsx。比如有人会直接在Edit组件里写一个SidebarPortal里面再塞一个表单最后再加一堆onChange处理函数。短期看能跑长期维护时你会被自己埋的雷炸懵。我去年帮一个教育机构重构官网他们就有一个“课程推荐区块”Edit.jsx文件长达 487 行里面混着 UI 渲染、表单逻辑、图片上传回调、外部 API 调用、错误提示……结果产品经理提了个小需求“在编辑面板加个‘是否置顶’开关”开发花了两天半因为没人敢动那段“祖传代码”。Volto 的SidebarPortal组件本质是一个“内容投射容器”它的唯一使命是把指定的 React 元素精准地渲染到页面右侧那个固定位置的侧边栏里。它不关心你投进去的是按钮、是表单、还是一个加载动画。所以Edit.jsx的核心职责只有一个作为区块的“主入口”和“状态协调者”。它负责接收 Volto 编辑器传来的props比如当前选中的区块 ID、整个页面的数据快照、更新数据的方法然后决定“把什么内容投到侧边栏”、“主区块区域显示什么预览效果”。而Data.jsx则是纯粹的“数据编辑器”。它只做一件事把data对象里的字段通过表单控件可视化出来并在用户操作时调用onChangeBlock把新值回传给 Volto 的状态管理层。这种拆分让每个文件的修改边界极其清晰。比如UI 设计师要改侧边栏的标题颜色他只改Data.jsx后端同事说“链接字段现在支持外部 URL 了”你只需要改Schema.js里的allowExternals: true而如果客户突然要求“这个区块在编辑模式下主区域要显示一个带占位符的卡片预览”你只动Edit.jsx里的div部分完全不影响侧边栏逻辑。这不是为了写更多文件而是为了当项目从 1 个区块膨胀到 50 个区块时你还能在 5 分钟内定位并修复一个 Bug。2.2 为什么选BlockDataForm而非InlineForm——Schema 驱动是 Volto 的底层 DNAVolto 文档里提到两种表单组件InlineForm和BlockDataForm。很多教程一笔带过说“用BlockDataForm更方便”。但“方便”背后是 Volto 整个扩展生态的设计哲学。InlineForm是一个通用的、无状态的表单渲染器它需要你手动传入完整的schema、formData、onChange回调甚至还要自己处理提交事件。它像一把瑞士军刀功能全但每次用都得先组装。而BlockDataForm是 Volto 官方为“区块数据编辑”这个特定场景量身定制的“专用扳手”。它的核心价值在于内置了withVariationSchemaEnhancer这个高阶组件。这意味着当你未来想为这个区块添加“变体Variation”功能——比如同一个“新闻卡片”区块可以有“简洁版”、“图文版”、“视频版”三种不同 Schema 和 UI——你只需要在Schema.js里定义好变体逻辑BlockDataForm会自动识别并切换表单结构无需重写任何 UI 代码。我上个月给一个媒体客户做的“专题报道区块”就用到了这个特性。他们要求编辑时能一键切换“横幅大图”和“多图轮播”两种模式每种模式下的字段完全不同轮播模式需要“轮播间隔”、“自动播放开关”横幅模式则需要“CTA 按钮文案”。如果当初用的是InlineForm我得写两套完全独立的表单组件再加一套复杂的条件判断逻辑来切换它们。而用BlockDataForm我只在Schema.js里加了一个variations字段定义了两个变体的schema剩下的事Volto 全包了。所以选择BlockDataForm不是图省事而是为未来的可扩展性提前埋下伏笔。它把“数据结构定义”Schema和“数据展示逻辑”Form彻底解耦这是现代 CMS 前端开发的黄金法则。2.3 为什么ObjectBrowserWidget是链接与图片字段的不二之选——超越“上传”的内容关联思维在Schema.js里href和bg_image字段都用了widget: object_browser。新手容易误解以为这只是个“文件上传框”。错了。ObjectBrowserWidget是 Plone 内容模型的“灵魂连接器”。它让你编辑的不是一个孤立的 URL 字符串而是一个对 Plone 站点内部其他内容对象的强引用。举个实际例子一个“相关文章”区块href字段指向/news/2024/05/my-article。如果未来这篇文章被移动到/blog/2024/05/my-articlePlone 的引用完整性机制会自动更新所有指向它的链接你的区块依然有效。而如果你用一个普通的text字段让用户手动输入 URL一旦文章路径变更这个链接就立刻 404。mode: link和mode: image的区别在于前者限制用户只能选择内容类型为Document、News Item等可链接对象后者则只允许选择Image类型的对象。selectedItemAttrs: [Title, Description]这个配置决定了当用户在侧边栏的弹出窗口里选中一个对象后表单里显示的“摘要文本”是什么——不是枯燥的路径/plone/news/2024/05/my-article而是优雅的My Article Title - A brief description...。这极大提升了编辑者的认知效率。我见过太多客户抱怨“后台找不到我要的图片”根源就在于他们用的是传统 CMS 的“上传-复制 URL”流程图片散落在服务器各处毫无组织。而ObjectBrowserWidget强制用户在 Plone 的内容树里找图图片天然就按栏目、日期、标签分类好了。所以用ObjectBrowserWidget本质上是在用 Plone 的内容管理能力来赋能 Volto 的编辑体验而不是绕开它。3. 核心细节解析与实操要点3.1SidebarPortal的正确打开方式selected属性是生命线SidebarPortal组件的selected属性绝不是个可有可无的开关。它是 Volto 编辑器判断“当前侧边栏应该显示哪个区块配置”的唯一依据。在Edit.jsx中你必须这样写SidebarPortal selected{selected} Data {...props} data{data} block{block} onChangeBlock{onChangeBlock} / /SidebarPortal注意selected是直接从props里解构出来的它是一个布尔值true或false表示当前这个区块是否被用户在编辑器里“点击选中”了。如果这里写成硬编码的selected{true}会发生什么后果很严重只要这个区块出现在页面上无论用户有没有点它侧边栏都会强行显示它的编辑面板。更糟的是如果页面上有多个同类型的MyCustomBlock它们会互相“抢夺”侧边栏的控制权导致编辑行为错乱。我第一次踩这个坑是在一个产品列表页页面里有 6 个“特色产品”区块结果我一打开编辑模式侧边栏里同时显示了 6 个一模一样的表单而且改任何一个所有区块的数据都同步变了。原因就是selected没绑定。正确的做法是让SidebarPortal的selected属性严格跟随 Volto 编辑器的状态。selected为true时SidebarPortal才会把它的子元素渲染到侧边栏为false时则完全不渲染保持侧边栏干净。这是一个非常关键的“守门员”逻辑务必牢记。3.2props解构的完整清单与实战意义不只是为了写代码在Edit.jsx和Data.jsx中我们反复解构props。但 Volto 传递给区块组件的props远不止selected、data、block、onChangeBlock这几个。官方文档列出了全部但哪些是真正高频、必须掌握的结合我三年的实战给你一份精简实用清单selected: 如前所述区块是否被选中。用于控制SidebarPortal和主区域的编辑态 UI比如加个半透明蒙层。data: 当前区块的全部数据一个纯 JavaScript 对象。这是你表单的“源数据”也是BlockDataForm的formData输入。block: 区块的元信息对象包含id唯一标识、type区块类型名如mycustomblock、variation当前变体名如果有的话。id在调试时极其有用比如你在控制台打印console.log(Editing block:, block.id)能瞬间定位是哪个实例出了问题。onChangeBlock: 这是 Volto 提供给你的“数据更新钩子”。它的签名是onChangeBlock(block, newData)。注意newData必须是完整的、合并后的数据对象而不是只传一个字段。比如你想改title不能写onChangeBlock(block, { title: New Title })这会把data里其他字段如href,subtitle全部清空正确写法是onChangeBlock(block, { ...data, title: New Title })。这是新手最容易犯的致命错误会导致数据丢失。readOnly: 一个布尔值表示当前编辑器是否处于只读模式比如预览模式或权限不足。你应该用它来禁用所有表单控件避免用户误操作。path: 当前页面的 Plone 路径如/plone/my-page。在需要构造绝对 URL 或调用 API 时很有用。把这些props的含义吃透比死记硬背语法重要得多。它们是你和 Volto 编辑器对话的语言。3.3Schema.js的字段定义艺术从“能用”到“好用”的质变Schema.js看似只是配置文件但它决定了编辑者的第一印象。一个糟糕的 Schema会让用户觉得“这个后台真难用”一个优秀的 Schema则能引导用户顺利完成配置。我们来逐行深挖DataSchema函数里的关键细节export const DataSchema (props) { return { title: My Custom Blocks Form, // 侧边栏顶部的大标题务必准确描述区块用途 fieldsets: [ // 字段集用于逻辑分组提升表单可读性 { id: default, title: Default, // 字段集标题会显示在表单顶部 fields: [href, title, subtitle, bg_image, openLinkInNewTab], // 此字段集包含哪些字段 }, ], properties: { // 每个字段的具体定义 href: { title: Link, // 字段标签显示在输入框左侧 widget: object_browser, // 使用 ObjectBrowserWidget mode: link, // 限定为链接模式 selectedItemAttrs: [Title, Description], // 选中后显示的摘要 allowExternals: true, // 允许输入外部 URL如 https://google.com }, title: { title: Title, // 简洁明了的标签 }, subtitle: { title: Subtitle Text, widget: textarea, // 明确指定为多行文本框而非单行输入框 }, bg_image: { title: Background Image, widget: object_browser, mode: image, // 限定为图片模式 allowExternals: true, // 同样允许外部图片 URL }, openLinkInNewTab: { title: Open Link in New Tab, type: boolean, // 类型为布尔值Volto 会自动渲染为开关Switch控件 }, }, required: [], // 哪些字段是必填的空数组表示全为可选 }; };这里有几个极易被忽略的“魔鬼细节”fieldsets的id虽然示例里是default但如果你的区块未来要支持变体每个变体的fieldsetsid必须唯一。比如id: banner和id: carousel。Volto 会根据id来缓存和恢复每个字段集的展开/折叠状态。widget的显式声明对于title和subtitle我们没写widgetVolto 会默认用text。但显式写widget: text或widget: textarea是一种良好的编码习惯能防止未来 Volto 版本升级时默认行为改变带来的意外。required数组它控制的是表单提交时的校验而不是 UI 上的星号*。Volto 的BlockDataForm默认不会在标签旁加星号。如果你想视觉上提示必填得在title里手动加比如title: Title *。真正的强制校验是在用户点击保存时如果required里的字段为空BlockDataForm会阻止提交并高亮错误字段。4. 实操过程与核心环节实现4.1 从零开始搭建文件创建与导入的精确步骤让我们把理论付诸实践。假设你的项目根目录是myvolto并且已经成功运行yarn start。现在我们要为MyCustomBlock添加完整的编辑功能。请严格按照以下顺序操作任何一步跳过都可能导致白屏或报错。第一步创建Data.jsx文件在终端中进入你的区块目录cd src/components/Blocks/MyCustomBlock/创建Data.jsxtouch Data.jsx用编辑器打开粘贴以下内容注意这是经过我实测、去除了所有冗余注释的“最小可用版本”import React from react; import { BlockDataForm } from plone/volto/components; import { DataSchema } from ./schema; const Data (props) { const { block, data, onChangeBlock } props; const schema DataSchema({ ...props }); return ( BlockDataForm schema{schema} title{schema.title} onChangeField{(id, value) { onChangeBlock(block, { ...data, [id]: value }); }} formData{data} block{block} / ); }; export default Data;提示import { DataSchema } from ./schema;这一行要求schema.js文件必须存在且导出DataSchema常量。如果文件名是Schema.js首字母大写这里必须写成./Schema否则 Webpack 会找不到模块。第二步创建schema.js文件在同一目录下touch schema.js粘贴DataSchema函数的完整代码注意export const DataSchema (props) { ... }这个结构必须完全一致export const DataSchema (props) { return { title: My Custom Block Settings, fieldsets: [ { id: default, title: Content, fields: [href, title, subtitle, bg_image, openLinkInNewTab], }, ], properties: { href: { title: Link, widget: object_browser, mode: link, selectedItemAttrs: [Title, Description], allowExternals: true, }, title: { title: Title, }, subtitle: { title: Subtitle, widget: textarea, }, bg_image: { title: Background Image, widget: object_browser, mode: image, allowExternals: true, }, openLinkInNewTab: { title: Open Link in New Tab, type: boolean, }, }, required: [], }; };第三步改造Edit.jsx打开现有的Edit.jsx将其内容完全替换为以下代码。这是最关键的一步任何拼写错误比如SidebarPortal写成SideBarPortal都会导致侧边栏不显示import React from react; import { SidebarPortal } from plone/volto/components; import Data from ./Data; const Edit (props) { const { selected, onChangeBlock, block, data } props; return ( div SidebarPortal selected{selected} Data {...props} data{data} block{block} onChangeBlock{onChangeBlock} / /SidebarPortal divIm the Block edit component! (This is the main area preview)/div /div ); }; export default Edit;注意import Data from ./Data;这行代码确保了Data.jsx被正确引入。{...props}是一种“属性透传”写法它把Edit接收到的所有props包括selected,readOnly,path等都原封不动地传给了Data组件。这是一种安全、简洁的写法避免了手动列出每一个prop。第四步启动并验证保存所有文件在终端中确保你的 Volto 开发服务器正在运行yarn start。打开浏览器进入一个已启用编辑模式的页面添加一个MyCustomBlock。点击它观察右侧侧边栏。如果一切顺利你应该看到一个标题为 “My Custom Block Settings” 的表单里面包含了 “Link”、“Title”、“Subtitle” 等字段。点击 “Link” 字段旁边的放大镜图标会弹出 Plone 的内容浏览器你可以浏览并选择站内的任何页面或新闻项。这就是成功的标志。4.2BlockDataForm的onChangeField回调数据流动的精确控制BlockDataForm的onChangeField属性是整个编辑流程的“心脏起搏器”。它的签名是(id, value) void其中id是字段的键名如titlevalue是用户输入的新值。在Data.jsx中我们这样实现它onChangeField{(id, value) { onChangeBlock(block, { ...data, [id]: value }); }}这段代码的执行过程是理解 Volto 数据流的关键用户在Title输入框里输入 “Hello World”。BlockDataForm检测到变化调用onChangeField(title, Hello World)。我们的回调函数执行{ ...data, [id]: value }。假设data原来是{ href: /news, subtitle: Old Sub }那么展开后就是{ href: /news, subtitle: Old Sub, title: Hello World }。这是一个全新的、包含了所有旧字段和新字段的对象。onChangeBlock(block, newData)被调用。onChangeBlock是 Volto 提供的“官方通道”它会把这个新对象连同block的id一起发送给 Volto 的全局状态管理器基于 Redux。Volto 的状态管理器更新内存中的数据并触发重新渲染。主区域的预览Edit.jsx里的div和侧边栏的表单都会拿到最新的data从而保持同步。这个过程看似简单但有两个极易出错的点不要直接修改datadata是一个不可变的immutable对象。你永远不应该写data.title New Title这在 React/Volto 的严格模式下会直接报错。不要遗漏...data如果你只写onChangeBlock(block, { title: value })那么data里原有的href、subtitle等所有其他字段都会消失变成一个只有title的空对象。这会导致数据被清空是生产环境最常见的数据丢失原因。4.3ObjectBrowserWidget的高级配置解锁内容关联的全部潜能ObjectBrowserWidget的能力远超基础示例。在真实项目中你可能需要更精细的控制。以下是我在多个项目中验证过的、最实用的高级配置配置 1限制可选内容的路径范围有时你只想让用户从某个特定文件夹里选图比如/plone/images/logos。这时可以使用rootPath属性bg_image: { title: Logo Image, widget: object_browser, mode: image, rootPath: /plone/images/logos, // 只显示此路径下的内容 },配置 2自定义搜索过滤器默认的搜索会匹配标题、描述、内容。如果只想按标题搜索可以加searchOptionshref: { title: Related Document, widget: object_browser, mode: link, searchOptions: { SearchableText: , // 清空默认搜索词 portal_type: [Document, News Item], // 只搜索这两种类型 } },配置 3设置默认值和占位符对于新创建的区块你可能希望openLinkInNewTab默认是true。这不能在Schema.js里设而是在Edit.jsx的data初始化时处理。但ObjectBrowserWidget支持placeholderhref: { title: Link, widget: object_browser, mode: link, placeholder: Select a page or enter an external URL..., // 输入框里的灰色提示文字 },这些配置都是通过ObjectBrowserWidget的props透传实现的。它们的存在证明了 Volto 的设计不是封闭的而是高度可定制的。你不需要 fork Volto 的源码只需在自己的schema.js里加几行配置就能获得企业级的内容管理体验。5. 常见问题与排查技巧实录5.1 侧边栏空白或不显示一份终极排查清单这是新手遇到的最高频问题。别慌按以下顺序逐一检查90% 的情况都能秒解检查项具体操作为什么重要我的实操经验1.SidebarPortal是否被正确导入检查Edit.jsx顶部是否有import { SidebarPortal } from plone/volto/components;。确认plone/volto包已安装yarn list plone/volto。如果导入失败SidebarPortal标签会被当作普通 HTML 标签浏览器无法识别直接忽略。我曾在一个离线环境中部署node_modules里漏装了plone/volto整个侧边栏功能失效查了 3 小时才发现是依赖问题。2.selected属性是否绑定检查SidebarPortal selected{selected}确认selected是从props解构出来的变量而不是字符串selected或布尔字面量true。selected是 Volto 控制侧边栏显示/隐藏的开关。硬编码true会导致所有区块侧边栏常驻false则永远不显示。有一次我把selected错打成seleted少了个cVS Code 没报错但侧边栏就是不出现最后靠console.log(props)才发现变量名错了。3.Data.jsx是否被正确导入和使用检查Edit.jsx中import Data from ./Data;的路径是否正确大小写敏感。检查SidebarPortal内部是否确实渲染了Data ... /。如果Data组件没被渲染侧边栏自然为空。路径错误是 Windows 和 macOS 开发者最容易犯的错data.jsxvsData.jsx。我们团队有个成员在 macOS 上开发文件名是data.jsx但他import时写了./Data在 macOS 上不报错不区分大小写但部署到 Linux 服务器后整个侧边栏白屏。4.Data.jsx内部是否有语法错误在浏览器开发者工具F12的 Console 标签页刷新页面看是否有红色报错。如果有SyntaxError或ReferenceError说明Data.jsx代码有错。React 组件一旦有语法错误整个组件树就会崩溃SidebarPortal无法渲染其子元素。最常见的错误是import语句末尾少了分号或者 JSX 里忘了闭合标签比如div没有/div。提示最快速的诊断方法是在Data.jsx的return语句前加一行console.log(Data component rendered);。如果在 Console 里看不到这条日志说明问题出在Edit.jsx到Data.jsx的链路上如果看到了但侧边栏还是空的问题就出在Data.jsx内部的BlockDataForm渲染逻辑上。5.2 表单字段不响应输入onChangeField的陷阱与救赎现象表单能正常显示但无论你怎么输入data对象里的值就是不变主区域的预览也不更新。这几乎 100% 是onChangeField回调的问题。陷阱 1onChangeBlock调用方式错误错误写法// ❌ 危险会清空所有其他字段 onChangeBlock(block, { title: value });正确写法// ✅ 安全保留所有旧字段只更新目标字段 onChangeBlock(block, { ...data, title: value });陷阱 2id参数未被正确使用onChangeField的第一个参数是id它代表当前发生变化的字段名。你必须用动态的[id]来设置新值而不是写死字段名// ❌ 错误无论哪个字段变化都只改 title onChangeField{(id, value) { onChangeBlock(block, { ...data, title: value }); // 这里写死了 title }} // ✅ 正确动态匹配字段名 onChangeField{(id, value) { onChangeBlock(block, { ...data, [id]: value }); // [id] 是动态的 }}陷阱 3data对象本身是undefined在区块首次创建时data可能是undefined。直接对undefined做...data展开会报错。安全的写法是onChangeField{(id, value) { const newData { ...(data || {}), [id]: value }; // 如果 data 是 undefined就用空对象 {} onChangeBlock(block, newData); }}5.3ObjectBrowserWidget弹窗不出现或报错后端权限与前端配置的双重校验现象点击字段旁的放大镜图标什么反应都没有或者弹出一个空白窗口或者控制台报403 Forbidden。原因与解决方案后端权限不足ObjectBrowserWidget需要调用 Plone 的searchAPI 来获取内容列表。如果当前登录用户没有View权限API 会返回 403。解决方案在 Plone 后台进入Site Setup-Users and Groups检查当前用户的权限确保其对根站点/plone有View权限。前端mode配置错误mode: link要求后端返回的内容必须有getURL()方法。如果mode写成了link但你却想选一个File类型的对象它没有getURL()弹窗可能会失败。解决方案确认你要选的内容类型link模式对应Document,News Itemimage模式对应Imagefile模式对应File。allowExternals: true缺失如果用户需要输入https://开头的外部链接但allowExternals是false默认值那么外部 URL 会被拒绝。解决方案在schema.js中为href和bg_image字段明确加上allowExternals: true。实操心得当ObjectBrowserWidget出问题时不要只盯着前端。打开浏览器的 Network 标签页点击放大镜观察发出的search请求。如果请求返回 403那就是后端权限问题如果请求根本没发出去那就是前端widget配置或mode有问题如果请求发出去了返回了 200 但数据为空那就要检查searchOptions的过滤条件是否太严格了。5.4 区块数据在保存后丢失onChangeBlock的异步陷阱现象在侧边栏改了字段点了页面右上角的“保存”按钮页面刷新后发现刚改的值又变回去了。根本原因onChangeBlock是一个异步操作。它把数据提交给 Volto 的状态管理器但状态管理器需要时间将数据序列化并发送给 Plone 后端。如果你在onChangeBlock调用后立即执行了某些依赖于新数据的操作比如window.location.reload()那么新数据很可能还没来得及存到后端页面就刷新了导致“丢失”。解决方案Volto 的onChangeBlock返回一个 Promise。你应该await它确保数据提交成功后再进行下一步// 在 Data.jsx 中如果你想在保存后做点什么比如关闭弹窗 const handleSaveAndClose async () { try { await onChangeBlock(block, { ...data, title: New Title }); console.log(Data saved successfully!); // 这里可以安全地执行后续操作比如关闭一个自定义弹窗 } catch (error) { console.error(Failed to save data:, error); } };不过在标准的BlockDataForm场景下你通常不需要手动调用onChangeBlock因为BlockDataForm内部已经处理了所有异步逻辑。这个陷阱主要出现在你自定义了保存按钮或需要特殊业务逻辑的场景。6. 进阶扩展与未来演进路径6.1 为区块添加“预览模式”让编辑者所见即所得目前我们的Edit.jsx主区域只显示了一行静态文字。一个专业的编辑体验应该让编辑者在修改侧边栏的同时实时看到主区域的变化。这可以通过data对象的实时监听来实现。在Edit.jsx中我们可以这样增强const Edit (props) { const { selected, onChangeBlock, block, data, readOnly } props; // 从 data 中提取字段用于主区域渲染 const { title Default Title, subtitle Default Subtitle, href #, bg_image, openLinkInNewTab false } data; return ( div SidebarPortal selected{selected} Data {...props} data{data} block{block} onChangeBlock{