本章将通过一个非常典型的案例来讨论 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 提供的,只能接收包括 valuechildren在内的,为数不多的 props

我们能不能自己对 <UserContext.Provider>进行封装,让它能接受更多的 props——包括修改共享数据的函数。顺着这个思路,有了以下代码:

  • <UserContextProvider value={{ user: 'axum.rs' }}>

    • 我们不再直接使用 UserContext.Provider,而是使用自己封装的 UserContextProvider (名字很像,偷懒的把中间的.去掉就好了)
    • 我们给 value 传递的不再是简单的字符串,而是一个 JS 简单对象
  • 来看 UserContextProvider 的封装

    function UserContextProvider({ value, children }) {
      const [user, setUser] = useState(value.user);
    
      return (
        <UserContext.Provider value={{ user, setUser }}>
          {children}
        </UserContext.Provider>
      );
    }
    

    看不懂?其实很简单——只要你能打破思维限制

    • 参数:我们从 props 里解构了 valuechilden
    • const [user, setUser] = useState(value.user);
      • 我们创建了一个用于维护用户信息的状态,以及设置它的函数
      • 使用 props 传递过来的 value.user 作为该状态的默认值
    • <UserContext.Provider value={{ user, setUser }}>:我们在返回值里调用原始的 UserContext.Provider
      • 并且给它的 value 传递了更丰富的内容:原样返回从 props 接收到的 user,以及设置状态的函数
  • Foo 组件里

    • const ctx = useContext(UserContext);同样,我们还是先使用这个 Context
    • <input value={ctx.user} onChange={(e) => ctx.setUser(e.target.value)} />
      • 将 Context 里的 user 作为 <input> 的值
      • onChange 的处理函数中,我们把<input> 的值,通过 Context 提供的 setUser 方法写入到共享数据里

案例说明

本章的案例是:只有登录用户才能访问用户中心,页面如下:

页面文件路由说明
Homepages/Home.jsx/首页
Userpages/User/index.jsx/user用户中心主组件,它会通过 <Outlet /> 来渲染子路由页面
UserHomepages/User/UserHome.jsx/user/index用户中心首页
UserOrderpages/User/UserOrder.jsx/user/order用户中心-我的订单
UserProfilepages/User/Profile.jsx/user/profile用户中心-个人资料
Loginpages/Login.jsx/login登录
Logoutpages/Logout.jsx/logout退出登录

组件如下:

组件文件名说明
Navbarcomponents/Navbar.jsx全局顶部导航
Containercomponents/Container.jsx通用布局容器

初始页面

我建议你现在先点开本节代码axum-rs-react-context-1进行体验一下,因为在引入 Context 之前,我们已经引入了一些新知识。

  • 打开 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 对只有一个根元素的要求
    • <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 }}>暴露 getUsersetUser 两个函数给外部

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 的用法请参考