[譯] 前端元件設計原則

_沒有好名字了_發表於2019-01-24

原文地址:Front end component design principles

原文作者:Andrew Dinihan

文中示例程式碼:傳送門

限於個人能力,如有錯漏之處,煩請不吝賜教。

img

前言

我在最近的工作中開始使用 Vue 進行開發,但是我在上一家公司積累了三年以上 React 開發經驗。雖然在兩種不同的前端框架之間進行切換確實需要學習很多,但是二者之間在很多基礎概念、設計思路上是相通的。其中之一就是元件設計,包括元件層次結構設計以及元件各自的職責劃分。

元件是大多數現代前端框架的基本概念之一,在 React 和 Vue 以及 Ember 和 Mithril 等框架中均有所體現。元件通常是由標記語言、邏輯和樣式組成的集合。它們被建立的目的就是作為可複用的模組去構建我們的應用程式。

類似於傳統 OOP 語言中 class 的設計,在設計元件的時候需要考慮到很多方面,以便它們可以很好的複用,組合,分離和低耦合,但是功能可以比較穩定的實現,即使是在超出實際測試用例範圍的情況下。這樣的設計說起來容易做起來卻很難,因為現實中我們往往沒有足夠的時間按照最優的方式去做。

方法

在本文中,我想介紹一些元件相關的設計概念,在進行前端開發時應該考慮這些概念。我認為最好的方法是給每個概念一個簡潔精煉的名字,然後逐一解釋每個概念是什麼以及為什麼重要,對於比較抽象概念的會舉一些例子來幫助理解。

以下這個列表並不是不全面也不完整,但我注意到的只有 8 件事情值得一提,對於那些已經可以編寫基本元件但想要提高他們的技術設計技能的人來說。所以這是列表: 以下列舉的這個列表僅僅是是我注意到的 8 個方面,當然元件設計還有其他一些方面。在此我只是列舉出來我認為值得一提的。

對於已經掌握基本的元件設計並且想要提高自身的元件設計能力的開發者,我認為以下 8 項是我認為值得去注意的,當然這並不是元件設計的全部。

  1. 層次結構和 UML 類圖
  2. 扁平化、面向資料的 state/props
  3. 更加純粹的 State 變化
  4. 低耦合
  5. 輔助程式碼分離
  6. 提煉精華
  7. 及時模組化
  8. 集中/統一的狀態管理

請注意,程式碼示例可能有一些小問題或有點人為設計。但是它們並不複雜,只是想通過這些例子來幫助更好的理解概念。

層次結構和類圖

應用內的元件共同形成元件樹, 而在設計過程中將元件樹視覺化展示可以幫助你全面瞭解應用程式的佈局。一個比較好的展示這些的辦法就是元件圖。

UML 中有一個在 OOP 類設計中經常使用的型別,稱為 UML 類圖。類圖中顯示了類屬性、方法、訪問修飾符、類與其他類的關係等。雖然 OOP 類設計和前端元件設計差異很大,但是通過圖解輔助設計的方法值得參考。對於前端元件,該圖表可以顯示:

  • State
  • Props
  • Methods
  • 與其他元件的關係( Relationship to other components )

因此,讓我們看一下下面這個基礎表元件的元件層次圖,該元件的渲染物件是一個陣列。該元件的功能包括顯示總行數、標題行和一些資料行,以及在單擊其單元格標題格時對該列進行排序。在它的 props 中,它將傳遞列列表(具有屬性名稱和該屬性的人類可讀版本),然後傳遞資料陣列。我們可以新增一個可選的'on row click'功能來進行測試。

img

雖然這樣的事情可能看起來有點多,但是它具有許多優點,並且在大型應用程式開發設計中所需要的。這樣會帶來的一個比較重要的問題是它會需要你在開始 codeing 之前就需要考慮到具體細節的實現,例如每個元件需要什麼型別的資料,需要實現哪些方法,所需的狀態屬性等等。

