如何在 Vue 3 中实现一个可嵌套、可复用的“状态机”模式,用于管理复杂组件的内部状态转换?

大家好!欢迎来到今天的“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 函数负责在 ONOFF 状态之间切换。

第三章:让状态机更强大:添加 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>

这个例子中,我们添加了 LOADINGERROR 状态,以及 isLoadingerror 响应式变量。toggle 函数现在是一个异步函数,模拟了一个网络请求。在进入 LOADING 状态时,isLoading 被设置为 true,阻止用户再次点击按钮 (Guard)。如果请求成功,状态切换到 ONOFF;如果请求失败,状态切换到 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 函数负责处理状态转换,它根据当前状态和事件来决定下一个状态。canPlayisPlayingcanStop 是计算属性,用于控制按钮的禁用状态 (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 组件的内部状态。通过理解状态机的基本概念、掌握状态图的绘制方法、以及灵活运用可复用的状态机,你可以写出更清晰、更易维护的代码。

希望今天的讲座对你有所帮助!记住,状态机不是啥怪物,它是你的好伙伴!下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注