1. 程式人生 > 實用技巧 >函式式 UI:Web開發終於擺脫了框架的束縛

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

為什麼要使用函式式 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 是非常簡單的。如果你想重用現有的解決方案,可以考慮raj或ferp專案這些很有用的庫,它們嚴格遵循函式式 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 實現可將用例與實現匹配,從而提升正確性。

資源搜尋網站大全 https://www.renrenfan.com.cn 廣州VI設計公司https://www.houdianzi.com

單元測試使用者場景

響應式系統執行時會產生蹤跡(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 技術的主要賣點,也是在安全性優先的軟體開發專案中使用它們的關鍵因素所在。