一晃已经 2 周过去了,更新慢的原因既有生活忙碌,比赛事务繁忙,也有开发遇到的困难。不过好歹是凭借着 Google 技巧和 CV 能力实现了用户登陆状态的全局管理。为了对 CV 内容有自己的理解,也为了下次能够减少开发类似功能所需要的时间,我对这次的功能实现做一个总结。

前端所有代码已经同步到 GitHub Repo: EthanLuu/soomooc: React + TypeScript 实践,在线教学直播平台。 (github.com)

一、需求分析

1.1 系统界面状态分析

就目前开发情况而言,整个网站包括以下几个界面:

  • 首页
  • 课程列表界面
  • 课程详情界面
  • 登陆/注册界面

虽然以上这些都应该面向游客,而对于在线教学直播平台来说,『用户』仍然是非常必要的,具体针对以下界面/功能:

  • 订阅课程

  • 观看直播

  • 直聊天发言

  • 课程作业

1.2 登陆注册需求整理

再整理一下我们的需求:

  • 未登录时
    • 页头组件上应该显示 登陆注册 两个按钮,点击进入对应页面。
    • 登陆界面
      • 用户可以通过已经注册用户名和密码进行登陆。
      • 如果用户名或密码错误,则提示用户登陆错误。
      • 登陆成功则跳转至主页。
    • 注册界面
      • 需要用户输入用户名,密码,并且需要重复输入一次密码进行确认。
      • 如果两次输入密码不一致,提示用户错误。
      • 注册成功则跳转至主页。
  • 已登录后
    • 页头组件显示 Hi, xxx,其中 xxx 为登录用户的用户名。
    • 鼠标移到 Hi, xxx 时显示悬浮菜单,悬浮菜单包含一个登出按钮。
    • 点击登出按钮,用户退出登陆状态,页头组件恢复为未登录时状态。

二、系统设计

2.1 全局状态

众所周知,React 比较有名的也是比较经典的全局状态管理插件是 Redux,作为一款成熟的状态管理库,Redux 可以很好地对全局状态进行统一管理。

但是,Redux 作为一款状态机,其使用和配置实在是有些复杂,就目前需求而言,并用不到。

而恰巧,React 16.8 添加的 React Hooks 中的 useContext() 就能够满足我们实现跨组件通信的需求。

不过要注意 useContext() 和 Redux 并不是一样的,前者是可以实现跨组件传值,而后者是对全局状态的统一管理。

2.2 初识 useContext

还是从经典的计数器来尝试理解这个 API。

第一步:创建上下文

为了实现全局的数据共享,我们需要一个对象,来存放需要共享的数据,这个对象在这里就叫做 context(上下文)

我们应该把当前的次数作为上下文,以 undefined 为初始值创建上下文对象:

1
const context = createContext<number | undefined>(undefined)

第二部:创建父组件

对于计数的组件而言,次数 应该是存放在状态中,并且能够通过点击按钮更新状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export const CountContext = createContext<number | undefined>(undefined)

const Counter = () => {
const [count, setCount] = useState(0)
return (
<div>
<h1>父组件</h1>
<h2>{`当前的点击次数为${count}`}</h2>
<button onClick={() => setCount(count + 1)}>+1</button>
<h2>子组件👇</h2>
<SubCounter />
</div>
)
}

目前为止,我们的点击次数 count 只在当前组件用到了,上下文对象也没有用到。

第三步:创建子组件

接下来,我们写子组件 SubCounter,用来接收它的父组件的 count 状态。

1
2
3
export const SubCounter = () => {
return <h2>{1}</h2> // 这里的 1 需要接收 count 的值的位置
}

一般来说,我们当然可以直接使用 prop 实现父子组件的传值,但是在这里我们就强行使用一下 useContext 来实现。

第四步:钩取上下文

利用这个钩子函数,获取这个上下文当前的值。

1
2
3
4
5
// 这里需要 import {CountContext} from 父组件
export const SubCounter = () => {
const count = useContext(CountContext)
return <h2>{count}</h2>
}

第五步:Provider 提供数据共享功能

但是,仅仅想要钩到数据是不够的,我们需要有一个 Provider 来包裹需要获取上下文的范围,并且设定上下文的 value

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const Counter = () => {
const [count, setCount] = useState(0)
return (
<div>
<CountContext.Provider value={count}>
<h1>父组件</h1>
<h2>{`当前的点击次数为${count}`}</h2>
<button onClick={() => setCount(count + 1)}>+1</button>
<h2>子组件👇</h2>
<SubCounter />
</CountContext.Provider>
</div>
)
}

由于我们需要共享的是次数,所以 value 的值设定为了 count 这个状态。

效果展示

效果如下:

动画 (1)

可以看到,子组件通过钩子函数获取的上下文对象的值跟父组件的完全同步。

爷孙组件,兄弟组件以及其他更复杂的远方亲戚组件只要被包裹在一个 XXXXContext.Provider 里,就能很好地实现数据的共享。

三、具体实现

通过以上的 useContext 体验,大概就能了解整套实现不同组件之间数据共享的方法了。

现在,我们需要加入一点点细节,来实现用户登陆状态的共享。

3.1 创建上下文对象

创建 src/context 文件夹用于存放和上下文相关的组件,新建 auth-context.tsx 组件存放上下文对象。

和登陆注册相关的 API 有哪些?

  • 当前登陆的用户信息
  • 注册操作
  • 登陆操作
  • 登出操作

为了能随时随地使用这些 API,所有的这些都需要放到上下文对象中,因此可以如下初始化上下文对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/context/auth-context.tsx
// 定义登陆/注册表单的数据格式
interface AuthForm {
username: string
password: string
}

// 创建上下文对象
const AuthContext =
createContext<
| {
user: User | null // 存放登陆的用户,null 表示未登录
register: (form: AuthForm) => Promise<void> // 注册操作
login: (form: AuthForm) => Promise<void> // 登陆操作
logout: () => Promise<void> // 登出操作
}
| undefined
>(undefined)

之后会解释每个方法具体是怎么定义的。

3.2 方法定义

所有登陆相关的方法统一定义在 src/auth-provider.ts 文件中。

一般项目使用第三方框架的话这个组件不需要手写,这里我们自己定义一下每个方法。

首先要明确,用户的登陆状态是怎么记录的。为了区分用户是否登陆,也为了区分当前登陆的用户的身份,我们需要在当前域名的 localStorage 中存储一个 token(令牌)

localStorage 是一个对象,它和 origin(源) 绑定(具体参考『跨源』的定义),存储的数据会被保存在浏览器会话中。localStorage 的数据可以长期保留,页面关闭并不会导致其被清除。

在这里我们可以简单理解为通过存储 token 值,用户的登陆状态可以持久地被存储在本地。

通常来讲,这个 token 值是在用户登陆时后端自动生成的,经过特定时间过期。但是由于我们这里使用的是 json-server 来伪造数据接口,所以如果要实现定时过期 token,要么废除 json-server 手动开发后端接口,要么在 json-server 中安装插件对登录和注册接口进行包装。

而我,则选择令 token 值在用户注册的时候直接写死到用户属性中,先暂时忽略 token 会过期的特性,将精力集中到状态的保持上。

用户数据的类型定义如下:

1
2
3
4
5
6
7
8
9
// src/type/user.ts

export interface User {
id: number
username: string // 用户名
password: string // 密码(明文)
token: string
}

我们需要把 token 存储到 localStorage 中,其存储格式为键值对形式。

1
2
3
4
5
6
7
8
9
10
11
12
// src/auth-provider.ts
// 用于实现用户认证的 token 的键名
const localStorageKey = '__auth_provider_token__'

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

export const handleUserResponse = (user: User) => {
// 用来处理登陆和注册的回调函数
window.localStorage.setItem(localStorageKey, user.token || '')
return user
}

接下来先写 login 方法,根据之前的定义 login: (form: AuthForm) => Promise<void>已知获取的表单信息包括 username, password

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const login = (data: { username: string; password: string }) => {
return fetch(`${apiUrl}/users?username=${data.username}`, {
// 根据用户名查询用户对象
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}).then(async (response) => {
const res = await response.json()
const user = res?.[0]
if (!user || data.password !== user.password) {
// 根据用户名无法检索到用户 || 用户输入密码错误,我们都直接提示用户名或密码错误。
return Promise.reject('用户名或密码错误❌')
} else if (response.ok && data.password === user.password) {
// 当用户密码正确,进行登陆成功的回调,存储 token。
return handleUserResponse(user)
} else {
// 其余错误直接返回。
return Promise.reject(res)
}
})
}

register 方法也类似,唯一区别是会注册时随机生成一个 6 位的 token 和用户绑定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export const register = (data: { username: string; password: string }) => {
const user = { ...data, token: Math.random().toString(36).slice(-6) }
return fetch(`${apiUrl}/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(user),
}).then(async (response) => {
if (response.ok) {
return handleUserResponse(await response.json())
} else {
return Promise.reject(await response.json())
}
})
}

logout 登出方法就简单的多了,直接清除 localStorage 中存储的 token 即可。

1
2
export const logout = async () =>
window.localStorage.removeItem(localStorageKey)

3.3 Provider 定义

在这里,我们把原本的 XXXContext.Provider 抽象成了一个组件 XXXProvider,既能提高组件的聚合性,也方便了多个 Provider 的组合(之后再谈)。

先记得安装一个 hooks 的库 ahooks

1
yarn add ahooks

让我们回到 src/context/auth-context.tsx 来继续开发用于组件提供共享数据的供应商 AuthProvider

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
import * as auth from 'auth-provider'
import { http } from 'utils/http'
import { useMount, useRequest } from 'ahooks'

// 该异步函数用于检测用户是否已经登陆,逻辑是根据 token 获取 user 信息
const bootstrapUser = async () => {
let user = null
const token = auth.getToken()
if (token) {
const data = await http(`users`, { token })
user = data?.[0]
}
return user
}

// 注意这里的参数是 {children},因为我们需要包裹指定范围内的所有组件。
export const AuthProvider = ({ children }: { children: ReactNode }) => {
// 利用 ahooks 中封装好的 useRequest 来执行异步函数
const {
data: user,
error,
loading,
mutate,
} = useRequest<User | null>(bootstrapUser)

// 定义登陆,注册,登出三个方法
const login = (form: AuthForm) => auth.login(form).then(mutate)
const register = (form: AuthForm) => auth.register(form).then(mutate)
const logout = () => auth.logout().then(() => mutate(null))

// 根据加载和报错情况,返回对应全局组件
if (loading) {
return <FullPageLoading />
}

if (error) {
return <FullPageErrorFallback error={error} />
}

// 返回一个可以共享上下文的组件,并且保留了范围内的子组件
return (
<AuthContext.Provider
children={children}
value={{ user, login, register, logout }}
/>
)
}

谈谈重点,在这里我们先定义了一个异步函数 bootstrapUser,用来加载已经登陆的用户。它的逻辑也比较简单,就是根据本地的 localStorage 中存储的 token 去请求用户数据。

在定义 AuthProvider 的时候,使用了 ahooks 库中提供的 useRequest 函数,该函数是专门封装好用来运行异步函数的钩子函数,它会返回一个对象,里面包含各种常用 API。

  • data 是执行完异步函数的返回值,将其自定义命名为 user

  • error, loading ,可以快速判断异步函数是否报错以及是否在加载中。

  • mutate 突变函数,用来修改 data 的值,即在登陆,注册,登出的时候我们需要将修改用户的状态。

3.4 包裹组件

为了能够在之后快速兼容各种其他的 Provider,我们需要在 AuthProvider 的外面包裹一层 AppProvider

1
2
3
4
5
6
7
8
// src/context/index.tsx

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

export const AppProviders = ({ children }: { children: ReactNode }) => {
return <AuthProvider>{children}</AuthProvider>
}

最后将这个 AppProvider 包裹到 App 的外面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/index.tsx

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

ReactDOM.render(
<React.StrictMode>
<AppProviders>
<App />
</AppProviders>
</React.StrictMode>,
document.getElementById('root')
)

3.5 自定义 Hook

为了能够在各个组件之中更加方便地调用上下文,我们需要自定义一个 hook。

1
2
3
4
5
6
7
8
9
// src/context/auth-context.tsx

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

四、界面组件

4.1 登陆界面

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
// src/screens/unauthenticated-app/login.tsx

import { useAuth } from 'context/auth-context'
import { Button, Form, Input } from 'antd'
import { useJumpTo } from 'utils'
import useRequest from '@ahooksjs/use-request'

export const LoginScreen = ({
onError,
}: {
onError: (error: string) => void
}) => {
const { login } = useAuth() // 获取 login 方法
const { run, loading } = useRequest(login, {
manual: true, // 手动操作登陆异步函数
throwOnError: true, // 允许抛出异常
})
const backHome = useJumpTo('/') // 使用自定义跳转 hook,用于返回主页

// 处理提交表单的操作
const handleSubmit = (values: { username: string; password: string }) => {
run(values)
.then(() => backHome())
.catch(onError)
}

// 返回登陆表单
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>
<Button
loading={loading}
style={{ width: '100%' }}
htmlType={'submit'}
type={'primary'}
>
登录
</Button>
</Form.Item>
</Form>
)
}

4.2 注册界面

注册和登陆两个组件大同小异,调整表单结构增加『确认密码』的输入框,并且在注册时候需要保证两个密码框的值相同。

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
// src/screens/unauthenticated-app/register.tsx

import { useAuth } from 'context/auth-context'
import { Button, Form, Input } from 'antd'
import { useJumpTo } from 'utils'
import { useRequest } from 'ahooks'

export const RegisterScreen = ({
onError,
}: {
onError: (error: string) => void
}) => {
const { register } = useAuth()
const { run, loading } = useRequest(register, {
manual: true,
throwOnError: true,
})
const backHome = useJumpTo('/')

const handleSubmit = ({
cpassword,
...values
}: {
username: string
password: string
cpassword: string
}) => {
if (cpassword !== values.password) {
onError('请确认两次输入的密码相同')
return
}

run(values)
.then(() => backHome())
.catch(onError)
}

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>
<Button
style={{ width: '100%' }}
loading={loading}
htmlType={'submit'}
type={'primary'}
>
注册
</Button>
</Form.Item>
</Form>
)
}

4.3 界面集成

为了让注册和登陆界面没那么单调,我们在其外面再包裹一层 UnauthenticatedApp 组件,用于渲染一些其他的装饰。

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
// src/screens/unauthenticated-app/index.tsx

import styled from '@emotion/styled'
import { Typography, notification } from 'antd'
import { useLocation } from 'react-router'
import { LoginScreen } from './login'
import { RegisterScreen } from './register'
import { LogoSvg } from 'components/lib'

export const UnauthenticatedApp = () => {
const url = useLocation() // 获取当前 URL,用于判断是注册界面还是登陆界面
const showError = (error: string) => { // 利用 antd 的提示窗口进行错误提示
notification.open({
key: error, // 设定 key 可以避免显示重复错误
message: error,
})
}
return (
<Container>
<Title>
<p>
<Typography.Text strong>SooMooc</Typography.Text>
<LogoSvg size={'7rem'} />
</p>
<p>你的不二之选</p>
</Title>
<FromContainer>
{url.pathname === '/login' ? (
<LoginScreen onError={showError} />
) : (
<RegisterScreen onError={showError} />
)}
</FromContainer>
</Container>
)
}

export const Container = styled.div`
display: flex;
justify-content: center;
align-items: center;
flex: 1;
`
const Title = styled.div`
font-size: 5rem;
margin-right: 15%;
p {
white-space: nowrap;
margin-bottom: 0;
}
`

const FromContainer = styled.div`
border-radius: 8px;
box-shadow: 0 2px 4px rgb(0 0 0 / 10%), 0 8px 16px rgb(0 0 0 / 10%);
border: none;
padding: 2rem 1rem 0 1rem;
background-color: #fff;
min-width: 30rem;
input,
button {
font-size: 2rem;
height: 4rem;
}
position: relative;
`

当然,还需要把这个组件包裹到 App.tsx 路由中。

1
2
<Route exact path={'/login'} component={UnauthenticatedApp} />
<Route exact path={'/register'} component={UnauthenticatedApp} />

五、成果展示

终于开发完了,来看看效果吧。

动画 (1)

Thumbnail (1)