阿川私房教材:學程式,拿 offer!

63 個專案實戰,直接上手!
無需補習,按步驟打造你的面試作品。

立即解鎖你的轉職秘笈

SOLID 是較常用的設計模式之一。它在許多語言和框架中都很常用,也有一些文章介紹如何在 React 中使用它。

每篇關於 SOLID 的 React 文章都以稍微不同的方式介紹該模型,有些將其應用於元件,有些將其應用於 TypeScript,但很少有人將這些原理應用於鉤子。

由於 hooks 是 React 基礎的一部分,我們將在這裡看看 SOLID 原則如何應用於這些。

單一職責原則(SRP)

Solid 中的第一個字母 S 是最容易理解的。本質上,它的意思是,讓一個鉤子/元件做一件事。

// Single Responsibility Principle
A module should be responsible to one, and only one, actor

例如,看看下面的 useUser 鉤子,它會取得使用者和待辦事項,並將任務合併到使用者物件中。

import { useState } from 'react'
import { getUser, getTodoTasks } from 'somewhere'

const useUser = () => {
  const [user, setUser] = useState()
  const [todoTasks, setTodoTasks] = useState()

  useEffect(() => {
    const userInfo = getUser()
    setUser(userInfo)
  }, [])

  useEffect(() => {
    const tasks = getTodoTasks()
    setTodoTasks(tasks)
  }, [])

  return { ...user, todoTasks }
}

這個鉤子並不牢固,它不遵守單一責任原則。這是因為它有責任獲取用戶資料和待辦任務,這是兩件事。

相反,上面的程式碼應該分為兩個不同的鉤子,一個用於獲取有關用戶的資料,另一個用於獲取任務。

import { useState } from 'react'
import { getUser, getTodoTasks } from 'somewhere'

// useUser hook is no longer responsible for the todo tasks.
const useUser = () => {
  const [user, setUser] = useState()

  useEffect(() => {
    const userInfo = getUser()
    setUser(userInfo)
  }, [])

  return { user }
}

// Todo tasks do now have their own hook.
// The hook should actually be in its own file as well. Only one hook per file!
const useTodoTasks = () => {
  const [todoTasks, setTodoTasks] = useState()

  useEffect(() => {
    const tasks = getTodoTasks()
    setTodoTasks(tasks)
  }, [])

  return { todoTasks }
}

這個原則適用於所有的鉤子和元件,它們都應該只做一件事。要問自己的事情是:

  1. 這是一個應該顯示 UI(演示性)或處理資料(邏輯性)的元件嗎?

  2. 這個鉤子應該處理什麼單一類型的資料?

  3. 這個鉤子/元件屬於哪一層?它是處理資料儲存還是 UI 的一部分?

如果您發現自己建造的鉤子對上述每個問題都沒有單一答案,那麼您就違反了單一責任原則。

這裡值得注意的一件有趣的事情是第一個問題。這實際上意味著渲染 UI 的元件不應該處理資料。這意味著,要真正嚴格遵循這項原則,每個顯示資料的 React 元件都應該有一個鉤子來處理其邏輯和資料。換句話說,不應在顯示資料的相同元件中取得資料。

為什麼在 React 中使用 SRP?

這種單一責任原則其實非常適合 React。 React 遵循基於元件的架構,這意味著它由組合在一起的小元件組成,因此它們一起可以建構並形成一個應用程式。元件越小,可重複使用的可能性就越大。這適用於元件和鉤子。

因此,React 或多或少是建立在單一職責原則上的。如果你不遵循它,你會發現自己總是在編寫新的鉤子和元件,並且很少重複使用它們中的任何一個。

違反單一責任原則將使您的程式碼難以測試。如果不遵循這個原則,您經常會發現您的測試文件有數百行,甚至可能多達 1000 行程式碼。