一旦你對如何構建一個元件(或一組元件)的整體有大概的思路,就會很容易認為當自己真正開始編碼實現時,它會如自己所期望的按部就班的完成,但事實上往往會出現一些預料之外的事情, 當然你肯定不希望因此去重構之前的某些部分,或者忍受初始設想中的缺點並因此擾亂你的程式碼思路。而這些類圖的以下優點可以幫助你有效的規避以上問題,優點如下:

  1. 一個易於理解的元件組成和關聯檢視
  2. 一個易於理解的應用程式 UI 層次結構的概述
  3. 一個結構資料層次及其流動方式的檢視
  4. 一個元件功能職責的快照
  5. 便於使用圖表軟體建立

順帶一提,上圖並不是基於某些官方標準,比如 UML 類圖,它是我基本上建立的一套表達規則。例如,在 props 、方法的引數和返回值的資料型別定義宣告都是基於 Typescript 語法。我還沒有找到書寫前端元件類圖的官方標準,可能是由於前端 Javascript 開發的相對較新且生態系統不夠完善所致,但如果有人知道主流標準,請在回覆中告訴我!

扁平的,面向資料的 state/props

在 state 和 props 頻繁被 watch 和 update 的情況下,如果你有使用巢狀資料,那麼你的效能可能會受到影響,尤其是在以下場景中,例如一些因為淺對於而觸發的重新渲染;在涉及 immutability 的庫中,比如 React,你必須建立狀態的副本而不是像在 Vue 中那樣直接更改它們,並且使用巢狀資料這樣做可能會建立笨拙,醜陋的程式碼。

img

即使使用展開運算子,這種寫法也並不夠優雅。扁平 props 也可以很好地清除元件正在使用的資料值。如果你傳給元件一個物件但是你並不能清楚的知道物件內部的屬性值,所以找出實際需要的資料值是來自元件具體的屬性值則是額外的工作。但如果 props 足夠扁平化,那麼起碼會方便使用和維護。

img

state / props 還應該只包含元件渲染所需的資料。You shouldn’t store entire components in the state/props and render straight from there.

(此外,對於資料繁重的應用程式,資料規範化可以帶來巨大的好處,除了扁平化之外,你可能還需要考慮一些別的優化方法)。

更加純粹的 State 變化

對 state 的更改通常應該響應某種事件,例如使用者單擊按鈕或 API 的響應。此外它們不應該因為別的 state 的變化而做出響應,因為 state 之間這種關聯可能會導致難以理解和維護的元件行為。state 變化應該沒有副作用。

如果你濫用watch而不是有限考慮以上原則,那麼在 Vue 的使用中就可能由此引發的問題。我們來看一個基本的 Vue 示例。我正在研究一個從 API 獲取一些資料並將其呈現給表的元件,其中排序,過濾等功能都是後端完成的,因此前端需要做的就是 watch 所有搜尋引數,並在其變化時觸發 API 呼叫。其中一個需要 watch 的值是“zone”,這是一個過濾器。當更改時,我們想要使用過濾後的值重新獲取服務端資料。watcher 如下:

img

你會發現一些奇怪的東西。如果他們超出了結果的第一頁,我們重置頁碼然後結束?這似乎不對,如果它們不在第一頁上,我們應該重置分頁並觸發 API 呼叫,對吧?為什麼我們只在第 1 頁上重新獲取資料?實際上原因是這樣,讓我們來看下完整的 watch:

img

當分頁改變時,應用首先會通過 pagination 的處理函式重新獲取資料。因此,如果我們改變了分頁,我們並不需要去關注資料更新這段邏輯。

讓我們一下來考慮以下流程:如果當前頁面超出了第 1 頁並且更改了 zone,而這個變化會觸發另一個狀態(pagination)發生變化,進而觸發 pagination 的觀察者重新請求資料。這樣並不是預料之中的行為,而且產生的程式碼也不夠直觀。

解決方案是改變頁碼這個行為的事件處理函式(不是觀察者,使用者更改頁面的實際處理函式)應該更改頁面值觸發 API 呼叫請求資料。這也將消除對觀察者的需求。通過這樣的設定,直接從其他地方改變分頁狀態也不會導致重新獲取資料的副作用。

