React18+React Hook+TS4 Jira 项目总结

221 阅读34分钟

创建项目

npx create-react-app jira --template typescript

配置绝对路径

tsconfig.json

{
  "compilerOptions": {
    "baseUrl": "./src",
      ...
  }
}

格式化配置

  1. 安装prettier依赖包

    yarn add --dev --exact prettier

  2. 创建配置文件

    echo {}> .prettierrc.json

    {
       "printWidth": 100,	//每行最多显示的字符数
       "tabWidth": 2,//tab的宽度 2个字符
       "useTabs": false,//禁止使用tab代替空格
       "semi": true,//结尾使用分号
       "singleQuote": true,//使用单引号代替双引号
       "trailingComma": "none",//结尾是否添加逗号
       "bracketSpacing": true,//对象括号俩边是否用空格隔开
       "bracketSameLine": true,;//组件最后的尖括号不另起一行
       "arrowParens": "always",//箭头函数参数始终添加括号
       "htmlWhitespaceSensitivity": "ignore",//html存在空格是不敏感的
       "vueIndentScriptAndStyle": false,//vue 的script和style的内容是否缩进
       "endOfLine": "auto",//行结尾形式 mac和linux是\n  windows是\r\n
       "singleAttributePerLine": false //组件或者标签的属性是否控制一行只显示一个属性
    }
    
  3. 创建忽略文件 .prettierignore

    build;
    coverage;
    
  4. 借助Pre-commit Hook代码提交前自动格式化

    node 18 以上 yarn add husky lint-staged -D npx husky install npx husky add.husky/pre-commit "npx lint-staged"

    // package.json 增加扩展名
    {
      "husky": {
        "hooks": {
          "pre-commit": "lint-staged"
        }
      },
      "lint-staged": {
        "*.{js,jsx,ts,tsx,json,css,scss,md}": "prettier --write"
      }
    }
    

    .husky/pre-commit

     #!/usr/bin/env sh
     . "$(dirname -- "$0")/_/husky.sh"
    
     npx lint-staged
    
  5. 解决eslint 和prettier冲突

    yarn add eslint-config-prettier -D

    // package.json prettier覆盖一部分eslint
    {
      "eslintConfig": {
        "extends": ["react-app", "react-app/jest", "prettier"]
      }
    }
    

git 提交规范校验

node 18 以上

  1. yarn add @commitlint/cli @commitlint/config-conventional -D
  2. npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'
  3. 根目录新建 commitlint.config.js 文件
module.exports = {
  extends: ['@commitlint/config-conventional'],
};
  1. package.json
{
  "husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  }
}
  1. git 规则: type: some message
[
  'build', // 构建相关的修改
  'ci', // 持续集成修改
  'chore', // 构建过程或辅助工具的修改
  'docs', // 文档修改
  'feat', // 新功能
  'fix', // 修复bug
  'perf', // 优化相关
  'refactor', // 重构相关
  'revert', // 回滚修改
  'style', // 代码格式修改
  'test', // 测试相关修改
];

本地node服务器 json-server

支持ajax fetch, 可以增删改查数据

REST API 接口设计风格
METHOD 代表行为 URI 代表资源/行为
GET /tickets // 列表
GET /tickets/12 // 详情
POST /tickets // 增加
PUT /tickets/12 // 替换
PATCH /tickets/12 // 修改
DELETE /tickets/12 // 删除
  1. 安装依赖

    yarn add json-server -D

  2. 根目录建数据文件
__json_server_mock__ / db.json;
  1. package.json 增加脚本
{
  "scripts": {
    "json-server": "json-server __json_server_mock__/db.json --watch --port 3001"
  }
}
  1. 增加环境变量

    .env

REACT_APP_API_URL=http://online.com

.env.development

REACT_APP_API_URL=http://localhost:3001
  1. 启动服务 npm run json-server
  2. 使用服务
const apiUrl = process.env.REACT_APP_API_URL;
fetch(`${apiUrl}/projects`);
  1. 模拟非REST标准的自定义API

    json_server_mock下新建 middleware.js

module.exports = function (req, res, next) {
  if (req.method === 'POST' && req.path === '/login') {
    if (req.body.username === 'jack' && req.body.password === '123456') {
      return res.status(200).json({
        user: {
          token: '123',
        },
      });
    } else {
      return res.status(400).json({ message: '用户名或密码错误' });
    }
  }
  next();
};

middleware 注入到json-server package.json

{
  "scripts": {
    "json-server": "json-server __json_server_mock__/db.json --watch --port 3001 --middlewares __json_server_mock__/middleware.js"
  }
}

分布式后端服务

用MSW 以 Service Worker 为原理实现的"分布式后端" 用这个开发者工具时, 把json-server删掉, 因为代替了它

  1. 所有请求被 Service Worker 代理(拦截请求)
  2. 后端逻辑处理后, 以 localStorage 为数据库进行增删改查操作

安装

安装时不能有git任务 yarn add jira-dev-tool@next npx msw init public 安装后, 自动创建了public/mockServiceWorker.js

// src/index.tsx 修改代码
import { DevTools, loadServer } from 'jira-dev-tool';

loadServer(() =>
  root.render(
    <React.StrictMode>
      <AppProviders>
        <DevTools />
        <App />
      </AppProviders>
    </React.StrictMode>,
  ),
);

yarn add react-query

// src/context/index.tsx 修改代码
import React, { ReactNode } from 'react';
import { QueryClientProvider, QueryClient } from 'react-query';
export const AppProviders = ({ children }: { children: ReactNode }) => {
  return (
    <QueryClientProvider client={new QueryClient()}>
      <AuthProvider>{children}</AuthProvider>
    </QueryClientProvider>
  );
};

安装使用 antd 组件库

yarn add antd@4.24.15

src/index.tsx

// 在jira-dev-tool 后面引入
import 'antd/dist/antd.less';

修改主题色

create-react-app 要安装额外依赖 yarn add @craco/craco yarn add craco-less

// package.json
{
  "scripts": {
    "start": "craco start",
    "build": "craco build",
    "test": "craco test"
    //...
  }
}

根目录建 craco.config.js

const CracoLessPlugin = require('craco-less');

module.exports = {
  plugins: [
    {
      plugin: CracoLessPlugin,
      options: {
        lessLoaderOptions: {
          lessOptions: {
            modifyVars: {
              '@primary-color': 'rgb(0, 82, 204)',
              '@font-size-base': '16px',
            },
            javascriptEnabled: true,
          },
        },
      },
    },
  ],
};

Drawer 关闭按钮没反应问题

要主动传入onClose事件,点击关闭图标才能关闭

<Drawer onClose={close} open={projectModalOpen} width={'100%'}></Drawer>

Form 表单里面的 input

Form会代理input的 value 和onChange

<Form layout='vertical' style={{width: '40rem'}} onFinish={onFinish}>
  <Form.Item label='名称' name='name' rules={[{required: true, message: '请输入项目名称'}]}>
    <Input placeholder='请输入项目名称'/>
  </Form.Item>
</Form>

// 相当于
<Input value={formValue.name} onChange={evt => onChangeFormValue({name: evt.value})} placeholder='请输入项目名称'/>


Router 路由

yarn add react-router@latest react-router-dom@latest history@latest

路由根组件

// src/authenticated-app.tsx
// react-router 和 react-router-dom 的关系, 类似于 react 和 react-dom/react-native/react-vr
import { Routes, Route, Navigate } from 'react-router';
import { BrowserRouter as Router } from 'react-router-dom';
export const AuthenticatedApp = () => {
  return (
    <Container>
      <Router>
        <PageHeader />
        <Main>
          <Routes>
            <Route path={'/projects'} element={<ProjectListScreen />}></Route>
            <Route
              path={'/projects/:projectId/*'}
              element={<ProjectScreen />}
            ></Route>
            {/* 默认路由 */}
            <Route
              index
              element={<Navigate to={'projects'} replace={true} />}
            ></Route>
          </Routes>
        </Main>
        <ProjectModal />
      </Router>
    </Container>
  );
};

路由子组件

src/screens/project/index.tsx

import React from 'react';
import { Link } from 'react-router-dom';
import { Routes, Route, Navigate } from 'react-router';
import { KanbanScreen } from 'screens/kanban';
import { EpicScreen } from 'screens/epic';
import styled from '@emotion/styled';
import { Menu } from 'antd';
import { useLocation } from 'react-router';

const useRouteType = () => {
  const units = useLocation().pathname.split('/');
  return units[units.length - 1];
};

export const ProjectScreen = () => {
  const routeType = useRouteType();
  return (
    <Container>
      <Aside>
        <Menu mode="inline" selectedKeys={[routeType]}>
          <Menu.Item key="kanban">
            <Link to={'kanban'}>看板</Link>
          </Menu.Item>
          <Menu.Item key="epic">
            <Link to={'epic'}>任务组</Link>
          </Menu.Item>
        </Menu>
      </Aside>
      <Main>
        <Routes>
          {/*projects/:projectId/kanban*/}
          <Route path={'/kanban'} element={<KanbanScreen />} />
          {/*projects/:projectId/epic*/}
          <Route path={'/epic'} element={<EpicScreen />} />
          {/* 默认路由 */}
          <Route
            index
            element={<Navigate to={'kanban'} replace={true} />}
          ></Route>
        </Routes>
      </Main>
    </Container>
  );
};

const Aside = styled.aside`
  background-color: rgb(244, 245, 247);
  display: flex;
`;
const Main = styled.main`
  box-shadow: -5px 0 5px -5px rgba(0, 0, 0, 0.1);
  display: flex;
  overflow: hidden;
`;
const Container = styled.div`
  display: grid;
  grid-template-columns: 16rem 1fr;
  overflow: hidden;
  width: 100%;
`;

路由跳转

import { Link } from 'react-router-dom';

 render(value, project) {
   return  <Link to={String(project.id)}>{project.name}</Link>
 }

重置路由, 刷新页面

// src/utils/index.ts
export const resetRoute = () => (window.location.href = window.location.origin);

<Button type="link" onClick={resetRoute}>
  <SoftwareLogo width="18rem" color="rgb(38, 132, 255" />
</Button>;

路由url参数

src/utils/url.ts

import { useSearchParams, URLSearchParamsInit } from 'react-router-dom';
import { useMemo, useState } from 'react';
import { cleanObject } from 'utils';
/**
 * 返回页面url中, 指定键的参数值(?name=tom&age=18,传入['name','age'],得到{name: 'tom', age: 18})
 */
export const useUrlQueryParam = <K extends string>(keys: K[]) => {
  // searchParams 相当于 new URLSearchParams(), 读取值只能 searchParams.get('name') 获取
  const [searchParams] = useSearchParams();
  const setSearchParams = useSetUrlSearchParam();
  const [stateKeys] = useState(keys);
  return [
    useMemo(
      () =>
        stateKeys.reduce(
          (prev, key) => {
            return { ...prev, [key]: searchParams.get(key) || '' };
          },
          {} as { [key in K]: string },
        ),
      [searchParams, stateKeys],
    ),
    (params: Partial<{ [key in K]: unknown }>) => {
      return setSearchParams(params);
    },
  ] as const;
};

/**
 * 设置url参数唯一入口
 */
export const useSetUrlSearchParam = () => {
  const [searchParams, setSearchParams] = useSearchParams();
  return (params: { [key in string]: unknown }) => {
    const o = cleanObject({
      ...Object.fromEntries(searchParams),
      ...params,
    }) as URLSearchParamsInit;
    return setSearchParams(o);
  };
};

路由url参数管理弹窗状态

src/screens/project-list/util.ts

import { useMemo } from 'react';
import { useProject } from 'utils/project';
import { useSetUrlSearchParam, useUrlQueryParam } from 'utils/url';

export const useProjectsSearchParams = () => {
  const [param, setParam] = useUrlQueryParam(['name', 'personId']);
  return [
    useMemo(
      () => ({ ...param, personId: Number(param.personId) || undefined }),
      [param],
    ),
    setParam,
  ] as const;
};

export const useProjectsQueryKey = () => {
  const [param] = useProjectsSearchParams();
  return ['projects', param];
};

export const useProjectModal = () => {
  const [{ projectCreate }, setProjectCreate] = useUrlQueryParam([
    'projectCreate',
  ]);
  const [{ editingProjectId }, setEditingProjectId] = useUrlQueryParam([
    'editingProjectId',
  ]);

  const setUrlParams = useSetUrlSearchParam();
  const { data: editingProject, isLoading } = useProject(
    Number(editingProjectId),
  );

  const open = () => setProjectCreate({ projectCreate: true });
  const close = () => setUrlParams({ projectCreate: '', editingProjectId: '' });
  const startEdit = (id: number) =>
    setEditingProjectId({ editingProjectId: id });

  return {
    projectModalOpen: projectCreate === 'true' || Boolean(editingProjectId),
    open,
    close,
    startEdit,
    editingProject,
    isLoading,
  };
};

