Skip to content

React Hooks + TypeScript 最佳实践 #28

@Godiswill

Description

@Godiswill

React Hooks + TypeScript 最佳实践

原文

本文根据日常开发实践,参考优秀文章、文档,来说说 TypeScript 是如何较优雅的融入 React 项目的。

温馨提示:日常开发中已全面拥抱函数式组件和 React Hooksclass 类组件的写法这里不提及。

前沿

  • 以前有 JSX 语法,必须引入 React。React 17.0+ 不需要强制声明 React 了。

具体参考:介绍全新的 JSX 转换

import React, { useState } from 'react';

// 以后将被替代成
import { useState } from 'react';
import * as React from 'react';

基础介绍

基本类型

  • 基础类型就没什么好说的了,以下都是比较常用的,一般比较好理解,也没什么问题。
type BasicTypes = {
    message: string;
    count: number;
    disabled: boolean;
    names: string[]; // or Array<string>
    id: string | number; // 联合类型
}

联合类型

一般的联合类型,没什么好说的,这里提一下非常有用,但新手经常遗忘的写法 —— 字符字面量联合。

  • 例如:自定义 ajax 时,一般 method 就那么具体的几种:getpostput 等。
    大家都知道需要传入一个 string 型,你可能会这么写:
type UnionsTypes = {
    method: string; // ❌ bad,可以传入任意字符串
};
  • 使用字符字面量联合类型,第一、可以智能提示你可传入的字符常量;第二、防止拼写错误。后面会有更多的例子。
type UnionsTypes = {
    method: 'get' | 'post'; // ✅ good 只允许 'get'、'post' 字面量
};

对象类型

  • 一般你知道确切的属性类型,这没什么好说的。
type ObjectTypes = {
    obj3: {
        id: string;
        title: string;
    };
    objArr: {
        id: string;
        title: string;
    }[]; // 对象数组,or Array<{ id: string, title: string }>
};
  • 但有时你只知道是个对象,而不确定具体有哪些属性时,你可能会这么用:
type ObjectTypes = {
    obj: object; // ❌ bad,不推荐
    obj2: {}; // ❌ bad 几乎类似 object
};
  • 一般编译器会提示你,不要这么使用,推荐使用 Record
type ObjectTypes = {
    objBetter: Record<string, unknown>; // ✅ better,代替 obj: object
    
    // 对于 obj2: {}; 有三种情况:
    obj2Better1: Record<string, unknown>; // ✅ better 同上
    obj2Better2: unknown; // ✅ any value
    obj2Better3: Record<string, never>; // ✅ 空对象
    
    /** Record 更多用法 */
    dict1: {
        [key: string]: MyTypeHere;
    };
    dict2: Record<string, MyTypeHere>; // 等价于 dict1
};
  • Record 有什么好处呢,先看看实现:
// 意思就是,泛型 K 的集合作为返回对象的属性,且值类型为 T
type Record<K extends keyof any, T> = {
    [P in K]: T;
};
  • 官方的一个例子
interface PageInfo {
    title: string;
}

type Page = 'home' | 'about' | 'contact';

const nav: Record<Page, PageInfo> = {
    about: { title: 'about' },
    contact: { title: 'contact' },
    // TS2322: Type '{ about: { title: string; }; contact: { title: string; }; hoem: { title: string; }; }' 
    // is not assignable to type 'Record<Page, PageInfo>'. ...
    hoem: { title: 'home' },
};

nav.about;

好处:

  1. 当你书写 home 值时,键入 h 常用的编辑器有智能补全提示;
  2. home 拼写错误成 hoem,会有错误提示,往往这类错误很隐蔽;
  3. 收窄接收的边界。

函数类型

  • 函数类型不建议直接给 Function 类型,有明确的参数类型、个数与返回值类型最佳。
type FunctionTypes = {
    onSomething: Function; // ❌ bad,不推荐。任何可调用的函数
    onClick: () => void; // ✅ better ,明确无参数无返回值的函数
    onChange: (id: number) => void; // ✅ better ,明确参数无返回值的函数
    onClick(event: React.MouseEvent<HTMLButtonElement>): void; // ✅ better
};

可选属性

  • React props 可选的情况下,比较常用。
type OptionalTypes = {
    optional?: OptionalType; // 可选属性
};
  • 例子:封装一个第三方组件,对方可能并没有暴露一个 props 类型定义时,而你只想关注自己的上层定义。
    nameage 是你新增的属性,age 可选,other 为第三方的属性集。
