写点什么

你要的 react+ts 最佳实践指南

作者:xiaofeng
  • 2023-02-27
    浙江
  • 本文字数:7685 字

    阅读完需:约 25 分钟

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


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

前沿

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


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 的集合作为返回对象的属性,且值类型为 Ttype 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.messagedata?.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 不匹配。


// ❌ baduseEffect(async () => {    const { data } = await ajax(params);    // todo}, [params]);
复制代码


  • 异步请求,处理方式:


// ✅ betteruseEffect(() => {    (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 };
// ✅ goodtype 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> );};
// 消费 contextfunction 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.tsexport enum StatusEnum {    Doing,   // 进行中    Success, // 成功    Fail,    // 失败}
//index.tsxif (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];
复制代码


用户头像

xiaofeng

关注

努力写代码中 2022-08-18 加入

努力写代码中

评论

发布
暂无评论
你要的react+ts最佳实践指南_前端_xiaofeng_InfoQ写作社区