{% 嵌入 https://dev.to/perssondennis/how-to-use-mvvm-in-react-using-hooks-and-typescript-3o4m %}

開閉原理 (OCP)

讓我們繼續遵循開閉原則,畢竟這是 SOLID 中的下一個字母。 OCP 和 SRP 一樣是較容易理解的原則之一,至少其定義是如此。

// Open/Closed Principle
Software entities (classes, modules, functions, etc.) should
be open for extension, but closed for modification 

對於最近開始使用 React 的傻瓜來說,這句話可以翻譯為:

Write hooks/component which you never will have a reason to 
touch again, only re-use them in other hooks/components

回想一下本文前面所說的單一責任原則;在 React 中,您需要編寫小元件並將它們組合在一起。讓我們看看為什麼這有幫助。

import { useState } from 'react'
import { getUser, updateUser } from 'somewhere'

const useUser = ({ userType }) => {
  const [user, setUser] = useState()

  useEffect(() => {
    const userInfo = getUser()
    setUser(userInfo)
  }, [])

  const updateEmail = (newEmail) => {
    if (user && userType === 'admin') {
      updateUser({ ...user, email: newEmail })
    } else {
      console.error('Cannot update email')
    }
  }

  return { user, updateEmail }
}

上面的鉤子獲取用戶並返回它。如果使用者的類型是管理員,則允許該使用者更新其電子郵件。普通使用者不允許更新其電子郵件。

上面的程式碼絕對不會讓你被解僱。但這會惹惱你團隊中的後端人員,他會為他的孩子閱讀設計模式書籍作為睡前故事。我們就叫他皮特吧。

皮特會抱怨什麼?他會要求你重寫該元件,如下所示。將管理功能提升到它自己的 useAdmin 掛鉤,並讓 useUser 掛鉤除了那些應該可供普通用戶使用的功能之外沒有其他功能。

import { useState } from 'react'
import { getUser, updateUser } from 'somewhere'

// useUser does now only return the user, 
// without any function to update its email.
const useUser = () => {
  const [user, setUser] = useState()

  useEffect(() => {
    const userInfo = getUser()
    setUser(userInfo)
  }, [])

  return { user }
}

// A new hook, useAdmin, extends useUser hook,
// with the additional feature to update its email.
const useAdmin = () => {
  const { user } = useUser()

  const updateEmail = (newEmail) => {
    if (user) {
      updateUser({ ...user, email: newEmail })
    } else {
      console.error('Cannot update email')
    }
  }

  return { user, updateEmail }
}

皮特為什麼要求更新?因為那個無禮挑剔的混蛋皮特寧願希望你現在花時間重寫那個鉤子,然後明天回來進行新的程式碼審查,而不是將來可能需要用一個微小的新 if 語句更新程式碼,如果有的話成為另一種類型的使用者。

好吧,這是消極的說法...樂觀的說法是,使用這個新的useAdmin 掛鉤,當您打算實現僅影響管理員用戶的功能時,或者當您打算實現僅影響管理員用戶的功能時,您不必更改useUser 掛鉤中的任何內容。

當新增新的使用者類型或更新 useAdmin 掛鉤時,無需弄亂 useUser 掛鉤或更新其任何測試。這意味著,當您新增的使用者類型(例如假使用者)時,您不必意外地將錯誤傳送給普通使用者。相反,您只需加入一個新的 userFakeUser 鉤子,您的老闆就不會在周五晚上 9 點給您打電話,因為客戶在發薪週末會遇到銀行帳戶顯示虛假資料的問題。

床下的前端開發人員

皮特的兒子知道要小心義大利麵式程式碼開發人員

為什麼在 React 中使用 OCP?

一個 React 專案應該有多少個 hooks 和元件是有爭議的。每一個都需要渲染效果圖的代價。 React 不是 Java,其中 22 種設計模式導致 422 個類別用於簡單的 TODO 清單實作。這就是狂野西部網絡 (www) 的魅力所在。

然而,開放/封閉原則顯然也是在 React 中使用的少數模式。上面的鉤子範例是最小的,鉤子沒有做太多事情。隨著更多實質的掛鉤和更大的專案,這項原則變得非常重要。

這可能會花費您一些額外的鉤子,並且需要稍長的時間來實現,但是您的鉤子將變得更加可擴展,這意味著您可以更頻繁地重複使用它們。您將不必經常重寫測試,從而使掛鉤更加牢固。最重要的是,如果您從不接觸舊程式碼,則不會在舊程式碼中產生錯誤。

沒有破損的東西不要碰

天知道不要碰沒有破損的東西

{% 嵌入 https://dev.to/perssondennis/react-anti-patterns-and-best-practices-dos-and-donts-3c2g %}

里氏替換原理 (LSP)

啊啊,這個名字……誰是利斯科夫?而誰來代替她呢?而這個定義,難道就沒有意義嗎?

If S subtypes T, what holds for T holds for S

這個原則顯然是關於繼承的,在 React 或 JavaScript 中,繼承的實踐並不像大多數後端語言中那麼多。 JavaScript 在 ES6 之前甚至沒有類,ES6 是在 2015/2016 年左右引入的,作為基於原型的繼承的語法糖。

考慮到這一點,該原則的用例實際上取決於您的程式碼的外觀。類似 Liskov 的原則在 React 中有意義,可能是:

If a hook/component accepts some props, all hooks and components 
which extends that hook/component must accept all the props the 
hook/component it extends accepts. The same goes for return values.

為了說明這一點,我們可以看一下兩個儲存鉤子:useLocalStorage 和 useLocalAndRemoteStorage。

import { useState } from 'react'
import { 
  getFromLocalStorage, saveToLocalStorage, getFromRemoteStorage 
} from 'somewhere'

// useLocalStorage gets data from local storage.
// When new data is stored, it calls saveToStorage callback.
const useLocalStorage = ({ onDataSaved }) => {
  const [data, setData] = useState()

  useEffect(() => {
    const storageData = getFromLocalStorage()
    setData(storageData)
  }, [])

  const saveToStorage = (newData) => {
    saveToLocalStorage(newData)
    onDataSaved(newData)
  }

  return { data, saveToStorage }
}

// useLocalAndRemoteStorage gets data from local and remote storage.
// I doesn't have callback to trigger when data is stored.
const useLocalAndRemoteStorage = () => {
  const [localData, setLocalData] = useState()
  const [remoteData, setRemoteData] = useState()

  useEffect(() => {
    const storageData = getFromLocalStorage()
    setLocalData(storageData)
  }, [])

  useEffect(() => {
    const storageData = getFromRemoteStorage()
    setRemoteData(storageData)
  }, [])

  const saveToStorage = (newData) => {
    saveToLocalStorage(newData)
  }

  return { localData, remoteData, saveToStorage }
}

透過上面的鉤子,useLocalAndRemoteStorage 可以被視為 useLocalStorage 的子類型,因為它與 useLocalStorage 執行相同的操作(保存到本地存儲),而且還通過將資料保存到其他位置來擴展 useLocalStorage 的功能。

這兩個鉤子有一些共享的屬性和回傳值,但是 useLocalAndRemoteStorage 缺少 useLocalStorage 接受的 onDataSaved 回呼屬性。傳回屬性的名稱也有不同的命名,本地資料在useLocalStorage中命名為data,而在useLocalAndRemoteStorage中命名為localData。

如果你問利斯科夫,這就違背了她的原則。實際上,當她嘗試更新Web 應用程式以在伺服器端保留資料時,她會非常憤怒,只是意識到她不能簡單地用useLocalAndRemoteStorage 鉤子替換useLocalStorage,只是因為一些懶惰的開發人員從未為useLocalAndRemoteStorage 鉤子實現onDataSaved回調。

利斯科夫會痛苦地更新鉤子來支持這一點。同時,她也會更新 useLocalStorage 掛鉤中的本地資料名稱,以符合 useLocalAndRemoteStorage 中的本地資料名稱。

import { useState } from 'react'
import { 
  getFromLocalStorage, saveToLocalStorage, getFromRemoteStorage 
} from 'somewhere'

// Liskov has renamed data state variable to localData
// to match the interface (variable name) of useLocalAndRemoteStorage.
const useLocalStorage = ({ onDataSaved }) => {
  const [localData, setLocalData] = useState()

  useEffect(() => {
    const storageData = getFromLocalStorage()
    setLocalData(storageData)
  }, [])

  const saveToStorage = (newData) => {
    saveToLocalStorage(newData)
    onDataSaved(newData)
  }

  // This hook does now return "localData" instead of "data".
  return { localData, saveToStorage }
}

// Liskov also added onDataSaved callback to this hook,
// to match the props interface of useLocalStorage.
const useLocalAndRemoteStorage = ({ onDataSaved }) => {
  const [localData, setLocalData] = useState()
  const [remoteData, setRemoteData] = useState()

  useEffect(() => {
    const storageData = getFromLocalStorage()
    setLocalData(storageData)
  }, [])

  useEffect(() => {
    const storageData = getFromRemoteStorage()
    setRemoteData(storageData)
  }, [])

  const saveToStorage = (newData) => {
    saveToLocalStorage(newData)
    onDataSaved(newData)
  }

  return { localData, remoteData, saveToStorage }
}

透過為鉤子提供通用介面(傳入的 props、傳出的返回值),它們可以變得非常容易交換。如果我們遵循里氏替換原則,繼承另一個鉤子/元件的鉤子和元件應該可以用它繼承的鉤子或元件替換。

擔心的利斯科夫

當開發人員不遵循她的原則時,利斯科夫感到失望

為什麼在 React 中使用 LSP?

儘管繼承在 React 中並不是很突出,但它肯定在幕後使用。 Web 應用程式通常可以有幾個外觀相似的元件。文字、標題、連結、圖示連結等都是類似類型的元件,可以從繼承中受益。

IconLink 元件可能會也可能不會包裝 Link 元件。無論哪種方式,它們都會受益於使用相同的介面(使用相同的 props)實作。這樣,您可以隨時在應用程式中的任何位置將 Link 元件替換為 IconLink 元件,而無需編輯任何其他程式碼。

鉤子也是如此。 Web 應用程式從伺服器取得資料。他們也可能使用本地儲存或狀態管理系統。這些最好可以共享道具以使它們可以互換。

應用程式可能會從後端伺服器取得使用者、任務、產品或任何其他資料。類似的函數也可以共享接口,從而更容易重複使用程式碼和測試。

{% 嵌入 https://dev.to/perssondennis/the-20-most-common-use-cases-for-javascript-arrays-2j8j %}

介面隔離原則(ISP)

另一個更明確的原則是介面隔離原則。定義很短。

No code should be forced to depend on methods it does not use

顧名思義,它與介面有關,基本上意味著函數和類別應該只實現它明確使用的介面。最容易實現這一點的方法是保持介面整潔,讓類別選擇其中的一些來實現,而不是被迫用它不關心的幾種方法來實現一個大介面。

例如,代表擁有網站的人的類別應該實現兩個接口,一個稱為 Person 的接口,描述有關此人的詳細訊息,另一個用於網站的接口,其中包含有關其擁有的網站的元資料。

interface Person {
  firstname: string
  familyName: string
  age: number
}

interface Website {
  domain: string
  type: string
}

如果相反,建立一個單一介面網站,包括有關所有者和網站的訊息,則將違反介面隔離原則。

interface Website {
  ownerFirstname: string
  ownerFamilyName: number
  domain: string
  type: string
}

你可能會想,上面的介面有什麼問題嗎?它的問題是它使介面不太可用。想想看,如果公司不是人,而是公司,你會怎麼做?公司其實沒有姓氏。然後您會修改介面以使其對人類和公司都可用嗎?或者您會建立一個新介面 CompanyOwnedWebsite 嗎?

然後,您最終會得到一個具有許多可選屬性的接口,或分別稱為 PersonWebsite 和 CompanyWebsite 的兩個接口。這些解決方案都不是最佳的。

// Alternative 1

// This interface has the problem that it includes 
// optional attributes, even though the attributes 
// are mandatory for some consumers of the interface.
interface Website {
  companyName?: string
  ownerFirstname?: string
  ownerFamilyName?: number
  domain: string
  type: string
}

// Alternative 2

// This is the original Website interface renamed for a person.
// Which means, we had to update old code and tests and 
// potentially introduce some bugs.
interface PersonWebsite {
  ownerFirstname: string
  ownerFamilyName: number
  domain: string
  type: string
}

// This is a new interface to work for a company.
interface CompanyOwnedWebsite {
  companyName: string
  domain: string
  type: string
}

ISP 遵循的解決方案如下所示。

interface Person {
  firstname: string
  familyName: string
  age: number
}

interface Company {
  companyName: string
}

interface Website {
  domain: string
  type: string
}

透過上述適當的接口,代表公司網站的類別可以實現接口 Company 和 Website,但不需要考慮 Person 接口中的 firstname 和 familyName 屬性。

React 中使用 ISP 嗎?

所以,這個原則顯然適用於接口,這意味著它只應該在您使用 TypeScript 編寫 React 程式碼時才有意義,不是嗎?

當然不是!不輸入介面並不意味著它們不存在。到處都有,只是你沒有明確地輸入它們。

在 React 中,每個元件和鉤子都有兩個主要接口,輸入和輸出。

// The input interface to a hook is its props.
const useMyHook = ({ prop1, prop2 }) => {

  // ...

  // The output interface of a hook is its return values.
  return { value1, value2, callback1 }
}

使用 TypeScript,您通常會鍵入輸入接口,但輸出接口通常會被跳過,因為它是可選的。

// Input interface.
interface MyHookProps { 
  prop1: string
  prop2: number
}

// Output interface.
interface MyHookOutput { 
  value1: string
  value2: number
  callback1: () => void
}

const useMyHook = ({ prop1, prop2 }: MyHookProps): MyHookOutput => {

  // ...

  return { value1, value2, callback1 }
}

如果鉤子不會將 prop2 用於任何用途,那麼它不應該成為其 props 的一部分。對於單一道具,可以輕鬆地將其從道具清單和介面中刪除。但是,如果 prop2 是物件類型,例如上一章不正確的 Website 介面範例,該怎麼辦?

interface Website {
  companyName?: string
  ownerFirstname?: string
  ownerFamilyName?: number
  domain: string
  type: string
}

interface MyHookProps { 
  prop1: string
  website: Website
}

const useMyCompanyWebsite = ({ prop1, website }: MyHookProps) => {

  // This hook uses domain, type and companyName,
  // but not ownerFirstname or ownerFamilyName.

  return { value1, value2, callback1 }
}

現在我們有一個 useMyCompanyWebsite 鉤子,它有一個 website 屬性。如果鉤子中使用了網站介面的部分內容,我們不能簡單地刪除整個網站道具。我們必須保留 website 屬性,因此也保留ownerFirstname 和ownerFamiliyName 的介面屬性。這也意味著,該針對公司的掛鉤可以由人類擁有的網站所有者使用,即使該掛鉤可能不適用於該用途。

為什麼在 React 中使用 ISP?

我們現在已經了解了 ISP 的含義,以及它如何應用於 React,即使不使用 TypeScript。透過查看上面的小例子,我們也看到了一些不遵循 ISP 的問題。

在更複雜的專案中,可讀性是最重要的。介面隔離原則的目的之一是避免混亂,避免不必要的程式碼的存在,這些程式碼只會破壞可讀性。不要忘記可測試性。您是否應該關心您實際未使用的道具的測試覆蓋率?

實現大型介面也迫使您將 props 設定為可選。導致更多的 if 語句來檢查函數的存在和潛在的誤用,因為在介面上顯示該函數將處理此類屬性。

{% 嵌入 https://dev.to/perssondennis/answers-to-common-nextjs-questions-1oki %}

依賴倒置原則(DIP)

最後一個原則,即 DIP,包括一些被廣泛誤解的術語。令人困惑的地方在於依賴反轉、依賴注入和控制反轉之間的差異。所以我們先聲明一下。

依賴倒置

依賴倒置原則(DIP)表示高階模組不應該從低階模組導入任何內容,兩者都應該依賴抽象。這意味著任何高階模組自然可能依賴它所使用的模組的實作細節,但不應該具有這種依賴性。

高級模組和低階模組的編寫方式應使它們都可以在不了解其他模組內部實現的任何細節的情況下使用。只要介面保持不變,每個模組都應該可以用它的替代實作來替換。

控制反轉

控制反轉(IoC)是用來解決依賴反轉問題的原理。它指出模組的依賴關係應由外部實體或框架提供。這樣,模組本身只需使用依賴項,而不必建立依賴項或以任何方式管理它。

依賴注入

依賴注入(DI)是實現 IoC 的常見方法。它透過建構函數或 setter 方法注入模組來提供對模組的依賴關係。這樣,模組就可以使用依賴項而無需負責建立它,這符合 IoC 原則。值得一提的是,依賴注入並不是實現控制反轉的唯一方法。

React 中使用 DIP 嗎?

澄清了這些術語,並知道 DIP 原則是關於依賴倒置的,我們可以再次看看這個定義是怎樣的。

High-level modules should not import anything from low-level modules.
Both should depend on abstractions

這如何適用於 React? React 不是一個通常與依賴注入相關的函式庫,那我們該如何解決依賴倒置的問題呢?

這個問題最常見的解決方案是鉤子。鉤子不能算作依賴注入,因為它們被硬編碼到元件中,並且不可能在不更改元件實現的情況下用另一個鉤子替換鉤子。相同的鉤子將在那裡,使用相同的鉤子實例,直到開發人員更新程式碼。

但請記住,依賴注入並不是實現依賴倒置的唯一方法。 Hooks 可以被視為 React 元件的外部依賴,它有一個介面(它的 props),可以抽像出 hook 中的程式碼。這樣,鉤子就實現了依賴倒置的原則,因為元件依賴抽象接口,而不需要知道有關鉤子的任何細節。

React 中 DIP 的另一個更直觀的實作(實際上使用依賴注入)是 HOC 和上下文的使用。請參閱下面的 withAuth HOC。

const withAuth = (Component) => {
  return (props) => {
    const { user } = useContext(AuthContext)

    if (!user) {
      return <LoginComponent>
    }

    return <Component {...props} user={user} />
  }
}

const Profile = () => { // Profile component... }

// Use the withAuth HOC to inject user to Profile component.
const ProfileWithAuth = withAuth(Profile)

上面顯示的 withAuth HOC 使用依賴項注入為使用者提供 Profile 元件。這個範例的有趣之處在於,它不僅顯示了依賴注入的一種用法,而且實際上包含了兩個依賴注入。

將使用者註入到設定檔元件並不是此範例中的唯一注入。 withAuth 鉤子實際上也透過 useContext 鉤子透過依賴注入來獲取使用者。在程式碼中的某個地方,有人聲明了一個將使用者註入上下文的提供者。該用戶實例甚至可以在執行時透過更新上下文中的用戶來更改。

為什麼在 React 中使用 DIP?

儘管依賴注入不是與 React 相關的常見模式,但它實際上與 HOC 和上下文相關。鉤子從 HOC 和上下文中佔據了大量市場份額,也很好地證實了依賴倒置原則。

因此,DIP 已經內建到 React 庫本身中,當然應該使用。它既易於使用,又具有模組之間的鬆散耦合、鉤子和元件的可重複使用性和可測試性等優點。它也使得實現其他設計模式(例如單一職責原則)變得更加容易。

我不鼓勵的是,當確實有更簡單的解決方案可用時,請嘗試實施智慧解決方案並過度使用該模式。我在網路和書籍中看到了使用 React 上下文的建議,其唯一目的是實現依賴注入。像下面這樣的東西。

const User = () => { 
  const { role } = useContext(RoleContext)

  return <div>{`User has role ${role}`}</div>
}

const AdminUser = ({ children }) => {
  return (
    <RoleContext.Provider value={{ role: 'admin' }}>
      {children}
    </RoleContext.Provider>
  )
}

const NormalUser = ({ children }) => {
  return (
    <RoleContext.Provider value={{ role: 'normal' }}>
      {children}
    </RoleContext.Provider>
  )
}

儘管上面的範例確實將角色注入到 User 元件中,但為其使用上下文純粹是矯枉過正。當上下文本身有其用途時,應該在適當的時候使用 React 上下文。在這種情況下,一個簡單的道具可能是更好的解決方案。

const User = ({ role }) => { 
  return <div>{`User has role ${role}`}</div>
}

const AdminUser = () => <User role='admin' />

const NormalUser = () => <User role='normal' />

{% cta https://2e015922.sibforms.com/serve/MUIFAGF3ypa0p6D6nTWI0MHVOIAC7q4TIJd0yXAhiBC9CswkNPnOlQBzeqSbR2XFM95gUn2G1IxWwCpDpDjkjk aaG9tz9UYhn_O_dWg1PPGS8kRM5ROREaJsslnGD8WEHszzZr0geJ9-g7lGsbn_hTT-wZSKWa1C8ay4Ok85ozro %}訂閱我的文章{% endcta %}

{% 嵌入 https://dev.to/perssondennis %}


原文出處:https://dev.to/perssondennis/write-solid-react-hooks-436o


共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。

阿川私房教材:學程式,拿 offer!

63 個專案實戰,直接上手!
無需補習,按步驟打造你的面試作品。

立即解鎖你的轉職秘笈