使用 src/screens/project-list/project-modal.tsx

import React, { useEffect } from 'react';
import { Button, Drawer, Spin, Form, Input } from 'antd';
import { useProjectModal, useProjectsQueryKey } from './util';
import { UserSelect } from 'components/user-select';
import { useAddProject, useEditProject } from 'utils/project';
import { useForm } from 'antd/es/form/Form';
import { ErrorBox } from 'components/lib';
import styled from '@emotion/styled';

export const ProjectModal = () => {
  const { projectModalOpen, close, editingProject, isLoading } =
    useProjectModal();
  const useMutateProject = editingProject ? useEditProject : useAddProject;
  const {
    mutateAsync,
    error,
    isLoading: mutateLoading,
  } = useMutateProject(useProjectsQueryKey());

  const [form] = useForm();
  const onFinish = (values: any) => {
    // 提交表单
    mutateAsync({ ...editingProject, ...values }).then(() => {
      form.resetFields();
      close();
    });
  };

  const closeModal = () => {
    form.resetFields();
    close();
  };
  const title = editingProject ? '编辑项目' : '创建项目';
  useEffect(() => {
    form.setFieldsValue(editingProject);
  }, [editingProject, form]);

  return (
    <Drawer
      forceRender={true}
      onClose={closeModal}
      open={projectModalOpen}
      width={'100%'}
    >
      <Container>
        {isLoading ? (
          <Spin size="large" />
        ) : (
          <>
            <h1>{title}</h1>
            <ErrorBox error={error} />
            <Form
              form={form}
              layout="vertical"
              style={{ width: '40rem' }}
              onFinish={onFinish}
            >
              <Form.Item
                label="名称"
                name="name"
                rules={[{ required: true, message: '请输入项目名称' }]}
              >
                <Input placeholder="请输入项目名称" />
              </Form.Item>

              <Form.Item
                label="部门"
                name="organization"
                rules={[{ required: true, message: '请输入部门名' }]}
              >
                <Input placeholder="请输入项目部门名" />
              </Form.Item>

              <Form.Item label="负责人" name="organization">
                <UserSelect defaultOptionName={'负责人'} />
              </Form.Item>
              <Form.Item style={{ textAlign: 'right' }}>
                {/* htmlType 为了避免跟type 冲突, 点击提交触发onFinish方法 */}
                <Button
                  loading={mutateLoading}
                  type="primary"
                  htmlType="submit"
                >
                  提交
                </Button>
              </Form.Item>
            </Form>
          </>
        )}
      </Container>
    </Drawer>
  );
};

const Container = styled.div`
  height: 80vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
`;
  const { open } = useProjectModal()
  <ButtonNoPadding onClick={open} type={'link'}>创建项目</ButtonNoPadding>

项目目录结构

|-- .husky // git 提交校验文件
|-- node_modules // 依赖
|-- public // 静态资源(不压缩)
|-- src
    |-- assets // 静态资源(打包压缩)
    |-- components // 公共组件
        |-- lib // 没有状态的公共小组件(含html和css组件)
    |-- context // 状态提升
    |-- screens // 页面
        |-- prject-list  // 项目列表
            |-- index.tsx  // 页面
            |-- list.tex  // 列表组件
            |-- project-modal.tex  // 弹窗组件
            |-- search-pancel.tex  // 搜索面板组件
            |-- util.ts  // 业务逻辑
    |-- types // 公共类型
        |-- index.ts  // 全局类型
        |-- project.ts // 组件类型
        |-- task.type.ts // 组件字典类型
    |-- unauthenticated-app // 未验证页面
        |-- index.tsx // 入口
        |-- login.tsx // 登录
        |-- register.tsx // 注册
    |-- utils // 工具库
        |-- http.ts // 封装axios
        |-- project.ts // project组件的api方法(利用react-query增删改查数据)
        |-- index.ts // 全局方法
        |-- url.ts // url参数获取设置
        |-- use-optimistic-options.ts // 生产 react-query 刷新或者乐观更新的配置
        |-- task-type.ts // 组件字典请求逻辑
    |-- App.css // 全局样式
    |-- App.tsx // 入口文件
    |-- auth-provider.ts // 登录用户验证
    |-- authenticated-app.tsx 已验证入口
    |-- index.tsx // 入口文件
|-- .env // 生产环境变量
|-- .env.development // 开发环境变量
|-- .gitignore // git忽略目录
|-- .prettierignore // 代码格式化忽略目录
|-- .prettierrc.json // 代码格式化配置
|-- commitlint.config.js // git 提交规范配置
|-- craco.config.js // antd修改主题色配置
|-- package.json // 项目依赖配置
|-- README.md // 项目说明文档
|-- tsconfig.json // ts配置

React 知识点

Hooks

只能在组件顶层声明使用, 不能在函数内部使用

Hook发展历史

  1. Mixin 不推荐使用

    优点: 重用代码 缺点: 隐式依赖(得跳转才知道依赖名), 名字冲突, 只能在React.createClass中使用(不支持 Class Component), 难以维护

var SeIntervalMixin = {
  componentDidMount() {
    this.intervals = [];
  },
  setInterval() {
    this.intervals.push(setInterval.apply(null, arguments));
  },
  componentWillUnmount() {
    this.intervals.forEach(clearInterval);
  },
};

var createReactClass = require('create-react-class');
var TickTock = createReactClass({
  mixins: [SeIntervalMixin],
  componentDidMount() {
    this.setInterval(this.tick, 1000);
  },
});
  1. HOC 高阶组件(2018年以前)

    采用装饰器模式复用代码 优点: 可以在任何组件包括 Class Component 中工作, 提倡容器组件与展示组件分离, 原则做得: 关注点分离 缺点: 不直观难以阅读, 名字冲突, 组件层层层层层层嵌套

function withWindowWidth(BaseComponet) {
  class DerivedClass extends React.Component {
    state = {
      windowWidth: window.innerWidth
    }
    onResize = () => {
      this.setState({
        windowWidth: window.innerWidth
      })
    }
    componentDidMount() {
      window.addEventListener('resize', this.onResize)
    }
    componentWillUnmount() {
      window.removeEventListener('resize', this.onResize)
    }
    render() {
      return <BaseComponet {...this.props} {...this.state}/>
    }
  }
}

const MyComponent = (props) => {
  return <div>Window width is: {props.windowWidth}</div>
}

const NewMyComponent = withWindowWidth(MyComponent)
<NewMyComponent/>
  1. Render Props

    采用 代理模式 复用代码 优点: 灵活 缺点: 难以阅读, 难以理解

class WindowWidth extends React.Component {
  // React 实现的类型检查, children是个函数, 且必传
  propTypes = {
    children: PropTypes.func.isRequired,
  };

  state = {
    windowWidth: window.innerWidth,
  };

  onResize = () => {
    this.setState({
      windowWidth: window.innerWidth,
    });
  };
  componentDidMount() {
    window.addEventListener('resize', this.onResize);
  }
  componentWillUnmount() {
    window.removeEventListener('resize', this.onResize);
  }
  render() {
    return this.props.children(this.state.windowWidth);
  }
}

const MyComponent = () => {
  return (
    <WindowWidth>{(width) => <div>Window width is: {width}</div>}</WindowWidth>
  );
};

**React Router 也采用了这样的API设计:

<Route path = "/about" render={ (props) => <About {...props}/>}>
  1. Custom Hooks (2018年推出)

    核心改变: 允许函数式组件存储自己的状态, 在这之前的函数式组件不能有自己的状态 这个改变使我们可以像抽象一个普通函数一样抽象React组件中的逻辑 实现原理: 闭包 优点: 1.提取逻辑非常容易 2.易于组合 3.可读性强 4.没有名字冲突 缺点: 1. Hook有自身用法限制: 只能在组件顶层使用, 只能在组件中使用 2. 由于原理为闭包, 极少数情况下会出现难以理解的问题

import { useState, useEffect } from 'react';

const useWindowWidth = () => {
  const [isScreenSmall, setIsScreenSmall] = useState(false);

  let checkScreenSize = () => {
    setIsScreenSmall(window.innerWidth < 600);
  };
  useEffect(() => {
    checkScreenSize();
    window.addEventListener('resize', checkScreenSize);
    return () => window.removeEventListener('resize', checkScreenSize);
  }, []);
};
export default useWindowWidth;

合并状态, 提取公共依赖

修改前

const [past, setPast] = useState<T[]>([]);
const [present, setPresent] = useState(initialPresent);
const [future, setFuture] = useState<T[]>([]);

const canUndo = state.past.length > 0;
const canRedo = state.future.length > 0;

const undo = useCallback(() => {
  if (!canUndo) return;
  const previous = past[past.length - 1]; // past 的最后一个,也就是现在的前一个
  const newPast = past.slice(0, past.length - 1); // 复制除最后一个的past
  // 处理过去
  setPast(newPast);
  // 处理现在
  setPresent(previous);
  // 处理未来, 因为是回退, 把当前加入未来
  setFuture([present, ...future]);
}, [past, present, future, canUndo]);

修改后

const [state, setState] = useState({
  past: [] as T[],
  present: initialPresent,
  future: [] as T[],
});

const canUndo = state.past.length > 0;
const canRedo = state.future.length > 0;

const undo = useCallback(() => {
  setState((currentState) => {
    const { past, present, future } = currentState;
    if (past.length === 0) return currentState;
    const previous = past[past.length - 1]; // past 的最后一个,也就是现在的前一个
    const newPast = past.slice(0, past.length - 1); // 复制除最后一个的past
    return {
      past: newPast,
      present: previous,
      future: [present, ...future],
    };
  });
}, []);

监测无限渲染的库 why-did-you-render

yarn add @welldone-software/why-did-you-render 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: true, // true 跟踪所有函数组件
    // 跟踪单个组件设置false, 在组件内写 组件名.whyDidYouRender = true
  });
}

src/index.tsx

第一句引入

import './wdyr.ts';

react hooks 与 闭包经典的坑

// useEffect 抽象成纯函数(闭包)
const test = () => {
  let num = 0;

  const effect = () => {
    num += 1;
    const message = `现在的num值: ${num}`;
    return function unmount() {
      console.log(message);
    };
  };

  return effect;
};

const add = test(); // 执行test, 返回effect函数
const unmount = add(); // 执行effect函数, 返回引用了message1的unmount函数
add(); // 再一次执行effect函数, 返回引用了message2的unmount函数
add(); // message3
add(); // message4
add(); // message5
unmount(); // 这里打印什么呢? 按照直觉似乎应该打印3, 实际上打印了1, 因为unmount定义的时候, 引用的是message1

如果useEffect 不指定依赖, 就只执行一次, 读到的值也是第一次执行的值

useState

