[譯] 使用 TypeScript 開發 React Hooks

原文: www.toptal.com/react/react…

React hooks 在 2019 年二月被引入,以改善代碼可讀性。本文將探討如何將其和 TypeScript 協同使用。前端

在 hooks 以前,有兩種風格的 React 組件:react

  • 處理狀態的 類組件(Classes)
  • 徹底由其 props 定義的 函數式(Functional)組件

一種常見用法是,由前者構建複雜的容器(Container)組件,然後者負責簡單些的展現型(Presentational)組件。git

何爲 React Hooks ?

容器組件負責狀態(state)管理,以及在本文中被稱爲「反作用(side effects)」的遠端請求。狀態將經由 props 傳播到子組件。github

What Are React Hooks?

但隨着代碼的增加,函數式組件也大有取代類組件成爲容器的意思。typescript

將函數式組件升級爲狀態龐雜的容器卻是談不上痛苦,只是費時費力。此外,嚴格區分所謂容器和展現組件也不那麼被看重了。json

Hooks 能夠很好地兼顧, 能讓代碼既通用,又擁有幾乎全部的優勢。這裏有個例子,用來演示如何向一個處理報價簽署的組件中增添一個本地狀態:數組

// 在一個本地狀態中放置簽名,並在簽署狀態改變時切換籤名
function QuotationSignature({quotation}) {
   const [signed, setSigned] = useState(quotation.signed);
   useEffect(() => {
       fetchPost(`quotation/${quotation.number}/sign`)
   }, [signed]); // 簽署狀態改變時,反作用將被觸發

   return <>
       <input type="checkbox" checked={signed} 
            onChange={() => {setSigned(!signed)}}/>
       Signature
   </>
}
複製代碼

還有個利好不得不說 -- 雖然相比於 TypeScript 在 Angular 中的絲滑編碼,到了 React 中總被詬病臃腫難用;但 用 TypeScript 搭配 React hooks 卻變爲了一種愉悅的體驗。安全

舊 React 裏的 TypeScript

TypeScript 由微軟設計並沿着 Angular 的路徑一路進發,而彼時 React 開發出的 Flow 已然式微。在 React 類組件中編寫原生 TypeScript 着實痛苦,由於 React 開發者不得不一樣時對 propsstate 定義類型,即使兩者的許多屬性是相同的。服務器

按原來的方式來講,先得有一個 Quotation 類型,用來管理某些 CRUD 組件的 state 和 props。並在其相關的 state 中,建立一個 Quotation 類型的屬性,以及指示已簽署或未簽署的狀態。框架

interface QuotationLine {
  price: number
  quantity: number
}

interface Quotation{
   id: number
   title: string;
   lines: QuotationLine[]
   price: number
}

interface QuotationState{
   readonly quotation: Quotation;
   signed: boolean
}

interface QuotationProps{
   quotation: Quotation;
}

class QuotationPage extends Component<QuotationProps, QuotationState> {
	 // ...
}
複製代碼

可是設想一下,在新建某個報價時咱們面臨的狀況,也就是 QuotationPage 還沒有向服務器成功請求到一個 id 時:以前定義的 QuotationProps 將沒法獲知這個關鍵的數字值 -- 不完整的數據也沒法被 Quotation 類型 精確 匹配。咱們可能不得不在 QuotationProps 接口中聲明更多的代碼:

interface QuotationProps{
   // 除去 id 以外 Quotation 中的全部屬性:
   title: string;
   lines: QuotationLine[]
   price: number
}
複製代碼

咱們拷貝了除去 id 以外的全部屬性搞出一個新類型。這...讓我回憶起在 Java 中,被不得不編寫的一大堆 DTO (譯註:Data Transfer Object,數據傳輸對象 -- 一種不包含業務邏輯的簡單容器,其行爲限於內部一致性檢查和基本驗證等) 所支配的恐懼。

爲了克服這種痛苦,咱們得在 TypeScript 的知識上補補課了。

