有限状态机在 CSS 动画中的应用

简介: 随着用户界面中可能出现的不同状态和状态间转换的数目的不断增长,样式和动画的管理很快就变得复杂起来。即使是一个简单的登录表单也可以有很多不同的“用户状态流”,并且有许多边界情况需要考虑。

有限状态机在 CSS 动画中的应用

随着用户界面中可能出现的不同状态和状态间转换的数目的不断增长,样式和动画的管理很快就变得复杂起来。即使是一个简单的登录表单也可以有很多不同的“用户状态流”,并且有许多边界情况需要考虑。

示例:https://codepen.io/davidkpiano/pen/WKvPBP

状态机作为一种很好的编程范式,通过符合直觉和声明式的方式来管理用户界面状态间的过渡。我们已经在 the Keyframers 中作为一种简化复杂动画和用户交互流的方式大量使用到了状态机。

所以,什么是状态机呢?听起来是很技术向的一个名词,对吗?它实际上可能比你想的要更简单和直观。(不要直接看 Wikipedia 的介绍,相信我)

让我们从动画的角度来探索一下状态机。假设你在编写一个 loading 动画,在任意给定时间,它只能处于以下四个状态之一。

  • idle (还未进入 loading 状态)
  • loading
  • failure
  • success

这很容易理解,你的动画不可能既处于 loading 状态又处于 success 状态中。但是,这些状态如何在彼此之间过渡是需要重点考虑的。

每个箭头告诉我们一个状态是如何通过事件过渡到另一个状态的,并且有些状态是不可能互相转换的。(比如说你不可能从 success 状态到 failure 状态)。每一个箭头代表一个可以落地的动画,或者可以说是一个过渡。CSS 过渡是用来描述一个视觉状态在 CSS 中是如何转换至另一个视觉状态的。

换句话说,只要你在使用 CSS 过渡动画,你就已经在使用状态机的思想,但你可能没有意识到这一点。在不同状态间切换时你可能会使用添加或者移除类名的方式在实现:

.button {
  /* ... button styles ... */
  transition: all 0.3s ease-in-out;
}
.button.is-loading {
  opacity: 0.5;
}
.button.is-loaded {
  opacity: 1;
  background-color: green;
}

这样可以正常工作,但是你必须确保 is-loading 类名被移除并且 is-loaded 类名被添加,因为更有可能出现的情况是类名变成 .button.is-loading.is-loaded。这样可能会导致不符合预期的副作用。

一个更好的方式是使用 data- 属性。它们只能展示一个值因此在这种场景下是有用的。当你的用户界面的某部分同时只能在一个状态下时(比如 loadingsuccesserror),更新 data- 属性是更直接的:

const elButton = document.querySelector('.button');
// set to loading
elButton.dataset.state = 'loading';
// set to success
elButton.dataset.state = 'success';

这种方式自然地限制在任意给定的时机里你的按钮只存在单个状态。你可以使用 data-state 属性来表示不同的按钮状态:

.button[data-state="loading"] {
  opacity: 0.5;
}
.button[data-state="success"] {
  opacity: 1;
  background-color: green;
}

有限状态机

通常来说,有限状态机由五部分组成:

  • 一系列有限的状态(如 idle,loading,success,failure)
  • 一系列有限的事件(如 FETCH,ERROR,RESOLVE,RETRY)
  • 一个初始状态(如 idle)
  • 一系列过渡方式(如 idle 通过 FETCH 事件过渡至 laoding)
  • 最终状态

它还有一些规范:

  • 一个有限状态机同时只能在一种状态中
  • 所有的过渡方式必须是确定的,意味着任意给定的状态和时间,必定会导致相同的预定义的下一个状态。没有意外。

现在,让我们看看我们如何在 HTML 和 CSS 中表示有限状态机。

上下文提供状态

有时,你需要根据当前应用(或某个父组件)的状态来决定其它组件的样式。只读的 data- 属性同样也可以在这种场景下使用,比如:data-show

.button[data-state="loading"] .text[data-show="loading"] {
  display: inline-block;
}
.button[data-state="loading"] .text[data-show]:not([data-show="loading"]) {
  display: none;
}

这是一种用来标记特定的 UI 元素仅仅应该在特定状态下展示的方式。然后再分别地在需要展示的元素上添加 data-show="..." 即可。如果你的组件在多个状态下都想显示,你可以像下面这样使用 空格分割属性选择器

<button class="button" data-state="idle">
  <!-- 处于 idle 和 loading 状态时展示下载图标 -->
  <span class="icon" data-show="idle loading"></span>
  <span class="text" data-show="idle">Download</span>
  <span class="text" data-show="loading">Downloading...</span>
  <span class="text" data-show="success">Done!</span>
</button>

这是对应的 CSS:

/* ... */
.button[data-state="loading"] [data-show~="loading"] {
  display: inline-block;
}

data-state 属性可以使用 JavaScript 进行改变:

const elButton = document.querySelector('.button');
function setButtonState(state) {
  // set the data-state attribute on the button
  elButton.dataset.state = state;
}
setButtonState('loading');
// the button's data-state attribute is now "loading"

