@dao3fun/fsm
v0.0.5
Published
多实体共享配置的轻量模型动画有限状态机
Keywords
Readme
@dao3fun/fsm — 多实体共享配置的模型动画轻量有限状态机
本 FSM 可视化编辑器: https://box3lab.com/tools/fsm-editor/ 使用说明在最底下~
用一张“简单、类型安全”的表,统一描述状态变化;在生命周期里自动注入安全的动画工具,降低出错概率,让多人协作有清晰边界。
- 避免状态分散:逻辑散落在多个脚本和 if-else。
- 事件统一:同语义多名字,重构成本高。
- 动画脆弱:初始化时序、片段缺失易崩。
- 多实体复用难:复制-粘贴导致分叉和倾斜。
为什么需要状态机(Before → After)
在实际项目中,我们常见到以下现象:
- 同一个实体的行为被写在多个脚本里,靠事件回调“互相调用”,流程不透明。
- 事件命名不统一,
startFetch/tryLoad/doLoad表达同一语义,换人维护代价大。 - 动画播放分散在各处,稍有顺序/资源问题就容易崩或卡住。
- 复制一份逻辑给另一个实体,随着时间推移产生分叉,回归困难。
your-fsm 的目标是把“状态变化”收敛到一张表:
- 用
MachineConfig明确“当前状态对哪些事件如何响应”,让迁移路径一目了然; - 用生命周期钩子
onEnter/onExit/onUpdate集中管理动画与持续逻辑; - 同一份配置可复用到 N 个实体,既统一又易于替换;
- 订阅与全局回调
subscribe/onTransition方便做日志与埋点。
对比示例
- ❌ Before(分散的 if-else + 回调 + 重试/时序/命名不一致):
// 逻辑散落在不同文件/模块中(简化在一处展示)
let isBusy = false;
let tries = 0;
const MAX_TRY = 3;
// 各处都在直接操作动画,名称也不统一
function playSpin() {
motion.play?.('旋转');
}
function stopSpin() {
motion.stop?.('旋转');
}
function playStart() {
motion.play?.('启动');
}
function playOk() {
motion.play?.('成功');
}
function playFail() {
motion.play?.('失败');
}
// 不同事件命名表达同一语义:startFetch / tryLoad / doLoad
function startFetch() {
if (isBusy || entity.status === 'loading') return;
isBusy = true;
entity.status = 'loading';
// 有时先播放“启动”,有时直接“旋转”,时序不一致
if (Math.random() > 0.5) playStart();
playSpin();
// 多处复制的重试逻辑
fetchData()
.then((res) => {
if (!res.ok) throw new Error('bad');
stopSpin();
playOk();
isBusy = false;
entity.status = 'success';
// 另一个监听器里也会把 status 改回 idle,产生竞争条件
})
.catch((err) => {
console.warn('fetch error', err);
stopSpin();
playFail();
tries++;
if (tries < MAX_TRY) {
// 分散的退避策略(可能还有 setTimeout 嵌套)
setTimeout(
() => {
// 命名不一致:这里叫 tryLoad
tryLoad();
},
500 + tries * 200
);
} else {
isBusy = false;
entity.status = 'failure';
}
});
}
function tryLoad() {
// 另一路也会播动画,造成重复/中断
playSpin();
doLoad()
.then(() => {
stopSpin();
playOk();
isBusy = false;
entity.status = 'success';
})
.catch(() => {
stopSpin();
playFail();
// 又一层 setTimeout 再试,逻辑分叉增多
setTimeout(() => startFetch(), 1000);
});
}
// UI/输入层又来一套 if-else,重复判断与动画
button.on('click', () => {
if (entity.status === 'idle' || !isBusy) {
startFetch();
} else if (entity.status === 'failure') {
playStart();
startFetch();
} else {
// 正在 loading 时,某些情况下也允许再次触发,导致并发
if (Math.random() < 0.2) startFetch();
}
});
// 另一个系统监听网络变化,直接改状态,未与上面流程对齐
network.on('online', () => {
if (entity.status === 'failure') {
entity.status = 'idle';
// 没停动画,可能造成“失败动画”与“启动动画”交错
playStart();
}
});- ✅ After(统一事件命名 + 配置集中 + 动画在生命周期 + 重试与外部输入事件都通过 send):
type S = 'idle' | 'loading' | 'success' | 'failure';
type E = 'FETCH' | 'RESOLVE' | 'REJECT' | 'RESET';
// 在实体上记录重试次数,集中处理退避
declare const entity: GameEntity & { tries?: number };
const MAX_TRY = 3;
const config: MachineConfig<S, E> = {
initial: 'idle',
states: {
// 空闲:统一从此处发起 FETCH
idle: {
on: { FETCH: 'loading' },
},
// 加载:只负责“转圈圈 + 等待结果事件”,不自行调接口
loading: {
on: { RESOLVE: 'success', REJECT: 'failure' },
onEnter: (_, motion) =>
motion.loadByName([{ name: '旋转', iterations: Infinity }]).play(),
onExit: (_, motion) => motion.pause?.(),
},
// 成功:进入即播成功动画
success: { onEnter: (_, motion) => motion.loadByName('成功').play() },
// 失败:集中处理“失败动画 + 退避重试 or 归零等待外部 RESET/ONLINE”
failure: {
on: {
// 允许用户/系统重置到 idle(对应 Before 里 network online 直接改状态)
RESET: 'idle',
// 也允许直接再次尝试(按钮点一次就再试一次)
FETCH: {
target: 'loading',
guard: (e) => (e.tries ?? 0) < MAX_TRY,
action: (e) => {
e.tries = (e.tries ?? 0) + 1; // 记录重试次数
},
},
},
onEnter: (e, motion) => {
motion.loadByName('失败').play();
// 集中退避策略:如果还有额度,延迟自动再发起 FETCH(替代 Before 里分散的 setTimeout)
if ((e.tries ?? 0) < MAX_TRY) {
const backoff = 500 + (e.tries ?? 0) * 200;
setTimeout(() => fsm.send(e, 'FETCH'), backoff);
}
},
},
},
};
// 创建状态机实例
const fsm = new StateMachine<S, E>(config);
// 注册实体
const entity = world.querySelector('#海盗船-1');
if (entity) {
// 注册实体
fsm.register(entity);
// 统一的 UI 入口:按钮点击仅发送 FETCH,不直接碰动画/状态
button.on('click', () => fsm.send(entity, 'FETCH'));
// 统一的系统入口:网络恢复只发送 RESET,不直接改状态/动画
network.on('online', () => fsm.send(entity, 'RESET'));
}收益:
- 迁移规则集中、可视;动画在对应状态的生命周期中,减少时序错误;
- 更易复用,多实体共享一份配置;
- 可观测、可埋点,问题定位快;
- 类型安全让错误更早暴露。
安装(Install)
npm i @dao3fun/fsm导出:StateMachine、类型(MachineConfig/StateConfig/...)。
上手(Getting Started)
import { StateMachine, type MachineConfig } from '@dao3fun/fsm';
// 定义状态和事件
type S = 'idle' | 'loading' | 'success' | 'failure';
type E = 'FETCH' | 'RESOLVE' | 'REJECT';
// 定义状态机配置
const config: MachineConfig<S, E> = {
// 初始状态
initial: 'idle',
// 状态配置
states: {
// 空闲状态
idle: {
// 事件迁移表
// - FETCH -> loading
on: { FETCH: 'loading' },
// 进入状态时触发
onEnter: (_, motion) => {
// 与实际项目中保持一致:按名称加载动画并播放
motion.loadByName('启动').play();
},
onExit: () => {
// 可选:做收尾或日志
},
onUpdate: () => {
// 可选:帧更新逻辑
},
},
// 加载状态
loading: {
// 事件迁移表:
// - RESOLVE -> success
// - REJECT -> failure
on: { RESOLVE: 'success', REJECT: 'failure' },
// 进入状态时触发
onEnter: (_, motion) => {
// 加载并播放动画,无限循环
motion.loadByName([{ name: '旋转', iterations: Infinity }]).play();
},
},
// 成功状态
success: {},
// 失败状态
failure: {},
},
};
// 创建状态机实例
const fsm = new StateMachine<S, E>(config, {
// 状态迁移时触发
onTransition: (entity, next, prev, event) => {
console.log('[onTransition]', { prev, next, event });
},
});
// 注册实体
const entity = world.querySelector('#海盗船-1');
if (entity) {
// 初始状态为 idle
fsm.register(entity);
// 3s后发送 FETCH 事件
setTimeout(() => fsm.send(entity, 'FETCH'), 3000);
}执行以上代码后,实体将3s后从 idle 状态迁移至 loading,播放旋转动画。
帧驱动
setInterval(() => fsm.update(entity, 0.016), 16); // dt 按秒执行以上代码后,实体将每帧更新一次,onUpdate 钩子会被调用。
配置入门(Step by Step)
用一个最小可跑的例子,逐步加到“动画 + 守卫/动作 + 帧驱动 + 订阅”。
- 定义状态与事件(S/E)
type S = 'idle' | 'loading' | 'success' | 'failure';
type E = 'FETCH' | 'RESOLVE' | 'REJECT';- 写出最小配置(只有 initial 与一个事件迁移)
const config: MachineConfig<S, E> = {
initial: 'idle',
states: {
idle: { on: { FETCH: 'loading' } },
loading: {},
success: {},
failure: {},
},
};- 给进入状态加动画(onEnter 自动注入 motion)
const config: MachineConfig<S, E> = {
initial: 'idle',
states: {
idle: {
on: { FETCH: 'loading' },
onEnter: (_, motion) => motion.loadByName('启动').play(),
},
loading: {
on: { RESOLVE: 'success', REJECT: 'failure' },
onEnter: (_, motion) =>
motion.loadByName([{ name: '旋转', iterations: Infinity }]).play(),
},
success: {},
failure: {},
},
};- 给事件加“守卫/动作”(需要时再用完整写法)
idle: {
on: {
FETCH: {
target: 'loading',
guard: (entity) => entity.canFetch === true, // 不满足则不跳转
action: async (entity) => entity.log?.('enter loading'), // 切换前执行
},
},
},- 驱动与订阅(让它真实跑起来)
const fsm = new StateMachine<S, E>(config, {
onTransition: (entity, next, prev, event) => {
console.log('[onTransition]', { prev, next, event });
},
});
const entity = world.querySelector('#海盗船-1') as GameEntity;
fsm.register(entity);
fsm.send(entity, 'FETCH'); // 触发一次迁移
// 帧驱动(dt=秒)
setInterval(() => fsm.update(entity, 0.016), 16);- 扩展 onUpdate(做计时/渐变/轮询)
loading: {
on: { RESOLVE: 'success', REJECT: 'failure' },
onUpdate: (entity, motion, current, dt) => {
entity.timer = (entity.timer ?? 0) + dt;
if (entity.timer > 2) {
// 两秒后自动成功
fsm.send(entity, 'RESOLVE');
entity.timer = 0;
}
},
},常见问题(Troubleshooting)
- 动画不播放?
- 确认
onEnter里使用的是motion.loadByName(...).play(),名称与资源一致; - 若在首帧触发,关注引擎侧“资源是否已就绪/可见”。
- 确认
- 事件发送无效?
- 当前状态下是否存在该事件的迁移;
guard是否返回了false。
onUpdate未执行?- 是否调用了
update(entity, dt);dt单位为秒。
- 是否调用了
核心概念(State / Event / Transition 等)
概念清晰,配起来就不抽象。
State(状态)
- 描述实体在某一时刻所处的“行为阶段”,如
idle(空闲)、loading(加载)、success、failure。 - 在
MachineConfig.states中逐个定义,每个状态可配置事件表on和生命周期钩子onEnter/onExit/onUpdate。 - 命名建议:短小、动名词或形容状态的形容词,统一小写,例如
idle、running、dead。
- 描述实体在某一时刻所处的“行为阶段”,如
Event(事件)
- 触发状态迁移的“信号”。你通过
fsm.send(entity, 'FETCH')发送事件,驱动状态图流转。 - 命名建议:统一风格,常用全大写动词/动宾,如
FETCH、RESOLVE、REJECT、DIE、RESPAWN。 - 一个事件只有在“当前状态的
on中有定义”时才生效;否则被忽略(或由 guard 返回 false)。
- 触发状态迁移的“信号”。你通过
Transition(迁移)
- 从“当前状态”响应某个事件跳到“目标状态”。
- 配置写在
on表里:- 简写:
on: { EVENT: 'target' } - 完整:
on: { EVENT: { target, guard?, action? } }
- 简写:
Guard(守卫)
- 类型:
(entity, event) => boolean。 - 用来判定此次事件是否允许跳转,比如“冷却未结束”“血量不足”。返回
false则不发生迁移。 - 建议把“条件判断”集中到 guard,避免在业务处处 if-else。
- 类型:
Action(迁移动作)
- 在“状态切换之前”执行(发生在
onExit之前),可异步;内部已 try/catch,失败不会阻断后续流程。 - 常见用途:上报埋点、预取资源、写入日志等“与切换紧密相关但非动画控制”的动作。
- 在“状态切换之前”执行(发生在
onEnter / onExit(生命周期)
onEnter(entity, motion, prev, next): 进入新状态时调用,常用于加载并播放动画、复位变量;onExit(entity, motion, prev, next): 离开旧状态时调用,常用于停止动画、释放资源。- 动画控制通过
motion提供的 API 进行,与你在示例中的motion.loadByName(...).play()一致。
onUpdate(帧更新)
onUpdate(entity, motion, current, dt)由fsm.update(entity, dt)驱动,dt单位为“秒”。- 适合做:时间累计、缓动/渐变、轮询检查、自动超时切换等。
Entity(实体)
- 运行中被状态机管理的对象(如游戏中的一个 NPC/模型)。
- 同一个
StateMachine可以注册多个实体,每个实体维护自己的当前状态,但共用同一份配置。
Motion(动画控制器)
- 在生命周期钩子里自动注入,用于安全地播放/暂停动画片段:
- 单个名称:
motion.loadByName('启动').play() - 批量配置:
motion.loadByName([{ name: '旋转', iterations: Infinity }]).play()
- 单个名称:
- 建议把“进入某状态该放哪段动画”放在对应状态的
onEnter,离开时在onExit停止或切换。
- 在生命周期钩子里自动注入,用于安全地播放/暂停动画片段:
Subscription / onTransition(订阅/全局回调)
fsm.subscribe((entity, next, prev) => { ... }):用于调试输出或统一埋点;返回 off 函数用于取消订阅。- 构造器里的
onTransition也会在迁移后触发,位置在 listeners 通知之后。
时序(再强调一次)
- action(若有)
- onExit(旧状态)
- 切换当前状态
- onEnter(新状态)
- 通知订阅者(subscribe)
- 触发 options.onTransition(若提供)
命名与建模建议
- 状态是“阶段”,事件是“触发器”。避免状态名中出现“事件语义”,也避免事件名中出现“目标状态”。
- 保持 S/E 为“字符串字面量联合”,让编辑器在配置里获得最强的类型提示与校验。
- 尽量用“简写迁移”,需要条件或副作用时再切换到“完整写法”。
反例(Anti-pattern)
- 在业务处四处
if (current === 'x') { ... }决策,而不是把跳转规则放进on; - 在
onEnter里做复杂的条件判断,本该是 guard 的逻辑; - 在
onUpdate里频繁地直接操纵外部全局状态,导致难以维护和测试。
配置详解(MachineConfig/StateConfig/TransitionConfig)
以下解释基于源码类型定义 server/src/lib/types.ts:
/**
* 迁移动作:在状态切换前执行(发生在 onExit 之前)。
* - 可异步;失败不会阻止后续 onExit/onEnter(内部已 try/catch)。
*/
export type TransitionAction<E> = (
entity: GameEntity,
event: E
) => void | Promise<void>;
/**
* 迁移守卫:返回 true 才允许从当前状态按该事件跳转。
* - 典型用途:冷却、资源条件、死亡保护等。
*/
export type TransitionGuard<E> = (entity: GameEntity, event: E) => boolean;
/**
* 完整迁移配置(当事件需要 guard/action 时使用)。
* 否则可使用简写:on: { EVENT: 'targetState' }
*/
export interface TransitionConfig<S extends string, E extends string> {
/** 目标状态 */
target: S;
/** 可选守卫:返回 true 才允许跳转 */
guard?: TransitionGuard<E>;
/** 可选动作:在状态实际切换前执行 */
action?: TransitionAction<E>;
}
/**
* 状态生命周期钩子:onEnter/onExit。
* - prev/next:用于区分是初次进入(prev===next===initial)还是普通迁移。
*/
export type StateHook<S extends string> = (
/** 实体 */
entity: GameEntity,
/** 动画控制 */
motion: GameMotionController<GameEntity>,
/** 上一状态 */
prev: S,
/** 新状态 */
next: S
) => void | Promise<void>;
/**
* 单个状态的配置。
*/
export interface StateConfig<S extends string, E extends string> {
/** 事件迁移表 */
on?: Partial<Record<E, TransitionConfig<S, E> | S>>;
/** 进入状态时的回调 */
onEnter?: StateHook<S>;
/** 离开状态时的回调 */
onExit?: StateHook<S>;
/** 每帧更新时的回调 */
onUpdate?: (
/** 实体 */
entity: GameEntity,
/** 动画控制 */
motion: GameMotionController<GameEntity>,
/** 当前状态 */
current: S,
/** 时差 */
deltaTime: number
) => void | Promise<void>;
// 显式终止态(可选)。若不写 on 或 on 为空也会被视为“自然终止态”。
final?: boolean;
}
/**
* 整个状态机的配置对象。
*/
export interface MachineConfig<S extends string, E extends string> {
/** 初始状态 */
initial: S;
/** 各状态的事件表与生命周期钩子 */
states: Record<S, StateConfig<S, E>>;
}
/**
* 单实例状态机的订阅函数签名(当前仓库仅使用了多实体版本)。
*/
export type TransitionListener<S extends string> = (
/** 下一状态 */
next: S,
/** 上一状态 */
prev: S
) => void;
/**
* 多实体状态机的订阅函数签名:实体、下一状态、上一状态。
*/
export type MultiTransitionListener<S extends string> = (
/** 实体 */
entity: GameEntity,
/** 下一状态 */
next: S,
/** 上一状态 */
prev: S
) => void;
/**
* 额外可选项:全局迁移回调等(可不传)。
*/
export interface MachineOptions<S extends string, E extends string> {
/** 发生迁移后触发(在 onEnter/onExit 执行完毕、listeners 通知之后) */
onTransition?: (
/** 实体 */
entity: GameEntity,
/** 下一状态 */
next: S,
/** 上一状态 */
prev: S,
/** 触发迁移的事件 */
event: E
) => void | Promise<void>;
}initial(必填)
- 初始状态,必须是
S联合类型中的一个。
- 初始状态,必须是
states(必填)
- 每个状态名对应一个
StateConfig;在该状态下定义可响应的事件、生命周期钩子。
- 每个状态名对应一个
on(可选)
- 事件迁移表。两种写法:
- 简写:
on: { EVENT: 'targetState' } - 完整:
on: { EVENT: { target: 'targetState', guard?, action? } }
- 简写:
- guard:返回 true 才允许跳转(冷却/条件检查等)。
- action:在状态切换前执行(发生在 onExit 之前),可异步。
- 事件迁移表。两种写法:
onEnter(entity, motion, prev, next)(可选)
- 进入状态时触发,常用于加载并播放动画、初始化变量、打印日志等。
- 示例:
motion.loadByName('启动').play()或批量{ name, iterations }。
onExit(entity, motion, prev, next)(可选)
- 离开状态时触发,常用于停止动画、资源清理等。
onUpdate(entity, motion, current, deltaTime)(可选)
- 每帧由
update(entity, dt)驱动触发,dt单位为秒。 - 适合做计时、渐变、轮询检查等连续性逻辑。
- 每帧由
终止态(final / 自然终止)
- 当
final: true或该状态on未定义/为空时,视为终止态; - 终止态将忽略
send事件且不会再执行onUpdate; - 建议在该状态的
onEnter中做资源回收(停动画、清理计时器、取消订阅等)。
- 当
示例:
type S = 'idle' | 'done' | 'error';
type E = 'START' | 'FAIL';
const cfg: MachineConfig<S, E> = {
initial: 'idle',
states: {
idle: { on: { START: 'done' } },
done: { final: true, onEnter: (_, m) => m.loadByName('完成').play() },
error: {}, // 自然终止态(无 on)
},
};生命周期执行顺序(一次合法迁移)
- action(若存在,发生在切换之前)
- onExit(旧状态)
- 切换当前状态
- onEnter(新状态)
- 通知订阅者(
subscribe) - 触发构造参数中的
onTransition(若提供)
FSM 可视化编辑器 - 使用说明
本工具用于可视化编辑有限状态机(FSM),支持直接导入/导出与代码一致的 MachineConfig 配置,所见即所得地维护状态、事件、迁移与生命周期函数预览。
快速上手
- 在画布空白处右键 → 新增状态。
- 右键某状态 → 新增迁移 → 选择源/事件/目标。
- 如需新事件,事件下拉选择“自定义事件…”,在出现的输入框填写事件名。
- 右键状态底部可直接编辑 onEnter/onExit/onUpdate,自动保存。
- 点击“复制 TS 代码”按钮,可生成 TypeScript 配置预览,或下载 TS 文件。
顶部操作区
- 按钮
导出 JSON:导出为 MachineConfig 结构的fsm-config.json(含initial与states)。导入 JSON:从文件选择器导入 JSON。支持两种格式:- MachineConfig JSON(推荐)。
- 兼容旧版“内部模型 JSON”(含
states/events/transitions/lifecycle)。
粘贴 Config:弹出对话框,支持粘贴整段 TypeScript/JavaScript 代码或对象字面量:- 可粘贴完整的
const config: MachineConfig<S, E> = { ... }代码片段(包含注释与类型也可)。 - 也可直接粘贴对象字面量或 JSON(例如
{ initial: 'idle', states: {...} })。 - 导入后自动重新生成图与列表,并自动保存。
- 可粘贴完整的
下载 TS 文件:生成 TypeScript 配置预览,可下载保存。清空:清空当前模型(localStorage 也会被清空)。
画布与右键菜单
- 右键画布空白:仅包含“新增状态”。
- 右键状态节点:
重命名、新增迁移、设为初始、删除(已移除“新增事件”,在“新增迁移”里可选“自定义事件…”)。- 菜单底部可直接编辑该状态的生命周期:
onEnter/onExit/onUpdate。- 支持自动保存:失焦、关闭菜单或 Cmd/Ctrl+Enter 即保存。
- 保存后会自动更新 TS 预览与本地存储。
- 右键事件箭头(边):
重命名事件:仅修改该条迁移的事件名。反转方向:将 from/to 对调。删除迁移:仅删除该条边。
生命周期函数在编辑器内以字符串形式保存与展示,方便预览与导出 MachineConfig;真正的代码落地建议在你的工程代码中实现与维护。
列表区
- 左侧“状态列表”:
- 展示所有状态,可点击高亮并在图中定位。
- 支持“重命名”“删除”。
- 中部“事件列表”(迁移列表):
- 展示每一条迁移(形如
EVENT from -> to)。 - 支持“选中”“重命名(该条迁移的事件名)”“删除(该条迁移)”。
- 展示每一条迁移(形如
导入/导出的格式
- MachineConfig JSON(推荐)
{
"initial": "idle",
"states": {
"idle": {
"on": { "FETCH": "loading" },
"onEnter": "(_, motion) => { /* 建议在工程代码中维护 */ }",
"onExit": "() => {}",
"onUpdate": "() => {}"
},
"loading": {
"on": { "RESOLVE": "success", "REJECT": "failure" },
"onEnter": "(_, motion) => {}"
},
"success": {},
"failure": {}
}
}- 粘贴 Config(对象字面量/整段 TS)
- 可粘贴:
- 完整 TS 片段(例如含
type S/E、const config: MachineConfig<S,E> = { ... }、注释、函数)。 - 只包含
{ initial, states }的对象字面量,允许函数值(会以字符串保存)。
- 完整 TS 片段(例如含
- 可粘贴:
自动保存
- 每次编辑(增删改状态/事件/迁移,或修改生命周期文本)后,都会自动保存到浏览器
localStorage。 - 生命周期编辑支持失焦/关闭菜单自动保存,Cmd/Ctrl+Enter 快捷保存。
- 下次打开页面会自动恢复,无需手动导入。
快捷键
- 画布/列表通用
- Enter:在对话框中确认
- Esc:关闭当前对话框
- 生命周期编辑
- Cmd/Ctrl + Enter:保存当前编辑内容(同时系统也会在失焦或关闭菜单时自动保存)
新增迁移与自定义事件
- 在“新增迁移”弹窗中:
- 选择
源状态与目标状态。 - 事件下拉可选择已有事件,或选择“自定义事件…”,此时会出现“新事件名”输入框。
- 未填写自定义事件名前,“确定”按钮会禁用,避免空值提交。
- 弹窗内提供即时的 from/event/to 预览(若你在页面中开启了预览区域)。
- 选择
行为与校验
- 去重与清理:
- 自动去重
(from + event),若重复则更新其目标状态to。 - 自动清理未使用的事件(当没有任何迁移引用某事件时,将其从事件集合中移除)。
- 自动去重
- 布局与位置:
- 首次使用自动布局,之后记忆节点位置,增删元素时尽量保持手动布局不被打乱。
界面与交互优化
- 对话框与右键菜单内的输入控件已统一样式(圆角、阴影、聚焦高亮)。
- 生命周期编辑区使用等宽字体与浅色背景,阅读与编写更舒适。
小技巧
- 快速新增状态:在画布空白处右键 → “新增状态”。
- 定位节点/事件:在左/中列表点击项,画布会自动选中并定位。
- 避免命名冲突:新增状态或事件时会校验重复,避免覆盖既有配置。
安全与注意事项
- “粘贴 Config”支持对粘贴的对象字面量进行求值(以支持函数值)。为安全起见:
- 仅在本地浏览器内求值,不会上传你的内容。
- 请仅粘贴来自可信来源的片段,避免执行潜在恶意代码。
- 生命周期函数建议在工程仓库的 TS/TSX 源码中维护,编辑器内的预览仅为参考。
常见问题(FAQ)
- Q:粘贴整段 TS 报“解析失败”?
- A:请确认包含
const config = { ... }字面量;若有语法错误或花括号不匹配,工具将无法识别。你也可以尝试只粘贴{ initial, states }对象部分。
- A:请确认包含
- Q:导入 JSON 后生命周期函数不生效?
- A:JSON 不能保存函数值。若需要函数,请使用“粘贴 Config”粘贴对象字面量(允许函数),或在导出后手动回填到你的工程代码中。
