响应式与事件
用 Ydesign 做编辑器时,你常常需要回答三类问题:
- "画布变了,我的 UI 怎么自动刷新?" → 响应式(Reactivity)
- "用户选中 / 修改 / 撤销了某个元素,我怎么拿到这个动作?" → 事件(Events)
- "我只想知道某一个特定属性变了,不想因为别的属性联动而被噪音淹没。" → 派生响应
本文把这三类场景各自的推荐姿势和真实 API 整理清楚。
React Hooks + MobX 响应式
store 是一个 MobX-State-Tree 实例;@ydesign/react-editor 内部用 mobx-react-lite 的 observer HOC 让组件自动订阅。
在自定义组件里"用 store 像用 state 一样"
import { observer } from 'mobx-react-lite';
const StatusBar = observer(({ store }) => {
// 这里读什么,组件就自动订阅什么
return (
<div>
画布:{store.width} × {store.height}px · 选中 {store.selectedElementsIds.length} 个元素 · 缩放 {Math.round(store.scale * 100)}%
</div>
);
});
关键规则:
- ✅ 组件函数体里读了哪些字段,就订阅哪些字段(MobX 按字段粒度追踪)
- ✅ 只有你读到的字段变化时才重新渲染
- ❌ 不要忘记
observer包裹,否则组件永远不更新 - ❌ 不要把字段读取写在
useMemo/useCallback之外的"只执行一次"的位置 —— 会丢失订阅
手动订阅:useEffect + reaction
有时你不想渲染 UI,只想在某个值变化时"执行副作用"(比如自动保存、上报埋点):
import { useEffect } from 'react';
import { reaction } from 'mobx';
function AutoSave({ store }) {
useEffect(() => {
const dispose = reaction(
// 1) 数据源:每次这个返回值变化,就触发第 2 个函数
() => store.toJSON(),
// 2) 副作用
json => {
fetch('/api/designs/current', {
method: 'PUT',
body: JSON.stringify(json),
});
},
{ delay: 1000 } // 1s 节流
);
return dispose; // 组件卸载时取消订阅
}, [store]);
return null;
}
autorun:立刻跑一次、之后每次变化再跑
import { autorun } from 'mobx';
const dispose = autorun(() => {
// 挂载时立即执行一次,之后只要读到的字段变化就再跑
if (store.selectedElementsIds.length === 0) {
store.openSidePanel('templates');
}
});
// 不再需要时
dispose();
事件系统(store.editor 事件总线)
@ydesign/core 的 Editor 实例本身就是一个事件发射器(内部是一个简化的 EventManager)。所有 Handler 在关键时机会往上发事件,你可以订阅它们来做自己的业务响应。
API
// 监听
store.editor?.on('event-name', handler);
// 取消监听(也可以不传 handler 清空某类型的全部监听)
store.editor?.off('event-name', handler);
// 监听全部事件(通配符)
store.editor?.on('*', (type, evt) => {
console.log(type, evt);
});
💡
on返回值为void,请在外层自行保留handler引用以便off—— 和 DOM 的addEventListener用法一致。
内置事件列表(基于真实代码)
| 事件名 | 触发时机 | 附带数据 |
|---|---|---|
object:modified | 元素属性被修改(包含锁定 / 解锁) | { target, action } |
textbox:modified | 文字缩放 / 文本内容修改 | { target, action } |
workarea:changed | 工作区尺寸 / 背景变化;撤销重做也会发 | { target, action } |
workarea:max-size | 画布尺寸达到上限(resize 拖拽封顶) | { maxWidth, maxHeight } |
history:changed | 历史栈变更(新增 / 撤销 / 重做) | { type, backgroundColor?, ... } |
inpaint:activate | 进入 AI 消除笔模式 | — |
inpaint:deactivate | 退出 AI 消除笔模式 | — |
inpaint:status | 消除笔状态变化 | { canUndo, canRedo, active } |
典型用法
① 监听元素修改,自动上报埋点
store.editor?.on('object:modified', ({ target, action }) => {
window.plausible?.('element-modified', { props: { type: target.type, action } });
});
② 监听工作区变化,同步到业务侧
store.editor?.on('workarea:changed', ({ target }) => {
console.log('画布尺寸变了', target.width, target.height);
// 触发"已修改"脏标记
setDirty(true);
});
③ 消除笔进入 / 退出时临时隐藏工具栏
const [inpainting, setInpainting] = useState(false);
useEffect(() => {
if (!store.editor) return;
const onIn = () => setInpainting(true);
const onOut = () => setInpainting(false);
store.editor.on('inpaint:activate', onIn);
store.editor.on('inpaint:deactivate', onOut);
return () => {
store.editor?.off('inpaint:activate', onIn);
store.editor?.off('inpaint:deactivate', onOut);
};
}, [store.editor]);
④ 调试:打开 '*' 通配监听,看看画布发了哪些事件
store.editor?.on('*', (type, evt) => {
console.log('[editor]', type, evt);
});
store.on('change', cb) —— 画布对象变动的高层订阅
除了底层事件,store 还提供了一个简单的 "画布任何一个对象有变化就通知我" 的接口。它底层基于 MST 的 onSnapshot,做了深度对比,只在真实变化时触发:
const dispose = store.on('change', objects => {
console.log('当前画布共', objects.length, '个元素');
});
// 不再需要时
dispose();
场景:自动保存、脏标记、"预览 JSON"抽屉。
与直接调 store.editor.on('object:modified', ...) 的区别:
store.on('change'):高频变动会被合并,适合"最终态"检测store.editor.on('object:modified'):每次修改都发,适合精细统计和埋点
判断"具体发生了什么"
光知道"变了"不够,你经常想知道变成什么了、是谁改的。几个常用思路:
用 reaction 对比前后值
import { reaction } from 'mobx';
reaction(
() => store.selectedElementsIds.slice(),
(current, previous) => {
const added = current.filter(id => !previous.includes(id));
const removed = previous.filter(id => !current.includes(id));
console.log('新选中', added, '取消选中', removed);
}
);
从事件的 action 字段区分动作
内置事件的 action 字段会告诉你"发生了什么样的修改":
store.editor?.on('object:modified', ({ target, action }) => {
switch (action) {
case 'lock':
console.log(target.id, '被锁定');
break;
case 'unlock':
console.log(target.id, '被解锁');
break;
case 'text:scale':
console.log('文字被缩放');
break;
default:
console.log('常规修改');
}
});
订阅历史栈:区分"用户改的" vs "撤销重做"
撤销和重做会触发 history:changed 事件,可以借此把它们和普通编辑区分开:
let isUndoRedo = false;
store.editor?.on('history:changed', data => {
isUndoRedo = data?.type === 'undo' || data?.type === 'redo';
});
store.editor?.on('object:modified', () => {
if (isUndoRedo) {
// 这次修改是撤销 / 重做产生的,不记入"用户新操作"
return;
}
trackUserEdit();
});
组合式:MobX + 事件一起用
常见模式:MobX 用于 UI 层(声明式),事件用于业务层(命令式 / 副作用)。
// UI:选中几个元素,按钮文案就变(响应式)
const Button = observer(({ store }) => (
<button disabled={!store.selectedElementsIds.length}>
删除 ({store.selectedElementsIds.length})
</button>
));
// 业务:每次真实修改,脏标记置为 true(事件)
store.editor?.on('object:modified', () => setDirty(true));
store.editor?.on('workarea:changed', () => setDirty(true));
性能注意事项
响应式好用,但"读多少订阅多少"的机制也意味着你很容易订阅到"不该订阅的东西"导致频繁重渲染。
① 拆细 observer 组件
// ❌ 整个 App 都在 observer 里 —— 任何字段变化都会让整棵树参与 diff
const App = observer(({ store }) => (
<>
<Header store={store} />
<Workspace store={store} />
<Footer store={store} />
</>
));
// ✅ 每个小组件自己 observer —— 只有它关心的字段变化时才重渲染
const Header = observer(({ store }) => <div>缩放 {store.scale}</div>);
const Footer = observer(({ store }) => <div>共 {store.objects.length} 个元素</div>);
② 节流 / 防抖 reaction
object:modified 类事件在拖拽、连续输入时每秒会触发几十次。如果副作用很重(写入服务器、生成 PDF),务必节流:
reaction(
() => store.toJSON(),
json => fetch('/api/save', { method: 'POST', body: JSON.stringify(json) }),
{ delay: 1000 } // 1 秒内变化只保留最后一次
);
③ 别在 observer 组件外直接读响应字段
// ❌ Comp 不是 observer,下面的读取不会被追踪
const Comp = ({ store }) => <div>{store.width}</div>;
// ✅ 加上 observer
const Comp = observer(({ store }) => <div>{store.width}</div>);
④ 避免 toJSON() 类重计算挂到每一帧
// ❌ 这段 reaction 会频繁序列化整个画布
reaction(
() => store.toJSON(),
json => {
/* 每次都做了一次大对象构造 */
}
);
// ✅ 如果只关心"元素数量变化",就让依赖细粒度一点
reaction(
() => store.objects.length,
len => console.log('元素数', len)
);
⑤ 组件卸载时清理订阅
observer自动清理,不用管reaction/autorun会返回一个dispose函数,在useEffect里 return 掉store.editor.on(...)/store.on('change', ...)手动off或调用它们返回的 dispose
延伸阅读
- 👉 Store 总览 —— 所有响应式字段一览
- 👉 元素操作 · 订阅变化 —— 更多事件订阅范例
- 👉 MobX 官方文档 ——
reaction/autorun/observer深入 - 👉 MobX-State-Tree 官方文档 ——
onSnapshot/getSnapshot等快照 API