雖然這個例子非常簡單,但不難看出將更復雜的狀態更改關聯在一起會產生令人難以理解的程式碼,這些程式碼不僅不可擴充套件並且是除錯的噩夢。

鬆耦合

元件的核心思想是它們是可複用的,為此要求它們必須具有功能性和完整性。“耦合”是指實體彼此依賴的術語。鬆散耦合的實體應該能夠獨立執行,而不依賴於其他模組。就前端元件而言,耦合的主要部分是元件的功能依賴於其父級及其傳遞的 props 的多少,以及內部使用的子元件(當然還有引用的部分,如第三方模組或使用者指令碼)。

緊密耦合的元件往往更不容易被複用,當它們作為特定父元件的子項時,就很難正常工作,當父元件的一個子元件或一系列子元件只能在該父元件才能夠正常發揮作用時,就會使得程式碼寫的很冗餘。因為父子元件別過度的關聯在一起了。

在設計元件時,你應該考慮到更加通用的使用場景,而不僅僅只是為了滿足最開始某個特定場景的需求。雖然一般來說元件最初都是出於特定目的進行設計,但沒關係,如果在設計它們站在更高的角度去看待,那麼很多元件將具有更好的適用性。

讓我們看一個簡單的 React 示例,你想在寫出一個帶有一個 logo 的連結列表,通過連線可以訪問特定的網站。最開始的設計可能是並沒有跟內容合理的進行解耦。下面是最初的版本:

img

雖然這這樣會滿足預期的使用場景,但卻很難被複用。如果你想要更改連結地址該怎麼辦?你必須重新複製一份相同程式碼,並且手動去替換連結地址。而且, 如果你要去實現一個使用者可以更改連線的功能,那麼意味著不可能將程式碼寫“死”,也不能期望使用者去手動修改程式碼,那麼讓我們來看一下複用性更高的元件應該如何設計:

img

在這裡我們可以看到,雖然它的原始連結和 logo 具有預設值,但我們可以通過 props 傳入的值去覆蓋掉預設值。讓我們來看一下它在實際中的使用:

img

並不需要重新編寫新的元件!如果我們解決上文中使用者可以自定義連結的使用場景,可以考慮動態構建連結陣列。此外,雖然在這個具體的例子中沒有解決,但我們仍然可以注意到這個元件沒有與任何特定的父/子元件建立密切關聯。它可以在任何需要的地方呈現。改進後的元件明顯比最初版本具有更好的複用性。

如果不是要設計需要服務於特定的一次性場景的元件,那麼設計元件的最終目標是讓它與父元件鬆散耦合,呈現更好的複用性,而不是受限於特定的上下文環境。

輔助程式碼分離

這個可能不那麼的偏理論,但我仍然認為這很重要。與你的程式碼庫打交道是軟體工程的一部分,有時一些基本的組織原則可以使事情變得更加順暢。在長時間與程式碼相處的過程中,即使改變一個很小的習慣也可以產生很大的不同。其中一個有效的原則就是將輔助程式碼分離出來放在特定的地方,這樣你在處理元件時就不必考慮這些。以下列舉一些方面:

  • 配置程式碼
  • 假資料
  • 大量非技術說明文件

因為在嘗試處理元件的核心程式碼時,你不希望看到與技術無關的一些說明(因為會多滾動幾下滑鼠滾輪甚至打斷思路)。在處理元件時,你希望它們儘可能通用且可重用。檢視與元件當前上下文相關的特定資訊可能會使得設計出來的元件不易與具體業務解耦。

提煉精華

雖然這樣做起來可能具有挑戰性,但開發元件的一個好方法是使它們包含渲染它們所需的最小 Javascript。一些無關緊要的東西,比如資料獲取,資料整理或事件處理邏輯,理想情況下應該將通用的部分移入外部 js 或或者放在共同的祖先中。

單獨從元件分的“檢視”部分來看,即你看到的內容(html 和 樣式)。其中的 Javascript 僅用於幫助渲染檢視,可能還有一些針對特定元件的邏輯(例如在其他地方使用時)。除此之外的任何事情,例如 API 呼叫,數值的格式化(例如貨幣或時間)或跨元件複用的資料,都可以移動外部的 js 檔案中。讓我們看一下 Vue 中的一個簡單示例,使用巢狀列表元件。我們可以先看下下面這個有問題的版本。