useState 适合于定义单个的状态, useReducer 适合于定义多个状态(一群会互相影响的状态) 定义状态, 作用: 定义状态变量, 设置函数可以写成 setState(value) setState(value => {//逻辑 return value}) 更新状态方法 setXXX 是异步的, 要在下次重绘才能获取新值, 不要试图在更改状态之后立马获取状态。 解决方法:

  1. 状态更新后立即获取状态, 可以使用 useRef 获取状态
  2. 状态更新后使用 useEffect 获取状态
const [data, setData] useState([])
const dataRef = useRef(data)
useEffect(()=> {
  dataRef.current = data;
},[data])

console.log(dataRef.current)//最新的数据
const [user, setUser] = useState<User | null>(null);

useState直接传入函数的含义是: 惰性初始化, 会自动运行, 错误写法会无限触发

// 错误写法
const [callback, setCallback] = useState(()=>{console.log('初始化')}) // 一直打印'初始化'
<Button onClick={()=>setCallback(()=>{console.log('update')})}></Button>


// 正确写法
const [callback, setCallback] = useState(() => ()=>{console.log('初始化')})
<Button onClick={()=>setCallback(()=>()=>{console.log('update')})}></Button>


// 或者用useRef 保存函数
const callbackRef = useRef(()=>{console.log('初始化')}
const callback = callbackRef.current
// 改变callback的值, 但不会触发组件刷新
<Button onClick={()=>callbackRef.current = ()=> {console.log('update')}}></Button>
// 读callback的值, 触发组件刷新
<Button onClick={()=>callbackRef.current()}></Button>


原因是useState 支持惰性初始state 即传入 ()=> 参数, 所以会执行函数

// 正常写法,每一次渲染都会执行
const [state, setState] = useState(someExpensiveComputation(props)); // 消耗性能的函数
// 惰性初始state, 只执行一次
const [state, setState] = useState(() => {
  const initialState = someExpensiveComputation(props); // 消耗性能的函数
  return initialState;
});

useEffect

定义副作用, 作用: 结束渲染的回调执行 第一个参数是回调函数(return 相当于 componentWillUnmount ),第二个参数是依赖项数组(传空相当于 componentDidMount )

useEffect(() => {
  // 每次在value变化以后,设置一个定时器
  const timeout = setTimeout(() => {
    setDebounceValue(value);
  }, delay);
  // 每次在上一次useEffect处理完以后再运行
  return () => clearTimeout(timeout);
}, [value, delay]);

export const useMount = (callback: () => void) => {
  useEffect(() => {
    callback();
    // 依赖项加上 callback 会无限循环, 这个和 useCallback 和 useMemo 有关系
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
};

useEffect 无限触发问题 依赖项不能是非组件状态的对象, 只能是状态或者基本类型, 否则会无限循环

// setNum 会刷新页面,再次声明 obj 并触发useEffect
// 如果obj是基本类型 或者是 useState 声明的对象, 就不会无限循环, 当 obj 是对象时, 新旧obj不相等, 就会无限循环
const obj = { name: 'jack' };
const [num, setNum] = useState(0);

useEffect(() => {
  setNum(num + 1);
}, [obj]);

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

useMemo

为了非基本类型的依赖而存在

定义缓存, 作用:缓存计算结果, 把普通对象变成状态属性, 可以作为依赖项 第一个参数是回调函数, 第二个参数是依赖项数组, 只有依赖项改变时, 才会重新计算 跟 useEffect 一样, 依赖项不能是非组件状态的对象, 只能是状态或者基本类型, 否则会无限循环

const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);

useCallback

为了非基本类型的依赖而存在

定义缓存, 作用:缓存函数, 把普通函数变成状态函数, 可以作为依赖项

const fetchProjects = useCallback(
  () => client('projects', { data: cleanObject(param || {}) }),
  [client, param],
);

useEffect(() => {
  run(fetchProjects(), {
    retry: fetchProjects,
  });
}, [param, run, fetchProjects]);

在useCallback 用到了state, 依赖里又加了state, 就会无限循环 解决方法

// 错误
const run = useCallback(() => {
  setState({ ...state, stat: 'loading' });
}, [state]);

//正确
const run = useCallback(() => {
  setState((prevState) => ({ ...prevState, stat: 'loading' }));
}, []);

useContext

定义上下文, 作用:状态提升到父组件, 传值给子组件

import React, { createContext, useContext } from 'react';

const AuthContext = createContext<>(undefined);
AuthContext.displayName = 'AuthContext'; // 更改Context名称

export const AuthProvider = () => {
  // ...
  return <AuthContext.Provider />;
};

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth必须在AuthProvider中使用');
  }
  return context;
};

useRef

返回一个可变的ref 对象, 其 .current 属性被初始化为传入的参数(initialValue), 返回的 ref 对象在组件的整个生命周期内保持不变 useRef 定义的值并不是组件的状态, 而是组件的一个变量, 不会触发组件重新渲染

// 保存值
const oldTitle = useRef(document.title).current;

// useRef 保存函数
const callbackRef = useRef(()=>{console.log('初始化')}
const callback = callbackRef.current
// 改变callback的值, 但不会触发组件刷新
<Button onClick={()=>callbackRef.current = ()=> {console.log('update')}}></Button>
// 读callback的值, 触发组件刷新
<Button onClick={()=>callbackRef.current()}></Button>

Custom Hook 提取并复用组件

只能在组件顶层声明使用, 不能在函数内部使用 名称以use开头, 普通的UI组件只需要首字母大写

useMount 组件加载

/**
 * 组件加载完执行
 */
export const useMount = (callback: () => void) => {
  useEffect(() => {
    callback()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])
}

// 使用
import { useMount } from 'utils';
useMount(() => {
  fetch(`${apiUrl}/users`).then(async (response) => {
    if (response.ok) {
      setUsers(await response.json());
    }
  });
});

useDebounce 防抖

/**
 * 防抖
 */
// export const useDebounce = (func, delay = 1000) => {
//   let timeout;
//   return (...params) => {
//     if (timeout) clearTimeout(timeout);
//     timeout = setTimeout(() => {
//       func(...params)
//     }, delay)
//   }
// }
export const useDebounce = (value, delay = 1000) => {
  const [debounceValue, setDebounceValue] = useState(value);

  useEffect(() => {
    // 每次在value变化以后,设置一个定时器
    const timeout = setTimeout(() => {
      setDebounceValue(value);
    }, delay);
    // 每次在上一次useEffect处理完以后再运行
    return () => clearTimeout(timeout);
  }, [value, delay]);

  return debounceValue;
};
// 使用
import { useDebounce } from 'utils';
const [param, setParam] = useState({
  name: '', // 搜索名称
  personId: '', // 负责人id
});
const useDebounceParam = useDebounce(param, 1000); // 防抖

useEffect(() => {
  fetch(
    `${apiUrl}/projects?${qs.stringify(cleanObject(useDebounceParam))}`,
  ).then(async (response) => {
    if (response.ok) {
      setList(await response.json());
    }
  });
}, [useDebounceParam]);

useAsync 管理异步操作

src/util/use-async.ts

import { useCallback, useState } from 'react';
import { useMountedRef } from 'utils';
interface State<D> {
  error: Error | null;
  data: D | null;
  stat: 'idle' | 'loading' | 'success' | 'error'; // idle 未发生, loading 正在发生
}

const defaultInitialState: State<null> = {
  stat: 'idle',
  data: null,
  error: null,
};

const defaultConfig = {
  throwOnError: false,
};

export const useAsync = <D>(
  initialState?: State<D>,
  initialConfig?: typeof defaultConfig,
) => {
  const config = { ...defaultConfig, ...initialConfig };
  const [state, setState] = useState<State<D>>({
    ...defaultInitialState,
    ...initialState,
  });
  const mountedRef = useMountedRef();
  const [retry, setRetry] = useState(() => () => { }); // 懒初始化

  const setData = useCallback((data: D) =>
    setState({
      data,
      stat: 'success',
      error: null,
    }), []);

  const setError = useCallback((error: Error) =>
    setState({
      error,
      stat: 'error',
      data: null,
    }), []);

  /**
   * 用来触发异步请求
   */
  const run = useCallback((
    promise: Promise<D>,
    runConfig?: { retry: () => Promise<D> },
  ) => {
    if (!promise || !promise.then) {
      throw new Error('请传入Promise类型数据');
    }
    setRetry(() => () => {
      if (runConfig?.retry) {
        run(runConfig?.retry(), runConfig);
      }
    });
    setState(prevState => ({ ...prevState, stat: 'loading' }));
    return promise
      .then((data) => {
        if (mountedRef.current) setData(data);
        return data;
      })
      .catch((error) => {
        // catch会消化异常, 如果不主动抛出, 外面是接收不到异常的
        setError(error);
        if (config.throwOnError) return Promise.reject(error);
        return error;
      });
  }, [config.throwOnError, mountedRef, setData, setError]);
  return {
    isIdle: state.stat === 'idle',
    isLoading: state.stat === 'loading',
    isError: state.stat === 'error',
    isSuccess: state.stat === 'success',
    run,
    setData,
    setError,
    // retry 调用时重新调用run, 让state刷新
    retry,
    ...state,
  };
};

使用useReducer改造

import { useCallback, useState, useReducer } from 'react';
import { useMountedRef } from 'utils';
interface State<D> {
  error: Error | null;
  data: D | null;
  stat: 'idle' | 'loading' | 'success' | 'error'; // idle 未发生, loading 正在发生
}

const defaultInitialState: State<null> = {
  stat: 'idle',
  data: null,
  error: null,
};

const defaultConfig = {
  throwOnError: false,
};

const useSafeDispatch = <T>(dispatch: (...args: T[]) => void) => {
  const mountedRef = useMountedRef()

  return useCallback((...args: T[]) => (mountedRef.current ? dispatch(...args) : void 0), [dispatch, mountedRef])
}

export const useAsync = <D>(
  initialState?: State<D>,
  initialConfig?: typeof defaultConfig,
) => {
  const config = { ...defaultConfig, ...initialConfig };
  const [state, dispatch] = useReducer((state: State<D>, action: Partial<State<D>>) => ({ ...state, ...action }), {
    ...defaultInitialState,
    ...initialState,
  } as State<D>);
  const safeDispatch = useSafeDispatch(dispatch);
  const [retry, setRetry] = useState(() => () => { }); // 懒初始化

  const setData = useCallback(
    (data: D) =>
      safeDispatch({
        data,
        stat: 'success',
        error: null,
      }),
    [safeDispatch],
  );

  const setError = useCallback(
    (error: Error) =>
      safeDispatch({
        error,
        stat: 'error',
        data: null,
      }),
    [safeDispatch],
  );

  /**
   * 用来触发异步请求
   */
  const run = useCallback(
    (promise: Promise<D>, runConfig?: { retry: () => Promise<D> }) => {
      if (!promise || !promise.then) {
        throw new Error('请传入Promise类型数据');
      }
      setRetry(() => () => {
        if (runConfig?.retry) {
          run(runConfig?.retry(), runConfig);
        }
      });
      safeDispatch(({ stat: 'loading' }));
      return promise
        .then((data) => {
          setData(data)
          return data;
        })
        .catch((error) => {
          // catch会消化异常, 如果不主动抛出, 外面是接收不到异常的
          setError(error);
          if (config.throwOnError) return Promise.reject(error);
          return error;
        });
    },
    [config.throwOnError, setData, setError, safeDispatch],
  );
  return {
    isIdle: state.stat === 'idle',
    isLoading: state.stat === 'loading',
    isError: state.stat === 'error',
    isSuccess: state.stat === 'success',
    run,
    setData,
    setError,
    // retry 调用时重新调用run, 让state刷新
    retry,
    ...state,
  };
};

src/utils.index.ts

/**
 * 返回组件的挂载状态, 如果还没挂载或者已经卸载返回false, 反之true
 * 阻止已卸载组件上赋值, 解决导致的报错
 */
export const useMountedRef = () => {
  const mountedRef = useRef(false);
  useEffect(() => {
    mountedRef.current = true;
    return () => {
      mountedRef.current = false;
    };
  });
  return mountedRef;
};

使用

const { isLoading, error, data: list, retry } = useAsync<Project[]>();

// 表单数据
const [param, setParam] = useProjectsSearchParams();
const { data: users } = useUsers();

return (
  <Container>
    <h1>项目列表</h1>
    <SearchPancel users={users} param={param} setParam={setParam} />
    <ErrorBox error={error} />
    <List
      refresh={retry}
      users={users}
      dataSource={list || []}
      loading={isLoading}
    />
  </Container>
);

文档标题

库实现: yarn add react-helmet yarn add @types/react-helmet -D

// src/unauthenticated-app/index.tsx
import { Helmet } from 'react-helmet';

<Container>
  <Helmet>
    <title>请登录或注册以继续</title>
  </Helmet>
</Container>;

// src/screens/project-list/index.tsx
import { Helmet } from 'react-helmet';

<Container>
  <Helmet>
    <title>项目列表</title>
  </Helmet>
</Container>;

手动实现 src/utils/index.ts

export const useDocumentTitle = (title: string, keepOnUnmount = true) => {
  const oldTitle = useRef(document.title).current;
  // 页面加载时: 旧title
  // 加载后: 新title

  useEffect(() => {
    document.title = title;
  }, [title]);

  useEffect(() => {
    return () => {
      if (!keepOnUnmount) {
        // 如果不指定依赖, 读到的就是旧title
        document.title = oldTitle;
      }
    };
  }, [keepOnUnmount, oldTitle]);
};

使用

// src/unauthenticated-app/index.tsx
import { useDocumentTitle } from 'utils/index.ts';

useDocumentTitle('请登录或注册以继续', false);

// src/screens/project-list/index.tsx
import { useDocumentTitle } from 'utils/index.ts';

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

项目公用类型

src/typescripts/index.ts

export type Raw = string | number;

src/typescripts/project.ts

export interface Project {
  id: number;
  name: string;
  personId: number;
  pin: boolean;
  organization: string;
  created: number;
}

src/typescripts/user.ts

export interface User {
  id: number;
  name: string;
  email: string;
  title: string;
  organization: string;
  token: string;
}

Context 状态提升

配合React Hooks 实现 Redux 状态管理功能

  1. src/context/auth-context.ts
import React, { ReactNode, createContext, useContext } from 'react';
import * as auth from '../auth-provider';
import { User } from 'typescripts/user';
import { http } from 'utils/http';
import { useMount } from 'utils';
import { useAsync } from './../utils/use-async';
import { FullPageErrorFallback, FullPageLoading } from 'components/lib';
import { useQueryClient } from 'react-query';
interface AuthForm {
  username: string;
  password: string;
}
const AuthContext = createContext<
  | {
      user: User | null;
      login: (form: AuthForm) => Promise<void>;
      register: (form: AuthForm) => Promise<void>;
      logout: () => Promise<void>;
    }
  | undefined
>(undefined);
AuthContext.displayName = 'AuthContext'; // 更改Context名称

/**
 * 初始化user, 防止页面刷新清空数据
 */
const bootstrapUser = async () => {
  let user = null;
  const token = auth.getToken();
  if (token) {
    const data = await http('me', { token });
    user = data.user;
  }
  return user;
};

export const AuthProvider = ({ children }: { children: ReactNode }) => {
  const {
    data: user,
    error,
    isLoading,
    isIdle,
    isError,
    run,
    setData: setUser,
  } = useAsync<User | null>();
  const queryClient = useQueryClient();

  const login = (form: AuthForm) => auth.login(form).then(setUser);
  const register = (form: AuthForm) => auth.register(form).then(setUser);
  const logout = () => auth.logout().then(() => {
    setUser(null)
    queryClient.clear()
  });

  useMount(() => {
    run(bootstrapUser());
  });

  if (isIdle || isLoading) {
    return <FullPageLoading />;
  }
  if (isError) {
    return <FullPageErrorFallback error={error} />;
  }

  return (
    <AuthContext.Provider
      children={children}
      value={{ user, login, register, logout }}
    />
  );
};

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth必须在AuthProvider中使用');
  }
  return context;
};
  1. src/context/index.tsx
import React, { ReactNode } from 'react';
import { AuthProvider } from 'context/auth-context';

export const AppProviders = ({ children }: { children: ReactNode }) => {
  return <AuthProvider>{children}</AuthProvider>;
};
  1. src/index.tsx
import { AppProviders } from 'context';

loadDevTools(() =>
  root.render(
    <React.StrictMode>
      <AppProviders>
        <App />
      </AppProviders>
    </React.StrictMode>,
  ),
);
  1. 使用
import { useAuth } from 'context/auth-context';

const { login, user } = useAuth();

组合组件 component composition

子组件只需要渲染,不用管逻辑, 父组件负责逻辑, 子组件负责渲染 方法属性跟子组件、孙子组件解耦了, 放在根组件, 但是加大根组件复杂性 控制反转(抽离耦合项, 只绑定中间项)

// 父组件
<ProjectScreen
  projectButton={
    <ButtonNodPadding onClick={() => setProjectModalVisible(true)} type="link">
      创建项目
    </ButtonNodPadding>
  }
/>;

// 子组件
export const ProjectListScreen = (props: { projectButton: JSX.Element }) => {
  return (
    <Container>
      <List projectButton={props.projectButton} />
    </Container>
  );
};

// 孙子组件
{
  props.projectButton;
}

跨组件状态管理选择

小场面

状态提升 / 组合组件 状态提升: 把状态提升到最近的公共父组件, 公共父组件负责管理状态

import React from 'react';
import styled from '@emotion/styled';
import { useDebounce, useDocumentTitle } from 'utils';
import { List } from './list';
import { SearchPancel } from './search-pancel';
import { useProjects } from 'utils/project';
import { useUsers } from './../../utils/user';
import { useProjectModal, useProjectsSearchParams } from './util';
import { ButtonNoPadding, ErrorBox, Row } from 'components/lib';

export const ProjectListScreen = () => {
  useDocumentTitle('项目列表', false);
  const { open } = useProjectModal();

  // 表单数据
  const [param, setParam] = useProjectsSearchParams();
  const { isLoading, error, data: list } = useProjects(useDebounce(param, 200));
  const { data: users } = useUsers();

  return (
    <Container>
      <Row between={true}>
        <h1>项目列表</h1>
        <ButtonNoPadding onClick={open} type={'link'}>
          创建项目
        </ButtonNoPadding>
      </Row>
      <SearchPancel users={users || []} param={param} setParam={setParam} />
      <ErrorBox error={error} />
      <List users={users || []} dataSource={list || []} loading={isLoading} />
    </Container>
  );
};

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

缓存状态

请求数据缓存 react-query / swf

客户端状态

类似场景: 用户是否登录, modal 弹窗打开关闭 url / redux / context

非全局状态管理--useUndo(React 自带的 reducer)

const [todos, dispatch] = useUndo(initialTodos);

const [
  countState,
  {
    set: setCount,
    reset: resetCount,
    undo: undoCount,
    redo: redoCount,
    canUndo,
    canRedo,
  },
] = useUndo(0);

const { present: presentCount } = countState;

return (
  <div>
    <button key="increment" onClick={() => setCount(presentCount + 1)}>
      +
    </button>
    <button key="decrement" onClick={() => setCount(presentCount - 1)}>
      -
    </button>
    <button key="undo" onClick={undoCount} disabled={!canUndo}>
      undo
    </button>
    <button key="undo" onClick={redoCount} disabled={!canRedo}>
      redo
    </button>
    <button key="reset" onClick={() => redoCount(0)}>
      reset to 0
    </button>
  </div>
);

手动实现 src/utils/use-undo.ts

import { useCallback, useState } from 'react';

export const useUndo = <T>(initialPresent: T) => {
  const [state, setState] = useState({
    past: [] as T[],
    present: initialPresent,
    future: [] as T[],
  });

  const canUndo = state.past.length > 0;
  const canRedo = state.future.length > 0;

  const undo = useCallback(() => {
    setState((currentState) => {
      const { past, present, future } = currentState;
      if (past.length === 0) return currentState;

      const previous = past[past.length - 1]; // past 的最后一个,也就是现在的前一个
      const newPast = past.slice(0, past.length - 1); // 复制除最后一个的past

      return {
        past: newPast,
        present: previous,
        future: [present, ...future],
      };
    });
  }, []);

  const redo = useCallback(() => {
    setState((currentState) => {
      const { past, present, future } = currentState;
      if (future.length === 0) return currentState;

      const next = future[0]; // past 的最后一个,也就是现在的前一个
      const newFuture = future.slice(1); // 复制除最后一个的past

      return {
        past: [...past, present],
        present: next,
        future: newFuture,
      };
    });
  }, []);

  const set = useCallback((newPresent: T) => {
    setState((currentState) => {
      const { past, present } = currentState;
      if (newPresent === present) return currentState;
      return {
        past: [...past, present],
        present: newPresent,
        future: [],
      };
    });
  }, []);

  const reset = useCallback((newPresent: T) => {
    setState(() => {
      return {
        past: [],
        present: newPresent,
        future: [],
      };
    });
  }, []);

  return [state, { set, reset, undo, redo, canUndo, canRedo }] as const;
};

利用useReducer手动实现

import { useCallback, useReducer } from 'react';

const UNDO = 'UNDO'
const REDO = 'REDO'
const SET = 'SET'
const RESET = 'RESET'

type State<T> = {
  past: T[]
  present: T
  future: T[]
}
type Action<T> = {
  newPresent?: T
  type: typeof UNDO | typeof REDO | typeof SET | typeof RESET
}

const undoReducer = <T>(state: State<T>, action: Action<T>) => {
  const { past, present, future } = state
  const { type, newPresent } = action
  switch (action.type) {
    case UNDO: {
      if (past.length === 0) return state;
      const previous = past[past.length - 1]; // past 的最后一个,也就是现在的前一个
      const newPast = past.slice(0, past.length - 1); // 复制除最后一个的past
      return {
        past: newPast,
        present: previous,
        future: [present, ...future],
      };
    }
    case REDO: {
      if (future.length === 0) return state;
      const next = future[0]; // past 的最后一个,也就是现在的前一个
      const newFuture = future.slice(1); // 复制除最后一个的past
      return {
        past: [...past, present],
        present: next,
        future: newFuture,
      };
    }
    case SET: {
      if (newPresent === present) return state;
      return {
        past: [...past, present],
        present: newPresent,
        future: [],
      };
    }
    case RESET: {
      return {
        past: [],
        present: newPresent,
        future: [],
      };
    }
  }
}

export const useUndo = <T>(initialPresent: T) => {
  const [state, dispatch] = useReducer(undoReducer, {
    past: [],
    present: initialPresent,
    future: [],
  } as State<T>)

  const canUndo = state.past.length > 0;
  const canRedo = state.future.length > 0;

  const undo = useCallback(() => dispatch({ type: UNDO }), []);

  const redo = useCallback(() => dispatch({ type: REDO }), []);

  const set = useCallback((newPresent: T) => dispatch({ type: SET, newPresent }), []);

  const reset = useCallback((newPresent: T) => dispatch({ type: RESET, newPresent }), []);

  return [state, { set, reset, undo, redo, canUndo, canRedo }] as const;
};

非全局状态管理--useReducer

useState 适合于定义单个的状态, useReducer 适合于定义多个状态(一群会互相影响的状态) useReducer 传入的第一个参数是reducer函数, 第二个参数是状态的初始值 reducer函数第一个参数 state 最新值, 第二个参数自定义action, 并返回处理后新的state(不能直接修改state, 引用地址没变) 返回值是 状态 和 dispatch函数(调用会触发reducer函数, 传入的参数被reducer函数第二个参数接收) 一定是同步纯函数, 才能保证可预测性

const undoReducer = <T>(state: State<T>, action: Action<T>) => {
  const { past, present, future } = state
  const { type, newPresent } = action
  switch (action.type) {
    case UNDO: {
      if (past.length === 0) return state;
      const previous = past[past.length - 1]; // past 的最后一个,也就是现在的前一个
      const newPast = past.slice(0, past.length - 1); // 复制除最后一个的past
      return {
        past: newPast,
        present: previous,
        future: [present, ...future],
      };
    }
    default:
    return state
  }
}
export const useUndo = <T>(initialPresent: T) => {
  const [state, dispatch] = useReducer(undoReducer, {
    past: [],
    present: initialPresent,
    future: [],
  } as State<T>)
}

全局状态管理--react-redux

redux 可预测的状态容器(一定是同步纯函数), 用于JavaScript, 由Action, Reducer, Store组成 react-redux 用于将redux和react关联起来(在render的时候把store里存的state和React.Component组件连接在一起, 变成组件状态) 如果要执行异步操作, 可以在异步.then里面写dispatch, 到时使用 redux-thunk 更方便直观

全局状态管理--redux-thunk 处理异步

异步操作, 比如网络请求, 需要放到action里, 然后通过dispatch触发reducer, 更新state 处理异步复制版的还有 redux-saga 和 redux-observable, 但大多数项目只需要用redux-thunk就够了 源码

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) =>
    (next) =>
    (action) => {
      if (typeof action === 'function') {
        return action(dispatch, getState, extraArgument);
      }
      return next(action);
    };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;

