跳到主要内容

6. React

6.1. 概述

目前,TypeScript 已经在 React 上得到了良好的支持。项目上目前采用 React 16 版本用做开发。此规范会定义一些我们日常使用 React API 所需注入的类型。

6.2. 组件类型

6.2.1. 函数组件

必须

函数组件采用 React.FC 类型进行类型定义。

interface Props {
message: string;
}

const App: React.FC<Props> = ({message, children}) => (
<div>{message}{children}</div>
);
信息

React.FC 不仅校验组件参数类型和返回的 ReactElement,他同时又校验了 displayNamepropTypesdefaultPropscontextTypes 属性类型。

6.2.2. 路由组件

必须

不同项目安装了不同的 react-router 版本,主要有 v4 和 v5 两大版本。针对不同版本推荐采用不同的方式进行路由参数的使用。

react-router v4

非顶层组件需要通过高阶组件的方式,使用 withRouter 包装组件来获取路由参数。 那么顶层组件或路由包装组件的类型定义就应当从 react-router 上的 RouteComponentProps 继承。

import {RouteComponentProps} from 'react-router';

interface Props extends RouteComponentProps {
message: string;
}

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

如果路由组件接收路由参数,可以通过 RouteComponentProps 提供的泛型参数注入路由参数类型。RouteComponentProps 允许接收3个泛型参数。

  • 第一个参数是路由 params 参数的类型。
  • 第二个参数是路由 statusCode 参数,表示当前路由状态编码是 404、500 还是其他。
  • 第三个参数是路由 state 参数的类型。
import {RouteComponentProps, StaticContext, withRouter} from 'react-router';

interface Props extends RouteComponentProps<{
id: string;
type: string;
},
StaticContext,
{
readonly: boolean;
}> {
message: string;
}

const App: React.FC<Props> = ({
history,
match: {params: {id, type}},
location: {state: routeState},
message,
children,
}) => (
<div>{message}{children}</div>
);

export default withRouter(App);

react-router v5(推荐)

任意组件可以采用 react-route 提供的 hooks 获取路由参数信息,主要有以下几个 hooks:

  • useHistory: 访问 history 对象,进行编程式导航(如 push 或 replace 路由)。
  • useLocation: 返回当前的 location 对象,表示应用的当前位置,通常用于获取当前 URL、路径名、状态等信息。
  • useParams: 获取当前路由的 URL 参数,常用于从 URL 中提取动态值。
  • useRouteMatch: 匹配当前 URL 与某个特定路径,类似于 v5 中的 match 对象,可以用于自定义路径匹配。
import {useHistory, useLocation, useParams, useRouteMatch} from 'react-router';

interface Props {
}

const App: React.FC<Props> = () => {
const history = useHistory();
const {id: scriptId, type: viewType} = useParams<{ id: string; type: string }>();
const {state: routeState = {readonly: false}} = useLocation<{ readonly: boolean }>();
const match = useRouteMatch<{ id: string; type: string }>();

function handleClick() {
history.push("/home");
}

return (
<button type="button" onClick={handleClick}>
Go home
</button>
);
};

因为 useLocation 没有暴露出 query 的参数类型,因此在 apaas 包中定义了一个 useSearchParams 的 hook,用于获取 query 参数。

import useSearchParams from 'hzero-front-apaas/lib/hooks/useSearchParams';

interface Props {
}

const App: React.FC<Props> = () => {
const {id} = useSearchParams<{id: string}>();

return id
};

提示

使用 react-route 提供的 hooks 时,对于需要泛型的 hook 尽量写明类型

6.3. Hooks 类型

6.3.1. useState

可选

useState 接收一个泛型指定其传入的数据类型,如果不传入则 TypeScript 根据初始值进行类型推断。

const [user, setUser] = useState<string>('张三');
必须

如果要给 useState 初始值设置一个空值,可以把空值添加到泛型中,或者在 useState 函数中直接设置初始值,禁止使用 as 覆盖初始值类型。

const [user, setUser] = useState<User | null>(null);

6.3.2. useEffect/useLayoutEffect

必须

useEffectuseLayoutEffect 都用于执行副作用,并返回一个可选的清理函数 ,这意味着如果它们不处理返回值,就不需要类型。当使用 useEffect 时,注意不要返回非 functionundefined 的内容。

// 不要这样做
const DelayedEffect: React.FC<{ timerMs: number }> = ({timerMs}) => {
const {timerMs} = props;

useEffect(
() =>
setTimeout(() => {
/* do stuff */
}, timerMs),
[timerMs]
);
return null;
}
// 应当这样做
const DelayedEffect: React.FC<{ timerMs: number }> = ({timerMs}) => {
const {timerMs} = props;

useEffect(() => {
setTimeout(() => {
/* do stuff */
}, timerMs);
}, [timerMs]);
return null;
}

6.3.3. useMemo/useCallback

可选

useMemouseCallback 都可选接收一个泛型,用于指定返回值类型,如果没有指定则通过类型推断。

const App: React.FC<{}> = () => {

const randomNum = useMemo<number>(() => {
return Math.random();
}, [])

return <div>{randomNum}</div>
};

6.3.4. useRef

应当

useRef 返回了一个引用,该引用类型可以是只读可修改,应在 useRef 显式指定泛型,尽量减少使用 any

const numberRef = useRef<number>(0);
提示

如果需要 useRef 的类型可修改,就需要在泛型参数中包含 | null

应当

如果 useRef 绑定的是 DOM 元素,那么就需要提供元素类型作为参数,并使用 null 作为初始值。

interface Props {
}

const App: React.FC<Props> = ({children}) => {
const divRef = useRef<HTMLDivElement>(null);

return <div ref={divRef}>{children}</div>
};
应当

对于已知元素的标签,必须使用对应标签的 HTMLElement 类型,例如 div 对应 HTMLDivElementinput 对应 HTMLInputElement 。不应当使用 HTMLElement

6.3.5. useImperativeHandle

应当

useImperativeHandle 应当搭配 forwardRef 进行使用。forwardRef 需通过泛型定义分别指定 ref 和 组件 props 类型。

interface RefProps {
getName: () => string;
}

interface Props {
message: string;
}

const App = forwardRef<RefProps, Props>(({message, children}, ref) => {

useImperativeHandle(ref, () => ({
getName() {
return 'hello';
},
}));

return <div>{message}{children}</div>
});

const Use: React.FC<{}> = () => {
const appRef = useRef<RefProps>(null)
return <App message="hello" ref={appRef} />
}

6.3.6. useContext

应当

useContext 需要搭配 createContext 进行使用。createContext 在创建的时候允许接收一个泛型用于指定返回的上下文类型。

import {createContext} from "react";

interface AppContextInterface {
name: string;
author: string;
url: string;
}

const AppCtx = createContext<AppContextInterface | null>(null);

// Provider in your app
const sampleAppContext: AppContextInterface = {
name: "Using React Context in a Typescript App",
author: "thehappybug",
url: "http://www.example.com",
};

export const App = () => (
<AppCtx.Provider value={sampleAppContext}>...</AppCtx.Provider>
);

// Consume in your app
import {useContext} from "react";

export const PostInfo: React.FC<{}> = () => {
const appContext = useContext(AppCtx);
return (
<div>
Name: {appContext.name}, Author: {appContext.author}, Url:{" "}
{appContext.url}
</div>
);
};

6.3.7. useSafeState(ahooks)

useSafeState 主要用于在组件卸载后异步回调内的 setState 不再执行,避免因组件卸载后更新状态而导致的内存泄漏。

应当

对于接口请求后需要异步更新状态的场景,推荐使用 useSafeState

import React, { useEffect } from 'react';
import { useSafeState } from 'ahooks';

const Child = () => {
const [value, setValue] = useSafeState<string>();

useEffect(() => {
setTimeout(() => {
setValue('data loaded from server');
}, 5000);
}, []);

const text = value || 'Loading...';

return <div>{text}</div>;
};

6.3.10. 自定义 hooks

如果在自定义 hooks 中返回一个数组,TypeScript 会推断出一个联合类型而不是元组类型,我们可以使用 as const ,把返回的数组指定成元组类型。

import {useState} from "react";

export function useLoading() {
const [isLoading, setState] = 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)[]
}

6.4. 表单事件

应当

针对表单类型,React 同样提供了丰富的类型。在使用 input 等这类表单元素时,需要指定例如 onChange 等事件类型( IDE 工具也会给出类型提示)。

interface Props {
}

const App: React.FC<Props> = ({children}) => {
const [text, setText] = useState<string>("");

const onChange = useCallback((e: React.FormEvent<HTMLInputElement>) => {
setText(e.currentTarget.value);
}, []);

return (
<div>
<input type="text" value={text} onChange={onChange} />
</div>
);
};

在 React 中,有一个很重要的概念就是:合成事件。他是基于 Virtual DOM 所实现的一套事件系统。我们在 React Element 中所定义的事件,会作为合成事件来处理,其对应的事件处理函数,会接收到一个 SyntheticEvent 的实例。

合成事件有什么优势?
  1. 抹平各浏览器之间的事件差异,不存在兼容性问题,对开发者极为友好。
  2. 合成事件利用冒泡机制,在顶层 document 完成事件注册和分发,避免直接操作 DOM 事件,减少内存开销,简化事件处理和回收机制。
  3. 内部使用事件池的概念,管理合成事件的创建,回收及其复用,提升性能。

因此我们也可以用合成事件( SyntheticEvent )作为通用的类型,去合并 form 的 onSubmit 事件类型。

<form
ref={formRef}
onSubmit={(e: React.SyntheticEvent) => {
e.preventDefault();
const target = e.target as typeof e.target & {
email: { value: string };
password: { value: string };
};
const email = target.email.value; // typechecks!
const password = target.password.value; // typechecks!
// etc...
}}
>
<div>
<label>
Email:
<input type="email" name="email" />
</label>
</div>
<div>
<label>
Password:
<input type="password" name="password" />
</label>
</div>
<div>
<input type="submit" value="Log in" />
</div>
</form>

6.5. cloneElement

应当

React.cloneElement 可以用来复制元素,并且可以添加新的 propsReact.cloneElement 允许传入一个泛型参数,用于指定 props 类型。

import {Button} from "choerodon-ui/pro";
// ...
const btn = <Button />;
React.cloneElement<typeof Button>(btn, {
color: 'primary',
})

6.6. 组件和 Hook 必须是幂等的

必须

组件必须始终根据其输入(props、state、和 context)返回相同的输出。这被称为“幂等性”。幂等性是函数式编程中经常使用的一个术语,它指的是只要你使用相同的输入运行代码,得到的结果总是一样的。

这意味着,为了遵循这一规则,所有在渲染期间执行的代码也必须是幂等的。例如,以下这行代码就不是幂等的(因此,包含这行代码的组件也不是幂等的):

function Clock() {
const time = new Date(); // 🔴 错误的:总是返回不同的结果!
return <span>{time.toLocaleString()}</span>
}

可以把副作用从组件中抽离出来,放到一个单独的自定义 hook 中。例:

import { useState, useEffect } from 'react';

function useTime() {
// 1. 跟踪当前日期的状态。`useState` 接受一个初始化函数作为其
// 初始状态。它只在调用 Hook 时运行一次,因此只有调用 Hook 时的
// 当前日期才被首先设置。
const [time, setTime] = useState(() => new Date());

useEffect(() => {
// 2. 使用 `setInterval` 每秒更新当前日期。
const id = setInterval(() => {
setTime(new Date()); // ✅ 正确的:非幂等代码不再在渲染中运行。
}, 1000);
// 3. 返回一个清理函数,这样我们就不会忘记清理 `setInterval` 定时器,导致内存泄漏。
return () => clearInterval(id);
}, []);

return time;
}

export default function Clock() {
const time = useTime();
return <span>{time.toLocaleString()}</span>;
}

6.7. 副作用必须在渲染之外执行

副作用不应该在渲染中执行,因为 React 可能会多次渲染组件以提供最佳的用户体验。

6.7.1. 局部 mutation

必须

禁止修改在组件外声明的变量。

const items = []; // 🔴 错误的:在组件外部创建
function FriendList({ friends }) {
for (let i = 0; i < friends.length; i++) {
const friend = friends[i];
items.push(
<Friend key={friend.id} friend={friend} />
); // 🔴 错误的:修改了一个在渲染之外创建的值。
}
return <section>{items}</section>;
}

可以在组件内部渲染前进行修改。

function FriendList({ friends }) {
const items = []; // ✅ 正确的:在局部创建
for (let i = 0; i < friends.length; i++) {
const friend = friends[i];
items.push(
<Friend key={friend.id} friend={friend} />
); // ✅ 正确的:局部修改是可以的。
}
return <section>{items}</section>;
}

6.7.2. 改变 DOM

必须

在 React 组件的渲染逻辑中不允许有直接对用户可见的副作用。换句话说,仅仅调用一个组件函数本身不应当在屏幕上产生变化。

function ProductDetailPage({ product }) {
document.window.title = product.title; // 🔴 错误的:改变 DOM
}

可以使用 useEffect/useLayoutEffect 处理渲染外副作用。

6.8. 参数不可变性

6.8.1. 不要修改 props

必须

props 是不可变的,因为如果你改变了它们,应用程序可能会产生不一致的结果,这会让调试变得困难,因为程序可能会在某些情况下工作,而在另一些情况下不工作。

function Post({ item }) {
item.url = new Url(item.url, base); // 🔴 错误的:永远不要直接修改 props
return <Link url={item.url}>{item.title}</Link>;
}
function Post({ item }) {
const url = new Url(item.url, base); // ✅ 正确的:创建一个新的副本替代
return <Link url={url}>{item.title}</Link>;
}

6.8.2. 不要修改 state

必须

我们不应该直接在 state 变量上进行更新,而应该使用 useState 返回的 setter 函数来进行更新。如果在 state 变量上直接修改值,并不会导致组件界面更新,这样用户界面就会显示过时的信息。
通过使用 setter 函数,我们告诉 React 状态已经发生了变化,需要进行重新渲染,以便更新用户界面。

function Counter() {
const [count, setCount] = useState(0);

function handleClick() {
count = count + 1; // 🔴 错误的:永远不要直接修改 state
}

return (
<button onClick={handleClick}>
You pressed me {count} times
</button>
);
}
function Counter() {
const [count, setCount] = useState(0);

function handleClick() {
setCount(count + 1); // ✅ 正确的:使用由 useState 返回的 setter 函数来修改 state。
}

return (
<button onClick={handleClick}>
You pressed me {count} times
</button>
);
}

6.8.3. 不要修改 Hook 的返回值和参数

必须

一旦值被传递给 Hook,就不应该再对它们进行修改。就像在 JSX 中的 props 一样,当值被传递给 Hook 时,它们就应该是不可变的了。

function useIconStyle(icon) {
const theme = useContext(ThemeContext);
if (icon.enabled) {
icon.className = computeStyle(icon, theme); // 🔴 错误的:永远不要直接修改 Hook 的参数。
}
return icon;
}
function useIconStyle(icon) {
const theme = useContext(ThemeContext);
const newIcon = { ...icon }; // ✅ 正确的:创建一个新的副本替代
if (icon.enabled) {
newIcon.className = computeStyle(icon, theme);
}
return newIcon;
}

6.8.4. 不要改变传递给 JSX 后的值

不要在 JSX 使用过值之后改变它们。应该在创建 JSX 之前完成值的更改。

当你在表达式中使用 JSX 时,React 可能会在组件完成渲染之前就急于计算 JSX。这意味着,如果在将值传递给 JSX 之后对它们进行更改,可能会导致 UI 过时,因为 React 不会知道需要更新组件的输出。

