本章将通过一个非常典型的案例来讨论 React 的状态共享:只有登录用户才能看到某些内容。早期的 React 完全依靠其生态中的 redux 等第三方库来实现状态共享;现在 React 提供了官方的 Context
来实现这一目的。
本章将重点讨论 Context
,由于 redux 无论在 API 设计还是设计理念上和 Context
都非常相似,所以只在本章结束部分给出示例代码的链接。
本章讨论的是 hook 形式的 Context 和 redux
本站不对诸如「Context 会代替 redux吗 / 项目里用 Context 还是 redux」等问题进行回答。我只能说,你甚至可以将 Context 和 redux 整合在一起使用。
快速体验 Context
import React from 'react';
import { createContext, useContext } from 'react';
const UserContext = createContext();
export default function App() {
return (
<UserContext.Provider value="axum.rs">
<Foo />
<Bar />
</UserContext.Provider>
);
}
function Foo() {
const user = useContext(UserContext);
return <div>Foo: {user}</div>;
}
function Bar() {
const user = useContext(UserContext);
return <div>Bar: {user}</div>;
}
const UserContext = createContext();
使用createContext()
创建一个 Context,并赋值给变量UserContext
<UserContext.Provider value="axum.rs">
- 使用
UserContext.Provider
包裹需要访问共享数据的组件 - 通过
value
传递共享的数据
- 使用
- 在其它组件中,使用
const user = useContext(UserContext);
来访问共享的数据 - 本例在一个文件中定义了多个组件,这并没什么奇怪的,毕竟从语言层面上说,它们只是JS函数
是的,你会看到并没有通过 props
来进行通讯。
现在的问题来了,如何在子组件中修改共享的数据呢?
实现 Context 的读写操作
建议你点击打开这里,先体验一下效果:在Foo
组件的文本框里改变共享的数据,Bar
组件能实时感知并显示出来。
在上面的例子里,我们猜测,实现数据共享的根源在于,我们使用了 <UserContext.Provider>
组件。回顾之前所学内容:props
可以传递包括函数在内的 JS 表达式,但 <UserContext.Provider>
是由 React 提供的,只能接收包括 value
和 children
在内的,为数不多的 props
。
我们能不能自己对 <UserContext.Provider>
进行封装,让它能接受更多的 props
——包括修改共享数据的函数。顺着这个思路,有了以下代码:
import React, { createContext, useContext, useState } from 'react';
const UserContext = createContext();
export default function App() {
return (
<UserContextProvider value={{ user: 'axum.rs' }}>
<Foo />
<Bar />
</UserContextProvider>
);
}
function UserContextProvider({ value, children }) {
const [user, setUser] = useState(value.user);
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
}
function Foo() {
const ctx = useContext(UserContext);
return (
<div>
Foo:{' '}
<input value={ctx.user} onChange={(e) => ctx.setUser(e.target.value)} />
</div>
);
}
function Bar() {
const ctx = useContext(UserContext);
return <div>Bar: {ctx.user}</div>;
}
-
<UserContextProvider value={{ user: 'axum.rs' }}>
- 我们不再直接使用
UserContext.Provider
,而是使用自己封装的UserContextProvider
(名字很像,偷懒的把中间的.
去掉就好了) - 我们给
value
传递的不再是简单的字符串,而是一个 JS 简单对象
- 我们不再直接使用
-
function UserContextProvider({ value, children }) { const [user, setUser] = useState(value.user); return ( <UserContext.Provider value={{ user, setUser }}> {children} </UserContext.Provider> ); }
看不懂?其实很简单——只要你能打破思维限制
- 参数:我们从
props
里解构了value
和childen
const [user, setUser] = useState(value.user);
- 我们创建了一个用于维护用户信息的状态,以及设置它的函数
- 使用
props
传递过来的value.user
作为该状态的默认值
<UserContext.Provider value={{ user, setUser }}>
:我们在返回值里调用原始的UserContext.Provider
- 并且给它的
value
传递了更丰富的内容:原样返回从props
接收到的user
,以及设置状态的函数
- 并且给它的
- 参数:我们从
-
const ctx = useContext(UserContext);
同样,我们还是先使用这个 Context<input value={ctx.user} onChange={(e) => ctx.setUser(e.target.value)} />
- 将 Context 里的
user
作为<input>
的值 - 在
onChange
的处理函数中,我们把<input>
的值,通过 Context 提供的setUser
方法写入到共享数据里
- 将 Context 里的
案例说明
本章的案例是:只有登录用户才能访问用户中心,页面如下:
页面 | 文件 | 路由 | 说明 |
---|---|---|---|
Home | pages/Home.jsx | / | 首页 |
User | pages/User/index.jsx | /user | 用户中心主组件,它会通过 <Outlet /> 来渲染子路由页面 |
UserHome | pages/User/UserHome.jsx | /user/index | 用户中心首页 |
UserOrder | pages/User/UserOrder.jsx | /user/order | 用户中心-我的订单 |
UserProfile | pages/User/Profile.jsx | /user/profile | 用户中心-个人资料 |
Login | pages/Login.jsx | /login | 登录 |
Logout | pages/Logout.jsx | /logout | 退出登录 |
组件如下:
组件 | 文件名 | 说明 |
---|---|---|
Navbar | components/Navbar.jsx | 全局顶部导航 |
Container | components/Container.jsx | 通用布局容器 |
初始页面
我建议你现在先点开本节代码axum-rs-react-context-1进行体验一下,因为在引入 Context 之前,我们已经引入了一些新知识。
-
tailwind:为了便于页面布局,我们引入了 tailwind,这也是本专题的子专题之一,你现在不用管它。我们是通过 JSX 里的
className
来使用 tailwind 的,所以当你浏览代码时,可以不用关注这些 CSS 样式。 -
打开
src/routeTable.jsx
,你可以看到熟悉的代码——但又不完全熟悉//... import { Navigate } from 'react-router-dom'; //... const routeTable = [ //... { path: '/user', element: <User />, children: [ { path: 'index', element: <UserHome />, }, { path: 'profile', element: <UserProfile />, }, { path: 'order', element: <UserOrder />, }, { path: '', element: <Navigate to="index" />, }, ], }, ]; //...
/user
路由- 它不再像上一章的
/news
那样,只是提供前缀。它除了提供前缀,还有另一个功能,那就是提供整个“用户中心”的布局,所以它由两部分组成:菜单和匹配嵌套在它里面的子路由(请看下面对该文件的分析) - 它还有一个
path: ''
的子路由- 首先,所有子路由有都有明确的
path
,而在全局导航里,“用户中心”的地址是/user
,所以在全局导航里点击“用户中心”实际匹配的是这个子路由 - 由于它并不提供有实际意义的功能,所以使用react-router提供的
<Navigate>
组件跳转到index
(完整的路由是/user/index
)
- 首先,所有子路由有都有明确的
- 它不再像上一章的
-
打开
src/pages/User/index.jsx
import React from 'react'; import { Outlet, NavLink } from 'react-router-dom'; export default function User() { return ( <div className="grid grid-cols-2 gap-3"> <div className="flex flex-col space-y-4 border-r"> <NavLink to="index">用户首页</NavLink> <NavLink to="profile">个人资料</NavLink> <NavLink to="order">我的订单</NavLink> <NavLink to="/logout">退出登录</NavLink> </div> <div> <Outlet /> </div> </div> ); }
- 正如上面所说,
User/index.jsx
是给整个用户中心提供布局的,所以它有两部分:一部分是由<NavLink>
组成的导航菜单,另一部分是适配子路由页面 - react-router 提供的
<Outlet>
派上用场了——适配嵌套路由中的子路由
- 正如上面所说,
-
打开
src/components/Container.jsx
import React from 'react'; export default function Container({ children }) { return <div className="container max-w-lg mx-auto p-3">{children}</div>; }
- 这个组件很简单:给子组件提供一个容器
- 这是tailwind里的一个重要思想:使用组件来复用,而不是使用 CSS 的类
-
打开
src/App.jsx
import React from 'react'; import { useRoutes } from 'react-router-dom'; import Navbar from './components/Navbar'; import Container from './components/Container'; import routeTable from './routeTable'; function App() { const routes = useRoutes(routeTable); return ( <> <header className="bg-gray-100 shadow-md"> <Container> <Navbar /> </Container> </header> <main> <Container>{routes}</Container> </main> </> ); } export default App;
<>
是什么鬼?- 我们说过,一个 VDOM 必须只有一个根元素,在之间的章节里,为了满足这一规范,我们不得不使用
<div>
来包裹多个组件,这样的结果是,在真实DOM里一大堆无用的div
- 我们可以用
<>
来包裹多个组件,并且在真实的DOM里,它会自动消失——只是为了满足 VDOM 对只有一个根元素的要求
- 我们说过,一个 VDOM 必须只有一个根元素,在之间的章节里,为了满足这一规范,我们不得不使用
<React.Fragment>
是另外一个和<>
类似的组件,不同之处在于,<React.Fragment>
可以指定key
,而<>
什么属性都不能指定
使用 Context 来控制访问权限
建议你打开本节代码:axum-rs-react-context-2先体验结果,并在编辑器里查看文件。
由于文件较多,请通过上面的链接打开完整的源码,这里只对重要的代码进行讲解
封装 UserContext
// src/contexts/UserContext.jsx
import React, { createContext, useContext, useState } from 'react';
export const UserContext = createContext();
export function UserContextProvider({ value, children }) {
const [user, setUser] = useState(value.user);
const getUser = () => {
return user;
};
return (
<UserContext.Provider value={{ getUser, setUser }}>
{children}
</UserContext.Provider>
);
}
export const defaultValue = { user: null, setUser: null };
export const UserContext = createContext();
创建UserContext
并导出<UserContext.Provider value={{ getUser, setUser }}>
暴露getUser
和setUser
两个函数给外部
App
组件
function App() {
const routes = useRoutes(routeTable);
return (
<UserContextProvider value={defaultValue}>
<header className="bg-gray-100 shadow-md">
<Container>
<Navbar />
</Container>
</header>
<main>
<Container>{routes}</Container>
</main>
</UserContextProvider>
);
}
- 使用我们自定义的
UserContextProvider
包裹所有子组件
导航栏
// ...
import { UserContext } from '../contexts/UserContext';
export default function Navbar() {
const { getUser } = useContext(UserContext);
return (
{/*...*/}
<ul className="flex space-x-4 justify-end items-center">
{getUser() ? (
<li>
<NavLink to="/logout">退出登录</NavLink>
</li>
) : (
<li>
<NavLink to="/login">登录</NavLink>
</li>
)}
</ul>
{/*...*/}
);
}
- 从
UserContext
读取用户信息,如果有用户信息,显示“退出登录”,反之显示“登录”。
用户中心
//...
export default function User() {
const { getUser } = useContext(UserContext);
if (!getUser()) {
return <Navigate to="/login" />;
}
return (
<div className="grid grid-cols-2 gap-3">
{/* ... */}
</div>
);
}
- 从
UserContext
读取用户信息,如果没有,跳转到登录页面
登录
import React, { useContext, useState } from 'react';
import { UserContext } from '../contexts/UserContext';
import { useNavigate } from 'react-router-dom';
export default function Login() {
const [formData, setFormData] = useState({ username: '', password: '' });
const { setUser } = useContext(UserContext);
const navigate = useNavigate();
const onChangeHandler = (el) => {
return (e) => {
setFormData((current) => ({ ...current, [el]: e.target.value }));
};
};
const onSubmitHandler = (e) => {
e.preventDefault();
if (
!(formData.username === 'AXUM中文网' && formData.password === 'axum.rs')
) {
alert('用户名或密码错误');
return;
}
setUser(formData.username);
navigate('/user');
return;
};
return (
<div className="border rounded-md mx-auto bg-gray-100 p-6">
<form className="flex flex-col space-y-4" onSubmit={onSubmitHandler}>
<div>
<label>
账号:{' '}
<input
className="border px-3 py-2 rounded"
placeholder="输入你的用户名"
value={formData.username}
onChange={onChangeHandler('username')}
required
/>
</label>
</div>
<div>
<label>
密码:{' '}
<input
className="border px-3 py-2 rounded"
placeholder="输入你的密码"
type="password"
onChange={onChangeHandler('password')}
required
/>
</label>
</div>
<div>
<button className="border px-8 py-2 rounded bg-blue-600 text-gray-200 hover:bg-blue-700">
登录
</button>
</div>
</form>
<div>{formData.username}</div>
<div>{formData.password}</div>
</div>
);
}
- 注意这里的受控组件的绑定是怎么写的,这是一个高阶函数的写法
- 如果用户名和密码正确,调用
setUser
将用户名写入UserContext
,并跳转的用户中心
退出登录
import React, { useContext } from 'react';
import { UserContext } from '../contexts/UserContext';
import { useNavigate } from 'react-router-dom';
export default function Logout() {
const { setUser } = useContext(UserContext);
const navigate = useNavigate();
function onClickHandler() {
setUser(null);
navigate('/user');
return;
}
return (
<div className="border rounded-md mx-auto text-center bg-gray-100 p-3">
<p className="py-24">确定退出本次登录?</p>
<button
className="border px-8 py-2 rounded bg-rose-600 text-gray-200 hover:bg-rose-700"
onClick={onClickHandler}
>
确定退出
</button>
</div>
);
}
- 清空已登录用户的信息,并跳转到用户中心(实际可以直接改成跳转到登录页面)
redux 的用法请参考
https://react-redux.js.org/api/hooks