vlambda博客
学习文章列表

React Hook + TypeScript 手把手打造类型安全的应用。

前言

TypeScript 可以说是今年的一大流行点,虽然 Angular 早就开始把 TypeScript 作为内置支持了,但是真正在中文社区火起来据我观察也就是没多久的事情,尤其是在 Vue3 官方宣布采用 TypeScript 开发以后达到了一个顶点。

社区里有很多 TypeScript 比较基础的分享,但是关于 React 实战的还是相对少一些,这篇文章就带大家用 React 从头开始搭建一个 TypeScript 的 todolist,我们的目标是实现类型安全,杜绝开发时可能出现的任何错误!

本文所使用的所有代码全部整理在了 ts-react-todo[1] 这个仓库里。

分别实现了宽松版和严格版的 axios 和 todolist,其中严格版本的实现会在文件夹加上.strict 的后缀,请注意区分。

本文默认你对于 TypeScript 的基础应用没有问题,对于泛型的使用也大概理解,如果对于 TS 的基础还没有熟悉的话,可以看我在上面 github 仓库的 Readme 的文末附上的几篇推荐。

实战

创建应用

首先使用的脚手架是 create-react-app,根据
https://www.html.cn/create-react-app/docs/adding-typescript/
的流程可以很轻松的创建一个开箱即用的 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 > [];
Todos

当然,useState 也是具有泛型推导的能力的,但是这要求你传入的初始值已经是你想要的类型了,而不是空数组。

const [todos, setTodos] = useState({
  id1,
  name"ssh",
  donefalse
});
React Hook + TypeScript 手把手打造类型安全的应用。

模拟 axios(简单版)

有了基本的骨架以后,就要想办法去拿到数据了,这里我选择自己模拟编写一个 axios 去返回想要的数据。

const refreshTodos = () => {
  // 这边必须手动声明axios的返回类型。
  axios < Todos > "/api/todos".then(setTodos);
};

useEffect(() => {
  refreshTodos();
}, []);

注意这里的 axios 也要在使用时手动传入泛型,因为我们现在还不能根据"/api/todos"这个字符串来推导出返回值的类型,接下来看一下 axios 的实现。

let todos = [
  {
    id1,
    name'待办1',
    donefalse
  },
  {
    id2,
    name'待办2',
    donefalse
  },
  {
    id3,
    name'待办3',
    donefalse
  }
]

// 使用联合类型来约束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 = {
      idMath.random(),
      name,
      donefalse
    };

    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 = {
    idMath.random(),
    name,
    donefalse
  };

  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 类型。

React Hook + TypeScript 手把手打造类型安全的应用。
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 的回答 - 知乎 https://www.zhihu.com/question/308844713/answer/574423626

本文涉及到的所有代码都在
https://github.com/sl1673495/ts-react-todo 中,有兴趣的同学可以拉下来自己看看。

参考资料

[1]

ts-react-todo: https://github.com/sl1673495/ts-react-todo