利用聲明式編程創建可維護的 Web 應用程序

已發表: 2022-03-11

在本文中,我將展示如何明智地採用聲明式編程技術,使團隊能夠創建更易於擴展和維護的 Web 應用程序。

“……聲明式編程是一種編程範式,它表達了計算的邏輯而不描述其控制流。” —Remo H. Jansen,使用 TypeScript 進行動手函數式編程

與軟件中的大多數問題一樣,決定在應用程序中使用聲明式編程技術需要仔細評估權衡。 查看我們之前的一篇文章,深入討論這些內容。

在這裡,重點是聲明式編程模式如何逐漸被用 JavaScript 編寫的新應用程序和現有應用程序採用,JavaScript 是一種支持多種範式的語言。

首先,我們討論如何在後端和前端使用 TypeScript,以使您的代碼更具表現力和適應變化的能力。 然後,我們探索有限狀態機 (FSM) 以簡化前端開發並增加利益相關者對開發過程的參與。

FSM 並不是一項新技術。 它們是在近 50 年前被發現的,在信號處理、航空和金融等行業很受歡迎,在這些行業中,軟件的正確性至關重要。 它們也非常適合現代 Web 開發中經常出現的建模問題,例如協調複雜的異步狀態更新和動畫。

這種好處是由於對狀態管理方式的限製而產生的。 狀態機只能同時處於一種狀態,並且它可以轉換到的相鄰狀態有限,以響應外部事件(例如鼠標單擊或獲取響應)。 結果通常是顯著降低的缺陷率。 但是,FSM 方法可能難以擴展以在大型應用程序中正常工作。 最近對 FSM 的擴展稱為狀態圖允許將復雜的 FSM 可視化並擴展到更大的應用程序,這是本文重點介紹的有限狀態機的特點。 對於我們的演示,我們將使用 XState 庫,它是 JavaScript 中 FSM 和狀態圖的最佳解決方案之一。

使用 Node.js 在後端聲明式

使用聲明性方法對 Web 服務器後端進行編程是一個很大的話題,通常可能從評估合適的服務器端函數式編程語言開始。 相反,讓我們假設您正在閱讀本文時,您已經選擇(或正在考慮)Node.js 作為後端。

本節詳細介紹了一種在後端建模實體的方法,該方法具有以下優點:

  • 提高代碼可讀性
  • 更安全的重構
  • 由於類型建模提供的保證,提高性能的潛力

通過類型建模的行為保證

JavaScript

