复杂状态流
问题陈述
多步骤操作之间存在依赖关系,容易出现顺序错误、遗漏先决条件和未测试的边缘情况。即使没有正式的状态机库,用状态和转换思考也能预防错误。
模式:状态机思维
**问题:**复杂流程有隐含状态未被建模,导致无效转换。
示例 - 重考流程状态:
IDLE → LOADING_COMPLETED → ENABLING_RETAKE → CLEARING_ANSWERS → READY → ANSWERING → MERGING → SUBMITTING → COMPLETE
↓
ERROR
每个转换应包含:
- 先决条件 - 此步骤之前必须为真的条件
- 动作 - 此步骤期间发生的事情
- 后决条件 - 此步骤之后必须为真的条件
- 错误处理 - 如果此步骤失败,应该做什么
// 明确记录流程
/*
* RETAKE FLOW
*
* State: IDLE
* Precondition: assessment exists
* Action: loadCompletedAssessmentAnswers
* Postcondition: completedAssessmentAnswers populated
*
* State: LOADING_COMPLETED
* Precondition: completedAssessmentAnswers loaded
* Action: enableSkillAreaRetake
* Postcondition: skillArea in retakeAreas set
*
* State: ENABLING_RETAKE
* Precondition: skillArea in retakeAreas
* Action: clearSkillAreaAnswers
* Postcondition: existing answers for skillArea removed
*
* ... continue for each state
*/
模式:显式流程实现
**问题:**流程逻辑分散在多个函数中,难以验证顺序。
// 错误 - 隐式流程,容易遗漏步骤或顺序错误
async function startRetake(assessmentId: string, skillArea: string) {
loadCompletedAssessmentAnswers(assessmentId); // Missing await!
await enableSkillAreaRetake(skillArea);
await clearSkillAreaAnswers(skillArea);
}
// 正确 - 显式流程带验证
async function startRetake(assessmentId: string, skillArea: string) {
const flowId = `retake-${Date.now()}`;
logger.info(`[${flowId}] Starting retake flow`, { assessmentId, skillArea });
// 第1步:加载完成的答案
await loadCompletedAssessmentAnswers(assessmentId);
const completedAnswers = useStore.getState().completedAssessmentAnswers;
if (Object.keys(completedAnswers).length === 0) {
throw new Error(`[${flowId}] Failed to load completed answers`);
}
logger.debug(`[${flowId}] Loaded ${Object.keys(completedAnswers).length} answers`);
// 第2步:启用技能区域重考
await enableSkillAreaRetake(skillArea);
const retakeAreas = useStore.getState().retakeAreas;
if (!retakeAreas.has(skillArea)) {
throw new Error(`[${flowId}] Failed to enable retake for ${skillArea}`);
}
logger.debug(`[${flowId}] Enabled retake for ${skillArea}`);
// 第3步:清除现有答案
await clearSkillAreaAnswers(skillArea);
logger.debug(`[${flowId}] Cleared answers for ${skillArea}`);
logger.info(`[${flowId}] Retake flow completed`);
}
模式:流程对象
**问题:**长异步函数包含多个步骤变得难以处理。
interface FlowStep<TContext> {
name: string;
execute: (context: TContext) => Promise<void>;
validate?: (context: TContext) => void; // 后决条件检查
}
interface RetakeContext {
assessmentId: string;
skillArea: string;
flowId: string;
}
const retakeSteps: FlowStep<RetakeContext>[] = [
{
name: 'loadCompletedAnswers',
execute: async (ctx) => {
await loadCompletedAssessmentAnswers(ctx.assessmentId);
},
validate: (ctx) => {
const answers = useStore.getState().completedAssessmentAnswers;
if (Object.keys(answers).length === 0) {
throw new Error(`[${ctx.flowId}] No completed answers loaded`);
}
},
},
{
name: 'enableRetake',
execute: async (ctx) => {
await enableSkillAreaRetake(ctx.skillArea);
},
validate: (ctx) => {
const retakeAreas = useStore.getState().retakeAreas;
if (!retakeAreas.has(ctx.skillArea)) {
throw new Error(`[${ctx.flowId}] Retake not enabled for ${ctx.skillArea}`);
}
},
},
{
name: 'clearAnswers',
execute: async (ctx) => {
await clearSkillAreaAnswers(ctx.skillArea);
},
},
];
async function executeFlow<TContext>(
steps: FlowStep<TContext>[],
context: TContext,
flowName: string
) {
const flowId = `${flowName}-${Date.now()}`;
logger.info(`[${flowId}] Starting flow`, context);
for (const step of steps) {
logger.debug(`[${flowId}] Executing: ${step.name}`);
try {
await step.execute(context);
if (step.validate) {
step.validate(context);
}
logger.debug(`[${flowId}] Completed: ${step.name}`);
} catch (error) {
logger.error(`[${flowId}] Failed at: ${step.name}`, { error: error.message });
throw error;
}
}
logger.info(`[${flowId}] Flow completed`);
}
// 使用方法
await executeFlow(retakeSteps, { assessmentId, skillArea, flowId }, 'retake');
模式:流程状态跟踪
**问题:**组件需要知道当前流程状态以提供UI反馈。
type RetakeFlowState =
| { status: 'idle' }
| { status: 'loading'; step: string }
| { status: 'ready' }
| { status: 'answering'; answeredCount: number }
| { status: 'submitting' }
| { status: 'complete' }
| { status: 'error'; message: string; step: string };
const useRetakeStore = create<{
flowState: RetakeFlowState;
setFlowState: (state: RetakeFlowState) => void;
}>((set) => ({
flowState: { status: 'idle' },
setFlowState: (flowState) => set({ flowState }),
}));
async function startRetake(assessmentId: string, skillArea: string) {
const { setFlowState } = useRetakeStore.getState();
try {
setFlowState({ status: 'loading', step: 'loadingAnswers' });
await loadCompletedAssessmentAnswers(assessmentId);
setFlowState({ status: 'loading', step: 'enablingRetake' });
await enableSkillAreaRetake(skillArea);
setFlowState({ status: 'loading', step: 'clearingAnswers' });
await clearSkillAreaAnswers(skillArea);
setFlowState({ status: 'ready' });
} catch (error) {
setFlowState({
status: 'error',
message: error.message,
step: useRetakeStore.getState().flowState.step,
});
}
}
// 组件使用
function RetakeScreen() {
const flowState = useRetakeStore((s) => s.flowState);
if (flowState.status === 'loading') {
return <Loading step={flowState.step} />;
}
if (flowState.status === 'error') {
return <Error message={flowState.message} step={flowState.step} />;
}
// ...根据状态渲染
}
模式:集成测试流程
**问题:**单元测试单个函数无法捕获流程级别的错误。
describe('Retake Flow', () => {
beforeEach(() => {
useAssessmentStore.getState()._reset();
});
it('persists answers through complete retake flow', async () => {
const assessmentId = 'test-assessment';
const skillArea = 'fundamentals';
const store = useAssessmentStore;
// 设置:模拟现有完成的评估
store.getState().setCompletedAnswers(assessmentId, mockCompletedAnswers);
// 执行完整流程
await store.getState().loadCompletedAssessmentAnswers(assessmentId);
// 验证后决条件
expect(Object.keys(store.getState().completedAssessmentAnswers).length)
.toBeGreaterThan(0);
await store.getState().enableSkillAreaRetake(skillArea);
// 验证后决条件
expect(store.getState().retakeAreas.has(skillArea)).toBe(true);
await store.getState().clearSkillAreaAnswers(skillArea);
// 模拟用户回答
await store.getState().saveAnswer('q1', 4);
// 关键检查 - 答案是否持久化?
expect(store.getState().userAnswers['q1']).toBe(4);
// 完成流程
await store.getState().submitRetake(assessmentId);
// 验证最终状态
expect(store.getState().flowState.status).toBe('complete');
});
it('handles error at each step', async () => {
// 测试第1步错误处理
mockApi.loadAnswers.mockRejectedValueOnce(new Error('Network error'));
await expect(
store.getState().startRetake(assessmentId, skillArea)
).rejects.toThrow('Network error');
expect(store.getState().flowState.status).toBe('error');
expect(store.getState().flowState.step).toBe('loadingAnswers');
});
});
模式:流程文档
用图表记录复杂流程,以便团队理解:
## 重考流程
### 快乐路径
┌─────────┐ ┌──────────────┐ ┌───────────────┐ ┌───────────────┐ │ Start │────▶│ Load Answers │────▶│ Enable Retake │────▶│ Clear Answers │ └─────────┘ └──────────────┘ └───────────────┘ └───────────────┘ │ │ │ ▼ ▼ ▼ Postcondition: Postcondition: Postcondition: answers.length > 0 retakeAreas.has(x) cleared for area
│
▼
┌──────────┐ ┌─────────┐ ┌───────────────┐ ┌──────────────────┐ │ Complete │◀────│ Merge │◀────│ User Answers │◀────│ Ready for Input │ └──────────┘ └─────────┘ └───────────────┘ └──────────────────┘
### 错误状态
任何步骤失败 → 转换到ERROR状态,并带有步骤上下文。
从ERROR:用户可以重试(返回IDLE)或退出。
清单:设计复杂流程
实施前:
- [ ] 绘制状态图(即使是在纸上)
- [ ] 确定所有状态,包括错误状态
- [ ] 记录每个转换的先决条件
- [ ] 记录后决条件以验证
- [ ] 计划如何向UI展示状态
实施中:
- [ ] 验证每个步骤之前的先决条件
- [ ] 验证每个步骤之后的后决条件
- [ ] 用流程ID记录状态转换
- [ ] 每个步骤处理错误并带有上下文
- [ ] 为UI反馈展示流程状态
实施后:
- [ ] 集成测试快乐路径
- [ ] 集成测试每个步骤的错误
- [ ] 验证日志是否足够用于调试
- [ ] 为团队记录流程
何时使用XState
考虑在以下情况下使用XState:
- 流程有> 6个状态
- 复杂的分支/并行状态
- 需要可视化/调试工具
- 状态机在团队中共享
对于更简单的流程,显式步骤带验证(如上所示)通常足够且更易读。