全局状态管理--redux-toolkit 标准化redux方案

解决redux存在的3个问题: 1.redux store配置复杂 2. 安装的依赖比较多 3.需要很多模板代码 将复杂逻辑简单化的redux 安装依赖 yarn add react-redux @reduxjs/toolkit yarn add @types/react-redux -D

store入口 src/store/index.ts

import { configureStore } from '@reduxjs/toolkit';
import { projectListSlice } from './project-list.slice';
import { authSlice } from './auth.slice';
export const rootReducer = {
  projectList: projectListSlice.reducer,
  auth: authSlice.reducer,
};

export const store = configureStore({
  reducer: rootReducer,
});

export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;

context容器包裹 src/context/index.tsx

import { Provider } from 'react-redux';
import { store } from 'store';

<Provider store={store}>{/* ... */}</Provider>;

切片文件 src/store/roject-list.slice.ts

import { createSlice } from '@reduxjs/toolkit';
import { RootState } from 'store';

interface State {
  projectModalOpen: boolean;
}

const initialState: State = {
  projectModalOpen: false,
};

export const projectListSlice = createSlice({
  name: 'projectListSlice',
  initialState,
  // redux-toolkit 内置了 immer, 可以赋值给新对象再返回新对象
  reducers: {
    openProjectModel: (state) => {
      state.projectModalOpen = true;
    },
    closeProjectModel: (state) => {
      state.projectModalOpen = false;
    },
  },
});

