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… 中,有兴趣的同窗能够拉下来本身看看。

相关文章
相关标签/搜索