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… 中,有兴趣的同学可以拉下来自己看看。