React + TypeScript + Hook 帶你手把手打造類型安全的應用。

  • 2020 年 4 月 11 日
  • 筆記

前言

TypeScript可以說是今年的一大流行點,雖然Angular早就開始把TypeScript作為內置支持了,但是真正在中文社區火起來據我觀察也就是沒多久的事情,尤其是在Vue3官方宣布採用TypeScript開發以後達到了一個頂點。

社區里有很多TypeScript比較基礎的分享,但是關於React實戰的還是相對少一些,這篇文章就帶大家用React從頭開始搭建一個TypeScript的todolist,我們的目標是實現類型安全,杜絕開發時可能出現的任何錯誤!

本文所使用的所有代碼全部整理在了 ts-react-todo 這個倉庫里。

分別實現了寬鬆版和嚴格版的axios和todolist,其中嚴格版本的實現會在文件夾加上.strict的後綴,請注意區分。

本文默認你對於TypeScript的基礎應用沒有問題,對於泛型的使用也大概理解,如果對於TS的基礎還沒有熟悉的話,可以看我在上面github倉庫的Readme的文末附上的幾篇推薦。

實戰

創建應用

首先使用的腳手架是create-react-app,根據 www.html.cn/create-reac… 的流程可以很輕鬆的創建一個開箱即用的typescript-react-app。

創建後的結構大概是這樣的:

my-app/    README.md    node_modules/    package.json    public/      index.html      favicon.ico    src/      App.css      App.ts      App.test.ts      index.css      index.ts      logo.svg  複製代碼

在src/App.ts中開始編寫我們的基礎代碼

import React, { useState, useEffect } from "react";  import classNames from "classnames";  import TodoForm from "./TodoForm";  import axios from "../api/axios";  import "../styles/App.css";    type Todo = {    id: number;    // 名字    name: string;    // 是否完成    done: boolean;  };    type Todos = Todo[];    const App: React.FC = () => {    const [todos, setTodos] = useState<Todos>([]);      return (      <div className="App">        <header className="App-header">          <ul>            <TodoForm />            {todos.map((todo, index) => {              return (                <li                  onClick={() => onToggleTodo(todo)}                  key={index}                  className={classNames({                    done: todo.done,                  })}                >                  {todo.name}                </li>              );            })}          </ul>        </header>      </div>    );  };    export default App;  複製代碼

useState

代碼很簡單,利用type關鍵字來定義Todo這個類型,然後順便生成Todos這個類型,用來給React的useState作為泛型約束使用,這樣在上下文中,todos這個變量就會被約束為Todos這個類型,setTodos也只能去傳入Todos類型的變量。

  const [todos, setTodos] = useState<Todos>([]);  複製代碼

當然,useState也是具有泛型推導的能力的,但是這要求你傳入的初始值已經是你想要的類型了,而不是空數組。

const [todos, setTodos] = useState({      id: 1,      name: 'ssh',      done: false    });  複製代碼

模擬axios(簡單版)

有了基本的骨架以後,就要想辦法去拿到數據了,這裡我選擇自己模擬編寫一個axios去返回想要的數據。

  const refreshTodos = () => {      // 這邊必須手動聲明axios的返回類型。      axios<Todos>("/api/todos").then(setTodos);    };      useEffect(() => {      refreshTodos();    }, []);  複製代碼

注意這裡的axios也要在使用時手動傳入泛型,因為我們現在還不能根據"/api/todos"這個字符串來推導出返回值的類型,接下來看一下axios的實現。

let todos = [    {      id: 1,      name: '待辦1',      done: false    },    {      id: 2,      name: '待辦2',      done: false    },    {      id: 3,      name: '待辦3',      done: false    }  ]    // 使用聯合類型來約束url  type Url = '/api/todos' | '/api/toggle' | '/api/add'    const axios = <T>(url: Url, payload?: any): Promise<T> | never => {    let data    switch (url) {      case '/api/todos': {        data = todos.slice()        break      }    }   default: {      throw new Error('Unknown api')   }      return Promise.resolve(data as any)  }    export default axios  複製代碼

重點看一下axios的類型描述

const axios = <T>(url: Url, payload?: any): Promise<T> | never  複製代碼

泛型T被原封不動的交給了返回值的Promise, 所以外部axios調用時傳入的Todos泛型就推斷出返回值是了Promise,Ts就可以推斷出這個promise去resolve的值的類型是Todos。

在函數的實現中我們把data給resolve出去。

接下來回到src/App.ts 繼續補充點擊todo,更改完成狀態時候的事件,

const App: React.FC = () => {    const [todos, setTodos] = useState<Todos>([]);    const refreshTodos = () => {      // FIXME 這邊必須手動聲明axios的返回類型。      axios<Todos>("/api/todos").then(setTodos);    };      useEffect(() => {      refreshTodos();    }, []);      const onToggleTodo = async (todo: Todo) => {      await axios("/api/toggle", todo.id);      refreshTodos();    };      return (      <div className="App">        <header className="App-header">          <ul>            <TodoForm refreshTodos={refreshTodos} />            {todos.map((todo, index) => {              return (                <li                  onClick={() => onToggleTodo(todo)}                  key={index}                  className={classNames({                    done: todo.done,                  })}                >                  {todo.name}                </li>              );            })}          </ul>        </header>      </div>    );  };  複製代碼

再來看一下src/TodoForm組件的實現:

import React from "react";  import axios from "../api/axios";    interface Props {    refreshTodos: () => void;  }    const TodoForm: React.FC<Props> = ({ refreshTodos }) => {    const [name, setName] = React.useState("");      const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {      setName(e.target.value);    };      const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {      e.preventDefault();        const newTodo = {        id: Math.random(),        name,        done: false,      };        if (name.trim()) {        // FIXME 這邊第二個參數沒有做類型約束        axios("/api/add", newTodo);        refreshTodos();        setName("");      }    };      return (      <form className="todo-form" onSubmit={onSubmit}>        <input          className="todo-input"          value={name}          onChange={onChange}          placeholder="請輸入待辦事項"        />        <button type="submit">新增</button>      </form>    );  };    export default TodoForm;  複製代碼

在axios里加入/api/toggle和/api/add的處理:

  switch (url) {      case '/api/todos': {        data = todos.slice()        break      }      case '/api/toggle': {        const todo = todos.find(({ id }) => id === payload)        if (todo) {          todo.done = !todo.done        }        break      }      case '/api/add': {        todos.push(payload)        break      }      default: {        throw new Error('Unknown api')      }    }  複製代碼

其實寫到這裡,一個簡單的todolist已經實現了,功能是完全可用的,但是你說它類型安全嗎,其實一點也不安全。

再回頭看一下axios的類型簽名:

const axios = <T>(url: Url, payload?: any): Promise<T> | never  複製代碼

payload這個參數被加上了?可選符,這是因為有的接口需要傳參而有的接口不需要,這就會帶來一些問題。

這裡編寫axios只約束了傳入的url的限制,但是並沒有約束入參的類型,返回值的類型,其實基本也就是anyscript了,舉例來說,在src/TodoForm里的提交事件中,我們在FIXME的下面一行稍微改動,把axios的第二個參數去掉,如果以現實情況來說的話,一個add接口不傳值,基本上報錯沒跑了,而且這個錯誤只有運行時才能發現。

  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {      e.preventDefault();        const newTodo = {        id: Math.random(),        name,        done: false,      };        if (name.trim()) {        // ERROR!! 這邊的第二個參數被去掉了,但是TS不會提示。        axios("/api/add");        refreshTodos();        setName("");      }    };    複製代碼

在src/App.ts的onToggleTodo事件里也有着同樣的問題

 const onToggleTodo = async (todo: Todo) => {      // ERROR!! 這邊的第二個參數被去掉了,但是TS不會提示。      await axios("/api/toggle");      refreshTodos();    };  複製代碼

另外在獲取數據時候axios,必須要手動用泛型來定義好返回類型,這個也很冗餘。

axios<Todos>("/api/todos").then(setTodos);  複製代碼

接下來我們用一個嚴格類型版本的axios函數來解決這個問題。

模擬axios(嚴格版)

// axios.strict.ts  let todos = [    {      id: 1,      name: '待辦1',      done: false    },    {      id: 2,      name: '待辦2',      done: false    },    {      id: 3,      name: '待辦3',      done: false    }  ]      export enum Urls {    TODOS = '/api/todos',    TOGGLE = '/api/toggle',    ADD = '/api/add',  }    type Todo = typeof todos[0]  type Todos = typeof todos    複製代碼

首先我們用enum枚舉定義好我們所有的接口url,方便後續復用, 然後我們用ts的typeof操作符從todos數據倒推出類型。

接下來用泛型條件類型來定義一個工具類型,根據泛型傳入的值來返回一個自定義的key

type Key<U> =    U extends Urls.TOGGLE ? 'toggle':    U extends Urls.ADD ? 'add':    U extends Urls.TODOS ? 'todos':    'other'  複製代碼

這個Key的作用就是,假設我們傳入

type K = Key<Urls.TODOS>  複製代碼

會返回todos這個字符串類型,它有什麼用呢,接着看就知道了。

現在需要把axios的函數類型聲明的更加嚴格,我們需要把入參payload的類型和返回值的類型都通過傳入的url推斷出來,這裡要利用泛型推導:

function axios <U extends Urls>(url: U, payload?: Payload<U>): Promise<Result<U>> | never  複製代碼

不要被這長串嚇到,先一步步來分解它,

  1. <U extends Urls>首先泛型U用extends關鍵字做了類型約束,它必須是Urls枚舉中的一個,
  2. (url: U, payload?: Payload<U>)參數中,url參數和泛型U建立了關聯,這樣我們在調用axios函數時,就會動態的根據傳入的url來確定上下文中U的類型,接下來用Payload<U>把U傳入Payload工具類型中。
  3. 最後返回值用Promise<Result<U>>,還是一樣的原理,把U交給Result工具類型進行推導。

接下來重要的就是看Payload和Result的實現了。

type Payload<U> = {    toggle: number    add: Todo,    todos: any,    other: any  }[Key<U>]    複製代碼

剛剛定義的Key<U>工具類型就派上用場了,假設我們調用axios(Urls.TOGGLE),那麼U被推斷Urls.TOGGLE,傳給Payload的就是Payload<Urls.TOGGLE>,那麼Key<U>返回的結果就是Key<Urls.TOGGLE>,即為toggle

那麼此時推斷的結果是

Payload<Urls.TOGGLE> = {    toggle: number    add: Todo,    todos: any,    other: any  }['toggle']  複製代碼

此時todos命中的就是前面定義的類型集合中第一個toggle: number, 所以此時Payload<Urls.TOGGLE>就這樣被推斷成了number 類型。

Result也是類似的實現:

type Result<U> = {    toggle: boolean    add: boolean,    todos: Todos    other: any  }[Key<U>]  複製代碼

這時候再回頭來看函數類型

function axios <U extends Urls>(url: U, payload?: Payload<U>): Promise<Result<U>> | never  複製代碼

是不是就清楚很多了,傳入不同的參數會推斷出不同的payload入參,以及返回值類型。

此時在來到app.ts里,看新版refreshTodos函數

  const refreshTodos = () => {      axios(Urls.TODOS).then((todos) => {        setTodos(todos)      })    }  複製代碼

axios後面的泛型約束被去掉了,then裏面的todos依然被成功的推斷為Todos類型。

這時候就完美了嗎?並沒有,還有最後一點優化。

函數重載

寫到這裡,類型基本上是比較嚴格了,但是還有一個問題,就是在調用呢axios(Urls.TOGGLE)這個接口的時候,我們其實是一定要傳遞第二個參數的,但是因為axios(Urls.TODOS)是不需要傳參的,所以我們只能在axios的函數簽名把payload?設置為可選,這就導致了一個問題,就是ts不能明確的知道哪些接口需要傳參,哪些接口不需要傳參。

注意下圖中的payload是帶?的。

要解決這個問題,需要用到ts中的函數重載。

首先把需要傳參的接口和不需要傳參的接口列出來。

type UrlNoPayload =  Urls.TODOS  type UrlWithPayload = Exclude<Urls, UrlNoPayload>  複製代碼

這裡用到了TypeScript的內置類型Exclude,用來在傳入的類型中排除某些類型,這裡我們就有了兩份類型,需要傳參的Url集合無需傳參的Url集合

接着開始寫重載

function axios <U extends UrlNoPayload>(url: U): Promise<Result<U>>  function axios <U extends UrlWithPayload>(url: U, payload: Payload<U>): Promise<Result<U>> | never  function axios <U extends Urls>(url: U, payload?: Payload<U>): Promise<Result<U>> | never {    // 具體實現  }  複製代碼

根據extends約束到的不同類型,來重寫函數的入參形式,最後用一個最全的函數簽名(一定是要能兼容之前所有的函數簽名的,所以最後一個簽名的payload需要寫成可選)來進行函數的實現。

此時如果再空參數調用toggle,就會直接報錯,因為只有在請求todos的情況下才可以不傳參數。

後記

到此我們就實現了一個嚴格類型的React應用,寫這篇文章的目的不是讓大家都要在公司的項目里去把類型推斷做到極致,畢竟一切的技術還是為業務服務的。

但是就算是寫寬鬆版本的TypeScript,帶來的收益也遠遠比裸寫JavaScript要高很多,尤其是在別人需要復用你寫的工具函數或者組件時。

而且TypeScript也可以在開發時就避免很多粗心導致的錯誤,詳見: TypeScript 解決了什麼痛點? – justjavac的回答 – 知乎 www.zhihu.com/question/30…

本文涉及到的所有代碼都在 github.com/sl1673495/t… 中,有興趣的同學可以拉下來自己看看。