考慮在 JavaScript 中通過電子郵件地址查找給定用戶的任務:

 function validateEmail(email) { if (typeof email !== "string") return false; return isWellFormedEmailAddress(email); } function lookupUser(validatedEmail) { // Assume a valid email is passed in. // Safe to pass this down to the database for a user lookup.. }

此函數接受電子郵件地址作為字符串,並在匹配時從數據庫中返回相應的用戶。

假設只有在執行基本驗證後才會調用lookupUser() 。 這是一個關鍵假設。 如果幾週後,進行了一些重構並且這個假設不再成立怎麼辦? 手指交叉單元測試捕捉到錯誤,或者我們可能會將未經過濾的文本發送到數據庫!

打字稿(第一次嘗試)

讓我們考慮一個等效於驗證函數的 TypeScript:

 function validateEmail(email: string) { // No longer needed the type check (typeof email === "string"). return isWellFormedEmailAddress(email); }

這是一個輕微的改進,TypeScript 編譯器使我們免於添加額外的運行時驗證步驟。

強類型可以帶來的安全保證還沒有真正被利用。 讓我們研究一下。

TypeScript(第二次嘗試)

讓我們提高類型安全性並禁止將未處理的字符串作為輸入傳遞給looukupUser

 type ValidEmail = { value: string }; function validateEmail(input: string): Email | null { if (!isWellFormedEmailAddress(input)) return null; return { value: email }; } function lookupUser(email: ValidEmail): User { // No need to perform validation. Compiler has already ensured only valid emails have been passed in. return lookupUserInDatabase(email.value); }

這更好,但它很麻煩。 email.value ValidEmail實際地址。 TypeScript 使用結構類型,而不是 Java 和 C# 等語言使用的名義類型。

雖然功能強大,但這意味著任何其他符合此簽名的類型都被視為等效。 例如,可以將以下密碼類型傳遞給lookupUser()而不會引起編譯器的投訴:

 type ValidPassword = { value: string }; const password = { value: "password" }; lookupUser(password); // No error.

TypeScript(第三次嘗試)

我們可以使用交集在 TypeScript 中實現名義輸入:

 type ValidEmail = string & { _: "ValidEmail" }; function validateEmail(input: string): ValidEmail { // Perform email validation checks.. return input as ValidEmail; } type ValidPassword = string & { _: "ValidPassword" }; function validatePassword(input: string): ValidPassword { ... } lookupUser("[email protected]"); // Error: expected type ValidEmail. lookupUser(validatePassword("MyPassword"); // Error: expected type ValidEmail. lookupUser(validateEmail("[email protected]")); // Ok.

我們現在已經實現了只有經過驗證的電子郵件字符串才能傳遞給lookupUser()的目標。

專業提示:使用以下幫助類型輕鬆應用此模式:

 type Opaque<K, T> = T & { __TYPE__: K }; type Email = Opaque<"Email", string>; type Password = Opaque<"Password", string>; type UserId = Opaque<"UserId", number>;

優點

通過在您的域中強輸入實體,我們可以:

  1. 減少需要在運行時執行的檢查次數,這些檢查會消耗寶貴的服務器 CPU 週期(雖然數量很少,但在每分鐘處理數千個請求時確實會加起來)。
  2. 由於 TypeScript 編譯器提供的保證,維護更少的基本測試。
  3. 利用編輯器和編譯器輔助的重構。
  4. 通過提高信噪比提高代碼可讀性。

缺點

類型建模需要考慮一些權衡:

  1. 引入 TypeScript 通常會使工具鏈複雜化,從而導致構建和測試套件執行時間更長。
  2. 如果您的目標是對功能進行原型設計並儘快將其交到用戶手中,那麼顯式建模類型並通過代碼庫傳播它們所需的額外工作可能不值得。

我們已經展示瞭如何使用類型擴展服務器上現有的 JavaScript 代碼或共享的後端/前端驗證層,以提高代碼的可讀性並允許更安全的重構——這是團隊的重要要求。

聲明式用戶界面

使用聲明式編程技術開發的用戶界面集中精力描述“什麼”而不是“如何”。 Web 的三個主要基本成分中的兩個,CSS 和 HTML,是聲明性編程語言,它們經受住了時間和超過 10 億個網站的考驗。

為網絡提供動力的主要語言
為網絡提供動力的主要語言。

React 於 2013 年由 Facebook 開源,它極大地改變了前端開發的進程。 當我第一次使用它時,我喜歡如何將 GUI聲明為應用程序狀態的函數。 我現在能夠從較小的構建塊組成大型而復雜的 UI,而無需處理 DOM 操作的混亂細節和跟踪應用程序的哪些部分需要更新以響應用戶操作。 在定義 UI 時,我可以在很大程度上忽略時間方面,並專注於確保我的應用程序正確地從一種狀態轉換到另一種狀態。

前端 JavaScript 從 how to what 的演進
前端 JavaScript 從howwhat的演變。

為了實現更簡單的 UI 開發方式,React 在開發人員和機器/瀏覽器之間插入了一個抽象層:虛擬 DOM

其他現代 Web UI 框架也彌補了這一差距,儘管方式不同。 例如,Vue 通過 JavaScript getter/setter (Vue 2) 或代理 (Vue 3) 使用功能響應性。 Svelte 通過額外的源代碼編譯步驟 (Svelte) 帶來反應性。

這些示例似乎表明了我們行業的強烈願望,即為開發人員提供更好、更簡單的工具,以通過聲明性方法表達應用程序行為。

聲明式應用程序狀態和邏輯

雖然表示層繼續圍繞某種形式的 HTML(例如,React 中的 JSX,Vue、Angular 和 Svelte 中的基於 HTML 的模板),但我假設如何以一種方式對應用程序的狀態建模的問題是其他開發人員易於理解並且隨著應用程序的增長而可維護的問題仍未解決。 我們通過持續到今天的狀態管理庫和方法的擴散看到了這一點。

對現代 Web 應用程序的期望越來越高,情況變得更加複雜。 現代狀態管理方法必須支持的一些新出現的挑戰:

  • 使用高級訂閱和緩存技術的離線優先應用程序
  • 簡潔的代碼和代碼重用可滿足不斷縮小的捆綁包大小要求
  • 通過高保真動畫和實時更新對日益複雜的用戶體驗的需求

(重新)有限狀態機和狀態圖的出現

有限狀態機已廣泛用於某些行業的軟件開發,這些行業的應用程序穩健性至關重要,例如航空和金融。 通過例如優秀的 XState 庫,它在 Web 應用程序的前端開發中也越來越受歡迎。

維基百科將有限狀態機定義為:

在任何給定時間都可以恰好處於有限數量的狀態之一的抽像機器。 FSM 可以響應一些外部輸入從一種狀態變為另一種狀態; 從一種狀態到另一種狀態的變化稱為轉換。 FSM 由其狀態列表、初始狀態和每個轉換的條件定義。

並進一步:

狀態是對等待執行轉換的系統狀態的描述。

由於狀態爆炸問題,基本形式的 FSM 不能很好地擴展到大型系統。 最近,創建了 UML 狀態圖來擴展具有層次結構和並發性的 FSM,這是在商業應用中廣泛使用 FSM 的推動力。

聲明你的應用程序邏輯

首先,作為代碼的 FSM 是什麼樣的? 有幾種方法可以在 JavaScript 中實現有限狀態機。

  • 有限狀態機作為 switch 語句

這是一台描述 JavaScript 可能處於的狀態的機器,使用 switch 語句實現:

 const initialState = { type: 'idle', error: undefined, result: undefined }; function transition(state = initialState, action) { switch (action) { case 'invoke': return { type: 'pending' }; case 'resolve': return { type: 'completed', result: action.value }; case 'error': return { type: 'completed', error: action.error ; default: return state; } }

使用過流行的 Redux 狀態管理庫的開發人員會熟悉這種代碼風格。

  • 作為 JavaScript 對象的有限狀態機

這是使用 JavaScript XState 庫實現為 JavaScript 對象的同一台機器:

 const promiseMachine = Machine({ id: "promise", initial: "idle", context: { result: undefined, error: undefined, }, states: { idle: { on: { INVOKE: "pending", }, }, pending: { on: { RESOLVE: "success", REJECT: "failure", }, }, success: { type: "final", actions: assign({ result: (context, event) => event.data, }), }, failure: { type: "final", actions: assign({ error: (context, event) => event.data, }), }, }, });

雖然 XState 版本不太緊湊,但對象表示有幾個優點:

  1. 狀態機本身是簡單的 JSON,可以很容易地持久化。
  2. 因為它是聲明性的,所以機器可以被可視化。
  3. 如果使用 TypeScript,編譯器會檢查是否只執行了有效的狀態轉換。

XState 支持狀態圖並實現了 SCXML 規範,這使得它適用於非常大的應用程序。

承諾的狀態圖可視化:

承諾的有限狀態機
一個承諾的有限狀態機。

XState 最佳實踐

以下是使用 XState 幫助保持項目可維護性時應用的一些最佳實踐。

從邏輯中分離副作用

XState 允許從狀態機的邏輯中獨立指定副作用(包括日誌記錄或 API 請求等活動)。

這有以下好處:

  1. 通過保持狀態機代碼盡可能乾淨和簡單,幫助檢測邏輯錯誤。
  2. 無需先刪除額外的樣板即可輕鬆可視化狀態機。
  3. 通過注入模擬服務更容易測試狀態機。
 const fetchUsersMachine = Machine({ id: "fetchUsers", initial: "idle", context: { users: undefined, error: undefined, nextPage: 0, }, states: { idle: { on: { FETCH: "fetching", }, }, fetching: { invoke: { src: (context) => fetch(`url/to/users?page=${context.nextPage}`).then((response) => response.json() ), onDone: { target: "success", actions: assign({ users: (context, event) => [...context.users, ...event.data], // Data holds the newly fetched users nextPage: (context) => context.nextPage + 1, }), }, onError: { target: "failure", error: (_, event) => event.data, // Data holds the error }, }, }, // success state.. // failure state.. }, });

雖然在您仍在工作時以這種方式編寫狀態機很誘人,但通過將副作用作為選項傳遞可以實現更好的關注點分離:

 const services = { getUsers: (context) => fetch( `url/to/users?page=${context.nextPage}` ).then((response) => response.json()) } const fetchUsersMachine = Machine({ ... states: { ... fetching: { invoke: { // Invoke the side effect at key: 'getUsers' in the supplied services object. src: 'getUsers', } on: { RESOLVE: "success", REJECT: "failure", }, }, ... }, // Supply the side effects to be executed on state transitions. { services } });

這也允許對狀態機進行簡單的單元測試,允許顯式模擬用戶獲取:

 async function testFetchUsers() { return [{ name: "Peter", location: "New Zealand" }]; } const machine = fetchUsersMachine.withConfig({ services: { getUsers: (context) => testFetchUsers(), }, });

拆分大型機器

在開始時,如何最好地將問題域構造成一個好的有限狀態機層次結構並不總是很明顯。

提示:使用 UI 組件的層次結構來幫助指導此過程。 請參閱下一節,了解如何將狀態機映射到 UI 組件。

使用狀態機的一個主要好處是顯式地對應用程序中的所有狀態和狀態之間的轉換進行建模,以便清楚地理解所產生的行為,從而輕鬆發現邏輯錯誤或差距。

為了使其正常工作,機器需要保持小而簡潔。 幸運的是,分層組合狀態機很容易。 在交通燈系統的規範狀態圖示例中,“紅色”狀態本身成為子狀態機。 父“light”機器不知道“red”的內部狀態,但決定何時進入“red”以及退出時的預期行為:

使用狀態圖的交通燈示例
使用狀態圖的交通燈示例。

1-1 狀態機到有狀態 UI 組件的映射

舉個例子,一個非常簡化的虛構電子商務網站,它具有以下 React 視圖:

 <App> <SigninForm /> <RegistrationForm /> <Products /> <Cart /> <Admin> <Users /> <Products /> </Admin> </App>

上述視圖對應的狀態機生成過程對於使用過 Redux 狀態管理庫的人來說可能很熟悉:

  1. 組件是否具有需要建模的狀態? 例如,管理員/產品可能不會; 分頁獲取到服務器加上緩存解決方案(例如 SWR)可能就足夠了。 另一方面,諸如 SignInForm 或 Cart 之類的組件通常包含需要管理的狀態,例如輸入到字段中的數據或當前的購物車內容。
  2. 本地狀態技術(例如,React 的setState() / useState() )是否足以捕獲問題? 跟踪購物車彈出模式當前是否打開幾乎不需要使用有限狀態機。
  3. 生成的狀態機是否可能過於復雜? 如果是這樣,請將機器分成幾個較小的機器,尋找機會創建可以在其他地方重複使用的子機器。 例如,SignInForm 和 RegistrationForm 機器可以調用子 textFieldMachine 的實例來對用戶電子郵件、姓名和密碼字段的驗證和狀態進行建模。

何時使用有限狀態機模型

雖然狀態圖和 FSM 可以優雅地解決一些具有挑戰性的問題,但確定用於特定應用程序的最佳工具和方法通常取決於幾個因素。

使用有限狀態機的一些情況會大放異彩:

  • 您的應用程序包含大量數據輸入組件,其中字段可訪問性或可見性受複雜規則控制:例如,保險索賠應用程序中的表單輸入。 在這裡,FSM 有助於確保穩健地實施業務規則。 此外,狀態圖的可視化功能可用於幫助加強與非技術利益相關者的協作,並在開發早期識別詳細的業務需求。
  • 為了更好地處理較慢的連接並為用戶提供更高保真度的體驗,Web 應用程序必須管理日益複雜的異步數據流。 FSM 顯式地對應用程序可能處於的所有狀態進行建模,並且狀態圖可以可視化以幫助診斷和解決異步數據問題。
  • 需要大量複雜的、基於狀態的動畫的應用程序。 對於復雜的動畫,使用 RxJS 將動畫建模為事件流的技術很流行。 對於許多場景,這很有效,但是,當豐富的動畫與一系列複雜的已知狀態相結合時,FSM 提供了動畫在其間流動的明確定義的“休息點”。 FSM 與 RxJS 相結合似乎是幫助交付下一波高保真、富有表現力的用戶體驗的完美組合。
  • 富客戶端應用程序,例如照片或視頻編輯、圖表創建工具或大部分業務邏輯駐留在客戶端的遊戲。 FSM 本質上與 UI 框架或庫分離,並且易於編寫測試,以允許快速迭代高質量的應用程序並充滿信心地交付。

有限狀態機注意事項

  • XState 等狀態圖庫的通用方法、最佳實踐和 API 對於大多數前端開發人員來說都是新穎的,他們需要投入時間和資源才能提高生產力,尤其是對於經驗不足的團隊。
  • 與前面的警告類似,雖然 XState 的受歡迎程度繼續增長並且有據可查,但現有的狀態管理庫(如 Redux、MobX 或 React Context)擁有大量的追隨者,提供了 XState 尚未匹配的大量在線信息。
  • 對於遵循更簡單的 CRUD 模型的應用程序,現有的狀態管理技術與良好的資源緩存庫(如 SWR 或 React Query)相結合就足夠了。 在這裡,FSM 提供的額外約束雖然對複雜的應用程序非常有用,但可能會減慢開發速度。
  • 該工具不如其他狀態管理庫成熟,改進 TypeScript 支持和瀏覽器開發工具擴展的工作仍在進行中。

包起來

聲明式編程在 Web 開發社區中的流行度和採用率繼續上升。

儘管現代 Web 開發繼續變得更加複雜,但採用聲明式編程方法的庫和框架越來越頻繁地出現。 原因似乎很清楚——需要創建更簡單、更具描述性的軟件編寫方法。

使用諸如 TypeScript 之類的強類型語言允許對應用程序域中的實體進行簡潔而明確的建模,從而減少出錯的機會和需要操作的易出錯檢查代碼的數量。 在前端採用有限狀態機和狀態圖允許開發人員通過狀態轉換來聲明應用程序的業務邏輯,從而能夠開發豐富的可視化工具並增加與非開發人員密切協作的機會。

當我們這樣做時,我們將關注點從應用程序如何工作的具體細節轉移到更高級別的視圖,使我們能夠更多地關注客戶的需求並創造持久的價值。