「譯」有限狀態機在 CSS 動畫中的應用

隨着用戶界面中可能出現的不一樣狀態和狀態間轉換的數目的不斷增加,樣式和動畫的管理很快就變得複雜起來。即便是一個簡單的登陸表單也能夠有不少不一樣的「用戶狀態流」,而且有許多邊界狀況須要考慮。css

示例:codepen.io/davidkpiano…前端

狀態機做爲一種很好的編程範式,經過符合直覺和聲明式的方式來管理用戶界面狀態間的過渡。咱們已經在 the Keyframers 中做爲一種簡化複雜動畫和用戶交互流的方式大量使用到了狀態機。react

因此,什麼是狀態機呢?聽起來是很技術向的一個名詞,對嗎?它實際上可能比你想的要更簡單和直觀。(不要直接看 Wikipedia 的介紹,相信我)git

讓咱們從動畫的角度來探索一下狀態機。假設你在編寫一個 loading 動畫,在任意給定時間,它只能處於如下四個狀態之一。github

  • idle (還未進入 loading 狀態)
  • loading
  • failure
  • success

這很容易理解,你的動畫不可能既處於 loading 狀態又處於 success 狀態中。可是,這些狀態如何在彼此之間過渡是須要重點考慮的。編程

每一個箭頭告訴咱們一個狀態是如何經過事件過渡到另外一個狀態的,而且有些狀態是不可能互相轉換的。(好比說你不可能從 success 狀態到 failure 狀態)。每個箭頭表明一個能夠落地的動畫,或者能夠說是一個過渡。CSS 過渡是用來描述一個視覺狀態在 CSS 中是如何轉換至另外一個視覺狀態的。echarts

換句話說,只要你在使用 CSS 過渡動畫,你就已經在使用狀態機的思想,但你可能沒有意識到這一點。在不一樣狀態間切換時你可能會使用添加或者移除類名的方式在實現:ide

.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狀態機 的討論。

相關文章
相關標籤/搜索