type AppProps = {
    name: string;
    age?: number;
    [propName: string]: any;
};
const YourComponent = ({ name, age, ...other }: AppProps) => (
    <div>
        {`Hello, my name is ${name}, ${age || 'unknown'}`}
        <Other {...other} />
    </div>
);

React Prop 类型

  • 如果你有配置 Eslint 等一些代码检查时,一般函数组件需要你定义返回的类型,或传入一些 React 相关的类型属性。
    这时了解一些 React 自定义暴露出的类型就很有必要了。例如常用的 React.ReactNode
export declare interface AppProps {
    children1: JSX.Element; // ❌ bad, 没有考虑数组类型
    children2: JSX.Element | JSX.Element[]; // ❌ 没考虑字符类型
    children3: React.ReactChildren; // ❌ 名字唬人,工具类型,慎用
    children4: React.ReactChild[]; // better, 但没考虑 null
    children: React.ReactNode; // ✅ best, 最佳接收所有 children 类型
    functionChildren: (name: string) => React.ReactNode; // ✅ 返回 React 节点
    
    style?: React.CSSProperties; // React style
    
    onChange?: React.FormEventHandler<HTMLInputElement>; // 表单事件! 泛型参数即 `event.target` 的类型
}

更多参考资料

函数式组件

熟悉了基础的 TypeScript 使用 与 React 内置的一些类型后,我们该开始着手编写组件了。

  • 声明纯函数的最佳实践
type AppProps = { message: string }; /* 也可用 interface */
const App = ({ message }: AppProps) => <div>{message}</div>; // 无大括号的箭头函数,利用 TS 推断。
  • 需要隐式 children?可以试试 React.FC
type AppProps = { title: string };
const App: React.FC<AppProps> = ({ children, title }) => <div title={title}>{children}</div>;
  • 争议
  1. React.FC(or FunctionComponent)是显式返回的类型,而"普通函数"版本则是隐式的(有时还需要额外的声明)。
  2. React.FC 对于静态属性如 displayNamepropTypesdefaultProps 提供了自动补充和类型检查。
  3. React.FC 提供了默认的 children 属性的大而全的定义声明,可能并不是你需要的确定的小范围类型。
  4. 2和3都会导致一些问题。有人不推荐使用。

目前 React.FC 在项目中使用较多。因为可以偷懒,还没碰到极端情况。

Hooks

项目基本上都是使用函数式组件和 React Hooks
接下来介绍常用的用 TS 编写 Hooks 的方法。

useState

  • 给定初始化值情况下可以直接使用
import { useState } from 'react';
// ...
const [val, toggle] = useState(false);
// val 被推断为 boolean 类型
// toggle 只能处理 boolean 类型
  • 没有初始值(undefined)或初始 null
type AppProps = { message: string };
const App = () => {
    const [data] = useState<AppProps | null>(null);
    // const [data] = useState<AppProps | undefined>();
    return <div>{data && data.message}</div>;
};
  • 更优雅,链式判断
// data && data.message
data?.message

useEffect

  • 使用 useEffect 时传入的函数简写要小心,它接收一个无返回值函数或一个清除函数。
function DelayedEffect(props: { timerMs: number }) {
    const { timerMs } = props;

    useEffect(
        () =>
            setTimeout(() => {
                /* do stuff */
            }, timerMs),
        [timerMs]
    );
    // ❌ bad example! setTimeout 会返回一个记录定时器的 number 类型
    // 因为简写,箭头函数的主体没有用大括号括起来。
    return null;
}
  • 看看 useEffect接收的第一个参数的类型定义。
// 1. 是一个函数
// 2. 无参数
// 3. 无返回值 或 返回一个清理函数,该函数类型无参数、无返回值 。
type EffectCallback = () => (void | (() => void | undefined));
  • 了解了定义后,只需注意加层大括号。
function DelayedEffect(props: { timerMs: number }) {
    const { timerMs } = props;

    useEffect(() => {
        const timer = setTimeout(() => {
            /* do stuff */
        }, timerMs);
        
        // 可选
        return () => clearTimeout(timer);
    }, [timerMs]);
    // ✅ 确保函数返回 void 或一个返回 void|undefined 的清理函数
    return null;
}
  • 同理,async 处理异步请求,类似传入一个 () => Promise<void>EffectCallback 不匹配。
// ❌ bad
useEffect(async () => {
    const { data } = await ajax(params);
    // todo
}, [params]);
  • 异步请求,处理方式:
