0.补充

0.1 简单的全局状态管理

方法一:使用 useReducer 取代 useState 的多次声明操作(非全局状态管理)

import { useReducer } from 'react'

type State = {
  count: number
  comment: string
}
type Action = {
  type: 'increment' | 'decrement'
  payload: number
}

const initState: State = { count: 0, comment: '' }

function countReducer(state: State, action: Action) {
  const { count, comment } = state
  const { type, payload } = action
  switch (type) {
    case 'increment': {
      // 千万注意,所有 state 中声明的对象,都要进行返回出来
      return { count: count + payload, comment: comment + payload }
    }
    case 'decrement': {
      return { count: count - payload, comment: comment + payload }
    }
  }
}

export const useCounter = () => {
  const [state, dispatch] = useReducer(countReducer, initState)
  const increment = (payload: number) => dispatch({ type: 'increment', payload })
  const decrement = (payload: number) => dispatch({ type: 'decrement', payload })
  return {
    ...state,
    increment,
    decrement
  }
}

// 使用:const { count, decrement, increment } = useCounter()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

方法二:使用 useContext 实现全局状态管理

状态管理封装

import { createContext, ReactNode, useContext, useState } from 'react'

// 创建上下文
const ProjectContext = createContext<
  | {
      openProject: boolean
      createProject: () => void
      deleteProject: (id: number) => void
      projectList: { id: number; content: string }[]
    }
  | undefined
>(undefined)
ProjectContext.displayName = 'ProjectContext'

// 封装提供者
export const ProjectProvider = ({ children }: { children: ReactNode }) => {
  const [openProject, setOpenProject] = useState(false)
  const [projectList, setProjectList] = useState<{ id: number; content: string }[]>([])
  const createProject = () => {
    setOpenProject(true)
    setProjectList([
      {
        id: 0,
        content: '项目一'
      },
      {
        id: 1,
        content: '项目二'
      }
    ])
    console.log('创建项目')
  }
  const deleteProject = (id: number) => {
    setProjectList(projectList.filter(project => project.id === id))
    console.log('删除项目')
  }
  return (
    <ProjectContext.Provider
      value={{ openProject, createProject, deleteProject, projectList }}
      children={children}
    />
  )
}

// 封装 context 方法实现
export const useProject = () => {
  const context = useContext(ProjectContext)
  if (!context) throw new Error('useProject 只能在 ProjectContext 中使用')
  return context
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

封装到 AppProviders 中

import { ReactNode } from 'react'
import { ProjectProvider } from './project-context'

export const AppProviders = ({ children }: { children: ReactNode }) => {
  return <ProjectProvider>{children}</ProjectProvider>
}
1
2
3
4
5
6

index.tsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { AppProviders } from './context'

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
  <React.StrictMode>
    <AppProviders>
      <App />
    </AppProviders>
  </React.StrictMode>
)
1
2
3
4
5
6
7
8
9
10
11
12
13

App.tsx

