学习内容来源:React + React Hook + TS 最佳实践-慕课网
相对原教程,我在学习开始时(2023.03)采用的是当前最新版本:
项 | 版本 |
---|---|
react & react-dom | ^18.2.0 |
react-router & react-router-dom | ^6.11.2 |
antd | ^4.24.8 |
@commitlint/cli & @commitlint/config-conventional | ^17.4.4 |
eslint-config-prettier | ^8.6.0 |
husky | ^8.0.3 |
lint-staged | ^13.1.2 |
prettier | 2.8.4 |
json-server | 0.17.2 |
craco-less | ^2.0.0 |
@craco/craco | ^7.1.0 |
qs | ^6.11.0 |
dayjs | ^1.11.7 |
react-helmet | ^6.1.0 |
@types/react-helmet | ^6.1.6 |
react-query | ^6.1.0 |
@welldone-software/why-did-you-render | ^7.0.1 |
@emotion/react & @emotion/styled | ^11.10.6 |
具体配置、操作和内容会有差异,“坑”也会有所不同。。。
- 一、项目起航:项目初始化与配置
- 二、React 与 Hook 应用:实现项目列表
- 三、 TS 应用:JS神助攻 - 强类型
- 四、 JWT、用户认证与异步请求(上)
- 四、 JWT、用户认证与异步请求(下)
- 五、CSS 其实很简单 - 用 CSS-in-JS 添加样式(上)
- 五、CSS 其实很简单 - 用 CSS-in-JS 添加样式(下)
- 六、用户体验优化 - 加载中和错误状态处理(上)
- 六、用户体验优化 - 加载中和错误状态处理(中)
- 六、用户体验优化 - 加载中和错误状态处理(下)
- 七、Hook,路由,与 URL 状态管理(上)
- 七、Hook,路由,与 URL 状态管理(中)
- 七、Hook,路由,与 URL 状态管理(下)
- 八、用户选择器与项目编辑功能(上)
- 八、用户选择器与项目编辑功能(下)
当前项目中使用 useAsync
进行异步请求,但是其中有一个隐藏 bug
,若是在页面中发起一个请求,这个请求需要较长时间3s(可以使用开发控制台设置请求最短时间来预设场景),在这个时间段内,退出登录,此时就会有报错:
Warning: Can’t perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
原因是虽然退出登录,组件销毁,但是异步函数还在执行,当它执行完进行下一步操作 setXXX
或是 更新组件都找不到对应已销毁的组件。
接下来解决一下这个问题。
编辑 src\utils\index.ts
:
...
/**
* 返回组件的挂载状态,如果还没有挂载或者已经卸载,返回 false; 反之,返回 true;
*/
export const useMountedRef = () => {
const mountedRef = useRef(false)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
return mountedRef
}
在 src\utils\use-async.ts
上应用:
...
import { useMountedRef } from "utils";
...
export const useAsync = <D>(...) => {
...
const mountedRef = useMountedRef()
...
const run = (...) => {
...
return promise
.then((data) => {
if(mountedRef.current)
setData(data);
return data;
})
.catch((error) => {...});
};
...
};
还有个遗留问题,在 useEffect
中使用的变量若是没有在依赖数组中添加就会报错,添加上又会造成死循环,因此之前用 eslint-disable-next-line
解决
// eslint-disable-next-line react-hooks/exhaustive-deps
现在换个方案,使用 useMemo
当然可以解决,这里推荐使用特殊版本的 useMemo
, useCallback
:
修改 src\utils\use-async.ts
import { useCallback, useState } from "react";
...
export const useAsync = <D>(...) => {
...
const setData = useCallback((data: D) =>
setState({
data,
stat: "success",
error: null,
}), [])
const setError = useCallback((error: Error) =>
setState({
error,
stat: "error",
data: null,
}), [])
// run 来触发异步请求
const run = useCallback((...) => {
...
}, [config.throwOnError, mountedRef, setData, state, setError],
)
...
};
可以按照提示配置依赖:
React Hook useCallback has missing dependencies: 'config.throwOnError', 'mountedRef', 'setData', and 'state'. Either include them or remove the dependency array. You can also do a functional update 'setState(s => ...)' if you only need 'state' in the 'setState' call.e
尽管如此,但还是难免会出现,在 useCallback
中改变 依赖值的行为,比如依赖值 XXX
对应的 setXXX
,这时需要用到 setXXX
的函数用法(这样也可以省去一个依赖):
继续修改 src\utils\use-async.ts
...
export const useAsync = <D>(...) => {
...
const run = useCallback((...) => {
...
setState(prevState => ({ ...prevState, stat: "loading" }));
...
}, [config.throwOnError, mountedRef, setData, setError],
)
...
};
修改 src\utils\project.ts
...
import { useCallback, useEffect } from "react";
...
export const useProjects = (...) => {
...
const fetchProject = useCallback(() =>
client("projects", { data: cleanObject(param || {})
}), [client, param])
useEffect(() => {
run(fetchProject(), { rerun: fetchProject });
}, [param, fetchProject, run]);
...
};
...
修改 src\utils\http.ts
...
import { useCallback } from "react";
...
export const useHttp = () => {
...
return useCallback((...[funcPath, customConfig]: Parameters<typeof http>) =>
http(funcPath, { ...customConfig, token: user?.token }), [user?.token]);
};
总结:非状态类型需要作为依赖 就要将其使用 useMemo
或者 useCallback
包裹(依赖细化 + 新旧关联),常见于 Custom Hook
中函数类型数据的返回
接下来定制化一个项目编辑模态框(编辑+新建项目),PageHeader
hover
后可以打开(新建),ProjectList
中可以打开模态框(新建),里面的 List
的每行也可以打开模态框(编辑)
在 src\components\lib.tsx
中新增 padding
为 0
的 Button
:
...
export const ButtonNoPadding = styled(Button)`
padding: 0;
`
新建 src\screens\ProjectList\components\ProjectModal.tsx
(模态框):
import { Button, Drawer } from "antd"
export const ProjectModal = ({isOpen, onClose}: { isOpen: boolean, onClose: () => void }) => {
return <Drawer onClose={onClose} open={isOpen} width="100%">
<h1>Project Modal</h1>
<Button onClick={onClose}>关闭</Button>
</Drawer>
}
新建 src\screens\ProjectList\components\ProjectPopover.tsx
:
import styled from "@emotion/styled"
import { Divider, List, Popover, Typography } from "antd"
import { ButtonNoPadding } from "components/lib"
import { useProjects } from "utils/project"
export const ProjectPopover = ({ setIsOpen }: { setIsOpen: (isOpen: boolean) => void }) => {
const { data: projects } = useProjects()
const starProjects = projects?.filter(i => i.star)
const content = <ContentContainer>
<Typography.Text type="secondary">收藏项目</Typography.Text>
<List>
{
starProjects?.map(project => <List.Item>
<List.Item.Meta title={project.name}/>
</List.Item>)
}
</List>
<Divider/>
<ButtonNoPadding type='link' onClick={() => setIsOpen(true)}>创建项目</ButtonNoPadding>
</ContentContainer>
return <Popover placement="bottom" content={content}>
项目
</Popover>
}
const ContentContainer = styled.div`
width: 30rem;
`
编辑 src\authenticated-app.tsx
(引入 ButtonNoPadding
、 ProjectPopover
、 ProjectModal
自定义组件,并将模态框的状态管理方法传到对应组件 PageHeader
和 ProjectList
,注意接收方要定义好类型):
...
import { ButtonNoPadding, Row } from "components/lib";
...
import { ProjectModal } from "screens/ProjectList/components/ProjectModal";
import { useState } from "react";
import { ProjectPopover } from "screens/ProjectList/components/ProjectPopover";
export const AuthenticatedApp = () => {
const [isOpen, setIsOpen] = useState(false)
...
return (
<Container>
<PageHeader setIsOpen={setIsOpen}/>
<Main>
<Router>
<Routes>
<Route path="/projects" element={<ProjectList setIsOpen={setIsOpen}/>} />
...
</Routes>
</Router>
</Main>
<ProjectModal isOpen={isOpen} onClose={() => setIsOpen(false)}/>
</Container>
);
};
const PageHeader = ({ setIsOpen }: { setIsOpen: (isOpen: boolean) => void }) => {
...
return (
<Header between={true}>
<HeaderLeft gap={true}>
<ButtonNoPadding type="link" onClick={resetRoute}>
<SoftwareLogo width="18rem" color="rgb(38,132,255)" />
</ButtonNoPadding>
<ProjectPopover setIsOpen={setIsOpen}/>
<span>用户</span>
</HeaderLeft>
<HeaderRight>
...
</HeaderRight>
</Header>
);
};
...
由于涉及登录后多个组件会发起调用,因此
ProjectModal
组件需要放在AuthenticatedApp
的Container
下
编辑 src\screens\ProjectList\index.tsx
(引入 模态框的状态管理方法):
...
import { Row, Typography } from "antd";
...
import { ButtonNoPadding } from "components/lib";
export const ProjectList = ({ setIsOpen }: { setIsOpen: (isOpen: boolean) => void }) => {
...
return (
<Container>
<Row justify='space-between'>
<h1>项目列表</h1>
<ButtonNoPadding type='link' onClick={() => setIsOpen(true)}>创建项目</ButtonNoPadding>
</Row>
...
<List
setIsOpen={setIsOpen}
{...}
/>
</Container>
);
};
...
编辑 src\screens\ProjectList\components\List.tsx
(引入 模态框的状态管理方法):
import { Dropdown, MenuProps, Table, TableProps } from "antd";
...
import { ButtonNoPadding } from "components/lib";
...
interface ListProps extends TableProps<Project> {
...
setIsOpen: (isOpen: boolean) => void;
}
export const List = ({ users, setIsOpen, ...props }: ListProps) => {
...
return (
<Table
pagination={false}
columns={[
...
{
render: (text, project) => {
const items: MenuProps["items"] = [
{
key: 'edit',
label: "编辑",
onClick: () => setIsOpen(true)
},
];
return <Dropdown menu={{ items }}>
<ButtonNoPadding type="link" onClick={(e) => e.preventDefault()}>...</ButtonNoPadding>
</Dropdown>
}
}
]}
{...props}
></Table>
);
};
可以明显看到,这种方式的状态提升(prop drilling
)若是间隔层数较多时(定义和使用相隔太远),不仅有“下钻”问题,而且耦合度太高
下面使用 组件组合(component composition)的方式解耦
组件组合(component composition) | Context – React
编辑 src\authenticated-app.tsx
(将 绑定了模态框 打开方法的 ButtonNoPadding
作为属性传给需要用到的组件):
...
export const AuthenticatedApp = () => {
...
return (
<Container>
<PageHeader projectButton={
<ButtonNoPadding type="link" onClick={() => setIsOpen(true)}>
创建项目
</ButtonNoPadding>
} />
<Main>
<Router>
<Routes>
<Route
path="/projects"
element={<ProjectList projectButton={
<ButtonNoPadding type="link" onClick={() => setIsOpen(true)}>
创建项目
</ButtonNoPadding>
} />}
/>
...
</Routes>
</Router>
</Main>
...
</Container>
);
};
const PageHeader = (props: { projectButton: JSX.Element }) => {
...
return (
<Header between={true}>
<HeaderLeft gap={true}>
...
<ProjectPopover { ...props } />
...
</HeaderLeft>
<HeaderRight>...</HeaderRight>
</Header>
);
};
...
编辑 src\screens\ProjectList\components\ProjectPopover.tsx
(使用传入的属性组件代替之前的 绑定了模态框 打开方法的 ButtonNoPadding
):
...
export const ProjectPopover = ({ projectButton }: { projectButton: JSX.Element }) => {
...
const content = (
<ContentContainer>
...
{ projectButton }
</ContentContainer>
);
...
};
...
编辑 src\screens\ProjectList\index.tsx
(使用传入的属性组件代替之前的 绑定了模态框 打开方法的 ButtonNoPadding
并继续“下钻”):
...
export const ProjectList = ({ projectButton }: { projectButton: JSX.Element }) => {
...
return (
<Container>
<Row justify="space-between">
...
{ projectButton }
</Row>
...
<List
projectButton={projectButton}
{...}
/>
</Container>
);
};
...
编辑 src\screens\ProjectList\components\List.tsx
(使用传入的属性组件代替之前的 绑定了模态框 打开方法的 ButtonNoPadding
):
...
interface ListProps extends TableProps<Project> {
...
projectButton: JSX.Element
}
// type PropsType = Omit
export const List = ({ users, ...props }: ListProps) => {
...
return (
<Table
pagination={false}
columns={[
...
{
render: (text, project) => {
return (
<Dropdown
dropdownRender={() => props.projectButton}>
<ButtonNoPadding
type="link"
onClick={(e) => e.preventDefault()}
>
...
</ButtonNoPadding>
</Dropdown>
);
},
},
]}
{...props}
></Table>
);
};
- 编辑按钮这里使用并不恰当,不过这不是最终解决方案,理解思路即可
- 浅析控制反转 - 知乎
部分引用笔记还在草稿阶段,敬请期待。。。