注册登陆 | SooMooc 直播平台
一晃已经 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
为初始值创建上下文对象:
const context = createContext<number | undefined>(undefined)
第二部:创建父组件
对于计数的组件而言,次数
应该是存放在状态中,并且能够通过点击按钮更新状态。
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
状态。
export const SubCounter = () => {
return <h2>{1}</h2> // 这里的 1 需要接收 count 的值的位置
}
一般来说,我们当然可以直接使用 prop
实现父子组件的传值,但是在这里我们就强行使用一下 useContext
来实现。
第四步:钩取上下文
利用这个钩子函数,获取这个上下文当前的值。
// 这里需要 import {CountContext} from 父组件
export const SubCounter = () => {
const count = useContext(CountContext)
return <h2>{count}</h2>
}
第五步:Provider 提供数据共享功能
但是,仅仅想要钩到数据是不够的,我们需要有一个 Provider
来包裹需要获取上下文的范围,并且设定上下文的 value
。
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
这个状态。
效果展示
效果如下:
可以看到,子组件通过钩子函数获取的上下文对象的值跟父组件的完全同步。
爷孙组件,兄弟组件以及其他更复杂的远方亲戚组件只要被包裹在一个 XXXXContext.Provider
里,就能很好地实现数据的共享。
三、具体实现
通过以上的 useContext
体验,大概就能了解整套实现不同组件之间数据共享的方法了。
现在,我们需要加入一点点细节,来实现用户登陆状态的共享。
3.1 创建上下文对象
创建 src/context
文件夹用于存放和上下文相关的组件,新建 auth-context.tsx
组件存放上下文对象。
和登陆注册相关的 API 有哪些?
- 当前登陆的用户信息
- 注册操作
- 登陆操作
- 登出操作
为了能随时随地使用这些 API,所有的这些都需要放到上下文对象中,因此可以如下初始化上下文对象:
// 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
会过期的特性,将精力集中到状态的保持上。
用户数据的类型定义如下:
// src/type/user.ts
export interface User {
id: number
username: string // 用户名
password: string // 密码(明文)
token: string
}
我们需要把 token
存储到 localStorage
中,其存储格式为键值对形式。
// 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
。
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
和用户绑定。
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
即可。
export const logout = async () =>
window.localStorage.removeItem(localStorageKey)
3.3 Provider 定义
在这里,我们把原本的 XXXContext.Provider
抽象成了一个组件 XXXProvider
,既能提高组件的聚合性,也方便了多个 Provider
的组合(之后再谈)。
先记得安装一个 hooks 的库 ahooks
。
yarn add ahooks
让我们回到 src/context/auth-context.tsx
来继续开发用于组件提供共享数据的供应商 AuthProvider
。
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
。
// 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
的外面。
// 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。
// src/context/auth-context.tsx
export const useAuth = () => {
const context = React.useContext(AuthContext)
if (!context) {
throw new Error('useAuth必须在AuthProvider中使用')
}
return context
}
四、界面组件
4.1 登陆界面
// 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 注册界面
注册和登陆两个组件大同小异,调整表单结构增加『确认密码』的输入框,并且在注册时候需要保证两个密码框的值相同。
// 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
组件,用于渲染一些其他的装饰。
// 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
路由中。
<Route exact path={'/login'} component={UnauthenticatedApp} />
<Route exact path={'/register'} component={UnauthenticatedApp} />
五、成果展示
终于开发完了,来看看效果吧。