// ✅ better
useEffect(() => {
    (async () => {
        const { data } = await ajax(params);
        // todo
    })();
}, [params]);

// 或者 then 也是可以的
useEffect(() => {
    ajax(params).then(({ data }) => {
        // todo
    });
}, [params]);

useRef

useRef 一般用于两种场景

  1. 引用 DOM 元素;
  2. 不想作为其他 hooks 的依赖项,因为 ref 的值引用是不会变的,变的只是 ref.current
  • 使用 useRef ,可能会有两种方式。
const ref1 = useRef<HTMLElement>(null!);
const ref2 = useRef<HTMLElement | null>(null);
  • 非 null 断言 null!。断言之后的表达式非 null、undefined
function MyComponent() {
    const ref1 = useRef<HTMLElement>(null!);
    useEffect(() => {
        doSomethingWith(ref1.current);
        // 跳过 TS null 检查。e.g. ref1 && ref1.current
    });
    return <div ref={ref1}> etc </div>;
}
  • 不建议使用 !,存在隐患,Eslint 默认禁掉。
function TextInputWithFocusButton() {
    // 初始化为 null, 但告知 TS 是希望 HTMLInputElement 类型
    // inputEl 只能用于 input elements
    const inputEl = React.useRef<HTMLInputElement>(null);
    const onButtonClick = () => {
        // TS 会检查 inputEl 类型,初始化 null 是没有 current 上是没有 focus 属性的
        // 你需要自定义判断! 
        if (inputEl && inputEl.current) {
            inputEl.current.focus();
        }
        // ✅ best
        inputEl.current?.focus();
    };
    return (
        <>
            <input ref={inputEl} type="text" />
            <button onClick={onButtonClick}>Focus the input</button>
        </>
    );
}

useReducer

使用 useReducer 时,多多利用 Discriminated Unions 来精确辨识、收窄确定的 typepayload 类型。
一般也需要定义 reducer 的返回类型,不然 TS 会自动推导。

  • 又是一个联合类型收窄和避免拼写错误的精妙例子。
const initialState = { count: 0 };

// ❌ bad,可能传入未定义的 type 类型,或码错单词,而且还需要针对不同的 type 来兼容 payload
// type ACTIONTYPE = { type: string; payload?: number | string };

// ✅ good
type ACTIONTYPE =
    | { type: 'increment'; payload: number }
    | { type: 'decrement'; payload: string }
    | { type: 'initial' };

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) };
        case 'initial':
            return { count: initialState.count };
        default:
            throw new Error();
    }
}

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

useContext

一般 useContextuseReducer 结合使用,来管理全局的数据流。

  • 例子
interface AppContextInterface {
    state: typeof initialState;
    dispatch: React.Dispatch<ACTIONTYPE>;
}

const AppCtx = React.createContext<AppContextInterface>({
    state: initialState,
    dispatch: (action) => action,
});
const App = (): React.ReactNode => {
    const [state, dispatch] = useReducer(reducer, initialState);

    return (
        <AppCtx.Provider value={{ state, dispatch }}>
            <Counter />
        </AppCtx.Provider>
    );
};

// 消费 context
function Counter() {
    const { state, dispatch } = React.useContext(AppCtx);
    return (
        <>
            Count: {state.count}
            <button onClick={() => dispatch({ type: 'decrement', payload: '5' })}>-</button>
            <button onClick={() => dispatch({ type: 'increment', payload: 5 })}>+</button>
        </>
    );
}

自定义 Hooks

Hooks 的美妙之处不只有减小代码行的功效,重点在于能够做到逻辑与 UI 分离。做纯粹的逻辑层复用。

  • 例子:当你自定义 Hooks 时,返回的数组中的元素是确定的类型,而不是联合类型。可以使用 const-assertions
export function useLoading() {
    const [isLoading, setState] = React.useState(false);
    const load = (aPromise: Promise<any>) => {
        setState(true);
        return aPromise.finally(() => setState(false));
    };
    return [isLoading, load] as const; // 推断出 [boolean, typeof load],而不是联合类型 (boolean | typeof load)[]
}
  • 也可以断言成 tuple type 元组类型。
export function useLoading() {
    const [isLoading, setState] = React.useState(false);
    const load = (aPromise: Promise<any>) => {
        setState(true);
        return aPromise.finally(() => setState(false));
    };
    return [isLoading, load] as [
        boolean, 
        (aPromise: Promise<any>) => Promise<any>
    ];
}
  • 如果对这种需求比较多,每个都写一遍比较麻烦,可以利用泛型定义一个辅助函数,且利用 TS 自动推断能力。
