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

前言

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

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

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

本文默認你對於TypeScript的基礎應用沒有問題,對於泛型的使用也大概理解,若是對於TS的基礎尚未熟悉的話,能夠看我在上面github倉庫的Readme的文末附上的幾篇推薦。node

實戰

建立應用

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

建立後的結構大概是這樣的:ios

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中開始編寫咱們的基礎代碼git

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類型的變量。github

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

Todos

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

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

模擬axios(簡單版)

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

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!! 這邊的第二個參數被去掉了
      axios("/api/add");
      refreshTodos();
      setName("");
    }
  };

複製代碼

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

const onToggleTodo = async (todo: Todo) => {
    // ERROR!! 這邊的第二個參數被去掉了
    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類型。

todos

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

函數重載

寫到這裏,類型基本上是比較嚴格了,可是還有一個問題,就是在調用呢axios(Urls.TOGGLE)這個接口的時候,咱們實際上是必定要傳遞第二個參數的,可是由於axios(Urls.TODOS)是不須要傳參的,因此咱們只能在axios的函數簽名把payload?設置爲可選,這就致使了一個問題,就是ts不能明確的知道哪些接口須要傳參,哪些接口不須要傳參。

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

toggle

要解決這個問題,須要用到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的狀況下才能夠不傳參數。

toggle嚴格

後記

到此咱們就實現了一個嚴格類型的React應用,寫這篇文章的目的不是讓你們都要在公司的項目裏去把類型推斷作到極致,畢竟一切的技術仍是爲業務服務的。

可是就算是寫寬鬆版本的TypeScript,帶來的收益也遠遠比裸寫JavaScript要高不少,尤爲是在別人須要複用你寫的工具函數或者組件時。

並且TypeScript也能夠在開發時就避免不少粗心致使的錯誤,詳見:
TypeScript 解決了什麼痛點? - justjavac的回答 - 知乎 www.zhihu.com/question/30…

本文涉及到的全部代碼都在
github.com/sl1673495/t… 中,有興趣的同窗能夠拉下來本身看看。

相關文章
相關標籤/搜索