使用 XState 协调 Svelte 动画

Avatar of Adam Rackis
Adam Rackis

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

这篇文章介绍了 XStateSvelte 项目中的可能用法。XState 在 JavaScript 生态系统中独树一帜。它不会使您的 DOM 与应用程序状态保持同步,但它将通过允许您将其建模为有限状态机 (FSM) 来帮助管理应用程序的状态。

深入研究状态机和形式语言超出了本文的范围,但 Jon Bellah 在另一篇 CSS-Tricks 文章中对此进行了探讨。目前,您可以将 FSM 想象成流程图。流程图具有许多状态,表示为气泡,以及从一个状态到下一个状态的箭头,表示从一个状态到下一个状态的转换。状态机可以有多个箭头从一个状态发出,或者如果它是一个最终状态则根本没有箭头,它们甚至可以有箭头离开一个状态,并指向回同一个状态。

如果这一切听起来都让人不知所措,请放松,我们会慢慢地深入了解所有细节。目前,高级别的观点是,当我们将应用程序建模为状态机时,我们将创建应用程序可以处于的不同“状态”(明白了吗……状态机……状态?),并且发生的事件并导致状态更改将是这些状态之间的箭头。XState 将状态称为“状态”,将状态之间的箭头称为“动作”。

我们的示例

XState 有一定的学习曲线,这使得它难以教授。如果使用过于牵强的用例,它看起来会不必要地复杂。只有当应用程序的代码变得有点复杂时,XState 才会发挥作用。这使得撰写关于它的文章变得很棘手。话虽如此,我们将要查看的示例是一个自动完成小部件(有时称为自动建议),或者是一个输入框,当单击时,会显示一个项目列表供选择,这些项目会根据您在输入中键入的内容进行筛选。

在这篇文章中,我们将研究如何清理动画代码。这是起点

这是我 svelte-helpers 库中的实际代码,尽管为了这篇文章去掉了不必要的片段。您可以点击输入并筛选项目,但您将无法选择任何项目,“向下箭头”遍历项目,悬停等。我已经删除了与本文无关的所有代码。

我们将关注项目列表的动画。当您单击输入,并且结果列表首次呈现时,我们希望将其向下动画。当您键入和筛选时,列表尺寸的变化将以动画形式变大或变小。当输入失去焦点或您单击ESC时,我们将列表的高度动画设置为零,同时淡出,然后将其从 DOM 中删除(并且不早于此)。为了使事情更有趣(并且对用户友好),让我们为打开使用不同的弹簧配置,而不是我们用于关闭的配置,这样列表关闭速度会更快一些,或者更僵硬一些,以便不必要的 UX 不会在屏幕上停留太久。

如果您想知道为什么我没有使用 Svelte 过渡来管理 DOM 中的动画,那是因为我也在列表打开时(当用户筛选时)动画化列表的尺寸,并且在过渡和常规弹簧动画之间进行协调比简单地等待弹簧更新完成达到零然后再从 DOM 中删除元素要困难得多。例如,如果用户在列表动画进入时快速键入和筛选列表会发生什么?正如我们将看到的,XState 使这种棘手的状态转换变得很容易。

确定问题范围

让我们看一下来自 迄今为止的示例 的代码。我们有一个open变量来控制列表何时打开,以及一个resultsListVisible属性来控制它是否应该在 DOM 中。我们还有一个closing变量来控制列表是否正在关闭过程中。

在第 28 行,有一个inputEngaged方法,当输入被点击或聚焦时运行。目前,我们只注意到它将openresultsListVisible设置为true。inputChanged在用户在输入中键入时被调用,并将open设置为true。这是为了当输入获得焦点时,用户点击 Escape 关闭它,然后开始键入,以便它可以重新打开。当然,inputBlurred函数在您期望的时候运行,并将closing设置为true,并将open设置为false。

让我们分解这个混乱的局面,看看动画是如何工作的。请注意顶部的slideInSpringopacitySpring。前者将列表上下滑动,并在用户键入时调整大小。后者在隐藏列表时将其淡出。我们将主要关注slideInSpring

看看名为setSpringDimensions的庞大函数。它更新了我们的滑动弹簧。专注于重要的部分,我们获取一些布尔属性。如果列表正在打开,我们设置打开弹簧配置,我们立即通过{ hard: true }配置设置列表的宽度(我希望列表只向下滑动,而不是向下和向外),然后设置高度。如果我们正在关闭,我们动画到零,并且当动画完成后,我们将resultsListVisible设置为false(如果关闭动画被打断,Svelte 将足够聪明地**不**解析 promise,因此回调将永远不会运行)。最后,每当结果列表的大小发生变化时(即用户筛选时),也会调用此方法。我们在其他地方设置了一个ResizeObserver来管理它。

意大利面条太多