export const projectListActions = projectListSlice.actions;

export const selectprojectModalOpen = (state: RootState) =>
  state.projectList.projectModalOpen;

src/store/auth.slice.ts

import { User } from 'typescripts/user';
import { createSlice } from '@reduxjs/toolkit';
import * as auth from 'auth-provider';
import { AuthForm, bootstrapUser } from 'context/auth-context';
import { AppDispatch, RootState } from 'store';

type TUser = User | null;
interface State {
  user: TUser;
}

const initialState: State = {
  user: null,
};

export const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    setUser(state, action) {
      state.user = action.payload;
    },
  },
});

const { setUser } = authSlice.actions;

export const selectUser = (state: RootState) => state.auth.user;

// 异步函数--redux-thunk
export const login = (form: AuthForm) => (dispatch: AppDispatch) =>
  auth.login(form).then((user) => dispatch(setUser(user)));
export const register = (form: AuthForm) => (dispatch: AppDispatch) =>
  auth.register(form).then((user) => dispatch(setUser(user)));
export const logout = () => (dispatch: AppDispatch) =>
  auth.logout().then(() => dispatch(setUser(null)));
export const bootstrap = () => async (dispatch: AppDispatch) => {
  const user: TUser = await bootstrapUser();
  dispatch(setUser(user));
  return user;
};

使用 src/sreens/project-list/project-modal.tsx

import React from 'react';
import { Button, Drawer } from 'antd';
import {
  projectListActions,
  selectprojectModalOpen,
} from 'store/project-list.slice';
import { useDispatch, useSelector } from 'react-redux';
import { AppDispatch } from 'store';

export const ProjectModal = () => {
  const dispatch: AppDispatch = useDispatch();
  const projectModalOpen = useSelector(selectprojectModalOpen);
  return (
    <Drawer
      onClose={() => dispatch(projectListActions.closeProjectModel())}
      open={projectModalOpen}
      width={'100%'}
    >
      <h1>Project model</h1>
      <Button
        onClick={() => dispatch(projectListActions.closeProjectModel())}
      ></Button>
    </Drawer>
  );
};

redux替换context src/context/auth-context.tsx

import React, { ReactNode, useCallback } from 'react';
import * as auth from '../auth-provider';
import { User } from 'typescripts/user';
import { http } from 'utils/http';
import { useMount } from 'utils';
import { useAsync } from './../utils/use-async';
import { FullPageErrorFallback, FullPageLoading } from 'components/lib';
import * as authStore from 'store/auth.slice';
import { useDispatch, useSelector } from 'react-redux';
import { selectUser, bootstrap } from 'store/auth.slice';
import { AppDispatch } from 'store';

export interface AuthForm {
  username: string;
  password: string;
}

/**
 * 初始化user, 防止页面刷新清空数据
 */
export const bootstrapUser = async () => {
  let user = null;
  const token = auth.getToken();
  if (token) {
    const data = await http('me', { token });
    user = data.user;
  }
  return user;
};

export const AuthProvider = ({ children }: { children: ReactNode }) => {
  const { error, isLoading, isIdle, isError, run } = useAsync<User | null>();

  const dispatch: AppDispatch = useDispatch();

  useMount(() => {
    run(dispatch(bootstrap()));
  });

  if (isIdle || isLoading) {
    return <FullPageLoading />;
  }
  if (isError) {
    return <FullPageErrorFallback error={error} />;
  }

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

export const useAuth = () => {
  const dispatch: AppDispatch = useDispatch();
  const user = useSelector(selectUser);
  const login = useCallback(
    (form: AuthForm) => dispatch(authStore.login(form)),
    [dispatch],
  );
  const register = useCallback(
    (form: AuthForm) => dispatch(authStore.register(form)),
    [dispatch],
  );
  const logout = useCallback(() => dispatch(authStore.logout()), [dispatch]);
  return {
    user,
    login,
    register,
    logout,
  };
};

React Query 缓存状态

数据请求, 缓存(缓存在内存中不是浏览器中), 更新, 错误处理, 批量处理, 数据持久化, 数据同步, react-query内部包含了useAsync 的代码和功能 2秒内遇到重复请求会合并请求 useMutation的主要作用是清除缓存,更新和删除数据 useQuery(key, 异步请求), key可以是字符串, 也可以是类似 useEffect 依赖的数组, 数组中的每一项都会触发重新请求

yarn add react-query 工具 yarn add react-query-devtools

import {
  useQuery,
  useQueryClient,
  QueryClientProvider,
  QueryClient,
  useMutation,
} from 'react-query';

// 请求
function Example() {
  const { status, data, error, isFetching, isError, isSuccess, isLoading } =
    useQuery('user', async () => {
      const res = await axios.get('/api/user');
      return res.data;
    });

  // 更新
  const queryClient = useQueryClient();
  const logoutMutation = useMutation(logout, {
    onSuccess: () => queryClient.invalidateQueries('user'),
  });
  const loginMutation = useMutation(login, {
    onSuccess: () => queryClient.invalidateQueries('user'),
  });
  return <div></div>;
}

// 获取数据
export default function App() {
  const queryClient = useQueryClient();
  const user = queryClient.getQueryData('user');
  return (
    <QueryClientProvider>
      <Example />
    </QueryClientProvider>
  );
}

// 清空数据
const queryClient = useQueryClient();
queryClient.clear();

代替useAsync改写useProjects

import { Project } from 'typescripts/project';
import { useHttp } from './http';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { QueryKey } from 'react-query';
import {
  useAddConfig,
  useDeleteConfig,
  useEditConfig,
} from './use-optimistic-options';

export const useProjects = (param?: Partial<Project>) => {
  const client = useHttp();
  return useQuery<Project[]>(['projects', param], () =>
    client('projects', { data: param }),
  );
};

export const useEditProject = (queryKey: QueryKey) => {
  const client = useHttp();
  return useMutation(
    (params: Partial<Project>) =>
      client(`projects/${params.id}`, { data: params, method: 'PATCH' }),
    useEditConfig(queryKey),
  );
};

export const useAddProject = (queryKey: QueryKey) => {
  const client = useHttp();
  return useMutation(
    (params: Partial<Project>) =>
      client(`projects`, { data: params, method: 'POST' }),
    useAddConfig(queryKey),
  );
};

export const useDeleteProject = (queryKey: QueryKey) => {
  const client = useHttp();
  return useMutation(
    ({ id }: { id: number }) => client(`projects/${id}`, { method: 'DELETE' }),
    useDeleteConfig(queryKey),
  );
};

export const useProject = (id?: number) => {
  const client = useHttp();
  return useQuery<Project>(
    ['project', { id }],
    () => client(`projects/${id}`),
    {
      enabled: id !== undefined, // id有值才触发请求
    },
  );
};

optimistic updates 乐观更新

乐观更新: 先更新界面, 后发送网络请求, 网络请求失败, 回滚界面 src/utils/use-optimistic-options.ts

import { QueryKey, useQueryClient } from 'react-query';

/**
 * 生产 react-query 刷新或者乐观更新的配置
 * callback 用来处理老数据
 */
export const useConfig = (
  queryKey: QueryKey,
  callback: (target: any, old?: any[]) => any[],
) => {
  const queryClient = useQueryClient();

  return {
    // 成功之后刷新
    onSuccess: () => queryClient.invalidateQueries(queryKey),
    // 删除、更新、修改,乐观更新
    async onMutate(target: any) {
      const previousItems = queryClient.getQueryData(queryKey);
      queryClient.setQueryData(queryKey, (old?: any[]) => {
        return callback(target, old);
      });
      return { previousItems };
    },
    // 接口错误时回滚机制
    onError: (error: any, newItem: any, context: any) => {
      queryClient.setQueryData(queryKey, context.previousItems);
    },
  };
};

export const useDeleteConfig = (queryKey: QueryKey) =>
  useConfig(queryKey, (target, old) => {
    return old?.filter((item) => item.id !== target.id) || [];
  });
export const useEditConfig = (queryKey: QueryKey) =>
  useConfig(queryKey, (target, old) => {
    return (
      old?.map((item) =>
        item.id === target.id ? { ...item, ...target } : item,
      ) || []
    );
  });
export const useAddConfig = (queryKey: QueryKey) =>
  useConfig(queryKey, (target, old) => {
    return old ? [...old, target] : [];
  });

CSS-in-JS

CSS-in-JS 不是指某一个具体的库, 是指组织CSS代码的一种方式, 代表库有 styled-components 和 emotion 传统CSS缺陷: 1. 缺乏模块组织 2. 缺乏作用域 3. 隐式依赖, 让样式难以追踪 4. 没有变量 5. CSS选择器和HTML元素耦合

安装使用 emotion

  1. 删除src/index.css, 重写src/App.css
/* App.css */
html {
  /* em 相对于父元素的fonts-size */
  /* rem 相对于根元素 html 的fonts-size */
  /* 浏览器默认 16px * 62.5% = 10px, 1rem = 10px */
  font-size: 62.5%;
}

html body #root .App {
  min-height: 100vh;
}
  1. 安装依赖

    yarn add @emotion/react @emotion/styled vscode 安装插件 vscode-styled-components(styled-components) emotion-auto-css(emotion) // react 样式组件代码提示 webstom 安装插件 styled-components styled.div,.后面只能接 html 自带元素, 其他组件用 styled(Card) 包裹

<Container>
  <ShadowCard>
    {isRegister ? <RegisterScreen /> : <LoginScreen />}
    <Button type="primary" onClick={() => setIsRegister(!isRegister)}>
      切换到{isRegister ? '登录' : '注册'}
    </Button>
  </ShadowCard>
</Container>;

const ShadowCard = styled(Card)`
  width: 40rem;
  min-height: 56rem;
  padding: 3.2rem 4rem;
  border-radius: 0.3rem;
  box-sizing: border-box;
  box-shadow: rgba(0, 0, 0, 0.1) 0 0 10px;
`;
const Container = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  min-height: 100vh;
`;

写行内样式

@jsxImportSource @emotion/react 加在组件第一行

/* @jsxImportSource @emotion/react */
<Form css={{ marginBottom: '2rem' }} layout={'inline'}></Form>;

// 或者
/* @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
<Form
  css={css`
    margin-bottom: 2rem;
  `}
  layout={'inline'}
></Form>;

利用变量写可定制组件

src/components/lib.tsx

import styled from '@emotion/styled';

export const Row = styled.div<{
  gap?: number | boolean;
  between?: boolean;
  marginBottom?: number;
}>`
  display: flex;
  align-items: center;
  justify-content: ${(props) => (props.between ? 'space-between' : undefined)};
  margin-bottom: ${(props) => props.marginBottom + 'rem'};
  > * {
    margin-top: 0 !important;
    margin-bottom: 0 !important;
    margin-right: ${(props) =>
      typeof props.gap === 'number'
        ? props.gap + 'rem'
        : props.gap
          ? '2rem'
          : undefined};
  }