function tuplify<T extends any[]>(...elements: T) {
    return elements;
}

function useArray() {
    const numberValue = useRef(3).current;
    const functionValue = useRef(() => {}).current;
    return [numberValue, functionValue]; // type is (number | (() => void))[]
}

function useTuple() {
    const numberValue = useRef(3).current;
    const functionValue = useRef(() => {
    }).current;
    return tuplify(numberValue, functionValue); // type is [number, () => void]
}

扩展

工具类型

学习 TS 好的途径是查看优秀的文档和直接看 TS 或类库内置的类型。这里简单做些介绍。

  • 如果你想知道某个函数返回值的类型,你可以这么做
// foo 函数原作者并没有考虑会有人需要返回值类型的需求,利用了 TS 的隐式推断。
// 没有显式声明返回值类型,并 export,外部无法复用
function foo(bar: string) {
    return { baz: 1 };
}

// TS 提供了 ReturnType 工具类型,可以把推断的类型吐出
type FooReturn = ReturnType<typeof foo>; // { baz: number }
  • 类型可以索引返回子属性类型
function foo() {
    return {
        a: 1,
        b: 2,
        subInstArr: [
            {
                c: 3,
                d: 4,
            },
        ],
    };
}

type InstType = ReturnType<typeof foo>;
type SubInstArr = InstType['subInstArr'];
type SubIsntType = SubInstArr[0];

const baz: SubIsntType = {
    c: 5,
    d: 6, // type checks ok!
};

// 也可一步到位
type SubIsntType2 = ReturnType<typeof foo>['subInstArr'][0];
const baz2: SubIsntType2 = {
    c: 5,
    d: 6, // type checks ok!
};

同理工具类型 Parameters 也能推断出函数参数的类型。

  • 简单的看看实现:关键字 infer
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

T extends (...args: any) => infer R ? R : any; 的意思是 T 能够赋值给 (...args: any) => any 的话,就返回该函数推断出的返回值类型 R

defaultProps

默认值问题。

type GreetProps = { age: number } & typeof defaultProps;
const defaultProps = {
    age: 21,
};

const Greet = (props: GreetProps) => {
    // etc
};
Greet.defaultProps = defaultProps;
  • 你可能不需要 defaultProps
type GreetProps = { age?: number };

const Greet = ({ age = 21 }: GreetProps) => { 
    // etc 
};

消除魔术数字/字符

本人比较痛恨的一些代码点。

  • 糟糕的例子,看到下面这段代码不知道你的内心,有没有羊驼奔腾。
if (status === 0) {
    // ...
} else {
    // ...
}

// ...

if (status === 1) {
    // ...
}
  • 利用枚举,统一注释且语义化
// enum.ts
export enum StatusEnum {
    Doing,   // 进行中
    Success, // 成功
    Fail,    // 失败
}

//index.tsx
if (status === StatusEnum.Doing) {
    // ...
} else {
    // ...
}

// ...

if (status === StatusEnum.Success) {
    // ...
}
  • ts enum 略有争议,有的人推崇去掉 ts 代码依旧能正常运行,显然 enum 不行。
// 对象常量
export const StatusEnum = {
    Doing: 0,   // 进行中
    Success: 1, // 成功
    Fail: 2,    // 失败
};
  • 如果字符单词本身就具有语义,你也可以用字符字面量联合类型来避免拼写错误
export declare type Position = 'left' | 'right' | 'top' | 'bottom';
let position: Position;

// ...

// TS2367: This condition will always return 'false' since the types 'Position' and '"lfet"' have no overlap.
if (position === 'lfet') { // 单词拼写错误,往往这类错误比较难发现
    // ...
}

延伸:策略模式消除 if、else

if (status === StatusEnum.Doing) {
    return '进行中';
} else if (status === StatusEnum.Success) {
    return '成功';
} else {
    return '失败';
}
  • 策略模式
// 对象常量
export const StatusEnumText = {
    [StatusEnum.Doing]: '进行中',
    [StatusEnum.Success]: '成功',
    [StatusEnum.Fail]: '失败',
};

// ...
return StatusEnumText[status];

参考资料

  1. React+TypeScript Cheatsheets
  2. The TypeScript Handbook
  3. Typescript 中文文档

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions