前端組件設計原則


方法

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

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

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

  • 層次結構和 UML 類圖

  • 扁平化、面向數據的 state/props

  • 更加純粹的 State 變化

  • 低耦合

  • 輔助代碼分離

  • 提煉精華

  • 及時模塊化

  • 集中/統一的狀態管理

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

 

層次結構和類圖

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

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

  • State

  • Props

  • Methods

  • 與其他組件的關系( Relationship to other components )

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

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

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

  • 一個易於理解的組件組成和關聯視圖

  • 一個易於理解的應用程序 UI 層次結構的概述

  • 一個結構數據層次及其流動方式的視圖

  • 一個組件功能職責的快照

  • 便於使用圖表軟件創建

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

 

扁平的,面向數據的 state/props

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

//Flat, data-oriented state/propsconst state = { clients: { allClients, firstClient, lastClient: { name: 'John', phone: 'Doe', address: { number: 5, street: 'Estin', suburb: 'Parrama', city: 'Sydney' } } }}// 倘若我們需要去修改 address number時需要怎么辦?const test = { clients: { ...state.clients, lastClient: { ...state.clients.lastClient, address: { ...state.clients.lastClient.address, number: 10 } } }}

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

// 我們無法得知 customer 這個對象里面擁有什么屬性
// 這個組件需要使用這個對象所有的屬性值或者只是需要其中的一部分?
// 如果我想要將這個組件在別處使用,我應該傳入什么樣的對象
<listItem customer={customer}/> // 下面的這個組件接收的屬性就一目了然 <listItem phone={customer.phone} name={customer.name} iNumber={customer.iNumber} /> 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 如下:

//State change purity
zone:{
handler() {
// 重置頁碼
if(this.pagination.page > 1){
this.pagination.page = 1
return;
}
this.getDataFromApi()
}}

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

watch: {
pagination() {
this.getDataFromApi()
}},
zone: {
handler() {
// 重置頁碼
if(this.pagination.page > 1) {
this.pagination.page = 1
return;
}
this.getDataFromApi()
}}

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

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

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

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

 

松耦合

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

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

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

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

const Links = ()=>(
<div className="links-container">
<div class="links-list">
<a href="/">
Home
</a>
<a href="/shop">
Products
</a>
<a href="/help">
Help
</a>
</div>
<div className="links-logo">
<img src="/default/logo.png"/>
</div>
</div>)

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