让我们总结一下这段代码。

  • 我们有open变量,它跟踪列表是否打开。
  • 我们有resultsListVisible变量,它跟踪列表是否应该在 DOM 中(并在关闭动画完成后设置为false)。
  • 我们有closing变量,它跟踪列表是否正在关闭过程中,我们在输入焦点/点击处理程序中检查它,以便如果用户在它完成关闭之前快速重新使用小部件,我们可以反转关闭动画。
  • 我们还有setSpringDimensions,我们在四个不同的地方调用它。它根据列表是打开、关闭还是在打开时调整大小(即用户筛选列表)来设置我们的弹簧。
  • 最后,我们有一个resultsListRendered Svelte 动作,它在结果列表 DOM 元素呈现时运行。它启动我们的ResizeObserver,并且当 DOM 节点卸载时,将closing设置为false。

你发现那个错误了吗?当按下ESC按钮时,我只将open设置为false。我忘记将 closing 设置为true,并调用setSpringDimensions(false, true)。这个错误不是专门为这篇文章而设计的!那是我在彻底修改这个小部件的动画时犯的一个真实错误。我可以将inputBlured中的代码复制粘贴到捕获 Escape 按钮的地方,或者甚至将其移动到一个新函数中,并从这两个地方调用它。这个错误从根本上来说并不难解决,但它确实增加了代码的认知负担。

我们正在跟踪很多东西,但最糟糕的是,这种状态散布在整个模块中。获取上面描述的任何状态片段,并使用 CodeSandbox 的查找功能查看使用该状态片段的所有位置。你会看到你的光标在文件中跳动。现在想象一下,你刚接触这段代码,试图理解它。想想你必须跟踪的所有这些状态片段的不断增长的思维模型,根据它存在的所有位置来弄清楚它是如何工作的。我们都经历过;这很糟糕。XState 提供了一种更好的方法;让我们看看它。

介绍 XState

让我们退一步。如果根据小部件所处的状态对其进行建模,并且在用户交互时发生事件,从而导致副作用并转换到新状态,会不会更简单?当然可以,但这就是我们一直在做的事情;问题是,代码散布在各处。XState 使我们能够以这种方式正确地建模我们的状态。

设定预期

不要指望 XState 能神奇地消除我们所有的复杂性。我们仍然需要协调我们的弹簧,根据打开和关闭状态调整弹簧的配置,处理调整大小等。XState 为我们提供的是能够以一种易于理解和调整的方式集中此状态管理代码的能力。事实上,由于我们的状态机设置,我们的总行数会略有增加。让我们看看。

你的第一个状态机

让我们直接开始,看看一个最基本的状态机是什么样的。我正在使用 XState 的 FSM 包,它是一个简化版的 XState,只有 1KB 的包大小,非常适合库(例如自动建议小部件)。它没有像完整 XState 包那样多的高级功能,但对于我们的用例来说,我们不需要它们,而且我们也不希望在一个像这样的入门文章中包含它们。

我们状态机的代码如下所示,交互式演示位于 Code Sandbox。代码很多,但我们很快就会讲解。需要明确的是,它目前还不能工作。

const stateMachine = createMachine(
  {
    initial: "initial",
    context: {
      open: false,
      node: null
    },
    states: {
      initial: {
        on: { OPEN: "open" }
      },
      open: {
        on: {
          RENDERED: { actions: "rendered" },
          RESIZE: { actions: "resize" },
          CLOSE: "closing"
        },
        entry: "opened"
      },
      closing: {
        on: {
          OPEN: { target: "open", actions: ["resize"] },
          CLOSED: "closed"
        },
        entry: "close"
      },
      closed: {
        on: {
          OPEN: "open"
        },
        entry: "closed"
      }
    }
  },
  {
    actions: {
      opened: assign(context => {
        return { ...context, open: true };
      }),
      rendered: assign((context, evt) => {
        const { node } = evt;
        return { ...context, node };
      }),
      close() {},
      resize(context) {},
      closed: assign(() => {
        return { open: false, node: null };
      })
    }
  }
);

让我们从上到下看。initial 属性控制初始状态是什么,我将其命名为“initial”。context 是与我们的状态机关联的数据。我存储了一个布尔值,用于表示结果列表当前是否已打开,以及一个用于表示同一结果列表的 node 对象。接下来我们看到我们的状态。每个状态都是 states 属性中的一个键。对于大多数状态,您可以看到我们有一个 on 属性和一个 entry 属性。

on 配置事件。对于每个事件,我们可以转换到一个新状态;我们可以运行副作用,称为操作;或者两者兼而有之。例如,当 OPEN 事件在 initial 状态内发生时,我们进入 open 状态。当 RENDERED 事件在 open 状态内发生时,我们运行 rendered 操作。当 OPEN 事件在 closing 状态内发生时,我们转换到 open 状态,并运行 resize 操作。您在大多数状态上看到的 entry 字段配置了一个操作,该操作在每次进入状态时自动运行。还有 exit 操作,尽管我们这里不需要它们。

我们还有几件事需要介绍。让我们看看状态机的数据或上下文如何改变。当我们希望操作修改上下文时,我们将其包装在 assign 中,并从我们的操作中返回新的上下文;如果我们不需要任何处理,我们可以直接将新状态传递给 assign。如果我们的操作不更新上下文,即它只是为了副作用,那么我们不将操作函数包装在 assign 中,而只是执行我们需要的任何副作用。