這是第一個層級:

img

這是巢狀列表元件:

img

在這裡我們可以看到此列表的兩個層級都具有外部依賴關係,最上層導引入外部 js 檔案中的函式和 JSON 檔案的資料,巢狀元件連線到 Vuex 儲存並使用 axios 傳送請求。它們還具有僅適用於當前場景的嵌入功能(最上層中源資料處理和巢狀列表的中度 click 時間的特定響應功能)。

雖然這裡採用了一些很好的通用設計技術,例如將通用的 資料處理方法移動到外部指令碼而不是直接將函式寫死,但這樣仍然不具備很高的複用性。如果我們是從 API 的響應中獲取資料,但是這個資料跟我們期望的資料結構或者型別不同的時候要怎麼辦?或者我們期望單擊巢狀項時有不同的行為?在遇到這些需求的場景下,這個元件無法被別的元件直接引用並根據實際需求改變自身的特性。

讓我們看看我們是否可以通過提升資料並將事件處理作為 props 傳遞來解決這個問題,這樣元件就可以簡單地呈現資料而不會封裝任何其他邏輯。

這是改進後的第一級別:

img

而新的第二級:

img

使用這個新列表,我們可以獲得想要的資料,並定義了巢狀列表的 onClick 處理函式,以便在父級中傳入任何我們想要的操作,然後將它們作為 props 傳遞給頂級元件。這樣,我們可以將匯入和邏輯留給單個根元件,所以不需要為了能夠在新的場景下使用去重新再實現一個類似元件。

有關此主題的簡短文章可以在這裡找到。它由 Redux 的作者 Dan Abramov 編寫,雖然是用 React 舉例說明。但是元件設計的思想是通用的。

及時模組化

我們在實際進行元件抽離工作的時候,需要考慮到不要過度的元件化,誠然將大塊程式碼變成鬆散耦合且可用的部分是很好的實踐,但是並不是所有的頁面結構(HTML 部分)都需要被抽離成元件,也不是所有的邏輯部分都需要被抽出到元件外部。

在決定是否將程式碼分開時,無論是 Javascript 邏輯還是抽離為新的元件,都需要考慮以下幾點。同樣,這個列表並不完整,只是為了讓你瞭解需要考慮的各種事項。(記住,僅僅因為它不滿足一個條件並不意味著它不會滿足其他條件,所以在做出決定之前要考慮所有條件):

  1. 是否有足夠的頁面結構/邏輯來保證它? 如果它只是幾行程式碼,那麼最終可能會建立更多的程式碼來分隔它,而不僅僅是將程式碼放入其中。
  2. 程式碼重複(或可能重複)? 如果某些東西只使用一次,並且服務於一個不太可能在其他地方使用的特定用例,那麼將它嵌入其中可能會更好。如果需要,你可以隨時將其分開(但不要在需要做這些工作的時候將此作為偷懶的藉口)。
  3. 它會減少需要書寫的模板嗎? 例如,假設你想要一個帶有特定樣式的 div 屬性結構和一些靜態內容/功能的元件,其中一些可變內容巢狀在內部。通過建立可重用的包裝器(與 React 的 HOC 或 Vue 的 slot 一樣),你可以在建立這些元件的多個例項時減少模板程式碼,因為你不需要重新再寫外部的包裝程式碼。
  4. 效能會收到影響嗎? 更改 state/props 會導致重新渲染,當發生這種情況時,你需要的是 只是重新去渲染經過 diff 之後得到的相關元素節點。在較大的、關聯很緊密的元件中,你可能會發現狀態更改會導致在不需要它的許多地方重新呈現,這時應用的效能就可能會開始受到影響。
  5. 你是否會在測試程式碼的所有部分時遇到問題? 我們總是希望能夠進行充分的測試,比如對於一個元件,我們會期望它的正常工作不依賴特定的用例(上下文),並且所有 Javascript 邏輯都按預期工作。當元素具有某個特定假設的上下文或者分別將一大堆邏輯嵌入到單個函式中時,這樣將會很難滿足我們的期望。如果測試的元件是具有比較大模板和樣式的單個巨型元件,那麼元件的渲染測試也會很難進行。
  6. 你是否有一個明確的理由? 在分割程式碼時,你應該考慮它究竟實現了什麼。這是否允許更鬆散的耦合?我是否打破了一個邏輯上有意義的獨立實體?這個程式碼是否真的可能在其他地方被重複使用?如果你不能清楚地回答這個問題,那最好先不要進行元件抽離。因為這樣可能導致一些問題(比如拆解掉原本某些潛在的耦合關係)。
  7. 這些好處是否超過了成本? 分離程式碼不可避免地需要時間和精力,其數量根據具體情況而變化,並且在最終做出此決定時會有許多因素(例如此列表中列舉出來的一些)。一般來說,進行一些對抽象的成本和收益研究可以幫助更快更準確去做出是否需要元件化的決策。最後,我提到了這一點,因為如果我們過分關注優勢,就很容易忘記達成目標所需要做的努力,所以在做出決定以前需要權衡這兩個方面。

