Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🔖TypeScript 备忘录:如何在 React 中完美运用? #68

Open
sl1673495 opened this issue Dec 17, 2020 · 1 comment
Open

🔖TypeScript 备忘录:如何在 React 中完美运用? #68

sl1673495 opened this issue Dec 17, 2020 · 1 comment

Comments

@sl1673495
Copy link
Owner

sl1673495 commented Dec 17, 2020

前言

一直以来,ssh 身边都有很多小伙伴对 TS 如何在 React 中运用有很多困惑,他们开始慢慢讨厌 TS,觉得各种莫名其妙的问题降低了开发的效率

其实如果运用熟练的话,TS 只是在第一次开发的时候稍微多花一些时间去编写类型,后续维护、重构的时候就会发挥它神奇的作用了,还是非常推荐长期维护的项目使用它的。

其实我一直知道英文版有个不错的备忘录,本来想直接推荐给小伙伴,奈何很多人对英文比较头痛,而它中文翻译的版本点进去竟然是这个景象

既然如此,就自己动手。结合英文原版里的一些示例进行一些扩展,总结成这篇备忘录。

前置基础

阅读本文的前提条件是:

  • 熟悉 React 的使用。
  • 熟悉 TypeScript 中的类型知识。
  • 本文会侧重使用 React Hook 作为示例,当然大部分类型知识都是通用的。

也就是说,这篇文章侧重点在于 「React 和 TypeScript 的结合」,而不是基础知识,基础知识阅读文档即可学习。

也推荐看我 初中级前端的高级进阶指南 这篇文章中的 React 和 TypeScript 章节,这里不多赘述。

工具

选择你觉得比较中意的调试工具即可。

组件 Props

先看几种定义 Props 经常用到的类型:

基础类型

type BasicProps = {
  message: string;
  count: number;
  disabled: boolean;
  /** 数组类型 */
  names: string[];
  /** 用「联合类型」限制为下面两种「字符串字面量」类型 */
  status: "waiting" | "success";
};

对象类型

type ObjectOrArrayProps = {
  /** 如果你不需要用到具体的属性 可以这样模糊规定是个对象 ❌ 不推荐 */
  obj: object;
  obj2: {}; // 同上
  /** 拥有具体属性的对象类型 ✅ 推荐 */
  obj3: {
    id: string;
    title: string;
  };
  /** 对象数组 😁 常用 */
  objArr: {
    id: string;
    title: string;
  }[];
  /** key 可以为任意 string,值限制为 MyTypeHere 类型 */
  dict1: {
    [key: string]: MyTypeHere;
  };
  dict2: Record<string, MyTypeHere>; // 基本上和 dict1 相同,用了 TS 内置的 Record 类型。
}

函数类型

type FunctionProps = {
  /** 任意的函数类型 ❌ 不推荐 不能规定参数以及返回值类型 */
  onSomething: Function;
  /** 没有参数的函数 不需要返回值 😁 常用 */
  onClick: () => void;
  /** 带函数的参数 😁 非常常用 */
  onChange: (id: number) => void;
  /** 另一种函数语法 参数是 React 的按钮事件 😁 非常常用 */
  onClick(event: React.MouseEvent<HTMLButtonElement>): void;
  /** 可选参数类型 😁 非常常用 */
  optional?: OptionalType;
}

React 相关类型

export declare interface AppProps {
  children1: JSX.Element; // ❌ 不推荐 没有考虑数组
  children2: JSX.Element | JSX.Element[]; // ❌ 不推荐 没有考虑字符串 children
  children4: React.ReactChild[]; // 稍微好点 但是没考虑 null
  children: React.ReactNode; // ✅ 包含所有 children 情况
  functionChildren: (name: string) => React.ReactNode; // ✅ 返回 React 节点的函数
  style?: React.CSSProperties; // ✅ 推荐 在内联 style 时使用
  // ✅ 推荐原生 button 标签自带的所有 props 类型
  // 也可以在泛型的位置传入组件 提取组件的 Props 类型
  props: React.ComponentProps<"button">;
  // ✅ 推荐 利用上一步的做法 再进一步的提取出原生的 onClick 函数类型 
  // 此时函数的第一个参数会自动推断为 React 的点击事件类型
  onClickButton:React.ComponentProps<"button">["onClick"]
}