`;

错误边界

react-error-boundary 库使用

手动实现

src/components/error-boundary.tsx

import React from 'react';

type FallbackRender = (props: { error: Error | null }) => React.ReactElement;
// React.PropsWithChildren<{fallbackRender: FallbackRender}> 等同于 {children: ReactNode, fallbackRender: FallbackRender}
export class ErrorBoundary extends React.Component<
  React.PropsWithChildren<{ fallbackRender: FallbackRender }>,
  { error: Error | null }
> {
  state = {
    error: null,
  };

  // 当子组件抛出异常, 这里会接收到并且调用
  static getDerivedStateFromError(error: Error) {
    return { error };
  }

  render() {
    const { error } = this.state;
    const { fallbackRender, children } = this.props;
    if (error) {
      return fallbackRender({ error });
    }
    return children;
  }
}

使用 src/App.tsx

import { ErrorBoundary } from 'components/error-boundary';
import { FullPageErrorFallback } from 'components/lib';

<div className="App">
  <ErrorBoundary fallbackRender={FullPageErrorFallback}>
    {user ? <AuthenticatedApp /> : <UnauthenticatedApp />}
  </ErrorBoundary>
</div>;

src/components/lib

const FullPage = styled.div`
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
`;

export const FullPageErrorFallback = ({ error }: { error: Error | null }) => (
  <FullPage>
    <DevTools />
    <ErrorBox error={error} />
  </FullPage>
);

小知识

a 标签警告

用 antd 的 Button type='link' 代替

渲染 svg

import { ReactComponent as SoftwareLogo } from 'assets/software-logo.svg';

<SoftwareLogo width="18rem" color="rgb(38, 132, 255)" />;

处理时间的库

yarn add dayjs momentjs已停止维护

子节点写法

<div>
  <label htmlFor="username">用户名</label>
  <input type="text" id={'username'} />
</div>

// 等同于
<div children={
  <>
    <label htmlFor="username">用户名</label>
  <input type="text" id={'username'} />
  </>
}/>

其他知识补充

grid 和 flex 各自的应用场景

  1. 一维布局用flex, 二维布局用grid
  2. flex 从内容出发: 先有一组内容(数量不固定), 希望他们均匀分布在容器中, 由内容自己的大小决定占据的空间
  3. grid 从布局出发: 先规划网格(网格数量比较固定), 然后再把元素往里填充

封装 fetch

axios 和 fetch 的表现不一样, axios可以直接在返回状态不为 2xx 的时候抛出异常, fetch只有网络连接断开才会抛出异常, 要手动抛出 src/utils/http.ts

import * as auth from 'auth-provider';
import { useAuth } from 'context/auth-context';
import qs from 'qs';

const apiUrl = process.env.REACT_APP_API_URL;

interface Config extends RequestInit {
  token?: string;
  data?: object;
}

export const http = async (
  endpoint: string,
  { data, token, headers, ...customConfig }: Config = {},
) => {
  const config = {
    method: 'GET',
    headers: {
      Authorization: token ? `Bearer ${token}` : '',
      'Content-Type': data ? 'application/json' : '',
    },
    ...customConfig,
  };
  if (config.method.toUpperCase() === 'GET') {
    endpoint += `?${qs.stringify(data)}`;
  } else {
    config.body = JSON.stringify(data || {});
  }
  return window
    .fetch(`${apiUrl}/${endpoint}`, config)
    .then(async (response) => {
      if (response.status === 401) {
        await auth.logout();
        window.location.reload();
        return Promise.reject({ message: '请重新登录' });
      }
      const data = await response.json();
      if (response.ok) {
        return data;
      } else {
        return Promise.reject(data);
      }
    });
};

export const useHttp = () => {
  const { user } = useAuth();

  return useCallback(
    (...[endpoint, config]: Parameters<typeof http>) =>
      http(endpoint, { ...config, token: user?.token }),
    [user?.token],
  );
};

使用

import { useHttp } from 'utils/http';

const client = useHttp();
useMount(() => {
  client('users').then(setUsers);
});

uri 转译

decodeURIComponent('%E6%96%87%E6%9C%AC') // 反转译成文本 encodeURIComponent('文本') // 转译文本 enencodeURI('url') // 转译整个url

去除对象的空值

/**
 * 去除对象的空值
 */
export const isVoid = (value: unknown) => value === undefined || value === null || value === '';
export const cleanObject = (object: { [key: string]: unknown}) => {
  const result = { ...object };
  Object.keys(result).forEach((key) => {
    const value = result[key];
    if (isVoid(value)) {
      delete result[key];
    }
  });
  return result;
};

url自动拼接参数

安装依赖 yarn add qs

fetch(`${apiUrl}/projects?${qs.stringify(cleanObject(param))}`);

typescript

类型忽略

// @ts-ignore

常用类型

  • 1.number
  • 2.string
  • 3.array

    内部类型统一 type[] 或 Array<type>

  • 4.boolean
  • 5.函数
// 声明函数有2种方法
// 1. 直接声明: 参数和返回值, 返回值支持类型推断时可以省略
export const isFalsy = (value: unknown): boolean => value === 0 ? false : !value

// 2. 直接声明你想要的函数类型:
export const isFalsy: (value: unknown) => boolean = (value) => value === 0 ? false : !value

// 带类型加默认值, 有默认值会自动变成可选
export const http = async (endpoint: string, { data, token, headers, ...customConfig }: Config = {}) {}
  • 6.any
  • 7.void

    没有返回值或者返回值为undefined

  • 8.object

    除了 number, string, boolean, bigint, symbol, null, undefined, 其他都是 object

9.tuple 元组

tuple 数量固定, 可以各异的数组,便于使用者重命名 后面加 as const 可以返回元组最原始的类型

// 例1:
const [list, setList] = useState([]);

// 例2:
const useHappy = () => {
  //....
  return [isHappy, makeHappy, makeUnHappy];
};

const SomeComponent = () => {
  const [tomIsHappy, makeTomHappy, makeTomUnHappy] = useHappy();
};

// 例3: as const
const a = ['jack', 12, {gender: 'male'}] as const // a: readonly ["jack", 12, {readonly gender: "male";}]

10.enum

  enum Color {
    Red,
    Green,
    Blue
  }
  let c: Color = Color.Green

11.null 和 undefined

既是一个值, 也是一个类型

 let u: undefined = undefined
 let n: null = null

12.unknown

类型为unknown的变量可以赋值为任意类型 当想用any的时候用unknown代替 当返回值跟参数类型无关时, 用unknown unknown 类型的值不能赋值给任意值, 也不能读取任务属性方法

// 错误示范
let value: unknown
let valueNumber = 1
valueNumber = value // 报错
value.toFixed() // 报错

类型守卫: 解决unknown类型报错

const isError = (value: any): value is Error => value?.message;

export const ErrorBox = ({error}: {error: unknown}) => {
  if (isError(error)) {
    return <Typography.Text type="danger">{error?.message}</Typography.Text>
  }
  return null
}

13.never

// 这个 函数返回的就说never类型
const func = () => {
  throw new Error();
};

interface

interface 不是一种类型, 应该被翻译成接口,或者说使用上面介绍的类型, 创建一个我们自己的类型

interface User {
  id: number
}
const u: User = { id: 1 }

泛型

当返回值跟参数类型有关时, 用泛型 实现方式: 函数名后面加泛型占位符<>, 里面写任意大写字母 <S>

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

// 普通函数
function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];

// 箭头函数
const useDebounce = <V>(value: V, delay = 200) => {
  //....
}
// type
type State<T> = {
  past: T[]
  present:T
  future:T[]
}

// 例子
/**
 * 数组操作
 */
export const useArray = <T>(initialArray: T[]) => {
  const [value, setValue] = useState(initialArray)
  return {
    value,
    setValue,
    add: (item: T) => setValue([...value, item]),
    removeIndex: (index: number) => {
      const copy = [...value]
      copy.splice(index, 1)
      setValue(copy)
    },
    clear: () => setValue([])
  }
}

extend 继承

实现方式: 接口名后面加extends, 里面写父接口 可以给 Base类型的值, 赋extends Base的更高级类型的值 如果有相同类型, 不是直接覆盖, 而是找最大公约数合并

interface Base {
  id: number
}
interface Advance extends Base {
  name: string
}
const test = (p: Base) => {}
const a: Advance = { id: 1, name: 'tom'}
// 可以给Base类型的test参数赋值更高级类型Advance的 a 值
test(a)

TS Utility Types (操作符)

Utility Types 的用法: 用泛型给它传入一个其他类型, 然后 Utility Types 对这个类型进行某种操作

| 联合类型
let myFavoriteNumber: string | number
myFavoriteNumber = 7
myFavoriteNumber = 'six'
& 交叉类型

把两个类型结合成一个类型

 type PropsWithChildren<P = unknown> = P & { children?: ReactNode | undefined };
as 类型断言

告诉编译器, 这里断言为某个类型, 编译器就不会报错

as const 返回元组最原始的类型
return [
  { ...param, personId: Number(param.personId) || undefined },
  setParam,
] as const;
type 类型别名

类型别名在很多情况下可以跟interface互换

// interface在这种情况下不能代替type
type FavoriteNumber = string | number
let roseFavoriteNumber: FavoriteNumber = '6'
typeof

提取出变量的类型 TS 的 typeof 是静态的操作符, 不能参与运行, js 中的 typeof 1==='number' 是在runtime 时运行

export const UserSelect = (props: React.ComponentProps<typeof IdSelect>) => {};
keyof

提取对象中的key, 联合在一起组成了联合类型

type Person = {
  name: string,
  age: number
}
type PersonKeys = keyof Person // 'name' | 'age'
ReturnType

传入函数, 返回函数的返回值类型

export type RootState = ReturnType<typeof store.getState>;
Parameters

获取函数形参的类型

export const http = async (
  endpoint: string,
  { data, token, headers, ...customConfig }: Config = {},
) => {}
export const useHttp = () => {
  const { user } = useAuth();

  return (...[endpoint, config]: Parameters<typeof http>) =>
    http(endpoint, { ...config, token: user?.token });
}
Partial

把类型里面的必选属性变成可选属性

type Person = {
  name: string,
  age: number
}
const xiaoMing: Partial<Person> = {name: 'xiaoMing'} // {name: string, age?: number}
// Partial 的实现
// in 遍历对象, 然后用? 变成可选属性
type Partial<T> = {
  [P in keyof T]?: T[P];
}
Omit

删除类型里面的属性, 剩下的属性变成可选属性 第二属性是要删除的属性名, 字符串类型, 返回值是删除后的属性 删除多个用 | 分割, Omit<Person, 'name' | 'age'>

type Person = {
  name: string,
  age: number
}
const shenMiRen: Omit<Person, 'name'> = {age: 16} // {age: number}
Pick

提取类型里面的属性, 变成可选属性 第二属性是要提取的属性名, 字符串类型, 返回值是提取的属性 提取多个用 | 分割, Pick<Person, 'name' | 'age'>

type Person = {
  name: string,
  age: number
}
type PersonOnlyName = Pick<Person, 'name'> // {name: string}
Exclude

排除类型里面的属性, 变成可选属性 第二属性是要排除的属性名, 字符串类型, 返回值是排除后的属性 排除多个用 | 分割, Exclude<Person, 'name' | 'age'>

type Person = {
  name: string,
  age: number
}
type PersonExcludeName = Exclude<keyof Person, 'name'> // 'age'
Record

用于创建一个具有指定属性键和对应值类型的对象类型

第一个属性传键类型, 第二属性传值类型

定义:

type Record<K extends keyof any, T> = { [P in K]: T; };

使用

type Person = {
  name: string,
  age: number
}
// 简单用法
type PersonRecord = Record<'name' | 'age', string | number> // 等价于 { id: string | number; name:  string | number; }


// 复杂用法
// 泛型 T 是对象类型,  K extends keyof T 表示 K 是 T 对象的键之一
// Record<K, T[K]>是泛型约束语法, T extends Record<K, T[K]> 表示 T 是一个拥有特定键值对应关系的对象类型
function personRecord<T extends Record<K, T[K]>, K extends keyof T>(person: T): {label: T[K], value: T[K]}[]

.d.ts

JS 文件 + .d.ts 文件 === ts 文件 .d.ts 文件可以让 JS 文件继续维持自己 JS 文件的身份, 而拥有TS的类型保护 一般写业务代码不会用到, 但是点击类型跳转一般会跳到 .d.ts 文件

antd 参数类型简化写法, 透传ant组件类型

table组件
// 父组件
<List users={users} dataSource={list} loading={isLoading} />;

// 子组件
import dayjs from 'dayjs';
import React from 'react';
import { Dropdown, Modal, Table, TableProps } from 'antd';
import { User } from 'typescripts/user';
import { Link } from 'react-router-dom';
import { Pin } from 'components/pin';
import { useDeleteProject, useEditProject } from 'utils/project';
import { ButtonNoPadding } from 'components/lib';
import { useProjectModal, useProjectsQueryKey } from './util';
import { Project } from 'typescripts/project';
interface ListProps extends TableProps<Project> {
  users: User[];
}
export const List = ({ users, ...props }: ListProps) => {
  const { mutate } = useEditProject(useProjectsQueryKey());
  const pinProject = (id: number) => (pin: boolean) => mutate({ id, pin });
  return (
    <Table
      pagination={false}
      columns={[
        {
          title: <Pin checked={true} disabled={true} />,
          render(value, project) {
            return (
              <Pin
                checked={project.pin}
                onCheckedChange={pinProject(project.id)}
              />
            );
          },
        },
        {
          title: '名称',
          sorter: (a, b) => a.name.localeCompare(b.name),
          render(value, project) {
            return <Link to={String(project.id)}>{project.name}</Link>;
          },
        },
        {
          title: '部门',
          dataIndex: 'organization',
        },
        {
          title: '负责人',
          render: (value, project) => {
            return (
              <span>
                {users.find((user) => user.id === project.personId)?.name ||
                  '未知'}
              </span>
            );
          },
        },
        {
          title: '创建时间',
          render: (value, project) => {
            return (
              <span>
                {project.created
                  ? dayjs(project.created).format('YYYY-MM-DD')
                  : '无'}
              </span>
            );
          },
        },
        {
          render(value, project) {
            return <More project={project} />;
          },
        },
      ]}
      {...props}
    ></Table>
  );
};

const More = ({ project }: { project: Project }) => {
  const { startEdit } = useProjectModal();
  const editProject = (id: number) => () => startEdit(id);
  const { mutate: deleteProject } = useDeleteProject(useProjectsQueryKey());
  const confirmDeleteProject = (id: number) => {
    Modal.confirm({
      title: '确定删除这个项目吗?',
      content: '点击确定删除',
      okText: '确定',
      cancelText: '取消',
      onOk() {
        deleteProject({ id });
      },
    });
  };
  return (
    <Dropdown
      menu={{
        items: [
          {
            key: 'edit',
            label: (
              <ButtonNoPadding onClick={editProject(project.id)} type="link">
                编辑
              </ButtonNoPadding>
            ),
          },
          {
            key: 'delete',
            label: (
              <ButtonNoPadding
                onClick={() => confirmDeleteProject(project.id)}
                type="link"
              >
                删除
              </ButtonNoPadding>
            ),
          },
        ],
      }}
    >
      <ButtonNoPadding type="link">...</ButtonNoPadding>
    </Dropdown>
  );
};
select 组件
import React from 'react';
import { Raw } from 'typescripts';
import { Select } from 'antd';

type SelectProps = React.ComponentProps<typeof Select>;

interface IdSelectProps
  extends Omit<SelectProps, 'value' | 'onChange' | 'options'> {
  value?: Raw | null | undefined;
  onChange?: (value?: number) => void;
  defaultOptionName?: string;
  options?: { name: string; id: number }[];
}

/**
 * 选id的select
 * value 可以传入多种类型的值
 * onChange 只会回调 number | undefined 类型, 当 isNaN(Number(value)) 为true, 代表选择默认类型
 * 当选择默认类型的时候, onChange 会回调 undefined
 * @param {IdSelectProps} props
 */
export const IdSelect = (props: IdSelectProps) => {
  const { value, onChange, defaultOptionName, options, ...resetProps } = props;
  return (
    <Select
      value={options?.length ? toNumber(value) : defaultOptionName}
      onChange={(value) => onChange?.(toNumber(value) || undefined)}
      {...resetProps}
    >
      {defaultOptionName ? (
        <Select.Option value={0}>{defaultOptionName}</Select.Option>
      ) : null}
      {options?.map((option) => (
        <Select.Option key={option.id} value={option.id}>
          {option.name}
        </Select.Option>
      ))}
    </Select>
  );
};

const toNumber = (value: unknown) => (isNaN(Number(value)) ? 0 : Number(value));

宏任务和微任务

宏任务(macro-task): 同步script(整体代码), setTimeout 回调函数, setInterval 回调函数, setImmediate 回调函数, I/O, UI rendering; 微任务(micro-task): process.nextTick(Node.js 环境), Promise 回调函数, Object.observe(已废弃), MutationObserver 回调函数;

事件循环

  1. 首先 Javascript 引擎会执行一个宏任务, 注意这个宏任务一般是指主干代码本身, 也就是目前的同步代码
  2. 执行过程中如果遇到微任务, 则会将它添加到微任务的任务队列中
  3. 宏任务执行完毕后, 立即执行当前微任务队列中的所有微任务(依次执行), 直到微任务队列被清空
  4. 微任务执行完成后, 开始执行下一个宏任务(如果存在的话)
  5. 如此循环往复, 直到宏任务和微任务队列都清空

iterator 遍历器

[], {}, Map 都是部署了iterator的, 特点: 可以用 for of 遍历

let a = [1, 2, 3];
for (let v of a) {
  console.log(v);
} // 1 2 3

查看是否部署了 iterator

let a = [1, 2, 3];
let i = a[Symbol.iterator]();
console.log(i); // Array Iterator {} 里面有next方法
i.next(); // {value: 1, done: false}
i.next(); // {value: 2, done: false}
i.next(); // {value: 3, done: false}
i.next(); // {value: undefined, done: true}, 停止工作

手动实现

const obj = {
  data: ['hello', 'world'],
  [Symbol.iterator]() {
    const self = this;
    let index = 0;
    return {
      next() {
        if (index < self.data.length) {
          return {
            value: self.data[index++],
            done: false,
          };
        } else {
          return { value: undefined, done: true };
        }
      },
    };
  },
};

函数柯里化应用

const pinProject = (id: number) => (pin: boolean) => mutate({id, pin})

<Pin onCheckedChange={pinProject(project.id)}/>

// 等同于

const pinProject = (id: number, pin: boolean) => mutate({id, pin})

<Pin onCheckedChange={pin => pinProject(project.id, pin)}/>

去除对象空值

/**
 * 判断值是否为0
 */
export const isFalsy = (value: unknown) => (value === 0 ? false : !value);
export const isVoid = (value: unknown) =>
  value === undefined || value === null || value === '';
/**
 * 去除对象的空值
 */
export const cleanObject = (object: { [key: string]: unknown }) => {
  const result = { ...object };
  Object.keys(result).forEach((key) => {
    const value = result[key];
    if (isVoid(value)) {
      delete result[key];
    }
  });
  return result;
};

封装 react-beautiful-dnd 拖拽组件

yarn add react-beautiful-dnd yarn add @types/react-beautiful-dnd -D src/components/drag-and-drop.tsx

import React from 'react';
import {
  Draggable,
  DraggableProps,
  Droppable,
  DroppableProps,
  DroppableProvided,
  DroppableProvidedProps,
} from 'react-beautiful-dnd';

type DropProps = Omit<DroppableProps, 'children'> & {
  children: React.ReactNode;
};

export const Drop = ({ children, ...props }: DropProps) => {
  return (
    <Droppable {...props}>
      {(provided) => {
        if (React.isValidElement(children)) {
          return React.cloneElement(children, {
            ...provided.droppableProps,
            ref: provided.innerRef,
            provided,
          });
        }
        return <div />;
      }}
    </Droppable>
  );
};
type DropChildProps = Partial<
  { provided: DroppableProvided } & DroppableProvidedProps
> &
  React.HTMLAttributes<HTMLDivElement>;
export const DropChild = React.forwardRef<HTMLDivElement, DropChildProps>(
  ({ children, ...props }, ref) => (
    <div ref={ref} {...props}>
      {children}
      {props.provided?.placeholder}
    </div>
  ),
);

type DragProps = Omit<DraggableProps, 'children'> & {
  children: React.ReactNode;
};
export const Drag = ({ children, ...props }: DragProps) => {
  return (
    <Draggable {...props}>
      {(provided) => {
        if (React.isValidElement(children)) {
          return React.cloneElement(children, {
            ...provided.draggableProps,
            ...provided.dragHandleProps,
            ref: provided.innerRef,
          });
        }
        return <div />;
      }}
    </Draggable>
  );
};

乐观更新配置 src/utils/use-optimistic-options.ts

export const useReorderKanbanConfig = (queryKey: QueryKey) =>
  useConfig(queryKey, (target, old) => reorder({ list: old, ...target }));

export const useReorderTaskConfig = (queryKey: QueryKey) =>
  useConfig(queryKey, (target, old) => {
    // 乐观更新task序列中的位置
    const orderedList = reorder({ list: old, ...target }) as Task[];
    return orderedList.map((item) =>
      item.id === target.taskId
        ? { ...item, kanbanId: target.toKanbanId }
        : item,
    );
  });

乐观更新逻辑 src/utils/reorder.ts

/**
 * 乐观更新逻辑
 * @param fromId 要排序的项目id
 * @param type 'before' | 'after'
 * @param referenceId 参照id
 * @param list 要排序的列表, 比如tasks, kanbans
 */
export const reorder = ({
  fromId,
  type,
  referenceId,
  list,
}: {
  list: { id: number }[];
  fromId: number;
  type: 'after' | 'before';
  referenceId: number;
}) => {
  const copiedList = [...list];
  // 找到fromId对应项目的下标
  const movingItemIndex = copiedList.findIndex((item) => item.id === fromId);
  if (!referenceId) {
    return insertAfter([...copiedList], movingItemIndex, copiedList.length - 1);
  }
  const targetIndex = copiedList.findIndex((item) => item.id === referenceId);
  const insert = type === 'after' ? insertAfter : insertBefore;
  return insert([...copiedList], movingItemIndex, targetIndex);
};

/**
 * 在list中,把from放在to的前边
 * @param list
 * @param from
 * @param to
 */
const insertBefore = (list: unknown[], from: number, to: number) => {
  const toItem = list[to];
  const removedItem = list.splice(from, 1)[0];
  const toIndex = list.indexOf(toItem);
  list.splice(toIndex, 0, removedItem);
  return list;
};

/**
 * 在list中,把from放在to的后面
 * @param list
 * @param from
 * @param to
 */
const insertAfter = (list: unknown[], from: number, to: number) => {
  const toItem = list[to];
  const removedItem = list.splice(from, 1)[0];
  const toIndex = list.indexOf(toItem);
  list.splice(toIndex + 1, 0, removedItem);
  return list;
};

接口封装 src/utils/kanban.ts

import { useReorderKanbanConfig } from './use-optimistic-options';
export interface SortProps {
  // 要重新排序的item
  fromId: number;
  // 目标item
  referenceId: number;
  // 放在目标item的前还是后
  type: 'before' | 'after';
  fromKanbanId?: number;
  toKanbanId?: number;
}
export const useReorderKanban = (queryKey: QueryKey) => {
  const client = useHttp();
  return useMutation(
    (params: SortProps) =>
      client(`kanban/reorder`, { data: params, method: 'POST' }),
    useReorderKanbanConfig(queryKey),
  );
};

src/utils/task.ts

import { useReorderKanbanConfig } from './use-optimistic-options';
import { SortProps } from './kanban';

export const useReorderTask = (queryKey: QueryKey) => {
  const client = useHttp();
  return useMutation(
    (params: SortProps) =>
      client(`tasks/reorder`, { data: params, method: 'POST' }),
    useReorderTaskConfig(queryKey),
  );
};

看板拖拽 src/screen/kanban/index.tsx

import React, { useCallback } from 'react';
import { useDocumentTitle } from 'utils';
import { useKanbans } from 'utils/kanban';
import {
  useKanbanSearchParams,
  useKanbansQueryKey,
  useProjectInUrl,
  useTasksQueryKey,
  useTasksSearchParams,
} from './util';
import { KanbanColumn } from './kanban-column';
import styled from '@emotion/styled';
import { SearchPanel } from './search-panel';
import { ScreenContainer } from 'components/lib';
import { useReorderTask, useTasks } from 'utils/task';
import { Spin } from 'antd';
import { CreateKanban } from './create-kanban';
import { TaskModal } from './task-modal';
import { DragDropContext, DropResult } from 'react-beautiful-dnd';
import { Drag, Drop, DropChild } from 'components/drag-and-drop';
import { useReorderKanban } from './../../utils/kanban';

export const KanbanScreen = () => {
  useDocumentTitle('看板列表');
  const { data: currentProject } = useProjectInUrl();
  const { data: kanbans, isLoading: kanbanIsLoading } = useKanbans(
    useKanbanSearchParams(),
  );
  const { isLoading: taskIsLoading } = useTasks(useTasksSearchParams());
  const isLoading = taskIsLoading || kanbanIsLoading;
  const onDragEnd = useDragEnd();
  return (
    // onDragEnd 做持久化的工作
    <DragDropContext onDragEnd={onDragEnd}>
      <ScreenContainer>
        <h1>{currentProject?.name}看板</h1>
        <SearchPanel />
        {isLoading ? (
          <Spin size={'large'} />
        ) : (
          <ColumnsContainer>
            <Drop type="COLUMN" direction="horizontal" droppableId="kanban">
              <DropChild style={{ display: 'flex' }}>
                {kanbans?.map((kanban, index) => (
                  <Drag
                    key={kanban.id}
                    draggableId={'kanban' + kanban.id}
                    index={index}
                  >
                    <KanbanColumn
                      kanban={kanban}
                      key={kanban.id}
                    ></KanbanColumn>
                  </Drag>
                ))}
              </DropChild>
            </Drop>
            <CreateKanban />
          </ColumnsContainer>
        )}
        <TaskModal />
      </ScreenContainer>
    </DragDropContext>
  );
};

export const useDragEnd = () => {
  const { data: kanbans } = useKanbans(useKanbanSearchParams());
  const { mutate: reorderKanban } = useReorderKanban(useKanbansQueryKey());
  const { mutate: reorderTask } = useReorderTask(useTasksQueryKey());
  const { data: allTasks = [] } = useTasks(useTasksSearchParams());
  return useCallback(
    ({ source, destination, type }: DropResult) => {
      if (!destination) {
        return;
      }
      // 看板排序
      if (type === 'COLUMN') {
        const fromId = kanbans?.[source.index].id;
        const toId = kanbans?.[destination.index].id;
        if (!fromId || !toId || fromId === toId) {
          return;
        }
        const type = destination.index > source.index ? 'after' : 'before';
        reorderKanban({ fromId, referenceId: toId, type });
      }
      // task排序
      if (type === 'ROW') {
        const fromKanbanId = +source.droppableId;
        const toKanbanId = +destination.droppableId;
        // 拖拽的task
        const fromTask = allTasks.filter(
          (task) => task.kanbanId === fromKanbanId,
        )[source.index];
        const toTask = allTasks.filter((task) => task.kanbanId === toKanbanId)[
          destination.index
        ];
        if (fromTask?.id === toTask?.id) {
          return;
        }
        const type =
          fromKanbanId === toKanbanId && destination.index > source.index
            ? 'after'
            : 'before';
        reorderTask({
          fromId: fromTask?.id,
          referenceId: toTask?.id,
          fromKanbanId,
          toKanbanId,
          type,
        });
      }
    },
    [kanbans, reorderKanban, allTasks, reorderTask],
  );
};

export const ColumnsContainer = styled.div`
  display: flex;
  overflow-x: scroll;
  flex: 1;
