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

文章目录

    • 一、项目起航:项目初始化与配置
    • 二、React 与 Hook 应用:实现项目列表
    • 三、TS 应用:JS神助攻 - 强类型
    • 四、JWT、用户认证与异步请求
    • 五、CSS 其实很简单 - 用 CSS-in-JS 添加样式
    • 六、用户体验优化 - 加载中和错误状态处理
    • 七、Hook,路由,与 URL 状态管理
    • 八、用户选择器与项目编辑功能
    • 九、深入React 状态管理与Redux机制
      • 1.useCallback应用,优化异步请求
      • 2.状态提升,组合组件与控制反转


学习内容来源: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 应用:实现项目列表

  • 二、React 与 Hook 应用:实现项目列表

三、TS 应用:JS神助攻 - 强类型

  • 三、 TS 应用:JS神助攻 - 强类型

四、JWT、用户认证与异步请求

  • 四、 JWT、用户认证与异步请求(上)

  • 四、 JWT、用户认证与异步请求(下)

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

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

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

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

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

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

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

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

  • 七、Hook,路由,与 URL 状态管理(上)

  • 七、Hook,路由,与 URL 状态管理(中)

  • 七、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
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>
  );
};
  • 编辑按钮这里使用并不恰当,不过这不是最终解决方案,理解思路即可
  • 浅析控制反转 - 知乎

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

你可能感兴趣的:(react.js,前端,前端框架)