函数式组件

最简单的:

interface AppProps = { message: string };

const App = ({ message }: AppProps) => <div>{message}</div>;

包含 children 的:

利用 React.FC 内置类型的话,不光会包含你定义的 AppProps 还会自动加上一个 children 类型,以及其他组件上会出现的类型:

// 等同于
AppProps & { 
  children: React.ReactNode 
  propTypes?: WeakValidationMap<P>;
  contextTypes?: ValidationMap<any>;
  defaultProps?: Partial<P>;
  displayName?: string;
}

// 使用
interface AppProps = { message: string };

const App: React.FC<AppProps> = ({ message, children }) => {
  return (
    <>
     {children}
     <div>{message}</div>
    </>
  )
};

Hooks

@types/react 包在 16.8 以上的版本开始对 Hooks 的支持。

useState

如果你的默认值已经可以说明类型,那么不用手动声明类型,交给 TS 自动推断即可:

// val: boolean
const [val, toggle] = React.useState(false);

toggle(false)
toggle(true)

如果初始值是 null 或 undefined,那就要通过泛型手动传入你期望的类型。

const [user, setUser] = React.useState<IUser | null>(null);

// later...
setUser(newUser);

这样也可以保证在你直接访问 user 上的属性时,提示你它有可能是 null。

通过 optional-chaining 语法(TS 3.7 以上支持),可以避免这个错误。

// ✅ ok
const name = user?.name

useReducer

需要用 Discriminated Unions 来标注 Action 的类型。

const initialState = { count: 0 };

type ACTIONTYPE =
  | { type: "increment"; payload: number }
  | { type: "decrement"; payload: string };

function reducer(state: typeof initialState, action: ACTIONTYPE) {
  switch (action.type) {
    case "increment":
      return { count: state.count + action.payload };
    case "decrement":
      return { count: state.count - Number(action.payload) };
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = React.useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "decrement", payload: "5" })}>
        -
      </button>
      <button onClick={() => dispatch({ type: "increment", payload: 5 })}>
        +
      </button>
    </>
  );
}

「Discriminated Unions」一般是一个联合类型,其中每一个类型都需要通过类似 type 这种特定的字段来区分,当你传入特定的 type 时,剩下的类型 payload 就会自动匹配推断。

这样:

  • 当你写入的 type 匹配到 decrement 的时候,TS 会自动推断出相应的 payload 应该是 string 类型。
  • 当你写入的 type 匹配到 increment 的时候,则 payload 应该是 number 类型。

这样在你 dispatch 的时候,输入对应的 type,就自动提示你剩余的参数类型啦。

useEffect

这里主要需要注意的是,useEffect 传入的函数,它的返回值要么是一个方法(清理函数),要么就是undefined,其他情况都会报错。

比较常见的一个情况是,我们的 useEffect 需要执行一个 async 函数,比如:

// ❌ 
// Type 'Promise<void>' provides no match 
// for the signature '(): void | undefined'
useEffect(async () => {
  const user = await getUser()
  setUser(user)
}, [])

虽然没有在 async 函数里显式的返回值,但是 async 函数默认会返回一个 Promise,这会导致 TS 的报错。

推荐这样改写:

useEffect(() => {
  const getUser = async () => {
    const user = await getUser()
    setUser(user)
  }
  getUser()
}, [])

或者用自执行函数?不推荐,可读性不好。

useEffect(() => {
  (async () => {
    const user = await getUser()
    setUser(user)
  })()
}, [])

useRef

这个 Hook 在很多时候是没有初始值的,这样可以声明返回对象中 current 属性的类型:

const ref2 = useRef<HTMLElement>(null);

以一个按钮场景为例:

function TextInputWithFocusButton() {
  const inputEl = React.useRef<HTMLInputElement>(null);
  const onButtonClick = () => {
    if (inputEl && inputEl.current) {
      inputEl.current.focus();
    }
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

onButtonClick 事件触发时,可以肯定 inputEl 也是有值的,因为组件是同级别渲染的,但是还是依然要做冗余的非空判断。

有一种办法可以绕过去。

const ref1 = useRef<HTMLElement>(null!);

null! 这种语法是非空断言,跟在一个值后面表示你断定它是有值的,所以在你使用 inputEl.current.focus() 的时候,TS 不会给出报错。

但是这种语法比较危险,需要尽量减少使用。

在绝大部分情况下,inputEl.current?.focus() 是个更安全的选择,除非这个值真的不可能为空。(比如在使用之前就赋值了)

useImperativeHandle

推荐使用一个自定义的 innerRef 来代替原生的 ref,否则要用到 forwardRef 会搞的类型很复杂。

type ListProps = {
  innerRef?: React.Ref<{ scrollToTop(): void }>
}

function List(props: ListProps) {
  useImperativeHandle(props.innerRef, () => ({
    scrollToTop() { }
  }))
  return null
}

结合刚刚 useRef 的知识,使用是这样的:

function Use() {
  const listRef = useRef<{ scrollToTop(): void }>(null!)

  useEffect(() => {
    listRef.current.scrollToTop()
  }, [])

  return (
    <List innerRef={listRef} />
  )
}

很完美,是不是?

可以在线调试 useImperativeHandle 的例子

也可以查看这个useImperativeHandle 讨论 Issue,里面有很多有意思的想法,也有使用 React.forwardRef 的复杂例子。

自定义 Hook

如果你想仿照 useState 的形式,返回一个数组给用户使用,一定要记得在适当的时候使用 as const,标记这个返回值是个常量,告诉 TS 数组里的值不会删除,改变顺序等等……

否则,你的每一项都会被推断成是「所有类型可能性的联合类型」,这会影响用户使用。

export function useLoading() {
  const [isLoading, setState] = React.useState(false);
  const load = (aPromise: Promise<any>) => {
    setState(true);
    return aPromise.finally(() => setState(false));
  };
  // ✅ 加了 as const 会推断出 [boolean, typeof load]
  // ❌ 否则会是 (boolean | typeof load)[]
  return [isLoading, load] as const;[]
}

对了,如果你在用 React Hook 写一个库,别忘了把类型也导出给用户使用。

React API

forwardRef

函数式组件默认不可以加 ref,它不像类组件那样有自己的实例。这个 API 一般是函数式组件用来接收父组件传来的 ref。

所以需要标注好实例类型,也就是父组件通过 ref 可以拿到什么样类型的值。

type Props = { };
export type Ref = HTMLButtonElement;
export const FancyButton = React.forwardRef<Ref, Props>((props, ref) => (
  <button ref={ref} className="MyClassName">
    {props.children}
  </button>
));

由于这个例子里直接把 ref 转发给 button 了,所以直接把类型标注为 HTMLButtonElement 即可。

父组件这样调用,就可以拿到正确类型:

export const App = () => {
  const ref = useRef<HTMLButtonElement>()
  return (
    <FancyButton ref={ref} />
  )
}

鸣谢

本文大量使用 react-typescript-cheatsheets 中的例子,加上自己的润色和例子补充,英文好的同学也可以读这个原文扩展学习。

欢迎关注「前端从进阶到入院」,如果这篇文章点赞的人数还不错的话,我会继续更新本系列。

@sl1673495 sl1673495 changed the title TypeScript 在 React 中的使用指南 🔖TypeScript 备忘录:如何在 React 中完美运用? Jan 3, 2021
@hemisu
Copy link

hemisu commented Jul 14, 2021

自定义 Hook 章节,return里多了一个 []

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants