函數式UI:Web開發終於擺脫了框架的束縛

本文要點

  • 用戶界面都是響應式系統,由用戶界面應用程序接收的事件與應用程序必須在接口系統上執行的動作之間的關係來定義
  • 流行的UI框架(如React、Vue或Angular)通常具有很高的次生複雜性,它們的狀態和效果零散地分散在一個組件樹中,並且由上帝組件處理大量無關的問題
  • 函數式UI是用於用戶界面應用程序的一組實現技術,它強調了應用程序的效果(effectful)和純函數部分之間的明確界限
  • 函數式UI從概念上講是很簡單的,可以更直接地反映應用程序的規範(specification),將UI框架降級爲一個單純的庫,允許開發人員對用戶場景進行單元測試,並減少應用程序的設計和實現錯誤。
  • 函數式UI會針對正確性進行優化,同時會爲開發人員創建一些選項,以便在將來獲得更多信息時重新考慮諸如UI框架或遠程數據獲取機制之類的關鍵決策。

爲什麼要使用函數式UI?

顧名思義,用戶界面允許用戶與其他系統交互,其理念是:相比直接與其他系統互動,這種交互界面會提供一些用戶期望的好處。用戶通過某種輸入方式(例如按鍵或聲音輸入)表達意圖,然後用戶界面通過在接口系統上預定義的動作來做出響應。用戶界面基本上是天然的響應式系統。用戶界面的任何規範技術都必須詳細說明用戶界面輸入和接口系統上的動作之間的對應關係,也就是應用程序的行爲規範。這樣一來,就可以根據用戶發起或應用程序接受的一系列事件,以及系統對應的預期反應來定義一個用戶故事。

許多用來實現用戶界面的框架(Angular2、Vue和React等)都使用回調過程或事件處理程序,後者會作爲事件的結果而直接執行相應的動作。決定要執行哪個動作(例如輸入驗證、本地狀態更新、錯誤處理或數據獲取等),通常意味着要訪問和更新某些狀態,而這些狀態並不總是在作用域內。因此框架會包含一些狀態管理或通信能力,以處理所需的相關狀態的傳遞,並在允許和要求時更新狀態。

基於組件的用戶界面實現往往包含一些狀態,而動作以不明顯的方式沿着組件樹散佈開來。例如,一個待辦事項列表應用程序可以寫爲。假設一個TodoItem管理其刪除操作,則必須將刪除操作與更新的項目列表沿着結構向上傳遞給要調用的父級TodoList。假設是由父級的TodoList管理項目的刪除操作,它可能還是要將刪除操作傳遞給子級的TodoItem(也許執行一些清理動作)。

這裏的底線是要將動作與給定的事件匹配,我們需要查看每個組件實現以瞭解事件及其處理的動作,以及它與組件樹中依賴它的組件所使用的消息傳遞協議,然後對依賴組件重複相同的過程,直到下面沒有依賴組件爲止。只有這樣,我們才能生成一個事件觸發動作的完整列表。此外,組件通常是給定框架專屬的,其選項取決於這個框架中可用的內容。

但是,我們選擇的的框架是與規範分離的實現細節。實現應用程序和組件間消息傳遞的組件樹,其特定形態(shape)在很大程度上也與規範緊密關聯。於是考慮這樣的問題:當用戶遵循某個用戶故事時,比如說當應用程序收到給定的事件序列[X,Y,…]時會發生什麼情況?回答這類問題需要馴服來自於框架的特性、組件、狀態管理和通信機制的次生複雜性

但是如果不回答這個問題,我們就不能確定實現是否符合規範,而符合規範就是軟件的存在價值。隨着用戶故事的數量和大小繼續增長,這種信心只會愈加脆弱。

而函數式UI技術試圖從事件/動作對應關係中導出函數等式,從而直接反映用戶界面的規範。由於等式是直接從規範中得出的,因此我們可以讓實現儘可能接近規範。一般來說,這會減少實現錯誤的生存空間,並且會在開發的早期階段就發現規範錯誤。由於函數式UI依賴於純函數,因此可以輕鬆、可靠和快速地對用戶故事進行單元測試。在某些情況下(狀態機建模),甚至可以高度自動化地生成實現和測試。因爲函數式UI只是標準的函數式編程,所以它不依賴於任何框架魔術。函數式UI可以很好地對接任何UI框架,需要的話也可以不使用任何框架。

