大家好!欢迎来到今天的“Vue 3 状态机炼金术”讲座。今天咱们不搞玄学,只聊点实用的,把状态机这玩意儿在 Vue 3 里玩出花来。
开场白:别怕,状态机不是啥怪物
很多人一听到“状态机”就觉得高深莫测,好像只有大神才能驾驭。其实啊,状态机本质上就是一种管理状态转换的思路,你每天都在用,只不过没意识到而已。想象一下:你点外卖,订单状态从“待支付”变成“已支付”,再到“商家接单”、“骑手配送”,最后变成“已完成”,这就是一个活生生的状态机啊!
在 Vue 组件里,状态机可以帮你更好地组织和控制复杂的逻辑,让代码更清晰、更易维护。
第一章:状态机的基本概念
要炼金,先得懂元素。状态机也是一样,咱们先来了解几个基本概念:
- 状态 (State): 组件在某一时刻所处的“样子”。 比如一个按钮可以是“启用”状态或者“禁用”状态。
- 事件 (Event): 触发状态转换的“动作”。比如点击按钮就是一个事件。
- 转换 (Transition): 从一个状态到另一个状态的“过程”。 比如从“启用”状态到“禁用”状态。
- 状态图 (State Diagram): 用图形化的方式描述状态和转换的关系。 就像一个电路图,告诉你状态之间怎么流转。
用表格来总结一下:
概念 | 描述 | 举例 |
---|---|---|
状态 | 组件在某一时刻的“样子” | 开/关,加载中/已加载,启用/禁用 |
事件 | 触发状态转换的“动作” | 点击,鼠标悬停,数据加载完成,定时器触发 |
转换 | 从一个状态到另一个状态的“过程”,由事件触发 | 从“开”到“关”,从“加载中”到“已加载” |
状态图 | 用图形化的方式描述状态和转换的关系,帮助理解状态的流转 | 用箭头表示状态之间的转换,用圆圈表示状态本身 |
第二章:在 Vue 3 中构建简单的状态机
咱们先从最简单的开始,用 Vue 3 的 Composition API 实现一个开关状态机。
<template>
<div>
<button @click="toggle">{{ currentState }}</button>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
// 定义状态
const states = {
ON: 'ON',
OFF: 'OFF'
};
// 初始化状态
const currentState = ref(states.OFF);
// 定义转换函数
const toggle = () => {
currentState.value = currentState.value === states.ON ? states.OFF : states.ON;
};
</script>
这个例子很简单,currentState
是一个响应式变量,表示当前状态。toggle
函数负责在 ON
和 OFF
状态之间切换。
第三章:让状态机更强大:添加 Actions 和 Guards
光有状态切换还不够,有时候我们需要在状态转换前后执行一些操作,或者根据条件判断是否允许转换。这就需要用到 Actions 和 Guards。
- Action (动作): 在状态转换时执行的函数。比如在进入“加载中”状态时,可以发起一个网络请求。
- Guard (守卫): 一个条件判断,只有当条件为真时,才允许状态转换。 比如只有用户登录后,才能进入“已登录”状态。
咱们来改进一下上面的例子,添加一个简单的 Action 和 Guard。
<template>
<div>
<button @click="toggle" :disabled="isLoading">
{{ currentState }}
</button>
<p v-if="isLoading">Loading...</p>
<p v-if="error">{{ error }}</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const states = {
ON: 'ON',
OFF: 'OFF',
LOADING: 'LOADING',
ERROR: 'ERROR'
};
const currentState = ref(states.OFF);
const isLoading = ref(false);
const error = ref(null);
const toggle = async () => {
if (isLoading.value) return; // Guard: 正在加载时不能切换
currentState.value = states.LOADING;
isLoading.value = true;
error.value = null;
try {
// Action: 模拟异步操作
await new Promise(resolve => setTimeout(resolve, 1000));
currentState.value = currentState.value === states.LOADING ? states.ON : states.OFF;
} catch (e) {
currentState.value = states.ERROR;
error.value = e.message || 'Unknown error';
} finally {
isLoading.value = false;
}
};
</script>
这个例子中,我们添加了 LOADING
和 ERROR
状态,以及 isLoading
和 error
响应式变量。toggle
函数现在是一个异步函数,模拟了一个网络请求。在进入 LOADING
状态时,isLoading
被设置为 true
,阻止用户再次点击按钮 (Guard)。如果请求成功,状态切换到 ON
或 OFF
;如果请求失败,状态切换到 ERROR
,并显示错误信息 (Action)。
第四章:状态机的灵魂:状态图的威力
状态图是状态机的蓝图,它可以帮助你更好地理解和设计状态机。咱们用一个更复杂的例子来说明状态图的威力:一个简单的播放器组件。
这个播放器有以下状态:
- IDLE (空闲): 播放器未加载任何媒体。
- LOADING (加载中): 正在加载媒体。
- PLAYING (播放中): 正在播放媒体。
- PAUSED (暂停): 媒体已暂停。
- STOPPED (停止): 媒体已停止。
- ERROR (错误): 播放过程中发生错误。
以下事件可以触发状态转换:
- LOAD (加载): 加载媒体。
- PLAY (播放): 开始播放。
- PAUSE (暂停): 暂停播放。
- STOP (停止): 停止播放。
- ERROR (错误): 发生错误。
- ENDED (播放结束): 媒体播放结束。
对应的状态图大概是这样的(文字描述,脑补画面):
IDLE --(LOAD)--> LOADING
LOADING --(PLAY)--> PLAYING
PLAYING --(PAUSE)--> PAUSED
PLAYING --(STOP)--> STOPPED
PLAYING --(ENDED)--> STOPPED
PLAYING --(ERROR)--> ERROR
PAUSED --(PLAY)--> PLAYING
PAUSED --(STOP)--> STOPPED
STOPPED --(LOAD)--> LOADING
ERROR --(LOAD)--> LOADING
有了状态图,咱们就可以开始写代码了。
<template>
<div>
<button @click="load" :disabled="isLoading">Load</button>
<button @click="play" :disabled="!canPlay">Play</button>
<button @click="pause" :disabled="!isPlaying">Pause</button>
<button @click="stop" :disabled="!canStop">Stop</button>
<p>State: {{ currentState }}</p>
<p v-if="isLoading">Loading...</p>
<p v-if="error">{{ error }}</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const states = {
IDLE: 'IDLE',
LOADING: 'LOADING',
PLAYING: 'PLAYING',
PAUSED: 'PAUSED',
STOPPED: 'STOPPED',
ERROR: 'ERROR'
};
const events = {
LOAD: 'LOAD',
PLAY: 'PLAY',
PAUSE: 'PAUSE',
STOP: 'STOP',
ERROR: 'ERROR',
ENDED: 'ENDED'
};
const currentState = ref(states.IDLE);
const isLoading = ref(false);
const error = ref(null);
const canPlay = computed(() => currentState.value === states.PAUSED || currentState.value === states.STOPPED);
const isPlaying = computed(() => currentState.value === states.PLAYING);
const canStop = computed(() => currentState.value === states.PLAYING || currentState.value === states.PAUSED);
const load = async () => {
if (isLoading.value) return;
transition(events.LOAD);
try {
isLoading.value = true;
error.value = null;
// Action: 模拟加载媒体
await new Promise(resolve => setTimeout(resolve, 2000));
transition(events.PLAY); // 加载完成后自动播放
} catch (e) {
transition(events.ERROR, e.message || 'Load Error');
} finally {
isLoading.value = false;
}
};
const play = () => {
transition(events.PLAY);
};
const pause = () => {
transition(events.PAUSE);
};
const stop = () => {
transition(events.STOP);
};
const transition = (event, payload) => {
console.log(`Transitioning from ${currentState.value} with event ${event}`);
switch (currentState.value) {
case states.IDLE:
if (event === events.LOAD) {
currentState.value = states.LOADING;
}
break;
case states.LOADING:
if (event === events.PLAY) {
currentState.value = states.PLAYING;
}
break;
case states.PLAYING:
switch (event) {
case events.PAUSE:
currentState.value = states.PAUSED;
break;
case events.STOP:
currentState.value = states.STOPPED;
break;
case events.ENDED:
currentState.value = states.STOPPED;
break;
case events.ERROR:
currentState.value = states.ERROR;
error.value = payload;
break;
}
break;
case states.PAUSED:
if (event === events.PLAY) {
currentState.value = states.PLAYING;
} else if (event === events.STOP) {
currentState.value = states.STOPPED;
}
break;
case states.STOPPED:
if (event === events.LOAD) {
currentState.value = states.LOADING;
}
break;
case states.ERROR:
if (event === events.LOAD) {
currentState.value = states.LOADING;
error.value = null;
}
break;
}
console.log(`New state: ${currentState.value}`);
};
</script>
这个例子比之前的复杂多了,但结构更清晰。transition
函数负责处理状态转换,它根据当前状态和事件来决定下一个状态。canPlay
、isPlaying
和 canStop
是计算属性,用于控制按钮的禁用状态 (Guards)。
第五章:状态机的进阶:嵌套状态机和可复用性
上面的例子虽然能工作,但如果状态机变得更复杂,transition
函数就会变得臃肿不堪。为了解决这个问题,我们可以使用嵌套状态机和可复用的状态机。
- 嵌套状态机 (Nested State Machine): 将一个状态机嵌入到另一个状态机中。就像俄罗斯套娃一样,可以帮助你更好地组织和管理复杂的状态。
- 可复用的状态机 (Reusable State Machine): 将状态机的逻辑封装成一个独立的函数或组件,可以在不同的地方重复使用。
咱们先来实现一个可复用的状态机。
// reusableStateMachine.js
import { ref, computed } from 'vue';
export function createStateMachine(initialState, transitions) {
const currentState = ref(initialState);
const transition = (event, payload) => {
const transitionConfig = transitions[currentState.value]?.[event];
if (!transitionConfig) {
console.warn(`Invalid transition from ${currentState.value} with event ${event}`);
return;
}
const { target, guard, action } = transitionConfig;
if (guard && !guard(payload)) {
console.warn(`Transition from ${currentState.value} with event ${event} blocked by guard`);
return;
}
if (action) {
action(payload);
}
currentState.value = target;
};
const stateIs = (state) => computed(() => currentState.value === state);
return {
currentState,
transition,
stateIs // 用于简化判断当前状态
};
}
这个 createStateMachine
函数接收两个参数:
initialState
: 初始状态。transitions
: 一个对象,描述了状态和转换的关系。
transitions
对象的结构如下:
{
[state]: { // 状态
[event]: { // 事件
target: nextState, // 下一个状态
guard: (payload) => boolean, // 可选的守卫函数
action: (payload) => void // 可选的动作函数
}
}
}
现在,咱们可以把播放器组件的状态机逻辑提取出来,放到 reusableStateMachine.js
文件中。
// playerStateMachine.js
import { createStateMachine } from './reusableStateMachine';
const states = {
IDLE: 'IDLE',
LOADING: 'LOADING',
PLAYING: 'PLAYING',
PAUSED: 'PAUSED',
STOPPED: 'STOPPED',
ERROR: 'ERROR'
};
const events = {
LOAD: 'LOAD',
PLAY: 'PLAY',
PAUSE: 'PAUSE',
STOP: 'STOP',
ERROR: 'ERROR',
ENDED: 'ENDED'
};
const playerStateMachineConfig = {
[states.IDLE]: {
[events.LOAD]: {
target: states.LOADING
}
},
[states.LOADING]: {
[events.PLAY]: {
target: states.PLAYING
}
},
[states.PLAYING]: {
[events.PAUSE]: {
target: states.PAUSED
},
[events.STOP]: {
target: states.STOPPED
},
[events.ENDED]: {
target: states.STOPPED
},
[events.ERROR]: {
target: states.ERROR,
action: (payload) => {
console.error('Player Error:', payload); // 可以在这里处理错误
}
}
},
[states.PAUSED]: {
[events.PLAY]: {
target: states.PLAYING
},
[events.STOP]: {
target: states.STOPPED
}
},
[states.STOPPED]: {
[events.LOAD]: {
target: states.LOADING
}
},
[states.ERROR]: {
[events.LOAD]: {
target: states.LOADING
}
}
};
export function usePlayerStateMachine() {
return createStateMachine(states.IDLE, playerStateMachineConfig);
}
export { states as playerStates, events as playerEvents };
然后,在播放器组件中使用这个状态机。
<template>
<div>
<button @click="load" :disabled="isLoading">Load</button>
<button @click="play" :disabled="!canPlay">Play</button>
<button @click="pause" :disabled="!isPlaying">Pause</button>
<button @click="stop" :disabled="!canStop">Stop</button>
<p>State: {{ playerStateMachine.currentState }}</p>
<p v-if="isLoading">Loading...</p>
<p v-if="error">{{ error }}</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { usePlayerStateMachine, playerStates, playerEvents } from './playerStateMachine';
const playerStateMachine = usePlayerStateMachine();
const isLoading = ref(false);
const error = ref(null);
const canPlay = computed(() => playerStateMachine.stateIs(playerStates.PAUSED).value || playerStateMachine.stateIs(playerStates.STOPPED).value);
const isPlaying = computed(() => playerStateMachine.stateIs(playerStates.PLAYING).value);
const canStop = computed(() => playerStateMachine.stateIs(playerStates.PLAYING).value || playerStateMachine.stateIs(playerStates.PAUSED).value);
const load = async () => {
if (isLoading.value) return;
playerStateMachine.transition(playerEvents.LOAD);
try {
isLoading.value = true;
error.value = null;
// Action: 模拟加载媒体
await new Promise(resolve => setTimeout(resolve, 2000));
playerStateMachine.transition(playerEvents.PLAY); // 加载完成后自动播放
} catch (e) {
playerStateMachine.transition(playerEvents.ERROR, e.message || 'Load Error');
} finally {
isLoading.value = false;
}
};
const play = () => {
playerStateMachine.transition(playerEvents.PLAY);
};
const pause = () => {
playerStateMachine.transition(playerEvents.PAUSE);
};
const stop = () => {
playerStateMachine.transition(playerEvents.STOP);
};
</script>
这个例子中,我们将状态机的逻辑提取到了 usePlayerStateMachine
hook 中,使播放器组件的代码更加简洁。
第六章:更上一层楼:XState 和 VueX
虽然咱们自己实现了状态机,但轮子也不是不能用。XState
是一个强大的 JavaScript 状态机库,它提供了更丰富的功能和更灵活的配置。VueX
也可以用来管理状态,但它更适合管理全局状态,而不是组件内部的状态。
- XState: 一个功能强大的 JavaScript 状态机库,支持复杂的转换逻辑、嵌套状态机、并行状态机等。
- VueX: Vue.js 的官方状态管理库,适合管理全局状态,但不适合管理组件内部的状态。
如果你的项目需要更复杂的状态管理,可以考虑使用 XState
。
第七章:实战案例:表单验证
咱们来一个实战案例,用状态机实现一个简单的表单验证。
这个表单有以下状态:
- IDLE (空闲): 表单未填写任何内容。
- TYPING (输入中): 用户正在输入内容。
- VALIDATING (验证中): 正在验证表单内容。
- VALID (验证通过): 表单内容验证通过。
- INVALID (验证失败): 表单内容验证失败。
- SUBMITTING (提交中): 正在提交表单。
- SUBMITTED (已提交): 表单已提交。
<template>
<form @submit.prevent="submit">
<label for="email">Email:</label>
<input type="email" id="email" v-model="email" @input="validateEmail">
<p v-if="isInvalid && !email">{{ emailError }}</p>
<label for="password">Password:</label>
<input type="password" id="password" v-model="password" @input="validatePassword">
<p v-if="isInvalid && !password">{{ passwordError }}</p>
<button type="submit" :disabled="isSubmitting || isInvalid || !email || !password">
{{ submitButtonText }}
</button>
<p v-if="isSubmitting">Submitting...</p>
<p v-if="isSubmitted">Submitted!</p>
<p v-if="error">{{ error }}</p>
</form>
</template>
<script setup>
import { ref, computed } from 'vue';
import { createStateMachine } from './reusableStateMachine';
const states = {
IDLE: 'IDLE',
TYPING: 'TYPING',
VALIDATING: 'VALIDATING',
VALID: 'VALID',
INVALID: 'INVALID',
SUBMITTING: 'SUBMITTING',
SUBMITTED: 'SUBMITTED'
};
const events = {
INPUT: 'INPUT',
VALIDATE: 'VALIDATE',
SUBMIT: 'SUBMIT'
};
const formStateMachineConfig = {
[states.IDLE]: {
[events.INPUT]: {
target: states.TYPING
},
[events.SUBMIT]: {
target: states.VALIDATING
}
},
[states.TYPING]: {
[events.VALIDATE]: {
target: states.VALIDATING
},
[events.INPUT]: {
target: states.TYPING
},
[events.SUBMIT]: {
target: states.VALIDATING
}
},
[states.VALIDATING]: {
[events.VALID]: {
target: states.VALID
},
[events.INVALID]: {
target: states.INVALID
},
[events.SUBMIT]: {
target: states.SUBMITTING
}
},
[states.VALID]: {
[events.SUBMIT]: {
target: states.SUBMITTING
}
},
[states.INVALID]: {
[events.INPUT]: {
target: states.TYPING
},
[events.SUBMIT]: {
target: states.VALIDATING
}
},
[states.SUBMITTING]: {
[events.SUBMIT]: {
target: states.SUBMITTED
}
}
};
const useFormStateMachine = () => {
return createStateMachine(states.IDLE, formStateMachineConfig);
};
const formStateMachine = useFormStateMachine();
const email = ref('');
const password = ref('');
const emailError = ref('Email is required');
const passwordError = ref('Password is required');
const error = ref(null);
const isSubmitting = computed(() => formStateMachine.stateIs(states.SUBMITTING).value);
const isSubmitted = computed(() => formStateMachine.stateIs(states.SUBMITTED).value);
const isInvalid = computed(() => formStateMachine.stateIs(states.INVALID).value);
const submitButtonText = computed(() => {
if (formStateMachine.stateIs(states.IDLE).value || formStateMachine.stateIs(states.TYPING).value) {
return 'Submit';
} else if (formStateMachine.stateIs(states.VALIDATING).value) {
return 'Validating...';
} else if (formStateMachine.stateIs(states.VALID).value) {
return 'Submit';
} else if (formStateMachine.stateIs(states.INVALID).value) {
return 'Fix Errors';
} else {
return 'Submit';
}
});
const validateEmail = () => {
formStateMachine.transition(events.INPUT);
// 简单的验证
if (!email.value) {
emailError.value = 'Email is required';
return;
}
// 触发验证
formStateMachine.transition(events.VALIDATE);
setTimeout(() => {
if (email.value.includes('@')) {
formStateMachine.transition(events.VALID);
} else {
formStateMachine.transition(events.INVALID);
emailError.value = 'Invalid email format';
}
}, 500);
};
const validatePassword = () => {
formStateMachine.transition(events.INPUT);
if (!password.value) {
passwordError.value = 'Password is required';
return;
}
formStateMachine.transition(events.VALIDATE);
setTimeout(() => {
if (password.value.length >= 6) {
formStateMachine.transition(events.VALID);
} else {
formStateMachine.transition(events.INVALID);
passwordError.value = 'Password must be at least 6 characters';
}
}, 500);
};
const submit = async () => {
formStateMachine.transition(events.SUBMIT);
try {
// 模拟提交
await new Promise(resolve => setTimeout(resolve, 1000));
formStateMachine.transition(events.SUBMIT);
} catch (e) {
error.value = e.message || 'Submission Error';
}
};
</script>
这个例子演示了如何使用状态机来管理表单的状态,以及如何根据状态来控制按钮的禁用状态和显示不同的提示信息。
总结:状态机是你的好伙伴
状态机是一种强大的工具,可以帮助你更好地组织和控制 Vue 组件的内部状态。通过理解状态机的基本概念、掌握状态图的绘制方法、以及灵活运用可复用的状态机,你可以写出更清晰、更易维护的代码。
希望今天的讲座对你有所帮助!记住,状态机不是啥怪物,它是你的好伙伴!下课!