动态 data- 属性样式

随着应用的逐渐迭代,将所有的 data- 属性规则添加进来会让样式表不断膨胀并且难以维护,因为你在 JavaScript 文件和样式表中都需要维护这些不同的状态。同时因为每个类名和 data- 属性添加了不同的权重,也会让权重变得异常复杂。为了减少这些问题带来的影响,我们可以依照以下两条原则使用动态的 data-active 属性:

  • 当匹配到 data-show="..." 属性时,元素应当具有 data-active 属性。
  • 当没有匹配到 data-hide="..." 属性时,元素也应当具有 data-active 属性。

下面是在 JavaScrit 实际应用的例子:

const elButton = document.querySelector('.button');
function setButtonState(state) {
  // change data-state attribute
  elButton.dataset.state = state;
  // remove any active data-attributes
  document.querySelectorAll(`[data-active]`).forEach(el => {
    delete el.dataset.active;
  });
  // add active data-attributes to proper elements
  document.querySelectorAll(`[data-show~="${state}"], [data-hide]:not([data-hide~="${state}"])`)
    .forEach(el => {
      el.dataset.active = true;
    });
}
// set button state to 'loading'
setButtonState('loading');

现在,我们上面的展示隐藏的样式可以被简化:

.text[data-active] {
  display: inline-block;
}
.text:not([data-active]) {
  display: none;
}

声明可视化的状态

目前为止,一切都好。但是我们想防止改变状态的函数包含业务逻辑,我们可以创建一个状态机转换函数,包含当前状态和触发事件后转换到的下个状态和返回此状态的逻辑。通过使用 switch 代码块,可能像下面这样:

// ...
function transitionButton(currentState, event) {
  switch (currentState) {
    case 'idle':
      switch (event) {
        case 'FETCH':
          return 'loading';
        default:
          return currentState;
      }
    case 'loading':
      switch (event) {
        case 'ERROR':
          return 'failure';
        case 'RESOLVE':
          return 'success';
        default:
          return currentState;
      }
    case 'failure':
      switch (event) {
        case 'RETRY':
          return 'loading';
        default:
          return currentState;
      }
    case 'success':
      default:
        return currentState;
  }
}
let currentState = 'idle';
function send(event) {
  currentState = transitionButton(currentState, event);
// change data-attributes
  setButtonState(currentState);
}
send('FETCH');
// => button state is now 'loading'

Switch 代码块基于事件对状态之间的转换进行编码,我们可以使用对象来简化它:

// ...
const buttonMachine = {
  initial: 'idle',
  states: {
    idle: {
      on: {
        FETCH: 'loading'
      }
    },
    loading: {
      on: {
        ERROR: 'failure',
        RESOLVE: 'success'
      }
    },
    failure: {
      on: {
        RETRY: 'loading'
      }
    },
    success: {}
  }
};
let currentState = buttonMachine.initial;
function transitionButton(currentState, event) {
  return buttonMachine
    .states[currentState]
    .on[event] || currentState; // fallback to current state
}
// ...
// use the same send() function

不仅这种方式看起来比 Switch 代码块更干净,同时也是可以 JSON 序列化的。同时我们可以声明式地对状态和事件进行枚举。这就可以让我们将 buttonMachine 的代码复制粘贴至可视化工具中,比如xviz

总结

