【实战】 七、Hook,路由,与 URL 状态管理(中) —— 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 状态管理

1+2.

3.添加项目列表和项目详情路由

Home v6.11.2 | React Router

安装 react-router react-router-dom

# 可能会需要 --force
npm i react-router react-router-dom history

目前最新版

  • “react-router”: “^6.11.2”
  • “react-router-dom”: “^6.11.2”
  • “history”: “^5.3.0”

新建项目详情页 src\screens\ProjectDetail\index.tsx

export const ProjectDetail = () => {
    
    
  return <h1>ProjectDetail</h1>
};

修改 src\authenticated-app.tsx(将 PageHeader 提取出来,引入并使用 路由相关组件):

...
import {
    
     Routes, Route } from "react-router";
import {
    
     BrowserRouter as Router } from "react-router-dom";
import {
    
     ProjectDetail } from "screens/ProjectDetail";

export const AuthenticatedApp = () => {
    
    

  useDocumentTitle("项目列表", false);

  return (
    <Container>
      <PageHeader/>
      <Main>
        <Router>
          <Routes>
            <Route path={
    
    '/projects'} element={
    
    <ProjectList/>}/>
            <Route path={
    
    '/projects/:projectId/*'} element={
    
    <ProjectDetail/>}/>
          </Routes>
        </Router>
      </Main>
    </Container>
  );
};
const PageHeader = () => {
    
    
  const {
    
     logout, user } = useAuth();
  const items: MenuProps["items"] = [
    {
    
    
      key: 1,
      label: "登出",
      onClick: logout,
    },
  ];

  return <Header between={
    
    true}>
    <HeaderLeft gap={
    
    true}>
      <SoftwareLogo width="18rem" color="rgb(38,132,255)" />
      <h2>项目</h2>
      <h2>用户</h2>
    </HeaderLeft>
    <HeaderRight>
      <Dropdown menu={
    
    {
    
     items }}>
        <Button type="link" onClick={
    
    (e) => e.preventDefault()}>
          Hi, {
    
    user?.name}
        </Button>
      </Dropdown>
    </HeaderRight>
  </Header>
}
...

修改项目列表页 src\screens\ProjectList\components\List.tsx(引入 路由组件 Link):

...
// react-router 和 react-router-dom 的关系,类似于 react 和 react-dom/react-native/react-vr...
import {
    
     Link } from 'react-router-dom'

// TODO 把所有 id personId 都改为 number 类型
...

// type PropsType = Omit<ListProps, 'users'>
export const List = ({
     
      users, ...props }: ListProps) => {
    
    
  return (
    <Table
      pagination={
    
    false}
      columns={
    
    [
        {
    
    
          title: "名称",
          dataIndex: "name",
          sorter: (a, b) => a.name.localeCompare(b.name),
          render: (text, record) => <Link to={
    
    String(record.id)}>{
    
    text}</Link>
        },
        ...
      ]}
      {
    
    ...props}
    ></Table>
  );
};

查看效果:

4. 添加看板和任务组路由

新建看板页 src\screens\ViewBoard\index.tsx

export const ViewBoard = () => {
    
    
  return <h1>ViewBoard</h1>
};

新建任务组页 src\screens\TaskGroup\index.tsx

export const TaskGroup = () => {
    
    
  return <h1>TaskGroup</h1>
};

修改项目详情页 src\screens\ProjectDetail\index.tsx(默认重定向到看板,也可自由切换看板和任务组):

import {
    
     Link, Navigate } from "react-router-dom";
import {
    
     Route, Routes } from "react-router";
import {
    
     TaskGroup } from "screens/TaskGroup";
import {
    
     ViewBoard } from "screens/ViewBoard";

export const ProjectDetail = () => {
    
    
  return <div>
    <h1>ProjectDetail</h1>
    <Link to='viewboard'>看板</Link>
    <Link to='taskgroup'>任务组</Link>
    <Routes>
      <Route path='/viewboard' element={
    
    <ViewBoard/>}/>
      <Route path='/taskgroup' element={
    
    <TaskGroup/>}/>
      <Route index element={
    
    <Navigate to='viewboard'/>}/>
    </Routes>
  </div>
};

  • / 代表根路由,若要在当前路由后指定追加路径,只需要写路径即可不需要加 /
  • 使用 Navigate 需要重定向的 Routepath='/' 可以写成 index
  • Navigate 使用过程中报错:[Navigate] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>

同理登录后需要默认跳转到 /projects,且点击 PageHeaderlogo 自动跳转根路径

修改 src\utils\index.ts(新增 resetRoute):

...
export const resetRoute = () => window.location.href = window.location.origin

修改 src\authenticated-app.tsx

...
import {
    
     resetRoute, useDocumentTitle } from "utils";
...
import {
    
     Navigate, BrowserRouter as Router } from "react-router-dom";
...

export const AuthenticatedApp = () => {
    
    
  ...
  return (
    <Container>
      <PageHeader />
      <Main>
        <Router>
          <Routes>
            ...
            <Route index element={
    
    <Navigate to='projects'/>}/>
          </Routes>
        </Router>
      </Main>
    </Container>
  );
};
const PageHeader = () => {
    
    
  ...

  return (
    <Header between={
    
    true}>
      <HeaderLeft gap={
    
    true}>
        <Button type='link' onClick={
    
    resetRoute}>
          <SoftwareLogo width="18rem" color="rgb(38,132,255)" />
        </Button>
        ...
      </HeaderLeft>
      ...
    </Header>
  );
};
...