const DEFAULT_LINKS = [ {route: "/", text: "Home"}, {route: "/shop", text: "Products"}, {route: "/help", text: "Help"}]const DEFAULT_LOGO = "/default/logo.png"const Links = ({links = DEFAULT_LINKS,logoPath = DEFAULT_LOGO }) => ( <div className="links-container"> <div class="links-list"> // 將數組依次渲染為超鏈接 links.map((link) => <a href={link.route}> {link.text}</a>) </div> <div className="links-logo"> <img src={logoPath}/> </div> </div>)

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

const adminLinks = {
links: [
{route: "/", text: "Home"},
{route: "/metrics", text: "Site metrics"},
{route: "/admin", text: "Admin panel"}
],
logoPath: "/admin/logo.png"}<Links {...adminLinks} />

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

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

 

輔助代碼分離

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

  • 配置代碼

  • 假數據

  • 大量非技術說明文檔

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

 

提煉精華

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

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

這是第一個層級:

// 組件父級<template> <div> <ul v-for="(topLevelItem,i) in fomattedItems" :key="i"> <img :src="topLevelItem.imagePath"> </ul> <nested-list :items="topLevelItem.nestedItems" /> </div></template><script>import { mapState } from 'vuex'import NestedList from '~/components/NestedList 'import data from ' ~/data/items.json'import removeFalsyItems from '~/scripts/removeFalsyItems'export default { data() { return {} }, components: { NestedList }, computed: { fomattedItems() { return removeFalsyItems(data) } }}</script>

這是嵌套列表組件:

// nestedList 組件<template> <div> <ul> <li v-for="(secondLevelItem, i) in items" :key="i" @click="updateText(secondLevelItem.text)"> {{secondLevelItem.text}}</li> </ul> </div></template><script >import axios from 'axios'export default { props: { items: { type: Array, defafult: () => [] } }, methods: { updataText(text) { this.$store.commit('updateText', text) axios.post('/endpoint', { text }).then(res => { console.log(res) }) } }}</script>

在這里我們可以看到此列表的兩個層級都具有外部依賴關系,最上層導引入外部 js 文件中的函數和 JSON 文件的數據,嵌套組件連接到 Vuex 存儲並使用 axios 發送請求。它們還具有僅適用於當前場景的嵌入功能(最上層中源數據處理和嵌套列表的中度 click 時間的特定響應功能)。

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

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

這是改進后的第一級別:

// 組件父級<template> <div> <ul v-for="(topLevelItem,i) in fomattedItems" :key="i"> <img :src="topLevelItem.imagePath"> </ul> <nested-list :items="topLevelItem.nestedItems" v-bind="{onNestedItemClick}" /> </div></template><script>import NestedList from '~/components/NestedList 'export default { components: { NestedList }, props: { items:{ type: Array, default: null }, onNestedItemClick:{ type: Function, default: null } }}</script>

而新的第二級:

// nestedList 組件<template> <div> <ul> <li v-for="(secondLevelItem, i) in items" :key="i" @click="onNestedItemClick(secondLevelItem.text)" >{{secondLevelItem.text}}</li> </ul> </div></template><script >import axios from 'axios'export default { props: { items: { type: Array, defafult: null }, onNestedItemClick: { type: Function, defafult: null } }}</script>

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

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

 

及時模塊化

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

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

  • 是否有足夠的頁面結構/邏輯來保證它?

    如果它只是幾行代碼,那么最終可能會創建更多的代碼來分隔它,而不僅僅是將代碼放入其中。

  • 代碼重復(或可能重復)?

    如果某些東西只使用一次,並且服務於一個不太可能在其他地方使用的特定用例,那么將它嵌入其中可能會更好。如果需要,你可以隨時將其分開(但不要在需要做這些工作的時候將此作為偷懶的借口)。

  • 它會減少需要書寫的模板嗎?

    例如,假設你想要一個帶有特定樣式的 div 屬性結構和一些靜態內容/功能的組件,其中一些可變內容嵌套在內部。通過創建可重用的包裝器(與 React 的 HOC 或 Vue 的 slot 一樣),你可以在創建這些組件的多個實例時減少模板代碼,因為你不需要重新再寫外部的包裝代碼。

  • 性能會收到影響嗎?

    更改 state/props 會導致重新渲染,當發生這種情況時,你需要的是 只是重新去渲染經過 diff 之后得到的相關元素節點。在較大的、關聯很緊密的組件中,你可能會發現狀態更改會導致在不需要它的許多地方重新呈現,這時應用的性能就可能會開始受到影響。

  • 你是否會在測試代碼的所有部分時遇到問題?

    我們總是希望能夠進行充分的測試,比如對於一個組件,我們會期望它的正常工作不依賴特定的用例(上下文),並且所有 Javascript 邏輯都按預期工作。當元素具有某個特定假設的上下文或者分別將一大堆邏輯嵌入到單個函數中時,這樣將會很難滿足我們的期望。如果測試的組件是具有比較大模板和樣式的單個巨型組件,那么組件的渲染測試也會很難進行。

  • 你是否有一個明確的理由?

    在分割代碼時,你應該考慮它究竟實現了什么。這是否允許更松散的耦合?我是否打破了一個邏輯上有意義的獨立實體?這個代碼是否真的可能在其他地方被重復使用?如果你不能清楚地回答這個問題,那最好先不要進行組件抽離。因為這樣可能導致一些問題(比如拆解掉原本某些潛在的耦合關系)。

  • 這些好處是否超過了成本?

    分離代碼不可避免地需要時間和精力,其數量根據具體情況而變化,並且在最終做出此決定時會有許多因素(例如此列表中列舉出來的一些)。一般來說,進行一些對抽象的成本和收益研究可以幫助更快更准確去做出是否需要組件化的決策。最后,我提到了這一點,因為如果我們過分關注優勢,就很容易忘記達成目標所需要做的努力,所以在做出決定以前需要權衡這兩個方面。

  •  

集中/統一的狀態管理

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

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

資源搜索網站大全 https://www.renrenfan.com.cn

最后

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

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

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

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM