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

阿里雲前端發表於2019-03-02

隨著使用者介面中可能出現的不同狀態和狀態間轉換的數目的不斷增長,樣式和動畫的管理很快就變得複雜起來。即使是一個簡單的登入表單也可以有很多不同的“使用者狀態流”,並且有許多邊界情況需要考慮。

示例:codepen.io/davidkpiano…

狀態機作為一種很好的程式設計正規化,通過符合直覺和宣告式的方式來管理使用者介面狀態間的過渡。我們已經在 the Keyframers 中作為一種簡化複雜動畫和使用者互動流的方式大量使用到了狀態機。

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

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

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

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

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

每個箭頭告訴我們一個狀態是如何通過事件過渡到另一個狀態的,並且有些狀態是不可能互相轉換的。(比如說你不可能從 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 動畫中的應用

總結

狀態機的模式讓應用中狀態的處理更簡便,並且讓 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狀態機 的討論。

相關文章