5. 初步实现 useUrlQueryParam 管理 URL 参数状态

新建 src\utils\url.ts

import {
    
     useSearchParams } from "react-router-dom";

/**
 * 返回页面 url 中,指定键的参数值
 * @param keys 
 *  - keys 的类型“{ [x: string]: string; }”缺少类型“{ name: string; personId: string; }”中的以下属性: name, personId
 *  - 由于数据的下游要求指定的 key name 且是 string 类型,因此 keys 需要设定为泛型以做兼容
 *  - 计算属性名的类型必须为 "string"、"number"、"symbol" 或 "any"。泛型 K 需要 `extends string` 约束
 * @returns 
 */
export const useUrlQueryParam = <K extends string>(keys: K[]) => {
    
    
  const [ searchParams, setSearchParams ] = useSearchParams()
  return [
    keys.reduce((prev, key) => {
    
    
      // searchParams.get 可能会返回 null,需要预设值来兼容
      return {
    
    ...prev, [key]: searchParams.get(key) || ''}
    // 初始值会对类型造成影响,需要手动指定
    }, {
    
    } as {
    
     [key in K]: string }),
    setSearchParams
  ] as const
}

这部分类型系统会有些问题,其他可见注释

  • as const 是类型断言的一种,避免使用其默认类型推断行为,导致更广泛或更一般的类型。
    • 比如:
      • let a = ['string', 123, true], a 会被推断为 (string | number | boolean)[]
      • let a = ['string', 123, true, {}], a 却会被推断为 {}[]
    • 加上 as constlet a = ["string", 123, true, {}] as const, a 会被推断为 readonly ["string", 123, true, {}]

修改 src\screens\ProjectList\index.tsx (使用 useUrlQueryParamurl 中拿参数):

...
import {
    
     useUrlQueryParam } from "utils/url";

export const ProjectList = () => {
    
    
  const [, setParam] = useState({
    
    
    name: "",
    personId: "",
  });

  const [param] = useUrlQueryParam(['name', 'personId'])
  ...
};

const Container = styled.div`
  padding: 3.2rem;
`;

至此,想要的功能实现了,但是页面在持续循环渲染。。。下面来找下原因

6.用useMemo解决依赖循环问题 - Hook的依赖问题详解

安装 why-did-you-render

# 可能会需要 --force
npm i @welldone-software/why-did-you-render

“@welldone-software/why-did-you-render”: “^7.0.1”

新建 src\wdyr.ts

import React from 'react';

if (process.env.NODE_ENV === 'development') {
    
    
  const whyDidYouRender = require('@welldone-software/why-did-you-render');
  whyDidYouRender(React, {
    
    
    // 跟踪所有组件
    trackAllPureComponents: false,
  });
}

src\index.tsx 第一行全局引入:

import './wdyr'

在需要追踪问题的组件 src\screens\ProjectList\index.tsx 中加入:

ProjectList.whyDidYouRenger = true

或者不确定哪个组件的问题可以将 src\wdyr.tstrackAllPureComponents 设为 true

我尝试单独加不管用,所以就使用了默认配置,全局追踪

运行结果如图:

在这里插入图片描述

Re-rendered because of hook changes:

  e-rendered because of props changes:"

    different objects that are equal by value.

不难发现,两个不同的 objects 值相等,而且在 useEffect 中监听了,然后就死循环了。。。

那说明两个 objects 不是同一个(引用地址不同),也就是说每次给 useEffect(useDebounce) 的都是新创建的对象

然后追根溯源找到 useUrlQueryParam (src\utils\url.ts)。

啊哈,问题就在这里了,每次 searchParams 都是新的,如何解决呢?使用 useMemo

import {
    
     useMemo } from "react";
...
export const useUrlQueryParam = <K extends string>(keys: K[]) => {
    
    
  const [searchParams, setSearchParams] = useSearchParams();
  return [
    useMemo(
      () => keys.reduce((prev, key) => {
    
    
        // searchParams.get 可能会返回 null,需要预设值来兼容
        return {
    
     ...prev, [key]: searchParams.get(key) || "" };
        // 初始值会对类型造成影响,需要手动指定
      }, {
    
    } as {
    
     [key in K]: string }),
      // eslint-disable-next-line react-hooks/exhaustive-deps
      [searchParams]
    ),
    setSearchParams,
  ] as const;
};

Hook API 索引 – React

基本类型,组件状态可以放到依赖里;非组件状态的引用类型(对象,数组,方法)不可以
当组件 state 值(通过 useState 定义)放在 useEffectuseMemo 的依赖列表中时,由于只承认对应 setState 才会触发 state 值的改变,因此不会造成无限循环。

修改后查看页面,无限循环没有了

bug 没了,可以将 src\wdyr.tstrackAllPureComponents 设为 false,或是组件中:

ProjectList.whyDidYouRenger = false

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

猜你喜欢

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