状态机的模式让应用中状态的处理更简便,并且让 CSS 中的样式过渡更简洁。总结一下,我们介绍了以下的 data- 属性:

  • data-state 表示组件上有限的状态(如 data-state="loading"
  • data-show 决定了当其中一种状态匹配到 data-state 中的状态时元素需要增加 data-active 属性。(如 data-state="idle loading"
  • data-hide 决定了当其中一种状态匹配到 data-state 中的状态时元素需要移除 data-active 属性。(如 data-state="success error"
  • data-active 在当前元素 data-showdata-hide 属性匹配到 data-state 中的状态时,动态添加至以上元素。

还有以下的编程范式,使用以下属性,通过 JavaScript 对象定义一个状态机:

  • initial - 状态机的初始状态(如 idle
  • states - 一个包含过渡方式和状态的 Map
  • on - 标识了转换至下个状态的事件(如 FETCH: "loading"
  • 创建一个 transition(currentState, event) 函数,根据当前状态在状态机中查找下一个状态
  • 创建一个 send(event) 函数,包含以下特点:

    1. 调用 transition(...) 方法来决定下一个状态
    2. 设置当前状态为获取到的下一个状态
    3. 执行相应的副作用(在这里是设置合适的 data- 属性)

我们同样可以通过调用 setButtonState(...) 人工测试想要的状态,这样就可以设置合适的 data- 属性和在特定状态下帮助我们开发和 debug 组件。这样可以减少为了到达合适的状态而不得不进行的一整套繁琐的流程。

更进一步

如果你想更深地探究状态机(和它延伸出来的概念,“状态表”),可以查阅下面的资源:

xstate 是一个能够帮助更好地创建和使用状态机和状态图的库,支持嵌套/扁平的状态,行为等等。通过阅读这篇文章,你已经知道如何去使用它了:

import { Machine } from 'xstate';
const buttonMachine = Machine({
  // the same buttonMachine object from earlier
});
let currentState = buttonMachine.initialState;
// => 'idle'
function send(event) {
  currentState = buttonMachine.transition(currentState, event);
// change data-attributes
  setButtonState(currentState);
}
send('FETCH');
// => button state is now 'loading'

The World of Statecharts 是由 Erik Mogensen 整理的非常棒的资源,可以透彻地解释状态表和如何在用户界面上应用。
Spectrum Statecharts community 有许多热心并且乐于助人,同时对 状态机和状态表很有兴趣的开发者。
Learn State Machines 是一个通过构建 Instagram 的应用示例来教你学习状态表基础概念的课程。
React-AutomataMichele Bertoli 开发的使用 xstate 的库,它能够让你在 React 中使用状态表,有很多好处,比如自动生成测试快照。
如果你想了解更多前端用户界面中状态机的好处,可以查看我曾经在 Shop Talk Show 和 Jon Bellah状态机 的讨论。

目录
相关文章
|
1月前
|
机器学习/深度学习 前端开发 JavaScript
|
25天前
|
前端开发 搜索推荐 UED
实现 CSS 动画效果的兼容性
【10月更文挑战第16天】实现 CSS 动画效果的兼容性需要对不同浏览器的特性有深入的了解,并采取适当的策略和方法。通过不断的实践和优化,你可以在各种浏览器上创造出流畅、美观且兼容的动画效果,为用户带来更好的体验。在实际开发中,要密切关注浏览器的发展动态,及时掌握最新的兼容性技巧和解决方案,以确保你的动画设计能够在广泛的用户群体中得到良好的呈现。
101 58
|
4天前
|
Web App开发 前端开发 JavaScript
如何在不牺牲动画效果的前提下,优化 CSS3 动画的性能?
如何在不牺牲动画效果的前提下,优化 CSS3 动画的性能?
13 1
|
19天前
|
前端开发 JavaScript UED
深入理解与应用 CSS 伪类选择器
【10月更文挑战第23天】通过以上对 CSS 伪类选择器的深入探讨,我们可以更好地理解和应用它们,为网页设计和开发带来更丰富、更灵活的样式效果。同时,要注意在实际应用中根据具体情况合理选择和使用伪类选择器,以达到最佳的设计效果和用户体验。
29 2
|
30天前
|
前端开发 JavaScript API
探索 CSS Houdini:轻松构建酷炫的 3D 卡片翻转动画
本文通过构建一个 3D 翻卡动画深入探讨了 CSS Houdini 的强大功能,展示了如何通过 Worklets、自定义属性、Paint API 等扩展 CSS 的能力,实现高度灵活的动画效果。文章首先介绍了 Houdini 的核心概念与 API,并通过构建一个动态星空背景、圆形进度条以及交互式 3D 翻卡动画的实际示例,展示了如何利用 CSS Houdini 赋予网页设计更多创造力。最后,还演示了如何将这种 3D 翻卡效果集成到公司网站中,提升用户体验。CSS Houdini 的创新能力为网页设计带来了前所未有的灵活性,推动了前端开发迈向新的高度。
26 0
探索 CSS Houdini:轻松构建酷炫的 3D 卡片翻转动画
|
1月前
|
前端开发 开发者 UED
CSS技术的演变与应用
【10月更文挑战第11天】CSS技术的演变与应用
|
2月前
|
JavaScript 前端开发
JS配合CSS3实现动画和拖动小星星小Demo
本文通过代码示例展示了如何使用JavaScript和CSS3实现动画效果和拖动小星星的交互效果,包括文字掉落动画和鼠标拖动产生小星星动画的实现方法。
45 0
JS配合CSS3实现动画和拖动小星星小Demo
|
1月前
|
前端开发
CSS 动画介绍及语法
CSS 动画介绍及语法
27 0
|
3月前
|
前端开发 UED 开发者
有趣的CSS - 文字加载动画效果
这个文本加载动画简单而有趣,可以在网站标题、广告标语或者关键信息的展示上吸引用户的注意力。开发者可以根据需要调整动画的持续时间、步骤数,或者光标颜色等,来适应特定的设计需求。使用这种动态元素,增强网站的互动性和用户体验,同时也为网站增添了一抹活泼的风格。
76 5
|
3月前
|
前端开发 Java UED
JSF 面向组件开发究竟藏着何种奥秘?带你探寻可复用 UI 组件设计的神秘之路
【8月更文挑战第31天】在现代软件开发中,高效与可维护性至关重要。JavaServer Faces(JSF)框架通过其面向组件的开发模式,提供了构建复杂用户界面的强大工具,特别适用于设计可复用的 UI 组件。通过合理设计组件的功能与外观,可以显著提高开发效率并降低维护成本。本文以一个具体的 `MessageComponent` 示例展示了如何创建可复用的 JSF 组件,并介绍了如何在 JSF 页面中使用这些组件。结合其他技术如 PrimeFaces 和 Bootstrap,可以进一步丰富组件库,提升用户体验。
55 0