const project = useProject()
return (
  <div className='App'>
    <Button onClick={project.createProject}>创建新项目</Button>
    <Button onClick={() => project.deleteProject(0)}>删除项目</Button>
    {project.openProject ? (
      <Card>
        <Typography.Text>
          {project.projectList.map(project => (
            <Fragment>
              {project.id}-{project.content}
            </Fragment>
          ))}
        </Typography.Text>
      </Card>
    ) : null}
    {count}
    <Button onClick={() => increment(10)}>click to add count</Button>
    <Button onClick={() => decrement(10)}>click to decrement count</Button>
  </div>
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

1.前置准备

1.1 初始化

  1. 安装 ts 版的 react 项目:create-react-app jira --template typescript
  2. 安装 prettier:npm install --save-dev --save-exact prettier
  3. 新建:echo {}> .prettierrc.json
  4. 在代码提交之前,自动对代码进行格式化: mrm lint-staged,下为 package.json
"lint-staged": {
    "*.{js,css,md,ts,tsx}": "prettier --write"
  }
1
2
3
  1. 安装:npm i eslint-config-prettier -D,下为 package.json
"eslintConfig": {
  "extends": [
    "react-app",
    "react-app/jest",
    "prettier"
  ]
}
1
2
3
4
5
6
7
  1. 安装:npm install --save-dev @commitlint/config-conventional @commitlint/cli,并运行命令:echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js
  2. 输入:npx husky add .husky/commit-msg "npx --no -- commitlint --edit ${1}"

1.2 mock 方案

使用 json-server 来实现

REST API

GET / tickets // 列表
GET / tickets / 12 // 详情
POST / tickets // 增加
PUT / tickets / 12 // 替换
PATCH / tickets / 12 // 修改
DELETE / tickets / 12 // 删除
1
2
3
4
5
6

  1. 全局安装:npm i json-server -g
  2. 项目根目录新建 __json_server_mock__/db.json
  3. package.json:
"json-server": "json-server __json_server_mock__/db.json --watch --port 3001"
1

1.3 用 jsx 写一个工程列表功能

动画

index.jsx:

import { useEffect, useState } from 'react'
import qs from 'qs'
import { SearchPanel } from './search-panel'
import { List } from './list'
import { cleanObject } from 'utils'

const apiUrl = process.env.REACT_APP_API_URL

export const ProjectListScreen = () => {
  // 用户
  const [users, setUsers] = useState([])

  // 用户名与其 id
  const [param, setParam] = useState({
    name: '',
    personId: ''
  })

  // 项目
  const [list, setList] = useState([])

  useEffect(() => {
    // 每次 param 已更改就会立即调用 fetch 更新数据
    fetch(`${apiUrl}/projects?${qs.stringify(cleanObject(param))}`).then(
      async response => {
        if (response.ok) {
          setList(await response.json())
        }
      }
    )
  }, [param])

  useEffect(() => {
    fetch(`${apiUrl}/users`).then(async response => {
      if (response.ok) {
        setUsers(await response.json())
      }
    })
  }, [])

  return (
    <div>
      <SearchPanel param={param} setParam={setParam} users={users} />
      <List users={users} list={list} />
    </div>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

search-panel.jsx:

export const SearchPanel = ({ param, setParam, users }) => {
  return (
    <form>
      <div>
        {/* setParam(Object.assign({}, param, { name: evt.target.value })) */}
        <input
          type='text'
          value={param.name}
          onChange={evt =>
            setParam({
              ...param,
              name: evt.target.value
            })
          }
        />
        <select
          value={param.personId}
          onChange={evt =>
            setParam({
              ...param,
              personId: evt.target.value
            })
          }>
          <option value={''}>负责人</option>
          {users.map(user => (
            <option key={user.id} value={user.id}>
              {user.name}
            </option>
          ))}
        </select>
      </div>
    </form>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

list.jsx:

export const List = ({ list, users }) => {
  return (
    <table>
      <thead>
        <tr>
          <th>名称</th>
          <th>负责人</th>
        </tr>
      </thead>
      <tbody>
        {list.map(project => (
          <tr key={project.id}>
            <td>{project.name}</td>
            <td>{users.find(user => user.id === project.personId)?.name || '未知'}</td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

1.4 自定义防抖 hook 和 useMount 的 hook

// 模拟 mount 的 hook
export const useMount = callback => {
  useEffect(() => {
    callback()
    //eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])
}

// 防抖
export const useDebounce = (value, delay) => {
  const [debouncedValue, setDebouncedValue] = useState(value)
  useEffect(() => {
    // 每次在 value 变化以后,设置一个定时器
    const timeout = setTimeout(() => setDebouncedValue(value), delay)
    // 每次在上一个 useEffect 处理完以后再运行(react 特性来的,相当于 componentDidUnmount)
    return () => clearTimeout(timeout)
  }, [value, delay])
  return debouncedValue
}

const debouncedParam = useDebounce(param, 2000)

useEffect(() => {
  // 每次 param 已更改就会立即调用 fetch 更新数据
  fetch(`${apiUrl}/projects?${qs.stringify(cleanObject(debouncedParam))}`).then(
    async response => {
      if (response.ok) {
        setList(await response.json())
      }
    }
  )
}, [debouncedParam])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

1.5 commit 提交规范

  • feat: 新功能、新特性
  • fix: 修改 bug
  • perf: 更改代码,以提高性能(在不影响代码内部行为的前提下,对程序性能进行优化)
  • refactor: 代码重构(重构,在不影响代码内部行为、功能下的代码修改)
  • docs: 文档修改
  • style: 代码格式修改, 注意不是 css 修改(例如分号修改)
  • test: 测试用例新增、修改
  • build: 影响项目构建或依赖项修改
  • revert: 恢复上一次提交
  • ci: 持续集成相关文件修改
  • chore: 其他修改(不在上述类型中的修改)
  • release: 发布新版本
  • workflow: 工作流相关文件修改

2TypeScript

2.1 js 改造成 ts

qs 库没有声明 ts 的文件的解决办法

  1. 安装:npm i -D @types/qs
  2. enjoy it!

没有能力解决的 ts 类型问题(下下策)

// @ts-ignore
1

给 useDebounce 添加泛型

泛型就是在调用函数时传入参数的类型

// 防抖
export const useDebounce = <V>(value: V, delay?: number) => {
  const [debouncedValue, setDebouncedValue] = useState(value)
  useEffect(() => {
    // 每次在 value 变化以后,设置一个定时器
    const timeout = setTimeout(() => setDebouncedValue(value), delay)
    // 每次在上一个 useEffect 处理完以后再运行
    return () => clearTimeout(timeout)
  }, [value, delay])
  // 返回类型也是 V 了
  return debouncedValue
}

// 用户名与其 id
const [param, setParam] = useState({
  name: '',
  personId: ''
})
const debouncedParam = useDebounce(param, 200)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

2.2 Hook - 泛型 - 数组操作 demo(重要)

import { useState } from 'react'

function App() {
  const useArray = <T>(initialArray: T[]) => {
  const [value, setValue] = useState(initialArray)
  return {
    value,
    setValue,
    add: (item: T) => setValue([...value, item]),
    clear: () => setValue([]),
    removeIndex: (index = 0) => setValue(value.slice(index + 1))
  }
}

  const persons: { name: string; age: number }[] = [
    { name: 'jack', age: 25 },
    { name: 'ma', age: 22 }
  ]
  const { value, add, clear, removeIndex } = useArray(persons)

  return (
    <div>
      <button onClick={() => add({ name: 'john', age: 22 })}>add john</button>
      <button onClick={() => removeIndex(0)}>remove 0</button>
      <button style={{ marginBottom: '50px' }} onClick={clear}>
        clear
      </button>
      {value.map((person, index) => (
        <div key={index} style={{ marginBottom: '30px' }}>
          <span style={{ color: 'red' }}>{index}</span>
          <span>{person.name}</span>
          <span>{person.age}</span>
        </div>
      ))}
    </div>
  )
}

export default App
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

2.3 TypeScript 的鸭子类型

Duck Typing

interface Base {
  id: number
}
interface Advance extends Base {
  name: string
}
const test = (p: Base) => {}
// 鸭子类型(duck typing) - 面向接口编程,而不是面向对象编程
const a: Advance = { id: 1, name: 'yuanke' }
test(a) // 不会报错!!!
1
2
3
4
5
6
7
8
9
10

2.4 json-server 注入登录中间件

由于 json-server 只支持模拟 restful api 的请求,对于登录这种明显违反 restful api 的请求是无法返回的。故使用中间件解决此问题

发送请求

const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
  event.preventDefault()
  const username = (event.currentTarget.elements[0] as HTMLInputElement).value
  const password = (event.currentTarget.elements[1] as HTMLInputElement).value
  login({ username, password })
}
1
2
3
4
5
6

_json_server_mock__/middleware.js:

module.exports = (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()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

package.json:

"json-server": "json-server __json_server_mock__/db.json --watch --port 3001 --middlewares __json_server_mock__/middleware.js"
1

2.5 auth 服务文件开发

包括了登录、注册、注销等功能的实现

auth-provider.ts:

// 在真实环境中,如果使用 firebase 这种第三方 auth 服务的话,不需要开发者开发

import { User } from 'screens/project-list/search-panel'
const apiUrl = process.env.REACT_APP_API_URL

// token
const localStorageKey = '__auth_provider_token__'

// 获取 token
export const getToken = () => window.localStorage.getItem(localStorageKey)

// 设置 token
export const handleUserResponse = ({ user }: { user: User }) => {
  window.localStorage.setItem(localStorageKey, user.token || '')
  return user
}

// 登录逻辑,登录成功则保存用户的 token
export const login = (data: { username: string; password: string }) => {
  fetch(`${apiUrl}/login`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(data)
  }).then(async response => {
    if (response.ok) {
      return handleUserResponse(await response.json())
    }
  })
}

// 注册逻辑,注册成功则保存用户的 token
export const register = (data: { username: string; password: string }) => {
  fetch(`${apiUrl}/register`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(data)
  }).then(async response => {
    if (response.ok) {
      return handleUserResponse(await response.json())
    }
  })
}

// 登出逻辑,登出成功则清除本地 token
export const logout = () => window.localStorage.removeItem(localStorageKey)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

2.6 point free 风格

function log(str) {
  console.log('haha', str)
}

// 下面两个的形式是等价的,对比可以发现下面第一个式子的 function 部分完全与上面一致,所以可以简写成下面的第二个式子
Promise.resolve('666').then(function (str) {
  log(str)
})
Promise.resolve('666').then(log)
1
2
3
4
5
6
7
8
9

2.7 封装 fetch 请求方法

src/util/http.ts:

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

const apiUrl = process.env.REACT_APP_API_URL

// RequestInit 是 fetch 的 config 区域的类型
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 如果有 POST 的 method,就会覆盖上面的 GET
    ...customConfig
  }

  if (config.method.toUpperCase() === 'GET') {
    endpoint += `?${qs.stringify(data)}`
  } else {
    config.body = JSON.stringify(data || {})
  }

  // axios 和 fetch 表现不一样,axios 可以直接在返回状态不为 2xx 的时候抛出异常
  return window.fetch(`${apiUrl}/${endpoint}`, config).then(async response => {
    if (response.status === 401) {
      // token 失效
      await auth.logout()
      window.location.reload()
      return Promise.reject({ message: '请重新登录' })
    }
    const data = await response.json()
    if (response.ok) {
      return data
    } else {
      // fetch 必须手动抛出异常
      return Promise.reject(data)
    }
  })
}

export const useHttp = () => {
  const { user } = useAuth()
  // TODO: 讲解 TS 操作符
  return (...[endpoint, config]: Parameters<typeof http>) =>
    http(endpoint, { ...config, token: user?.token })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

2.8 使用 useContext 管理登录相关逻辑

src/context/auth-context.tsx:

import { ReactNode, useState } from 'react'
import * as auth from 'auth-provider'
import { User } from 'screens/project-list/search-panel'
import React from 'react'
import { http } from 'utils/http'
import { useMount } from 'utils'

interface AuthForm {
  username: string
  password: string
}

// 去 localStorage 找 token 并将信息返回
const bootstrapUser = async () => {
  let user = null
  const token = auth.getToken()
  if (token) {
    const data = await http('me', { token })
    user = data.user
  }
  return user
}

// 创建一个登录的上下文状态进行相关数据的保存
const AuthContext = React.createContext<
  | {
      user: User | null
      register: (form: AuthForm) => Promise<void>
      login: (form: AuthForm) => Promise<void>
      logout: () => Promise<void>
    }
  | undefined
>(undefined)
AuthContext.displayName = 'AuthContext'

// 封装登录相关的 Provider 组件(包裹 <App /> 达到注入全局状态的效果)
export const AuthProvider = ({ children }: { children: ReactNode }) => {
  const [user, setUser] = useState<User | null>(null)
  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))
  // 每次加载时查看是否 token 失效
  useMount(() => {
    bootstrapUser().then(setUser)
  })
  return (
    <AuthContext.Provider
      children={children}
      value={{ user, login, register, logout }}
    />
  )
}

// hook 方法,可以调用 AuthContext 的方法
export const useAuth = () => {
  const context = React.useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth 必须在 AuthProvider 中使用')
  }
  return context
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

入口文件 src/context/index.ts:

import { ReactNode } from 'react'
import { AuthProvider } from './auth-context'

export const AppProviders = ({ children }: { children: ReactNode }) => {
  return <AuthProvider>{children}</AuthProvider>
}
1
2
3
4
5
6

index.tsx:

import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import App from './App'
import { loadDevTools } from 'jira-dev-tool'
import { AppProviders } from 'context'

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
loadDevTools(() =>
  root.render(
    <React.StrictMode>
      <AppProviders>
        <App />
      </AppProviders>
    </React.StrictMode>
  )
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

2.9 TS 的联合类型、Partial 和 Omit 介绍

类型别名和 interface 区别

  1. 定义联合类型和交叉类型的时候(|&),interface 无法替代 type
  2. interface 没法实现 utility type

Parameters 讲解

将函数的参数以 tuple 的形式提取出来

function fun1(a: number, b: string, c: { name: string }) {
  console.log(a, b, c)
}

const useFun1 = () => {
  return ([aa, bb, cc]: Parameters<typeof fun1>) => ({ aa, bb, cc })
}
1
2
3
4
5
6
7

一些其他的 utility type

type Person = {
  name: string
  age: number
}

// 全部类型都为可选类型
const xiaoming: Partial<Person> = { name: 'xiaoming' }
// 排除一个类型,这是操作键值对的
const shenMiRen: Omit<Person, 'name'> = { age: 18 }
// 挑选一个类型
const xiaozhang: Pick<Person, 'name'> = { name: 'yuanke' }

type PersonKeys = keyof Person // 'name' | 'age'
// 这是操作联合类型的
type Age = Exclude<PersonKeys, 'name'> // 'age'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

2.10 一些 utility type 的源码实现

Partial 实现

type Partial1<T> = {
  [P in keyof T]?: T[P]
}
1
2
3

Pick 实现

type Pick1<T, K extends keyof T> = {
  [P in K]: T[P]
}
1
2
3

Exclude 实现

// 遍历类型 T,发现有 'name' 和 'age'。'name' 属于 'name',则返回 never;'age' 不属于 'name',得到保留
type Exclude1<T, U> = T extends U ? never : T
1
2

Omit 实现

type Omit1<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>
1

3.css in js

3.1 grid 和 flex 示例

  1. 安装: yarn add @emotion/react @emotion/styled 以支持 css in js
  2. 使用 grid 实现布局:
import styled from '@emotion/styled'
import { useAuth } from 'context/auth-context'
import { ProjectListScreen } from 'screens/project-list'

export const AuthenticateApp = () => {
  const { logout } = useAuth()
  return (
    <Container>
      <Header>
        <button onClick={logout}>登出</button>
      </Header>
      <Nav>nav</Nav>
      <Main>
        <ProjectListScreen />
      </Main>
      <Aside>aside</Aside>
      <Footer>footer</Footer>
    </Container>
  )
}

const Container = styled.div`
  display: grid;
  grid-template-rows: 6rem 1fr 6rem;
  grid-template-columns: 20rem 1fr 20rem;
  grid-template-areas: 'header header header' 'nav main aside' 'footer footer footer';
  grid-gap: 10rem;
  height: 100vh;
`

const Header = styled.header`
  grid-area: header;
`
const Main = styled.main`
  grid-area: main;
`
const Nav = styled.nav`
  grid-area: nav;
`
const Aside = styled.aside`
  grid-area: aside;
`
const Footer = styled.footer`
  grid-area: footer;
`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
  1. 完整版:

authenticated.tsx

import styled from '@emotion/styled'
import { Row } from 'components/lib'
import { useAuth } from 'context/auth-context'
import { ProjectListScreen } from 'screens/project-list'

export const AuthenticateApp = () => {
  const { logout } = useAuth()
  return (
    <Container>
      <Header between={true}>
        <HeaderLeft gap={true}>
          <h3>Logo</h3>
          <h3>项目</h3>
          <h3>用户</h3>
        </HeaderLeft>
        <HeaderRight>
          <button onClick={logout}>登出</button>
        </HeaderRight>
      </Header>
      <Main>
        <ProjectListScreen />
      </Main>
    </Container>
  )
}

const Container = styled.div`
  display: grid;
  grid-template-rows: 6rem 1fr 6rem;
  height: 100vh;
`

const Header = styled(Row)``
const HeaderLeft = styled(Row)``
const HeaderRight = styled.div``
const Main = styled.main``
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

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};
  }
`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

4.体验优化

4.1 给页面添加 Loading 和 Error 状态

  1. 在请求前设置 isLoadingtrue,请求结束后设置为 false
const [isLoading, setIsLoading] = useState(false)

useEffect(() => {
  setIsLoading(true)
  client('projects', { data: cleanObject(debouncedParam) })
    .then(setList)
    .finally(() => setIsLoading(false))
  // eslint-disable-next-line
}, [debouncedParam])
1
2
3
4
5
6
7
8
9
  1. isLoadingtrue 时,Table 组件应该显示 Loading 条,但是 Table 组件在子组件中,且 Table 组件有 Loading 属性。这时候有两种思路:一、传入 isLoading 值到子组件的 Table 组件中;二、将 Table 属性暴露出去。这里使用方法二:
// 子组件
interface ListProps extends TableProps<Project> {
  users: User[]
}
export const List = ({ users, ...props }: ListProps) => {
  return (
    <Table
      rowKey={'id'}
      pagination={false}
      columns={[
        {
          title: '名称',
          dataIndex: 'name',
          sorter: (a, b) => a.name.localeCompare(b.name)
        },
        {
          title: '负责人',
          render(value, project) {
            return (
              <span>
                {users.find(user => user.id === project.personId)?.name || '未知'}
              </span>
            )
          }
        }
      ]}
      {...props}
    />
  )
}

// 父组件,传入 isLoading 值和 dataSource 值都会自动并入到 {...props} 中去
;<List loading={isLoading} users={users} dataSource={list} />
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

包括错误处理的全部代码

父组件

import { useEffect, useState } from 'react'
import { SearchPanel } from './search-panel'
import { List } from './list'
import { cleanObject, useDebounce, useMount } from 'utils'
import { useHttp } from 'utils/http'
import { Typography } from 'antd'

export const ProjectListScreen = () => {
  const [users, setUsers] = useState([])
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<null | Error>(null)
  const [param, setParam] = useState({
    name: '',
    personId: ''
  })
  const [list, setList] = useState([])
  const debouncedParam = useDebounce(param, 200)
  const client = useHttp()

  useEffect(() => {
    setIsLoading(true)
    client('projects', { data: cleanObject(debouncedParam) })
      .then(setList)
      .catch(error => {
        setList([])
        setError(error)
      })
      .finally(() => setIsLoading(false))
    // eslint-disable-next-line
  }, [debouncedParam])

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

  return (
    <div>
      <h1>项目列表</h1>
      <SearchPanel param={param} setParam={setParam} users={users} />
      {error ? (
        <Typography.Text type={'danger'}>{error.message}</Typography.Text>
      ) : null}
      <List loading={isLoading} users={users} dataSource={list} />
    </div>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

子组件

import { Table, TableProps } from 'antd'
import { User } from './search-panel'

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

interface ListProps extends TableProps<Project> {
  users: User[]
}

export const List = ({ users, ...props }: ListProps) => {
  return (
    <Table
      rowKey={'id'}
      pagination={false}
      columns={[
        {
          title: '名称',
          dataIndex: 'name',
          sorter: (a, b) => a.name.localeCompare(b.name)
        },
        {
          title: '负责人',
          render(value, project) {
            return (
              <span>
                {users.find(user => user.id === project.personId)?.name || '未知'}
              </span>
            )
          }
        }
      ]}
      {...props}
    />
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

4.2 useAsync 统一处理 Loading 和 Error 状态

utils/use-async.ts

import { useState } from 'react'

interface State<D> {
  error: Error | null
  data: D | null
  stat: 'idle' | 'loading' | 'error' | 'success'
}

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 setData = (data: D) =>
    setState({
      data,
      stat: 'success',
      error: null
    })
  const setError = (error: Error) =>
    setState({
      error,
      stat: 'error',
      data: null
    })
  // run 用来触发异步请求
  const run = (promise: Promise<D>) => {
    if (!promise || !promise.then) {
      throw new Error('请传入 Promise 类型数据')
    }
    setState({ ...state, stat: 'loading' })
    return promise
      .then(data => {
        setData(data)
        return data
      })
      .catch(error => {
        // catch 会消化异常,如果不主动抛出,外面是接收不到异常的
        setError(error)
        if (config.throwOnError) return Promise.reject(error)
        return error
      })
  }
  return {
    isIdle: state.stat === 'idle',
    isLoading: state.stat === 'loading',
    isError: state.stat === 'error',
    isSuccess: state.stat === 'success',
    run,
    setData,
    setError,
    ...state
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68

index.tsx

import { Button, Card, Divider, Typography } from 'antd'
import { useState } from 'react'
import { LoginScreen } from './login'
import { RegisterScreen } from './register'
import styled from '@emotion/styled'
import logo from 'assets/logo.svg'
import left from 'assets/left.svg'
import right from 'assets/right.svg'

export const UnauthenticatedApp = () => {
  const [isRegister, setIsRegister] = useState(false)
  const [error, setError] = useState<Error | null>(null)
  return (
    <Container>
      <Background />
      <Header />
      <ShadowCard>
        <Title>{isRegister ? '请注册' : '请登录'}</Title>
        {/* 错误提示 */}
        {error ? (
          <Typography.Text type={'danger'}>{error.message}</Typography.Text>
        ) : null}
        {isRegister ? (
          <RegisterScreen onError={setError} />
        ) : (
          <LoginScreen onError={setError} />
        )}
        <Divider />
        <Button type={'link'} onClick={() => setIsRegister(!isRegister)}>
          {isRegister ? '已经有账号了?直接登录' : '没有账号?注册新账号'}
        </Button>
      </ShadowCard>
    </Container>
  )
}

export const LongButton = styled(Button)`
  width: 100%;
`

const Title = styled.h2`
  margin-bottom: 2.4rem;
  color: rgb(94, 108, 132);
`

const Background = styled.div`
  position: absolute;
  width: 100%;
  height: 100%;
  background-repeat: no-repeat;
  background-attachment: fixed;
  background-position: left bottom, right bottom;
  background-size: calc(((100vw - 40rem) / 2) - 3.2rem), calc(
      ((100vw - 40rem) / 2) - 3.2rem
    ), cover;
  background-image: url(${left}), url(${right});
`

const Header = styled.header`
  background: url(${logo}) no-repeat center;
  padding: 5rem 0;
  background-size: 8rem;
  width: 100%;
`

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;
  text-align: center;
`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81

login.tsx

import { useAuth } from 'context/auth-context'
import { Form, Input } from 'antd'
import { LongButton } from 'unauthenticated-app'
import { useAsync } from 'utils/use-async'

export function LoginScreen({ onError }: { onError: (error: Error) => void }) {
  const { login } = useAuth()
  const { run, isLoading } = useAsync(undefined, { throwOnError: true })

  const handleSubmit = async (values: { username: string; password: string }) => {
    try {
      await run(login(values))
    } catch (e: any) {
      onError(e)
    }
  }
  return (
    <Form onFinish={handleSubmit}>
      <Form.Item
        name={'username'}
        rules={[{ required: true, message: '请输入用户名' }]}>
        <Input placeholder={'用户名'} type={'text'} id={'username'} />
      </Form.Item>
      <Form.Item name={'password'} rules={[{ required: true, message: '请输入密码' }]}>
        <Input placeholder={'密码'} type={'password'} id={'password'} />
      </Form.Item>
      <Form.Item>
        <LongButton loading={isLoading} htmlType={'submit'} type={'primary'}>
          登录
        </LongButton>
      </Form.Item>
    </Form>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

register.tsx

import { Form, Input } from 'antd'
import { useAuth } from 'context/auth-context'
import { LongButton } from 'unauthenticated-app'
import { useAsync } from 'utils/use-async'

export function RegisterScreen({ onError }: { onError: (error: Error) => void }) {
  const { register } = useAuth()
  const { run, isLoading } = useAsync(undefined, { throwOnError: true })

  const handleSubmit = async ({
    cpassword,
    ...values
  }: {
    username: string
    password: string
    cpassword: string
  }) => {
    if (cpassword !== values.password) {
      onError(new Error('请确认两次输入的密码相同'))
      return
    }
    try {
      await run(register(values))
    } catch (e: any) {
      onError(e)
    }
  }
  return (
    <Form onFinish={handleSubmit}>
      <Form.Item
        name={'username'}
        rules={[{ required: true, message: '请输入用户名' }]}>
        <Input placeholder={'用户名'} type={'text'} id={'username'} />
      </Form.Item>
      <Form.Item name={'password'} rules={[{ required: true, message: '请输入密码' }]}>
        <Input placeholder={'密码'} type={'password'} id={'password'} />
      </Form.Item>

      <Form.Item
        name={'cpassword'}
        rules={[{ required: true, message: '请确认密码' }]}>
        <Input placeholder={'确认密码'} type={'password'} id={'cpassword'} />
      </Form.Item>

      <Form.Item>
        <LongButton loading={isLoading} htmlType={'submit'} type={'primary'}>
          注册
        </LongButton>
      </Form.Item>
    </Form>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

4.3 错误边界

error-boundary.ts:

import React from 'react'

type FallbackRender = (props: { error: Error | null }) => React.ReactElement

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
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

App.tsx

<ErrorBoundary fallbackRender={FullPageErrorFallback}>
  {user ? <AuthenticateApp /> : <UnauthenticatedApp />}
</ErrorBoundary>
1
2
3

lib.tsx

export const FullPageErrorFallback = ({ error }: { error: Error | null }) => (
  <FullPage>
    <DevTools />
    <Typography.Text type={'danger'}>{error?.message}</Typography.Text>
  </FullPage>
)
1
2
3
4
5
6

5.hook 的进一步使用

5.1 useRef 实现 useDocumentTitle

方式一(不推荐):

  1. utils/index.ts 写一个 useDocumentTitle 的 hook:
export const useDocumentTitle = (title: string) => {
  useEffect(() => {
    document.title = title
  }, [title])
}
1
2
3
4
5
  1. 在各个页面都引入:
useDocumentTitle('请登录注册以继续')
1

方式二

以闭包案例引入

const test = () => {
  let num = 0
  const effect = () => {
    num += 1
    const message = `现在的 num 值: ${num}`
    return function unmount() {
      console.log(message)
    }
  }
  return effect
}
// 执行 test,返回 effect 函数
const add = test()
// 执行 effect 函数,返回引用了 message1 的 unmount 函数
const unmount = add()
// 再一次执行 effect 函数,返回引用了 message2 的 unmount 函数
add()
// 同上,返回引用了 message3 的 unmount 函数
add()
// unmount 执行的是引用了 message1 的 unmount 函数,所以是打印值是 1
unmount() // 打印的不是 3,而是 1!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

代码:

export const useDocumentTitle = (title: string, keepOnUnmount = true) => {
  // useRef 可以保存某一数据的初始值,且在全部周期函数中都不会变化
  const oldTitle = useRef(document.title).current
  // 页面加载时,旧 title
  // 加载后, 新 title
  useEffect(() => {
    document.title = title
  }, [title])
  // 页面卸载时,如果 keepOnmount 为 false,则保留标题
  useEffect(() => {
    return () => {
      if (!keepOnUnmount) {
        // 如果不指定依赖,读到的就是旧 title
        document.title = oldTitle
      }
    }
  }, [keepOnUnmount, oldTitle])
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

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

utils/url.ts

  1. 返回元组时,需要加上 as const,否则类型推导会发生错误
  2. reduce 方法的返回类型是 reduce 方法中第二个参数的类型,所以第二个参数要用断言声明
import { useSearchParams } from 'react-router-dom'

/**
 * 返回页面 url 中,指定键的参数值
 */
export const useUrlQueryParam = (keys: string[]) => {
  const [searchParams, setSearchParam] = useSearchParams()
  return [
    keys.reduce(
      (prev, key) => ({
        ...prev,
        [key]: searchParams.get(key) || ''
      }),
      {} as { [key in string]: string }
    ),
    setSearchParam
  ] as const
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

5.3 使用 useMemo 解决依赖循环问题

引入:引入类型作为 useEffect 的依赖导致的页面无限刷新问题

import { useEffect, useState } from 'react'

function App() {
  // 当 obj 是对象的时候,就会无限循环
  /**
   * 原因:页面刚开始渲染的时候,useEffect 会执行一次 setNum(num + 1),这会导致页面的重新渲染。
   * 这时候 obj 也会变化(因为是引用类型),导致又执行了 useEffect,导致无限循环
   */
  const obj = 1
  const [num, setNum] = useState(0)

  useEffect(() => {
    console.log('effect')
    setNum(num + 1)
  }, [obj])

  return <div className='App'>{num}</div>
}

export default App
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

使用 useMemo 解决依赖循环的问题

useMemo 返回一个 memoized,像 useState 一样会比对前后的值(对象也能比对),从而减少不必要的渲染并解决依赖循环的问题。总结:基本类型可以放在 useEffect 的依赖里;组件状态可以放在依赖里;非组件状态的对象,绝不可以放在依赖里,除非使用 useMemo 进行了包裹

import { useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'

/**
 * 返回页面 url 中,指定键的参数值
 */
export const useUrlQueryParam = <K extends string>(keys: K[]) => {
  const [searchParams, setSearchParam] = useSearchParams()
  return [
    useMemo(
      () =>
        keys.reduce(
          (prev, key) => ({
            ...prev,
            [key]: searchParams.get(key) || ''
          }),
          {} as { [key in K]: string }
        ),
      // eslint-disable-next-line react-hooks/exhaustive-deps
      [searchParams]
    ),
    setSearchParam
  ] as const
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

5.4 封装 id-select.tsx(贼牛逼)

import { Select } from 'antd'
import { Raw } from 'types'

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 }[]
}

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

const toNumber = (value: unknown) => (isNaN(Number(value)) ? 0 : Number(value))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

在封装 id-select 的基础上再封装 user-select

import { useUsers } from 'utils/user'
import { IdSelect } from './id-select'

export const UserSelect = (props: React.ComponentProps<typeof IdSelect>) => {
  const { data: users } = useUsers()
  return <IdSelect options={users || []} {...props} />
}
1
2
3
4
5
6
7

5.5 编辑后刷新 - useState 的懒初始化与保存函数状态

使用 const [state, setState] = useState(() => {}) 这样初始化,为惰性初始化 state。只会在组件初始渲染中起作用(初始化会直接运行,返回的返回值赋给 lazyValue),后续渲染会被忽略。

页面初始化执行 i am lazy,点击按钮执行 updated lazy value

import React from 'react'

export default function App() {
  const [lazyValue, setLazyValue] = React.useState(() => {
    return 'i am lazy'
  })
  console.log(lazyValue)
  return (
    <div className='App'>
      <button onClick={() => setLazyValue(() => 'updated lazy value')}>
        setCallBack
      </button>
    </div>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

利用上述理论实现保存函数

import React from 'react'

export default function App() {
  const [callback, setCallBack] = React.useState(() => () => {
    alert('init')
  })
  console.log(callback)
  return (
    <div className='App'>
      <button
        onClick={() =>
          setCallBack(() => () => {
            alert('updated lazy value')
          })
        }>
        setCallBack
      </button>
      <button onClick={callback}>call callback</button>
    </div>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

使用 useRef 保存函数

import React from 'react'
import { useRef } from 'react'

export default function App() {
  const callbackRef = useRef(() => alert('init'))
  const callback = callbackRef.current
  console.log(callback)
  return (
    <div className='App'>
      <button
        onClick={() => {
          callbackRef.current = () => alert('updated')
        }}>
        setCallBack
      </button>
      {/* 这里不能使用 callbackRef.current 或 callback,因为 callbackRef 只在初始化时执行 */}
      <button onClick={() => callbackRef.current()}>call callback</button>
    </div>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

5.6 useCallback 和 useMemo

useCallbackuseMemo 都是为了解决非基本类型的依赖而生的

project.ts

export const useProjects = (param?: Partial<Project>) => {
  const client = useHttp()
  const { run, ...result } = useAsync<Project[]>()

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

  useEffect(() => {
    // 这里的 run 函数和 fetchProjects 函数都是使用 useCallback 进行包裹的,防止进行无限循环
    run(fetchProjects(), {
      retry: fetchProjects
    })
  }, [param, fetchProjects, run])
  return result
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

use-async.ts

// 用来触发异步请求的
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,因为 state 不适合作为依赖(state 改变重新执行 run 函数,从而又进行一次 setState,造成无限循环)
    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)
      })
  },
  [config.throwOnError, mountedRef, setData, setError]
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

5.7 状态提升、组合组件和控制反转

使用 props 传递组件(component composition),能够实现对逻辑的解耦,有时候是要比 context 更好的解决方案

authenticated-app.tsx

import styled from '@emotion/styled'
import { ButtonNoPadding, Row } from 'components/lib'
import { useAuth } from 'context/auth-context'
import { ProjectListScreen } from 'screens/project-list'
import { ReactComponent as SoftwareLogo } from 'assets/software-logo.svg'
import { Button, Dropdown, Menu } from 'antd'
import { BrowserRouter as Router, Navigate, Route, Routes } from 'react-router-dom'
import { ProjectScreen } from 'screens/project'
import { resetRoute } from 'utils'
import { useState } from 'react'
import { ProjectModal } from 'screens/project-list/project-modal'
import { ProjectPopover } from 'components/project-popover'

export const AuthenticatedApp = () => {
  const [projectModalOpen, setProjectModalOpen] = useState(false)

  return (
    <Container>
      <PageHeader
        projectButton={
          <ButtonNoPadding onClick={() => setProjectModalOpen(true)} type={'link'}>
            创建项目
          </ButtonNoPadding>
        }
      />
      <Main>
        <Router>
          <Routes>
            <Route
              path={'/projects'}
              element={
                <ProjectListScreen
                  projectButton={
                    <ButtonNoPadding
                      onClick={() => setProjectModalOpen(true)}
                      type={'link'}>
                      创建项目
                    </ButtonNoPadding>
                  }
                />
              }></Route>
            <Route path={'/projects/:projectId/*'} element={<ProjectScreen />}></Route>
            <Route path='/' element={<Navigate to={'/projects'} />}></Route>
          </Routes>
        </Router>
      </Main>
      <ProjectModal
        projectModalOpen={projectModalOpen}
        onClose={() => setProjectModalOpen(false)}
      />
    </Container>
  )
}

const PageHeader = (props: { projectButton: JSX.Element }) => {
  return (
    <Header between={true}>
      <HeaderLeft gap={true}>
        <ButtonNoPadding type={'link'} onClick={resetRoute}>
          <SoftwareLogo width={'18rem'} color={'rgb(38, 132, 255)'} />
        </ButtonNoPadding>
        <ProjectPopover {...props} />
        <span>用户</span>
      </HeaderLeft>
      <HeaderRight>
        <User />
      </HeaderRight>
    </Header>
  )
}

const User = () => {
  const { logout, user } = useAuth()
  return (
    <Dropdown
      overlay={
        <Menu>
          <Menu.Item key={'logout'}>
            <Button type={'link'} onClick={logout}>
              登出
            </Button>
          </Menu.Item>
        </Menu>
      }>
      <Button type={'link'} onClick={e => e.preventDefault()}>
        Hi, {user?.name}
      </Button>
    </Dropdown>
  )
}

const Container = styled.header`
  display: grid;
  grid-template-rows: 6rem 1fr 6rem;
  height: 100vh;
`

const Header = styled(Row)`
  padding: 3.2rem;
  box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
  z-index: 1;
`

const HeaderLeft = styled(Row)``

const HeaderRight = styled.div``

const Main = styled.main``
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108

6.redux

6.1 实现 useUndo

import { useCallback, useState } from 'react'

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

  const canUnodo = 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]
      const newPast = past.slice(0, past.length - 1)

      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]
      const newFuture = future.slice(1)

      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, canUnodo, canRedo }]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

6.2 用 useReducer 进行状态管理

使用 useReducer 改造 use-async.ts

import { useReducer } from 'react'
import { useCallback, useState } from 'react'
import { useMountedRef } from 'utils'

interface State<D> {
  error: Error | null
  data: D | null
  stat: 'idle' | 'loading' | 'error' | 'success'
}

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
    }
  )
  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 => {
          return data
        })
        .catch(error => {
          // catch 会消化异常,如果不主动抛出,外面是接收到异常的
          setError(error)
          if (config.throwOnError) return Promise.reject(error)
        })
    },
    [config.throwOnError, 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
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98

use-undo.ts

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 (type) {
    case 'UNDO': {
      if (past.length === 0) return state
      const previous = past[past.length - 1]
      const newPast = past.slice(0, past.length - 1)
      return {
        past: newPast,
        present: previous,
        future: [present, ...future]
      }
    }
    case 'REDO': {
      if (future.length === 0) return state
      const next = future[0]
      const newFuture = future.slice(1)
      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 canUnodo = 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, canUnodo, canRedo }]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84

6.3 redux-tookit

  1. 安装:yarn add react-redux @reduxjs/toolkit
  2. store/index.tsx
import { configureStore } from '@reduxjs/toolkit'
import { projectListSlice } from 'screens/project-list/project-list.slice'

export const rootReducer = {
  kanbanList: projectListSlice.reducer
}

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

export type AppDispatch = typeof store.dispatch
export type RootState = ReturnType<typeof store.getState>
1
2
3
4
5
6
7
8
9
10
11
12
13
  1. src/screens/project-list/project-list.slice.ts
import { createSlice } from '@reduxjs/toolkit'

interface State {
  projectModalOpen: boolean
}

const initialState: State = {
  projectModalOpen: false
}

export const projectListSlice = createSlice({
  name: 'projectListSlice',
  initialState,
  reducers: {
    openProjectModal(state, action) {
      state.projectModalOpen = true
    },
    closeProjectModal(state, action) {
      state.projectModalOpen = false
    }
  }
})

export const projectListActions = projectListSlice.actions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

6.4 使用 redux-toolkit 管理模态框

完美的教学:快速开始 | Redux 中文官网open in new window

src/store/index.tsx

import { configureStore } from '@reduxjs/toolkit'
import { projectListSlice } from 'screens/project-list/project-list.slice'

// 创建根 reducer
export const rootReducer = {
  // 根 reducer 中包括子 reducer 的切片
  projectList: projectListSlice.reducer
}

// 创建 store,存放根 reducer
export const store = configureStore({
  reducer: rootReducer
})

// dispatch 的类型
export type AppDispatch = typeof store.dispatch
// state 的类型
export type RootState = ReturnType<typeof store.getState>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

src/screens/project-list.slice.ts

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

interface State {
  projectModalOpen: boolean
}

const initialState: State = {
  projectModalOpen: false
}

// slice 切片
export const projectListSlice = createSlice({
  name: 'projectListSlice',
  initialState,
  reducers: {
    // 第二个参数为 action,state 会根据 initialState 做自动推断
    openProjectModal(state) {
      state.projectModalOpen = true
    },
    closeProjectModal(state) {
      state.projectModalOpen = false
    }
  }
})

// 操作 state 的方法
export const projectListActions = projectListSlice.actions
// 将切片的 state 传出
export const selectProjectModalOpen = (state: RootState) =>
  state.projectList.projectModalOpen
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

使用 state 和使用 action 实例

import { Button, Drawer } from 'antd'
import { useDispatch, useSelector } from 'react-redux'
import { projectListActions, selectProjectModalOpen } from './project-list.slice'

export const ProjectModal = () => {
  const dispatch = useDispatch()
  const projectModalOpen = useSelector(selectProjectModalOpen)
  return (
    <Drawer
      open={projectModalOpen}
      onClose={() => dispatch(projectListActions.closeProjectModal())}
      width={'100%'}>
      <h1>Project Modal</h1>
      <Button onClick={() => dispatch(projectListActions.closeProjectModal())}>
        关闭
      </Button>
    </Drawer>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

auth.slice.ts

redux 中使用异步函数

import { createSlice } from '@reduxjs/toolkit'
import { User } from 'screens/project-list/search-panel'
import * as auth from 'auth-provider'
import { AuthForm, bootstrapUser } from 'context/auth-context'
import { AppDispatch, RootState } from 'store'

interface State {
  user: User | null
}

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

// 下面都是 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 = () => (dispatch: AppDispatch) =>
  bootstrapUser().then(user => dispatch(setUser))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

store/index.ts

import { configureStore } from '@reduxjs/toolkit'
import { projectListSlice } from 'screens/project-list/project-list.slice'
import { authSlice } from './auth.slice'

// 创建根 reducer
export const rootReducer = {
  // 根 reducer 中包括子 reducer 的切片
  projectList: projectListSlice.reducer,
  auth: authSlice.reducer
}

// 创建 store,存放根 reducer
export const store = configureStore({
  reducer: rootReducer
})

// dispatch 的类型
export type AppDispatch = typeof store.dispatch
// state 的类型
export type RootState = ReturnType<typeof store.getState>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

使用

import * as authStore from 'store/auth.slice'

export const useAuth = () => {
  const dispatch = useDispatch<AppDispatch>()
  const user = useSelector(authStore.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
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

7.url

7.1 用 url 参数管理项目模态框状态

src/screens/project-list/util.ts

export const useProjectModal = () => {
  // 下面一行作用是将 url 参数中键为 projectCreate 的键值对取出来,并且初始化设定 projectCreate 的方法
  const [{ projectCreate }, setProjectCreate] = useUrlQueryParam(['projectCreate'])
  const open = () => setProjectCreate({ projectCreate: true })
  const close = () => setProjectCreate({ projectCreate: undefined })
  return {
    projectModalOpen: projectCreate === 'true',
    open,
    close
  }
}
1
2
3
4
5
6
7
8
9
10
11

简单使用

import React from 'react'
import { Button, Drawer } from 'antd'
import { useProjectModal } from './util'

export const ProjectModal = () => {
  const { projectModalOpen, close } = useProjectModal()
  return (
    <Drawer onClose={close} visible={projectModalOpen} width={'100%'}>
      <h1>Project Modal</h1>
      <Button onClick={close}>关闭</Button>
    </Drawer>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13

7.2 用 react-query 来处理 - 服务端缓存

数据请求利器 React Query - 知乎 (zhihu.com)open in new window

前置知识:类型守卫

// 类型守卫(当 value: any 符合 value?.message 条件时,即拥有 message 属性时,视之为 Error 类型)
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
}
1
2
3
4
5
6
7
8

7.3 乐观更新

举例:点击收藏按钮,还未等后端进行确认,前端就已经点亮收藏的图标了;如果后端报错,则前端进行回滚

export const useEditProject = () => {
  const client = useHttp()
  const queryClient = useQueryClient()
  const [searchParams] = useProjectsSearchParams()
  const queryKey = ['projects', searchParams]
  return useMutation(
    (params: Partial<Project>) =>
      client(`projects/${params.id}`, {
        method: 'PATCH',
        data: params
      }),
    {
      onSuccess: () => queryClient.invalidateQueries(queryKey),
      async onMutate(target) {
        const previousItems = queryClient.getQueryData(queryKey)
        queryClient.setQueryData(queryKey, (old?: Project[]) => {
          return (
            old?.map(project =>
              project.id === target.id ? { ...project, ...target } : project
            ) || []
          )
        })
        return { previousItems }
      },
      onError(error, newItem, context) {
        queryClient.setQueryData(
          queryKey,
          (context as { previousItems: Project[] }).previousItems
        )
      }
    }
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33