本文將介紹函數式UI的意義,及其背後的基本函數等式,還會展示這種技術的具體用法示例,以及如何測試以這種風格編寫的應用程序。與此同時,本文將努力揭示在Web應用程序開發中使用函數式UI方法的優缺點。

但什麼是函數式UI呢?

任何用戶界面應用程序都會隱式或顯式地實現以下內容:

  1. 一個接口,應用程序通過它來接收事件
  2. 事件和動作之間的一種關係(~),形如:event ~ action,其中
  • 〜稱爲響應關係
  • event是通過用戶界面接收並觸發接口系統上一個action的事件。事件可以是
    • 用戶發起的(如按鈕點擊)
    • 系統發起的,即由環境或外部世界生成的(如API響應)
  1. 一個與外部系統對接的接口,必須通過該接口執行用戶預期的動作

因爲大多數響應式系統都是有狀態的,所以一般來說關係〜不是一個數學函數(也就是只將一個輸出關聯到一個輸入)。切換按鈕就是一個簡單的有狀態UI應用程序。按下按鈕一次,應用程序將呈現一個切換後的按鈕。再按一次,應用程序將呈現一個切換前的按鈕。由於相同的用戶事件會在對接的輸出設備(屏幕)上執行不同的渲染動作,因此應用程序是有狀態的,無法定義一個數學函數使action = f(event)。