`;

任务拖拽 src/sreens/kanban/kanban-column.tsx

import React from 'react';
import { Kanban } from 'typescripts/kanban';
import { useTasks } from './../../utils/task';
import {
  useTasksSearchParams,
  useTasksModal,
  useKanbansQueryKey,
} from './util';
import { useTaskTypes } from 'utils/task-type';
import taskIcon from 'assert/task.svg';
import bugIcon from 'assert/bug.svg';
import styled from '@emotion/styled';
import { Button, Card, Dropdown, Modal } from 'antd';
import { CreateTask } from './create-task';
import { Task } from 'typescripts/task';
import { Mark } from 'components/mark';
import { useDeleteKanban } from './../../utils/kanban';
import { Row } from 'components/lib';
import { Drag, Drop, DropChild } from 'components/drag-and-drop';

const TaskTypeIcon = ({ id }: { id: number }) => {
  const { data: taskTypes } = useTaskTypes();
  const name = taskTypes?.find((taskType) => taskType.id === id)?.name;
  if (!name) return null;
  return <img src={name === 'task' ? taskIcon : bugIcon} alt="task-icon" />;
};

const TaskCard = ({ task }: { task: Task }) => {
  const { startEdit } = useTasksModal();
  const { name: keyword } = useTasksSearchParams();
  return (
    <Card
      onClick={() => startEdit(task.id)}
      style={{ marginBottom: '0.5rem', cursor: 'pointer' }}
      key={task.kanbanId}
    >
      <p>
        <Mark name={task.name} keyword={keyword} />
      </p>
      <TaskTypeIcon id={task.typeId} />
    </Card>
  );
};

// React.forwardRef 转发ref, 或者利用html 元素包裹实现转发ref
export const KanbanColumn = React.forwardRef<
  HTMLDivElement,
  { kanban: Kanban }
>(({ kanban, ...props }, ref) => {
  const { data: allTasks } = useTasks(useTasksSearchParams());
  const tasks = allTasks?.filter((task) => task.kanbanId === kanban.id);
  return (
    <Container {...props} ref={ref}>
      <Row between={true}>
        <h3>{kanban.name}</h3>
        <More kanban={kanban} key={kanban.id} />
      </Row>
      <TasksContainer>
        <Drop type="ROW" direction="vertical" droppableId={String(kanban.id)}>
          <DropChild style={{ minHeight: '5px' }}>
            {tasks?.map((task, taskIndex) => (
              <Drag
                key={task.id}
                index={taskIndex}
                draggableId={'task' + task.id}
              >
                <div>
                  <TaskCard key={task.id} task={task} />
                </div>
              </Drag>
            ))}
          </DropChild>
        </Drop>
        <CreateTask kanbanId={kanban.id} />
      </TasksContainer>
    </Container>
  );
});

const More = ({ kanban }: { kanban: Kanban }) => {
  const { mutateAsync } = useDeleteKanban(useKanbansQueryKey());
  const startDelete = () => {
    Modal.confirm({
      okText: '确定',
      cancelText: '取消',
      title: '确定删除看板吗?',
      onOk() {
        return mutateAsync({ id: kanban.id });
      },
    });
  };
  const menu = {
    items: [
      {
        key: 'delete',
        label: (
          <Button onClick={startDelete} type="link">
            删除
          </Button>
        ),
      },
    ],
  };
  return (
    <Dropdown menu={menu}>
      <Button type="link">...</Button>
    </Dropdown>
  );
};

export const Container = styled.div`
  min-width: 27rem;
  border-radius: 6px;
  background-color: rgb(244, 245, 247)
  display: flex;
  flex-direction: column;
  padding: 0.7rem 0.7rem 1rem;
  margin-right: 1.5rem;
