创建项目
npx create-react-app jira --template typescript
配置绝对路径
tsconfig.json
{
"compilerOptions": {
"baseUrl": "./src",
...
}
}
格式化配置
-
安装prettier依赖包
yarn add --dev --exact prettier
-
创建配置文件
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 //组件或者标签的属性是否控制一行只显示一个属性 } -
创建忽略文件 .prettierignore
build; coverage; -
借助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 -
解决eslint 和prettier冲突
yarn add eslint-config-prettier -D
// package.json prettier覆盖一部分eslint { "eslintConfig": { "extends": ["react-app", "react-app/jest", "prettier"] } }
git 提交规范校验
node 18 以上
- yarn add @commitlint/cli @commitlint/config-conventional -D
- npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'
- 根目录新建 commitlint.config.js 文件
module.exports = {
extends: ['@commitlint/config-conventional'],
};
- package.json
{
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
}
}
- 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 // 删除
- 安装依赖
yarn add json-server -D
- 根目录建数据文件
__json_server_mock__ / db.json;
- package.json 增加脚本
{
"scripts": {
"json-server": "json-server __json_server_mock__/db.json --watch --port 3001"
}
}
- 增加环境变量
.env
REACT_APP_API_URL=http://online.com
.env.development
REACT_APP_API_URL=http://localhost:3001
- 启动服务 npm run json-server
- 使用服务
const apiUrl = process.env.REACT_APP_API_URL;
fetch(`${apiUrl}/projects`);
- 模拟非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删掉, 因为代替了它
- 所有请求被 Service Worker 代理(拦截请求)
- 后端逻辑处理后, 以 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发展历史
- 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);
},
});
- 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/>
- 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}/>}>
- 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 是异步的, 要在下次重绘才能获取新值, 不要试图在更改状态之后立马获取状态。 解决方法:
- 状态更新后立即获取状态, 可以使用 useRef 获取状态
- 状态更新后使用 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 状态管理功能
- 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;
};
- 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>;
};
- src/index.tsx
import { AppProviders } from 'context';
loadDevTools(() =>
root.render(
<React.StrictMode>
<AppProviders>
<App />
</AppProviders>
</React.StrictMode>,
),
);
- 使用
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
客户端状态
非全局状态管理--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
- 删除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;
}
- 安装依赖
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 各自的应用场景
- 一维布局用flex, 二维布局用grid
- flex 从内容出发: 先有一组内容(数量不固定), 希望他们均匀分布在容器中, 由内容自己的大小决定占据的空间
- 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 回调函数;
事件循环
- 首先 Javascript 引擎会执行一个宏任务, 注意这个宏任务一般是指主干代码本身, 也就是目前的同步代码
- 执行过程中如果遇到微任务, 则会将它添加到微任务的任务队列中
- 宏任务执行完毕后, 立即执行当前微任务队列中的所有微任务(依次执行), 直到微任务队列被清空
- 微任务执行完成后, 开始执行下一个宏任务(如果存在的话)
- 如此循环往复, 直到宏任务和微任务队列都清空
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