Vue、React、Angular 或 Svelte 等前端库和框架具有许多功能,但最重要的功能之一是状态管理。我们可以管理组件的本地状态,或者使用专用状态管理库(如 Pinia、Vuex、Redux 或 Zusand)更全局地处理它。但有时我们需要管理不同类型的状态——特定于机器的状态。

让我们想象一些物理机器,例如咖啡机。我们可以考虑一下机器可以处于什么状态。让我们尝试列出其中的一些:

  • 空闲

  • 升温

  • 清洗

  • 煮咖啡

  • 错误状态

像这样的机器只能同时处于一种状态。清洁时不能倒咖啡。此外,状态的顺序和路径以不可能破坏特定顺序的方式定义和限制。如果没有事先加热和清洁,就不可能在打开设备后直接煮咖啡。这种机器称为有限状态机

有限状态机

有限状态机是一种抽象机器,可以同时处于一个状态。机器可以使用转换来改变它的状态。转换是从一种状态改变到另一种状态的行为。

[有限状态机](https://res.cloudinary.com/practicaldev/image/fetch/s--lqcsG-mL--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev -to-uploads.s3.amazonaws.com/uploads/articles/slvmbf05820p00zmudjp.png)

实施问题

假设我们想为任何类型的机器构建一个简单的 UI。停车场机器,自动售货机,这并不重要,因为它们都是某种形式的有限状态机。机器越复杂,状态越多,这些状态的管理就越困难。如果机器处于状态 A 并且可以从该状态转换到状态 B 和 D,我们必须小心不要将这台机器移动到任何其他禁止状态。如果我们有 20 多个状态,您只能想象管理和验证状态会变得多么困难。这可能会导致许多难以调试的错误。我们可以利用经过验证的解决方案,以出色的库形式 - XState 来代替手动完成所有操作。

XState 来救援!

XState 是 JavaScript 和 TypeScript 库,可帮助创建和管理状态机和状态图。为简单起见,XState 具有:

  • 很棒的文档

  • 大型社区

  • 伟大的工具Visual Studio 代码扩展

  • 支持流行的框架(Vue、React、Svelte)

我想没有必要再说服了!有关更多信息,请查看官方文档

由于没有比创建一个简单的应用程序更好的学习新库的方法,我们将尝试使用唯一的 Vue 3! 重新创建 iPod 状态机。

使用 Vue3 和 Xstate 构建应用程序

首先,让我们看一下我们即将创建的应用程序:

[Ipod 状态机](https://res.cloudinary.com/practicaldev/image/fetch/s--OX7igAfx--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to -uploads.s3.amazonaws.com/uploads/articles/3uhf13z9zievfbmjw98i.png)

UI 模拟 iPod 用户界面,滚轮上的按钮触发所需的操作。对于本教程,我们将省略应用程序的 CSS 部分,但如果您有兴趣,可以随时查看源代码。

好的,让我们开始建造吧! 💪🏻

我们可以从搭建 Vue 应用程序开始:

npm install vue@latest

进入全屏模式 退出全屏模式

我们不需要路由、测试库、状态管理等,因此您可以选择以下选项:

[查看选项](https://res.cloudinary.com/practicaldev/image/fetch/s--2SJuWi6F--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/wim0pdquk8ou12v64pk8.png)

安装 XState 主要依赖项:

npm install xstate

进入全屏模式 退出全屏模式

由于我们使用 Vue 3 作为框架,我们必须安装一个特定于框架的包:

npm install @xstate/vue

进入全屏模式 退出全屏模式

在我们开始研究状态机逻辑之前,值得一提的是,有一个神奇的工具,我们可以在其中绘制机器状态并生成机器代码,而无需手动编写!我们来看看Stately。

[庄严的](https://res.cloudinary.com/practicaldev/image/fetch/s--4YB456iN--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads .s3.amazonaws.com/uploads/articles/o5nmk0wtvb7opcegkigg.png)

与其直接跳到绘制我们的图表,我们应该头脑风暴一下关于 iPod 的状态。

  • idle(当我们打开设备时)

  • playingBegin(在曲目的最开头播放 - 点击“上一个”按钮时的状态将转到上一个曲目)

  • 正在播放(当点击“上一个按钮”时将倒退到曲目的开头)

  • 暂停

只有 4 个状态,但我们必须牢记一些关于状态转换的规则:

  • idle 状态,我们只能过渡到 play_begin 状态。我们不能直接转换到播放状态,因为该状态必须跟随播放_begin 状态,该状态是轨道开头的状态。由于显而易见的原因,我们也不能直接进入暂停状态。

  • playingBegin 状态,我们可以进入播放或暂停状态

  • playing 状态我们可以进入 playBegin 或 paused 状态

  • from paused 我们可以进入播放状态

伟大的!现在我们可以尝试使用 Stately 创建图表:

[iPod图](https://res.cloudinary.com/practicaldev/image/fetch/s--QDbhPvhM--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/gkibsna69nyttwqs8wdw.png)

然后在右上角,单击“导出”将机器 JSON 文件复制到剪贴板。

在我们的案例中,文件将如下所示:

{
    id: "playerMachine",
    initial: "idle",
    states: {
        idle: {
            on: { PLAY_BEGIN: { target: "playingBegin" } },
        },
        playingBegin: {
            on: {
                PLAY: { target: "playing" },
                PAUSE: { target: "paused" },
            },
        },
        playing: {
            on: {
                PAUSE: { target: "paused" },
                PLAY_BEGIN: { target: "playingBegin" } 
            },
        },
        paused: {
            on: {
                PLAY: { target: "playing" },
            },
        },
    },
}

进入全屏模式 退出全屏模式

我们现在基本上已经定义了整个状态机。 Stately 生成的 JSON 描述了所有状态以及它们之间的可用转换。它还定义了初始状态。好的,既然我们已经准备好了状态定义,我们可以在 Vue 中实现它!

我们可以从创建一个音乐播放器组件开始。让我们在 src/components 目录中创建一个名为MusicPlayer.vue的新文件。接下来,您可以清理由 Vue CLI 搭建的App.vue文件。让我们在那里注册音乐播放器组件:

<script setup lang="ts">
import MusicPlayer from './components/MusicPlayer.vue'
</script>

<template>
  <main>
    <MusicPlayer />
  </main>
</template>

进入全屏模式 退出全屏模式

我们不再需要这个文件,所以我们可以关闭它。接下来,让我们使用我们之前生成的状态机。让我们创建一个名为/machines的新目录,并在此目录中创建一个新文件playerMachine.ts。现在我们可以使用XState提供的第一个函数了。让我们使用createMachine函数:

import { createMachine } from "xstate";

export const playerMachine = createMachine({
    id: "playerMachine",
    initial: "idle",
    states: {
        idle: {
            on: { PLAY_BEGIN: { target: "playingBegin" } },
        },
        playingBegin: {
            on: {
                PLAY: { target: "playing" },
                PAUSE: { target: "paused" },
            },
        },
        playing: {
            on: {
                PAUSE: { target: "paused" },
                PLAY_BEGIN: { target: "playingBegin" } 
            },
        },
        paused: {
            on: {
                PLAY: { target: "playing" },
            },
        },
    },
});

进入全屏模式 退出全屏模式

我们在这里所做的是创建一个名为playerMachine的机器实例,使用createMachine()函数和机器 JSON 描述符作为参数。此实例已导出,因此我们可以在另一个文件中使用它。我们现在可以关闭文件并返回MusicPlayer.vue文件。我们剩下的工作将在这个文件中进行。

我们必须以某种方式使用我们之前创建的机器实例并使其与 Vue 一起工作。为了实现它,我们必须导入机器实例和一个名为@xstate/vue的专用 vue xstate 包。这个库提供了一个名为useMachine的反应式可组合。我们将使用它在我们的 Vue 组件中在我们的机器上进行操作。

<script setup lang="ts">
import { useMachine } from "@xstate/vue";
import { playerMachine } from "./../machines/playerMachine";

const { state, send } = useMachine(playerMachine);
</script>

进入全屏模式 退出全屏模式

正如我们所见,useMachine钩子提供了一个state对象,它包含有关状态的所有重要信息和负责触发转换的send函数。

要触发转换,我们必须这样做:

send("PLAY");

进入全屏模式 退出全屏模式

此函数调用将触发从当前状态的转换。根据当前状态,这种转换可能导致不同的状态。行为是在我们的机器实例中定义的。

检查机器是否处于某种状态:

在脚本中:

state.value.matches('playingBegin')

进入全屏模式 退出全屏模式

在模板中:

state.matches('playingBegin')

进入全屏模式 退出全屏模式

由于我们的演示应用只需要触发转换并检查当前状态,我们现在可以为音乐播放器创建 UI。这是模板降价:

<template>
  <div class="music-player">
      <div class="display">
            <div class="current-track">
              <div class="track-name">{{ currentTrack.name }}</div>
              <div class="track-artist">{{ currentTrack.artist }}</div>
            </div>
            <div class="state-icon">
              <IconPlay v-if="state.matches('idle') || state.matches('paused')" class="icon icon-play"></IconPlay>
              <IconPause v-if="state.matches('playingBegin') || state.matches('playing')" class="icon icon-play"></IconPause>
            </div>
            <div class="progress-bar">
                <div class="progress-bar-inner"></div>
            </div>
      </div>
      <div class="wheel">
          <button class="button-control menu">menu</button>
          <button class="button-control next" @click="nextTrack">
              <IconNext class="icon"></IconNext>
          </button>
          <button class="button-control prev" @click="rewindOrPrevious">
              <IconPrev class="icon"></IconPrev>
          </button>
          <button class="button-control playpause" @click="togglePlayPause">
              <IconPlay class="icon icon-play"></IconPlay>
              <IconPause class="icon"></IconPause>
          </button>
          <div class="wheel-inner"></div>
      </div>
  </div>
</template>

进入全屏模式 退出全屏模式

如前所述,本文不是关于 CSS 的,因此我不会广泛讨论这个主题,但如果您有兴趣,可以查看源代码(链接在结论部分)。让我们专注于逻辑。该界面由以下元素组成:

  • 显示(不可点击),显示有关当前曲目标题、作者、长度和播放/暂停状态的信息

  • 带 4 个按钮的滚轮(菜单、上一曲/快退曲目、下一曲、播放/暂停)

为了让这一切正常工作,我们需要一些模拟轨道:让我们创建一个由 ref 函数包装的数组,其中包含两个随机轨道:

<script setup lang="ts">
import { computed, ref } from "vue";
const tracks = ref([
    {
        name: "Ask The Mountains",
        artist: "Vangelis",
        length: 240
    },
    {
        name: "Colors of Love",
        artist: "Thomas Bergesen",
        length: 200
    }
]);
</script>

进入全屏模式 退出全屏模式

我们有两个可用的轨道,现在我们应该创建一些变量来保存有关当前轨道索引和当前轨道本身的信息。

<script setup lang="ts">
import { computed, ref } from "vue";

const currentTrackIndex = ref(0);

const currentTrack = computed(() => {
    return tracks.value[currentTrackIndex.value];
});
</script>

进入全屏模式 退出全屏模式

track 对象在length属性中保存有关其持续时间的信息(以秒为单位)。我们可以创建一个计时器,以秒为单位保存有关当前轨道进度的信息。如果当前曲目完成,我们将需要它来自动更改曲目并在显示屏上显示进度条。

<script setup lang="ts">
let progressInterval: ReturnType<typeof setInterval> | null = null;

const currentTrackProgress = ref(0);

const currentTrackProgressPercentage = computed(() => {
    return `${(currentTrackProgress.value / currentTrack.value.length) * 100}%`;
});

function startOrContinueProgressTimer() {
    progressInterval = setInterval(() => {
        checkForTrackEnd();
        currentTrackProgress.value += 1;
    }, 1000);
}
</script>

进入全屏模式 退出全屏模式

让我们讨论一下我们在这里做了什么。我们创建了一个名为currentTrackProgress的反应变量,它负责保存有关当前轨道进度的信息,并且由于我们将使用setInterval函数来递增计时器,因此我们创建了progressInterval来保存 setInterval 实例。currentTrackProgressPercentage是一个计算值,它计算跟踪进度百分比并将其作为百分比字符串返回。这个值在 CSS 中用于动画进度条:

.progress-bar{
    width: 80%;
    height: 25px;
    border-top: 1px solid gainsboro;
    border-bottom: 1px solid gainsboro;
    .progress-bar-inner {
        background: #16a1ea;
        height: 100%;
        width: v-bind(currentTrackProgressPercentage);
    }
}

进入全屏模式 退出全屏模式

startOrContinueProgressTimer函数以一秒的间隔设置定时器。在每次调用时,它都会将 currentTrackProgress 增加一秒,并调用一个函数checkForTrackEnd负责检查轨道是否已经结束。

<script setup lang="ts">
function checkForTrackEnd() {
    if (currentTrackProgress.value === currentTrack.value.length) {
        nextTrack();
    }
}
</script>

进入全屏模式 退出全屏模式

由于我们有计时器,我们肯定需要一些逻辑来重置计时器。

<script setup lang="ts">
function resetTimer() {
    currentTrackProgress.value = 0;
    progressInterval && clearInterval(progressInterval);
}
</script>

进入全屏模式 退出全屏模式

显然我们还必须在组件卸载时清除它:

<script setup lang="ts">
onUnmounted(() => {
    progressInterval && clearInterval(progressInterval);
});
</script>

进入全屏模式 退出全屏模式

伟大的!我们拥有计时器逻辑所需的一切。现在我们可以转到状态部分。让我们实现在播放/暂停按钮单击时触发的功能。由于此按钮根据当前状态执行两件事,因此我们需要顶级功能:

<script setup lang="ts">
function togglePlayPause() {
    if (state.value.matches('idle')) {
        playBeginTrack();
    }
    else if (state.value.matches('paused')) {
        playTrack();
    }
    else {
        send("PAUSE");
        progressInterval && clearInterval(progressInterval);
    }
}
</script>

进入全屏模式 退出全屏模式

它使用 state.value.matches 函数检查当前状态并调用单独的方法,如playBeginTrackplayTrack或直接触发PAUSE转换。PlayingBegin是曲目开头的状态,按下previous按钮将切换到上一曲目,而不是在当前曲目的开头倒带。

我们来看看playBeginTrack函数:

<script setup lang="ts">
function playBeginTrack() {
    send("PLAY_BEGIN");

    startOrContinueProgressTimer();

    setTimeout(() => {
        send("PLAY");
    }, playBeginStateDuration);
}
</script>

进入全屏模式 退出全屏模式

一开始,它触发PLAY_BEGIN转换并通过调用startOrContinueProgressTimer()启动进度计时器。setTimeout函数的第二个参数包含有关在多少毫秒后状态应该切换到正常播放状态(send("PLAY")的信息。在我们的例子中是 5 秒

const playBeginStateDuration = 5000;

进入全屏模式 退出全屏模式

让我们转到另一个函数playTrack。它只是 playBeginTrack 的简化版本,并带有PLAY触发器:

<script setup lang="ts">
function playTrack() {
    send("PLAY");
    startOrContinueProgressTimer();
}
</script>

进入全屏模式 退出全屏模式

接下来,让我们创建nextTrack函数:

<script setup lang="ts">
function nextTrack() {
    resetTimer();

    if (currentTrackIndex.value < tracks.value.length - 1) {
        currentTrackIndex.value++;
    }
    else {
        currentTrackIndex.value = 0;
    }

    startOrContinueProgressTimer();
}
</script>

进入全屏模式 退出全屏模式

当我们单击“下一曲目”按钮时,将调用此函数。由于我们即将更改曲目,因此我们必须重置计时器。如果下一个轨道在我们的轨道数组的范围内,我们增加currentTrackIndex,如果不是,我们将 currentTrackIndex 重置回 0。在轨道改变后,我们再次启动计时器。

太好了,我们有第二个按钮的逻辑!让我们转到最后一个按钮,即“上一首曲目/倒带”按钮。作为播放/暂停按钮,其行为取决于当前状态。让我们创建用于检查状态的顶级函数:

<script setup lang="ts">
function rewindOrPrevious() {
    if (state.value.matches('playingBegin')) {
        previousTrack();
    }
    else {
        rewindTrack();
    }
}
</script>

进入全屏模式 退出全屏模式

如果曲目刚开始播放并且其状态为playingBegin,单击“倒带/上一个”按钮应切换到上一个曲目:

<script setup lang="ts">
resetTimer();

if (currentTrackIndex.value > 0) {
    currentTrackIndex.value--;
}
else {
    currentTrackIndex.value = tracks.value.length - 1;
}

startOrContinueProgressTimer();
</script>

进入全屏模式 退出全屏模式

逻辑与nextTrack函数非常相似。首先,我们需要在切换轨道时重置计时器,然后如果它在轨道数组的范围内,我们将减少currentTrackIndex。最后,我们必须再启动一次计时器。

倒带曲目功能如下所示:

<script setup lang="ts">
function rewindTrack() {
    resetTimer();

    send("PLAY_BEGIN");

    startOrContinueProgressTimer();
}
</script>

进入全屏模式 退出全屏模式

它重置计时器并触发PLAY_BEGIN转换,因为我们从头开始跟踪。我们必须再次启动计时器。

瞧!该应用程序已完成!我们的 3 个按钮有完整的逻辑!

结论

使用 XState 我们可以创建复杂的状态机并轻松管理状态,确保只有一个状态处于活动状态。它还验证状态之间的转换。在强大的扩展、文档和其他可视化工具的帮助下,使用 XState 开发应用程序是一种很棒的体验!

查看演示:

演示

查看源代码:

源代码

Logo

前往低代码交流专区

更多推荐