【实战】 九、深入React 状态管理与Redux机制(一) —— React17+React Hook+TS4 最佳实践,仿 Jira 企业级项目(十六)


学习内容来源: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、用户认证与异步请求


五、CSS 其实很简单 - 用 CSS-in-JS 添加样式


六、用户体验优化 - 加载中和错误状态处理



七、Hook,路由,与 URL 状态管理



八、用户选择器与项目编辑功能


九、深入React 状态管理与Redux机制

1.useCallback应用,优化异步请求

当前项目中使用 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 中函数类型数据的返回

2.状态提升,组合组件与控制反转

接下来定制化一个项目编辑模态框(编辑+新建项目),PageHeader hover 后可以打开(新建),ProjectList 中可以打开模态框(新建),里面的 List 的每行也可以打开模态框(编辑)

src\components\lib.tsx 中新增 padding0Button

...
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(引入 ButtonNoPaddingProjectPopoverProjectModal 自定义组件,并将模态框的状态管理方法传到对应组件 PageHeaderProjectList,注意接收方要定义好类型):

...
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 组件需要放在 AuthenticatedAppContainer

编辑 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<ListProps, 'users'>
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>
  );
};

部分引用笔记还在草稿阶段,敬请期待。。。

猜你喜欢

转载自blog.csdn.net/qq_32682301/article/details/131994515