TypeScript 結合 hooks 的好處

經過使用 hooks,咱們就能夠摒棄以前的 QuotationState -- 能夠將其拆分爲不一樣的兩部分:

// ...

interface QuotationProps{
   quotation: Quotation;
}
function QuotationPage({quotation}:QuotationProps) {
   // 譯註:由兩個 useXXX 函數分攤了以前接口中的兩個屬性
   const [quotation, setQuotation] = useState(quotation);
   const [signed, setSigned] = useState(false);
   
   // ...
}
複製代碼

經過拆分狀態,就省去了明確建立新的接口。本地狀態類型每每能推導出默認的狀態值。

由於 hooks 組件就是函數,故能夠編寫返回 React.FC<Props> 類型(譯註:FC 即 function components)的相同組件函數。這樣的函數顯式聲明瞭其函數式組件的返回類型,並明確了 props 類型。

const QuotationPage: FC<QuotationProps> = ({quotation}) => {
   const [quotation, setQuotation] = useState(quotation);
   const [signed, setSigned] = useState(false);
   
   // ...
}
複製代碼

顯然,在 React hooks 中使用 TypeScript 比在類組件中容易。而且由於強類型對於代碼安全是個有力的保障,若是你的新項目中用了 hooks 就應該考慮採用 TypeScript 了。

適配 hooks 的 TypeScript 特性

在以前的 React hooks TypeScript 例子中,對於 QuotationProps 接口中的屬性如何使用、使用哪些,還是不甚了了、很有不便。

TypeScript 其實提供了很多「工具方法」,以便在 React 中描述接口時有效「降噪」。

  • Partial<T> : T 類型全部鍵的任意子集
  • Omit<T, 'x'> : 除 x 以外的 T 類型全部鍵
  • Pick<T, 'x', 'y', 'z'> : 從 T 類型中明確拾取 x, y, z

Specific Features of TypeScript Suitable for Hooks

在咱們的用例中,能夠用 Omit<Quotation, 'id'> 的形式來將 id 排除在 Quotation 類型以外。結合 type 關鍵字反手就能甩出一個新類型。

Partial<T>Omit<T> 並不存在於 Java 等大部分強類型語言中,但常在前端開發中以各類方式大展身手。它們簡化了類型定義的負擔。

type QuotationProps = Omit<Quotation, id>;
function QuotationPage({quotation}: QuotationProps){
   const [quotation, setQuotation] = useState(quotation);
   const [signed, setSigned] = useState(false);
   
   // ...
}
複製代碼

固然,或許也能夠用 extends 更清晰地區分出持久化類型等:

interface Quote{
   title: string;
   lines: QuotationLine[]
   price: number
}

interface PersistedQuote extends Quote{
  id: number;
}
複製代碼

這樣在處理相關屬性時,也簡化了常見的 ifundefined 問題。

慎用 Partial<T>,它基本不會帶來任何保障。

Pick<T, ‘x’|’y’> 是另外一種不用聲明新接口就能隨時定義新類型的方式。若是一個組件只須要簡單編輯報價標題的話:

type QuoteEditFormProps = Pick<Quotation, 'id'|'title'>
複製代碼

或直接在行內聲明:

function QuotationNameEditor({id, title}: Pick<Quotation, 'id'|'title'>){ ...}
複製代碼

別懷疑,我但是領域驅動設計(DDD - Domain Driven Design)的鐵桿擁躉。我並非懶得爲了聲明個新接口而懶得多寫兩行 -- 須要精確描述領域內命名時,我會使用接口;而出於保證本地代碼正確性、降噪的目的,我就使用這些 TS 工具語法。

React Hooks 的其餘益處

React 團隊始終將 React 視爲一個函數式框架。過去他們使用類組件以處理自身狀態,如今有了 hooks 這種容許一個函數跟蹤組件狀態的技術。

interface Place{
  city: string,
  country: string
}
const initialState: Place = {
  city: 'Rosebud',
  country: 'USA'
};
function reducer(state: Place, action): Partial<Place> {
  switch (action.type) {
    case 'city':
      return { city: action.payload };
    case 'country':
      return { country: action.payload };
  }
}
function PlaceForm() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <form>
      <input type="text" name="city" 
        onChange={(event) => {
          dispatch({ type: 'city', payload: event.target.value})
        }}
        value={state.city} />
      <input type="text" name="country"   
        onChange={(event) => {
          dispatch({type: 'country', payload: event.target.value })
        }}
        value={state.country} />
   </form>
  );
}
複製代碼

這就是一個安全使用 Partial 的良好用例。

儘管 reducer 函數會被屢次執行,但相關的 useReducer hook 將只被建立一次。

經過 天然而然地 將 reducer 函數定義在組件以外,代碼能夠被分割成多個獨立的函數,而不是都集中在一個類中並共同圍繞着其內部狀態。

這對可測試性大有裨益 -- 某些函數只處理 JSX,其餘一些只處理業務邏輯,等等。

你(幾乎)再也不須要高階組件(HOC - Higher Order Components)了。渲染屬性(render props)模式更易於編寫函數式組件。

這樣一來,閱讀代碼變得更容易了。代碼再也不是連綿混雜的 類/函數/模式,而僅僅是函數的集合。然而,由於這些函數並未附加到一個對象中,對它們命名可能有點難。

TypeScript 還是 JavaScript

JavaScript 的樂趣在於你能以任何方式擺弄你的代碼。加上 TypeScript 後,你仍能夠用 keyof 訪問對象的全部鍵,也能使用類型聯合建立出晦澀難搞的某些東西 -- 怕了怕了。

要確保你的 tsconfig.json 設置了 "strict":true 選項。在項目動工前就檢查它,不然你將不得不重構不少東西!

對於以何種程度類型化代碼是有爭議的。你能夠手動定義全部東西,也可讓編譯器推斷出類型。這取決於 linter 工具的配置和團隊約定。

同時,你仍會遇到運行時錯誤!TypeScript 比 Java 簡單,而且迴避了泛型的協變/逆變問題。

在下例中,有一個 Animal 列表,以及一個相同的 Cat 列表。

interface Animal {}

interface Cat extends Animal {
  meow: () => string;
}

const duck = { age: 7 };
const felix = {
  age: 12,
  meow: () => "Meow"
};

const listOfAnimals: Animal[] = [duck];
const listOfCats: Cat[] = [felix];

function MyApp() {

  const [cats , setCats] = useState<Cat[]>(listOfCats);
  // 問題1:再次被使用的 listOfCats 聲明爲了一個 Animal[]
  const [animals , setAnimals] = useState<Animal[]>(listOfCats)
  const [animal , setAnimal] = useState(duck)

  return <div onClick={()=>{
      animals.unshift(animal) // 問題2:指鴨爲貓
      setAnimals([...animals]) // 形成 dirty forceUpdate
    }}>
    The first cat says {cats[0].meow()} // 不言而喻
  </div>;
}
複製代碼

糟糕的是,因爲分別用 Cat[]Animal[] 兩種泛型聲明瞭 listOfCats,然後把 listOfAnimals 中的 duck 錯誤地壓入了第二次聲明爲 Animal[] 的 listOfCats 數組 -- 僅從 TS 靜態語法上看是一個 Animal 進入了一個 Animal[],但這就讓隨後對第一次聲明爲 Cat[] 的 listOfCats 元素調用發生了運行時錯誤。

TypeScript 只有一種泛型的簡單 雙變(bivariant) 實現,以供 JS 開發者採用。若是對變量命名得當,就能很大程度上避免指鴨爲貓。

同時,存在向 TS 中增長 inout 約束的提案( https://github.com/microsoft/TypeScript/issues/10717),以支持協變和逆變。



--End--

查看更多前端好文
請搜索 fewelife 關注公衆號

轉載請註明出處

相關文章
相關標籤/搜索