集中/統一的狀態管理

許多大型應用程式使用 Redux 或 Vuex 等狀態管理工具(或者具有類似 React 中的 Context API 狀態共享設定)。這意味著他們從 store 獲得 props 而不是通過父級傳遞。在考慮元件的可重用性時,你不僅要考慮直接的父級中傳遞而來的 props,還要考慮 從 store 中獲取到的 props。如果你在另一個專案中使用該元件,則需要在 store 中使用這些值。或許其他專案根本不使用集中儲存工具,你必須將其轉換為從父級中進行 props 傳遞 的形式。

由於將元件掛接到 store(或上下文)很容易並且無論元件的層次結構位置如何都可以完成,因此很容易在 store 和 web 應用的元件之間快速建立大量緊密耦合(不關心元件所處的層級)。通常將元件與 store 進行關聯只需簡單幾行程式碼。但是請注意一點,雖然這種連線(耦合)更方便,但它的含義並沒有什麼不同,你也需要考慮儘量符合如同在使用父級傳遞方式時的要點。

最後

我想提醒大家的是:應該更注重以上這些元件設計的原則和你已知的一些最佳實踐在實際中的應用。雖然你應該盡力維護良好的設計,但是不要為了包裝 JIRA ticket 或一個取消請求而有損程式碼完整性,同時總是把理論置於現實世界結果之上的人也往往會讓他們的工作受到影響。大型軟體專案有許多活動部分,軟體工程的許多方面與編碼沒有特別的關係,但仍然是不可或缺的,例如遵守最後期限和處理非技術期望。

雖然充分的準備很重要,應該成為任何專業軟體設計的一部分,但在現實世界中,切實的結果才是最為重要的。當你被僱用來實際創造一些東西時,如果在最後期限到來之前,你有的只是一個如何構建完美產品的驚人計劃,但卻沒有實際的成果,你的僱主可能不會太高興吧?此外,軟體工程中的東西很少完全按計劃進行,因此過度具體的計劃往往會在時間使用方面得到適得其反的效果。

此外,元件規劃和設計的概念也適用於元件重構。雖然用了 50 年的時間來計劃一切令人難以忍受的細節,然後從一開始就完美地編寫它就會很好,回到現實世界,我們往往會遇到這種情況,即為了趕進度而不能使程式碼達到完美的預期。然而,一旦我們有了空閒時間,那麼一個推薦的做法就是回過頭來重構早期不夠理想的的程式碼,這樣它就可以作為我們向前發展的堅實基礎。

在一天結束時,雖然你的直接責任可能是“編寫程式碼”,但你不應忽視你的最終目標,即建立一些東西。建立產品。為了產生一些你可以引以為豪的東西並幫助別人,即使它在技術上並不完美,永遠記得找到一個平衡點。不幸的是,在一週內每天 8 小時盯著眼前的程式碼會使得眼界和角度變得更為“狹窄”,這個時候你需要的你是退後一步,確保你不要為了一顆樹而失去整個森林。

相關文章