天天看點

你要的react+ts最佳實踐指南

本文根據日常開發實踐,參考優秀文章、文檔,來說說

TypeScript

是如何較優雅的融入

React

項目的。

溫馨提示:日常開發中已全面擁抱函數式元件和

React Hooks

class

類元件的寫法這裡不提及。

前沿

  • 以前有 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

    就那麼具體的幾種:

    get

    post

    put

    等。

    大家都知道需要傳入一個

    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 類型定義時,而你隻想關注自己的上層定義。

    name

    age

    是你新增的屬性,

    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

    對于靜态屬性如

    displayName

    propTypes

    defaultProps

    提供了自動補充和類型檢查。
  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 來精确辨識、收窄确定的

type

payload

類型。

一般也需要定義

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

一般

useContext

useReducer

結合使用,來管理全局的資料流。

  • 例子
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;

           

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];