function Page({ colour }) {
const styles = { colour, size: "large" };
const header = <Header styles={styles} />;
styles.size = "small"; // 🔴 错误的:styles 已经在上面的 JSX 中使用了。
const footer = <Footer styles={styles} />;
return (
<>
{header}
<Content />
{footer}
</>
);
}
function Page({ colour }) {
const headerStyles = { colour, size: "large" };
const header = <Header styles={headerStyles} />;
const footerStyles = { colour, size: "small" }; // ✅ 正确的:我们创建了一个新的值。
const footer = <Footer styles={footerStyles} />;
return (
<>
{header}
<Content />
{footer}
</>
);
}

6.9. Hook 的规则

Hook 是使用 JavaScript 函数定义的,但它们代表了一种特殊的可重用的 UI 逻辑,并且对它们可以被调用的位置有限制。

6.9.1 只在顶层调用 Hook

必须

不要在循环、条件语句、嵌套函数或 try/catch/finally 代码块中调用 Hook。相反,你应该在 React 函数组件的顶层使用 Hook,且在任何提前返回之前。你只能在 React 渲染函数组件时调用 Hook:

  • ✅ 在 函数组件主体 的顶层调用它们。
  • ✅ 在 自定义 Hook 主体 的顶层调用它们。
function Counter() {
// ✅ 正确的:在函数组件顶层
const [count, setCount] = useState(0);
// ...
}

function useWindowWidth() {
// ✅ 正确的:在自定义 Hooks 顶层
const [width, setWidth] = useState(window.innerWidth);
// ...
}

不支持在其他任何情况下调用以 use 开头的 Hook,例如:

  • 🔴 不要在条件语句或循环中调用 Hook。
  • 🔴 不要在条件性的 return 语句之后调用 Hook。
  • 🔴 不要在事件处理函数中调用 Hook。
  • 🔴 不要在类组件中调用 Hook。
  • 🔴 不要在传递给 useMemouseReduceruseEffect 的函数内部调用 Hook。
  • 🔴 不要在 try/catch/finally 代码块中调用 Hook。
function Bad({ cond }) {
if (cond) {
// 🔴 错误的:在条件语句内部(要修复这个问题,将其移到外部!)
const theme = useContext(ThemeContext);
}
// ...
}

function Bad() {
for (let i = 0; i < 10; i++) {
// 🔴 错误的:在循环语句内部(要修复这个问题,将其移到外部!)
const theme = useContext(ThemeContext);
}
// ...
}

function Bad({ cond }) {
if (cond) {
return;
}
// 🔴 错误的:在条件性 return 语句之后(要修复这个问题,将其移到 return 之前!)
const theme = useContext(ThemeContext);
// ...
}

function Bad() {
function handleClick() {
// 🔴 错误的:在事件处理函数内部(要修复这个问题,将其移到 return 之前!)
const theme = useContext(ThemeContext);
}
// ...
}

function Bad() {
const style = useMemo(() => {
// 🔴 错误的:在 useMemo 内部调用(要修复这个问题,将其移到外部!)
const theme = useContext(ThemeContext);
return createStyle(theme);
});
// ...
}

class Bad extends React.Component {
render() {
// 🔴 错误的:在类组件内部调用(要修复这个问题,改写为函数组件!)
useEffect(() => {})
// ...
}
}

function Bad() {
try {
// 🔴 错误的:在 try、catch、finally 代码块内部调用(要修复这个问题,将其移到外部!)
const [x, setX] = useState(0);
} catch {
const [x, setX] = useState(1);
}
}

6.9.2 仅在 React 函数中调用 Hook

必须

不要在常规的 JavaScript 函数中调用 Hook。相反,你可以:

  • ✅ 在 React 函数组件中调用 Hook。
  • ✅ 在 自定义 Hook 中调用 Hook。

遵循这条规则,你可以确保组件中的所有状态逻辑在其源代码中清晰可见。

此篇维护者:黄振敏