跳到主要内容

响应式与事件

用 Ydesign 做编辑器时,你常常需要回答三类问题:

  1. "画布变了,我的 UI 怎么自动刷新?" → 响应式(Reactivity)
  2. "用户选中 / 修改 / 撤销了某个元素,我怎么拿到这个动作?" → 事件(Events)
  3. "我只想知道某一个特定属性变了,不想因为别的属性联动而被噪音淹没。" → 派生响应

本文把这三类场景各自的推荐姿势和真实 API 整理清楚。


React Hooks + MobX 响应式

store 是一个 MobX-State-Tree 实例;@ydesign/react-editor 内部用 mobx-react-liteobserver 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/coreEditor 实例本身就是一个事件发射器(内部是一个简化的 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

延伸阅读