我們稱函數****式UI爲用戶界面應用程序的一組實現技術,其重點在於以下內容:

  • 將事件表示與事件調度分離開來
  • 將動作表示與動作執行分離開來
  • 將應用程序執行的動作與應用程序接收到的事件關聯在一起的顯式純函數(響應函數

因此,函數式UI隔離了應用程序的效果部分(調度事件,運行效果),並將它們與純函數鏈接在一起。結果,函數式UI自然會產生分層的架構,其中每一層僅與相鄰層交互。最簡單的分層架構由三層組成,可以表示如下:


命令處理程序(command handler)模塊負責執行通過每個接口系統定義的編程接口所接收的命令。接口系統(interfaced system)可以將針對之前API調用的響應作爲事件,發送給命令處理程序。接口系統還可以通過一個調度程序(dispatcher)將事件發送給應用程序。DOM通常就是這種情況,它是以渲染命令的結果來做更新的,並且包含事件處理程序,它們只會調度事件。

這樣的概念框架建立起來後,我們來介紹實現函數式UI的基本等式。

響應式系統的基本等式

在大多數情況下,一個響應式系統的狀態可以表述爲這樣的形式:(action, new state) = f(state,event),其中:

  • f是一個純函數,
  • state包含由環境和響應式系統的規範帶來的所有可變性,這樣f就是純粹的。

這裏的f被稱爲響應函數。如果我們用自然整數按時間順序來索引,以使索引n對應於發生的第n個事件,則以下條件成立:

  • (action_n, state_n+1) = f(state_n, event_n) ,其中:
    • n是響應式系統處理的第n個事件,
    • state_n是處理第n個事件時響應式系統的狀態,
    • 因此,在事件的發生和用於計算(compute)系統響應的狀態之間存在一個隱式的時間關係

基於這些觀察結果而誕生的實現技術依賴於一個響應函數f,該函數爲每個事件顯式計算響應式系統的新狀態,以及要執行的動作。這方面知名的例子有:

  • Elm:其中update :: Msg -> Model -> (Model, Cmd Msg) 函數嚴格對應響應函數f,Msg對應events,Model對應狀、states,Cmd Msg對應actions。
  • Pux(PureScript):其中foldp :: Event -> State -> EffModel State Event 函數是Pux框架中的等效公式。在Pux中,EffModel State Event是包含新狀態值和一組效果(動作)的一個記錄,這些效果可能會生成新的事件供應用程序處理。
  • Seed(Rust):其更新函數fn update(msg: Msg, model: &mut Model, _: &mut impl Orders)對應的是Elm更新函數(Cmd變成了Orders),同時利用了Rust帶來的可變性。

下面我們來看一些具體示例。在純函數式語言中,函數式UI是使用這類語言編程的自然結果。在其他語言(例如JavaScript)中,開發人員需要努力遵循函數式UI的原則。下文提供了分別使用純函數式語言Elm和香草JavaScript編寫函數式UI的示例。

示例

Elm

下面展示一個簡單的Elm應用程序的示例,其在單擊一個按鈕時顯示隨機的小貓動圖:

-- 按一個按鈕,發送一個GET請求來獲取隨機的小貓動圖。
-- 工作機制介紹: https://guide.elm-lang.org/effects/json.html

(some imports...)

-- MAIN
main =
  Browser.element
    { init = init
    , update = update
    , view = view
    }

-- MODEL
type Model
  = Failure
  | Loading
  | Success String

-- Initial state
init : () -> (Model, Cmd Msg)
init _ =
  (Loading, getRandomCatGif)

-- UPDATE
type Msg
  = MorePlease
  | GotGif (Result Http.Error String)

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    MorePlease ->
      (Loading, getRandomCatGif)

    GotGif result ->
      case result of
        Ok url ->
          (Success url, Cmd.none)

        Err _ ->
          (Failure, Cmd.none)

-- VIEW
view : Model -> Html Msg
view model =
  div []
    [ h2 [] [ text "Random Cats" ]
    , viewGif model
    ]

viewGif : Model -> Html Msg
viewGif model =
  case model of
    Failure ->
      div []
        [ text "I could not load a random cat for some reason. "
        , button [ onClick MorePlease ] [ text "Try Again!" ]
        ]

    Loading ->
      text "Loading..."

    Success url ->
      div []
        [ button [ onClick MorePlease, style "display" "block" ] [ text "More Please!" ]
        , img [ src url ] []
        ]

-- HTTP
getRandomCatGif : Cmd Msg
getRandomCatGif =
  Http.get
    { url = "https://api.giphy.com/v1/gifs/random?api_key=dc6zaTOxFJmzC&tag=cat"
    , expect = Http.expectJson GotGif gifDecoder
    }

gifDecoder : Decoder String
gifDecoder =
  field "data" (field "image_url" string)

從代碼中可以推斷出:

  • 該應用程序始於某個初始狀態,並運行一個初始命令(init _ = (Loading, getRandomCatGif))
  • 該初始狀態會顯示一個由view函數生成的初始視圖
  • 點擊一個view按鈕會將MorePlease消息發送到Elm的運行時([ button [ onClick MorePlease, … ])
  • 其中update函數update msg model = case msg of MorePlease -> (Loading, getRandomCatGif)將確保有一個MorePlease消息來獲取一張隨機的小貓動圖,同時將應用程序的狀態(model)更新爲Loading(從而使用戶界面顯示一條加載消息)。
  • 如果獲取成功,它將返回一個URL(GotGif Ok url消息),使用戶界面顯示相應的圖像(img [ src url ])

除了update函數外,Elm還定義了一個運行時,負責接收事件,將事件傳遞給更新函數,並執行所計算的(computed)命令。因此,開發人員只需要定義應用程序狀態和更新函數的內容。有了一個單獨的,中心化的update函數來計算針對事件的響應,我們就能輕鬆回答"當事件[X,Y,……]發生時會出現什麼情況"這樣的問題。

香草JavaScript

在JavaScript世界中,Hyperapp這個框架採用的架構深受Elm的影響,只是細節略有不同。Hyperapp非常輕巧(2KB),其中大多數代碼(80%)專門用來處理它自己的虛擬DOM實現。但是,Hyperapp不會公開一個純粹的響應函數,而是像Elm一樣使用一個view函數。與Elm不同,這裏的view函數不僅將某個狀態作爲其第一個參數來接收,還將包含應用程序可執行的所有動作的對象作爲第二個參數來接收。

因此view函數不是純函數,而是Jessica Kerr所描述的隔離函數。這意味着該函數僅有的依賴項是它的參數。純函數是隔離的,但是隔離函數不一定是純函數,因爲它們的參數可能是生成效果的函數,或受外部世界控制的變量。但是如有必要,我們仍然可以通過mocking隔離函數的參數來對它們進行單元測試。於是乎,Hyperapp無法遵循函數式UI的原則,但仍然保留了函數式UI的某些長處。

想要了解如何使用Hyperapp構建相對複雜的應用程序,讀者可以參考Hyperapp的一個名爲Conduit的(Medium克隆版示例應用)實現。這個應用程序也有一個Elm實現,以及其他十幾個框架中的實現版本。

但在使用JavaScript實現用戶界面時,無需放棄任何函數式UI原則。在一個假想的實現中,應用程序外殼負責將事件源連接到更新函數,並用類似的方式將更新函數連接到執行所計算的動作的模塊,從而複製各種事件循環。update函數可以採用以下形式(舉例),用單個{command, params}對象編碼其返回值(在Elm中爲Cmd Msg類型)。

這裏我們考慮使用前面討論過的,顯示隨機小貓動圖的應用程序,做一個JavaScript的等效實現。更新函數如下:

// Update function
function update(event, model) {
  // Event has shape `{[eventName]: eventData}`
  const eventName = Object.keys(event)[0];
  const eventData = event[eventName];

  if (eventName === MORE_PLEASE) {
    return {
      model: LOADING,
      commands: [
        { command: GET_RANDOM_CAT_GIF, params: void 0 },
        { command: RENDER, params: void 0 }
      ]
    };
  } else if (eventName === GOT_GIF) {
    if (eventData instanceof Error) {
      return {
        model: FAILURE,
        commands: [{ command: RENDER, params: void 0 }]
      };
    } else {
      const url = eventData;
      return {
        model: SUCCESS,
        commands: [{ command: RENDER, params: url }]
      };
    }
  }

  // 一些預期外的event, 應該什麼都不會做
  return {
    model: model,
    commands: []
  };

這裏有一個基本的事件發射器用來調度事件。儘管這裏可以使用任何UI框架的渲染函數,但這個簡單演示中的渲染函數是通過直接DOM克隆來實現的。因此,命令執行如下:

[MORE_PLEASE, GOT_GIF].forEach(event => {
  eventEmitter.on(event, eventData => {
    const { model: updatedModel, commands } = update(
      { [event]: eventData },
      model
    );
    model = updatedModel;

    if (commands) {
      commands.filter(Boolean).forEach(({ command, params }) => {
        if (command === GET_RANDOM_CAT_GIF) {
          getRandomCatGif()
            .then(response => {
              if (!response.ok) {
                console.warn(`Network request error`, response.status);
                throw new Error(response);
              } else return response.json();
            })
            .then(x => {
              if (x instanceof Error) {
                eventEmitter.emit(GOT_GIF, x);
              }
              if (x && x.data && x.data.image_url) {
                eventEmitter.emit(GOT_GIF, x.data.image_url);
              }
            })
            .catch(x => {
              eventEmitter.emit(GOT_GIF, x);
            });
        }
        if (command === RENDER) {
          if (model === LOADING) {
            setDOM(initViewEl.cloneNode(true), appEl);
          } else if (model === FAILURE) {
            setDOM(failureViewEl.cloneNode(true), appEl);
          } else if (model === SUCCESS) {
            const url = params;
            setDOM(successViewEl(url).cloneNode(true), appEl);
          }
        }
      });
    }
  });

如上所述,自己來實現函數式UI是非常簡單的。如果你想重用現有的解決方案,可以考慮rajferp項目這些很有用的庫,它們嚴格遵循函數式UI原則。你不必擔心它們會超出你的應用程序預算。整個raj庫非常小(33行代碼),因此可以完整粘貼在這裏:

exports.runtime = function (program) {
  var update = program.update
  var view = program.view
  var done = program.done
  var state
  var isRunning = true

  function dispatch (message) {
    if (isRunning) {
      change(update(message, state))
    }
  }

  function change (change) {
    state = change[0]
    var effect = change[1]
    if (effect) {
      effect(dispatch)
    }
    view(state, dispatch)
  }

  change(program.init)

  return function end () {
    if (isRunning) {
      isRunning = false
      if (done) {
        done(state)
      }
    }
  }
}

儘管類似Elm的實現從根本上講很簡單,但與基於組件的實現相比,用它通常可以更好地瞭解應用程序的行爲。一般來說,基於組件的實現可以讓你很快搞明白用戶界面會長什麼樣,但你可能不得不費力地從組件的實現細節中分辨出界面的行爲(發生事件X時出現的情況)。換句話說,基於組件的實現可通過組件重用來優化生產力,而函數****式UI實現可將用例與實現匹配,從而提升正確性

單元測試用戶場景

響應式系統運行時會產生蹤跡(trace),也就是運行期間發生的(events, actions)序列。爲了讓響應式系統的行爲正確,應設置一組允許的蹤跡。相對應的,測試響應式系統時要驗證實際蹤跡與許可蹤跡的集合是否匹配。從我們的基本等式得出的另一個純函數可用於此用途:

For all n: (action_n, state_n+1) = f(state_n, event_n)

先前的等式意味着:

(action_0, state_1) = f(state_0, event_0)
(action_1, state_2) = f(state_1, event_1)
(action_2, state_3) = f(state_2, event_2)
...
(action_n, state_n+1) = f(state_n, event_n)

如果我們將h定義爲將事件序列映射到相應動作序列的函數:

h([event_0]) = [action_0]
h([event_0, event_1]) = [action_0, action_1]
h([event_0, event_1, event_2]) = [action_0, action_1, action_2]
h([event_0, event_1, event_2, ..., event_n]) = [action_0, action_1, action_2, ..., action_n]

那麼h就是一個純函數!這意味着h可以很容易地測試,只需向其提供輸入並檢查它是否產生了預期的輸出即可。請注意,在h中不會再提及應用程序的狀態。由此以來我們就有了以下結果:

  • 可以單獨測試用戶場景,也就是說可以對各個用戶場景進行單元測試,因爲各個用戶場景都是具有各自期望動作的事件序列
  • 針對應用程序的指定行爲進行測試,而不是針對實現細節(例如應用程序狀態的形狀,或者用來獲取數據的HTTP或套接字)進行測試
  • 對用戶場景進行單元測試使開發人員能夠遵循測試金字塔原則,並在他們的一大堆單元測試中添加少量針對性的集成和端到端測試
  • 因此,開發人員無需執行運行時間過長或不穩定的測試,他們的工作效率就會更高(集成和端到端測試編寫起來昂貴且難以維護)
  • 開發人員可以選擇任何測試框架(或哪個都不用)

當用戶場景測試可以快速編寫和執行時,就可以在給定的時間內設想和測試更多的用戶場景。由於用戶場景是簡單的序列,因此更容易自動生成此類序列。在使用狀態機對用戶界面行爲建模的情況下,實際上我們可以自動生成數以千計的測試,這樣比起來手工且痛苦地編寫測試,我們就可以覆蓋更多用戶場景和邊緣案例。

最終的成果是我們能較早發現設計和實現錯誤,從而帶來更快的迭代和更高的軟件質量。毫無疑問,這是函數式UI技術的主要賣點,也是在安全性優先的軟件開發項目中使用它們的關鍵因素所在。

結論

用戶界面都是響應式系統,因此可以使用一個純響應函數,將用戶界面接受的事件映射到接口系統上的動作來定義用戶界面。利用函數式編程的實現技術可以讓實現更接近規範,更易推理和測試。函數式UI可以讓開發人員擺脫不兼容的UI和測試框架帶來的麻煩,並將重點轉移到實現(how)上的規範(what)上。也許有人懷疑沒有UI框架就沒法開發嚴肅的應用程序,但我們要知道GitHub網站就不依賴任何UI框架

使用函數式UI(它強調隔離的,單關注點的動作)和UI組件時,大多數時候我們只關注視圖——一些框架將此類組件稱爲組件。此外,應用程序外殼程序會調用UI框架,而不是UI框架調用用戶提供的框架感知函數。簡而言之,UI框架仍然可以使用,但它們現在只充當簡單的庫而已。

另一面來說,使用函數式UI時很難重用非純組件,從而降低了框架組件生態系統的價值。此外,函數式UI需要前端開發人員在心態和方法上都做出轉變,以前大家相比應用程序行爲要更重視渲染(在屏幕上生成內容),並且更在乎生產效率(編寫代碼) 而非正確性(需要編寫全面的測試)。

但是,Elm在其七年的發展歷程中已經驗證了函數式UI方法的可行性,並證明只要有適當的工具,開發人員就可以快速學習並享受這種方法

原文鏈接

Functional UI (Framework-Free at Last)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章