一晃已经 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> }
|
一般来说,我们当然可以直接使用 prop
实现父子组件的传值,但是在这里我们就强行使用一下 useContext
来实现。
第四步:钩取上下文
利用这个钩子函数,获取这个上下文当前的值。
1 2 3 4 5
| 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
这个状态。
效果展示
效果如下:
可以看到,子组件通过钩子函数获取的上下文对象的值跟父组件的完全同步。
爷孙组件,兄弟组件以及其他更复杂的远方亲戚组件只要被包裹在一个 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
|
interface AuthForm { username: string password: string }
const AuthContext = createContext< | { user: User | 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
|
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
|
const localStorageKey = '__auth_provider_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) { 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
。
让我们回到 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'
const bootstrapUser = async () => { let user = null const token = auth.getToken() if (token) { const data = await http(`users`, { token }) user = data?.[0] } return user }
export const AuthProvider = ({ children }: { children: ReactNode }) => { 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
|
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
|
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
|
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
|
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() const { run, loading } = useRequest(login, { manual: true, throwOnError: true, }) const backHome = useJumpTo('/')
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
|
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
|
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() const showError = (error: string) => { notification.open({ key: error, 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} />
|
五、成果展示
终于开发完了,来看看效果吧。