`;

const TasksContainer = styled.div`
  overflow: scroll;
  flex: 1;
  ::-webkit-scrollbar {
    display: none;
  }
`;

GitHub Pages 部署静态网站

username.github.io/ 用户信息地址, jira 项目部署到io地址下

1. 新建仓库

2. 代码安装 yarn add gh-pages -D

3. 配置 package.json

{
  "scripts": {
    "predeploy": "npm run build",
    "deploy": "gh-pages -d build -r git@github.com:username/username.github.io.git -b main"
  }
}

4. 解决刷新404

public/404.html

<!--
 * @Descripttion: 404页面
 * @Author: huangjitao
 * @Date: 2021-07-29 21:14:10
 * @Function: 该文件用途描述
-->
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Single Page Apps for GitHub Pages</title>
    <script type="text/javascript">
      // Single Page Apps for GitHub Pages
      // MIT License
      // https://github.com/rafgraph/spa-github-pages
      // This script takes the current url and converts the path and query
      // string into just a query string, and then redirects the browser
      // to the new url with only a query string and hash fragment,
      // e.g. https://www.foo.tld/one/two?a=b&c=d#qwe, becomes
      // https://www.foo.tld/?/one/two&a=b~and~c=d#qwe
      // Note: this 404.html file must be at least 512 bytes for it to work
      // with Internet Explorer (it is currently > 512 bytes)

      // If you're creating a Project Pages site and NOT using a custom domain,
      // then set pathSegmentsToKeep to 1 (enterprise users may need to set it to > 1).
      // This way the code will only replace the route part of the path, and not
      // the real directory in which the app resides, for example:
      // https://username.github.io/repo-name/one/two?a=b&c=d#qwe becomes
      // https://username.github.io/repo-name/?/one/two&a=b~and~c=d#qwe
      // Otherwise, leave pathSegmentsToKeep as 0.
      var pathSegmentsToKeep = 0;

      var l = window.location;
      l.replace(
        l.protocol +
          '//' +
          l.hostname +
          (l.port ? ':' + l.port : '') +
          l.pathname
            .split('/')
            .slice(0, 1 + pathSegmentsToKeep)
            .join('/') +
          '/?/' +
          l.pathname
            .slice(1)
            .split('/')
            .slice(pathSegmentsToKeep)
            .join('/')
            .replace(/&/g, '~and~') +
          (l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
          l.hash,
      );
    </script>
  </head>
  <body></body>
</html>

public/index.html

<head>
  <!-- .... -->
  <!-- Start Single Page Apps for GitHub Pages -->
  <script type="text/javascript">
    // Single Page Apps for GitHub Pages
    // MIT License
    // https://github.com/rafgraph/spa-github-pages
    // This script checks to see if a redirect is present in the query string,
    // converts it back into the correct url and adds it to the
    // browser's history using window.history.replaceState(...),
    // which won't cause the browser to attempt to load the new url.
    // When the single page app is loaded further down in this file,
    // the correct url will be waiting in the browser's history for
    // the single page app to route accordingly.
    (function (l) {
      if (l.search[1] === '/') {
        var decoded = l.search
          .slice(1)
          .split('&')
          .map(function (s) {
            return s.replace(/~and~/g, '&');
          })
          .join('?');
        window.history.replaceState(
          null,
          null,
          l.pathname.slice(0, -1) + decoded + l.hash,
        );
      }
    })(window.location);
  </script>
  <!-- End Single Page Apps for GitHub Pages -->
</head>

5. npm run deploy

自动化测试

目的 让我们对自己的代码更有信心, 防止出现"新代码破坏旧代码"的无限循环

分类

  • 单元测试: 传统单元测试、组件测试、hook测试
  • 集成测试
  • e2e测试(端对端测试)

传统单元测试 - 测试函数

yarn add @testing-library/react-hooks msw -D src/_test_/http.ts

// setupServer mock模拟异步请求
import { setupServer } from 'msw/node'
import { http } from 'src/utils/http'

const apiUrl = process.env.REACT_APP_API_URL

const server = setupServer()

// jest 是对react最友好的一个测试库
// beforeAll代表执行所有的测试之前, 先来执行一下回调函数
beforeAll(() => server.listen())

// afterEach 表示每一个测试跑完以后, 都重置mock路由
afterEach(() => server.resetHandlers())

//afterAll 表示所有的测试跑完后, 关闭mock路由
afterAll(() => server.close())

// test 测试单元
test('http方法放送异步请求', async () => {
  const endpoint = 'test-endpoint'
  const mockResult = { mockValue: 'mock'}

  server.use(
    rest.get(`${apiUrl}${endpoint}`, (req, res, ctx) => res(ctx.json(mockResult))
    )
  )

  const result = await http(endpoint)
  expect(result).toEqual(mockResult) // toEqual对象的值相等
})

test('http请求时会在header里带上token', async () => {
  const token = 'FAKE_TOKEN'
  const endpoint = 'test-endpoint'
  const mockResult = { mockValue: 'mock'}

  let request: any

  server.use(
    rest.get(`${apiUrl}${endpoint}`, async (req, res, ctx) => {
      request = req
      return res(ctx.json(mockResult))
    })
  )

  await http(endpoint, {token})
  expect(result.headers.get('Authorization')).toBe(`Bearer ${token}`) // toBe对象的值和引用都相等
})

npm run test

自动化测试 hook

src/_test_/use-async.ts

import { useAsync } from 'utils/use-async'
import { act, renderHook } from "@testing-library/react-hooks";

const defaultState: ReturnType<typeof useAsync> = {
  stat: 'idle',
  data: null,
  error: null,
  isIdle: true,
  isLoading: false,
  isError: false,
  isSuccess: false,

  run: expect.any(Function),
  setData: expect.any(Function),
  setError: expect.any(Function),
  retry: expect.any(Function),
}
const loadingState: ReturnType<typeof useAsync> = {
  ...defaultState,
  stat: 'loading',
  isIdle: false,
  isLoading: true
}
const successState: ReturnType<typeof useAsync> = {
  ...defaultState,
  stat: 'success',
  isIdle: false,
  isSuccess: true
}
test('useAsync 可以异步处理', async () => {
  let resolve: any, reject;
  const promise = newPromise((res, rej) =>{
    resolve = res
    reject = rej
  })

  const { result } = renderHook(()=> useAsync())
  expect(react.current).toEqual(defaultState)

  let p: Promise<any>
  // setState的操作要用act包起来
  act(() =>){
    p = result.current.run(promise)
  }
  expect(result.current).toEqual(loadingState)

  const resolvedValue = { mockedValue: 'resolved' }
  act(async () => {
    resolve(resolvedValue)
    await p
  })
  expect(result.current).toEqual({...successState, data: resolvedValue})
})

npm run test

自动化测试组件

src/_test_/mark.tsx

import React from 'react'
import { render, screen } from '@testing-library/react'
import { Mark } from 'components/mark'

test('Mark 组件正确高亮关键词', () => {
  const name = '物料管理'
  const keyword = '管理'
  
  render(<Mark name={name} keyword={keyword}/>)

  expect(screen.getByText(keyword)).toBeInTheDocument()
  expect(screen.getByText(name)).toHaveStyle('color', '#257AFD')
  expect(screen.getByText(name)).not.toHaveStyle('color', '#257AFD')
})

npm run test