影响状态机的变化

我们有一个很棒的状态机模型,但我们如何运行它呢?我们使用 interpret 函数。

const stateMachineService = interpret(stateMachine).start();

现在 stateMachineService 是我们正在运行的状态机,我们可以在其上调用事件以强制执行我们的转换和操作。要触发一个事件,我们调用 send,传递事件名称,然后可选地传递事件对象。例如,在我们 Svelte 操作中,当结果列表第一次挂载到 DOM 中时,我们有以下代码

stateMachineService.send({ type: "RENDERED", node });

这就是 rendered 操作获取结果列表节点的方式。如果您查看 AutoComplete.svelte 文件的其余部分,您会看到所有临时状态管理代码都被替换为单行事件分派。在我们的输入点击/聚焦的事件处理程序中,我们运行 OPEN 事件。我们的 ResizeObserver 触发 RESIZE 事件。等等。

让我们暂停一下,欣赏一下 XState 为我们免费提供的功能。让我们看看在添加 XState 之前,输入被点击或聚焦时运行的处理程序。

function inputEngaged(evt) {
  if (closing) {
    setSpringDimensions();
  }
  open = true;
  resultsListVisible = true;
} 

之前,我们正在检查是否正在关闭,如果是,则强制重新计算我们的滑动弹簧。否则,我们打开小部件。但是,如果我们在小部件已经打开时点击输入会发生什么?相同的代码会重新运行。幸运的是,这并不重要。Svelte 不在乎我们是否将 openresultsListVisible 重置为它们已经具有的值。但是,这些问题在使用 XState 后消失了。新版本如下所示


function inputEngaged(evt) {
  stateMachineService.send("OPEN");
}

如果我们的状态机已经在 open 状态,并且我们触发了 OPEN 事件,那么什么也不会发生,因为该状态没有配置 OPEN 事件。那么,当点击输入时结果列表正在关闭时的特殊处理呢?这也在状态机配置中处理——请注意,当 OPEN 事件从 closing 状态运行时,它会附加 resize 操作。

当然,我们也修复了之前 ESC 键的错误。现在,按下该键只会触发 CLOSE 事件,就是这样。

收尾工作

结尾几乎毫无悬念。我们需要获取之前所做的所有工作,并将其简单地移动到操作中的正确位置。XState 并没有消除我们编写代码的必要性;它只是提供了一个结构化、清晰的位置来放置代码。

{
  actions: {
    opened: assign({ open: true }),
    rendered: assign((context, evt) => {
      const { node } = evt;
      const dimensions = getResultsListDimensions(node);
      itemsHeightObserver.observe(node);
      opacitySpring.set(1, { hard: true });
      Object.assign(slideInSpring, SLIDE_OPEN);
      slideInSpring.update(prev => ({ ...prev, width: dimensions.width }), {
        hard: true
      });
      slideInSpring.set(dimensions, { hard: false });
      return { ...context, node };
    }),
    close() {
      opacitySpring.set(0);
      Object.assign(slideInSpring, SLIDE_CLOSE);
      slideInSpring
        .update(prev => ({ ...prev, height: 0 }))
        .then(() => {
          stateMachineService.send("CLOSED");
        });
    },
    resize(context) {
      opacitySpring.set(1);
      slideInSpring.set(getResultsListDimensions(context.node));
    },
    closed: assign(() => {
      itemsHeightObserver.unobserve(resultsList);
      return { open: false, node: null };
    })
  }
}

其他细节

我们的动画状态在我们的状态机中,但我们如何将其输出呢?我们需要 open 状态来控制结果列表的渲染,并且,虽然在本演示中未使用,但此自动建议小部件的真实版本需要结果列表 DOM 节点来执行诸如将当前突出显示的项目滚动到视图中之类的事情。

事实证明,我们的 stateMachineService 有一个 subscribe 方法,该方法在每次状态更改时都会触发。您传递的回调会使用当前状态机状态调用,其中包括一个 context 对象。但是 Svelte 有一个特殊的技巧:它的 $: 反应式语法不仅适用于组件变量和 Svelte 存储;它也适用于任何具有 subscribe 方法的对象。这意味着我们可以使用以下简单的方法与我们的状态机同步

$: ({ open, node: resultsList } = $stateMachineService.context);

只是一个普通的解构,带有一些括号来帮助正确解析。

这里快速说明一下,作为改进的领域。现在,我们有一些操作既执行副作用,又更新状态。理想情况下,我们可能应该将它们拆分为两个操作,一个仅用于副作用,另一个使用 assign 用于新状态。但我决定在本篇文章中尽可能地保持简单,以帮助简化 XState 的介绍,即使一些事情最终并不理想。

这是演示

结语

希望这篇文章能激发您对 XState 的兴趣。我发现它是一个非常有用、易于使用的工具,可以管理复杂的状态。请注意,我们只是触及了表面。我们专注于最小的 fsm 包,但整个 XState 库的功能远不止我们在这里介绍的这些,从嵌套状态到对 Promise 的一流支持,它甚至还有一个状态可视化工具!我敦促您去查看一下。

编码愉快!