使用 React Hooks 和 context API 构建可重用的通知系统

2025-06-09

使用 React Hooks 和 context API 构建可重用的通知系统

问题

在构建可重复使用的组件时,我们往往会忘记组件本身应该具备的基本功能。

让我解释一下,假设有一个通知/警报/toast 弹出组件,作为一个组件,它应该能够渲染传递给它的任何子组件,并且能够在点击关闭按钮时关闭/隐藏自身(甚至在设定的超时后关闭或隐藏自身)。在最简单的设计中,工程师会使用 prop 钻孔模式,并将 onClose 函数传递给 toast 组件,该函数可以切换承载通知组件的父组件的状态。

这种设计本身并没有错,但是,从开发者体验的角度来看,为什么要让父组件来负责隐藏/关闭通知呢?这个责任应该由组件本身承担。React-notifier 高度可复用的原因在于,任何其他使用它的组件都无需担心通知组件的状态(隐藏/显示或打开/关闭),它只需暴露一个addandremove方法即可替你处理这些状态。传统上,这可以通过使用 Redux 来管理全局状态,但是,为了拥抱最新的 React 特性,我们将使用 React Hooks 和 context API 来实现。够激动了吗?那就开始吧!

特征

通知系统基于 React 构建,无需任何外部库。它高度可复用,可在应用程序的任何位置触发。Toast 通知将可堆叠,这意味着我们可以同时显示多个通知,这些通知本身能够渲染字符串或其他 React 组件。

背景

以下内容假设读者对 React 和 React Hooks 有深入的了解,并将仅简要介绍所需的 React Hooks。如需详细了解 React Hooks,请参阅React Hooks 文档

我们将使用以下钩子

  • useState,这使我们能够在功能组件中使用反应状态(这以前只能在基于类的组件中实现,并且功能组件仅用作展示组件)。

  • useContext,此钩子接受一个 context 对象作为输入,并返回传入的值Context.Provider。React context API 提供了一种在组件树中传递 props/data 的方法,而无需将 props/data 传递给每个级别的每个子组件(prop 钻取)。

以下是上下文 API 的语法,供参考



const SampleContext = React.createContext(/*initialVAlue*/);

// wrap the parent component with the context provider
<SampleContext.Provider value={/*value*/}>
  .
  .
  .
  .
  /* n level child can access the provider value using SampleContext.Consumer */
  <SampleContext.Consumer>
    {value => /* Component with access to value object */}
  </SampleContext.Consumer>
</SampleContext.Provider>


Enter fullscreen mode Exit fullscreen mode
  • useReducer这是一个嵌入到 React Hooks 中的自定义 Hook,它提供了类似 Redux Reducer 的接口。Reducer 接收一个初始状态和一个具有类型和负载的 Action 对象,并根据类型重新创建初始状态(纯函数)并返回。使用 dispatch 函数来触发 Reducer 的切换。

下面的用法useReducer是从 react 文档中复制的。



// the reducer function that provides new state based on action.type
function todosReducer(state, action) {
  switch (action.type) {
    case 'add':
      return [
        ...state,
        {
          text: action.text,
          completed: false
        }
      ];
    // ... other actions ...
    default:
      return state;
  }
}

// the useReducer function keeps track of the state and returns the new state and a dispatcher function.
function useReducer(reducer, initialState) {
  const [state, setState] = useState(initialState);

  function dispatch(action) {
    const nextState = reducer(state, action);
    setState(nextState);
  }

  return [state, dispatch];
}

// Sample usage of the useReducer.
function Todos() {
  const [todos, dispatch] = useReducer(todosReducer, []);

  function handleAddClick(text) {
    dispatch({ type: 'add', text });
  }

  // ...
}


Enter fullscreen mode Exit fullscreen mode

让我们构建

注意:我们将使用 create-react-app 来搭建一个基本的 react 应用程序,同时请安装最新稳定版本的 NodeJS。

使用创建一个基本的反应应用程序create-react-app



$: npx create-react-app react-notifier
$: cd react-notifier
$: npm run start # this will start a development server at http://localhost:3000/


Enter fullscreen mode Exit fullscreen mode

现在在您最喜欢的代码编辑器中打开创建的项目,并进行src/App.js编辑



// src/App.js
import React from 'react';
import './App.css';

function App() {
  return <div className="App">Hello</div>;
}

export default App;


Enter fullscreen mode Exit fullscreen mode

还编辑src/App.css了下面的代码。



.App {
  text-align: left;
}


Enter fullscreen mode Exit fullscreen mode

接下来,创建如下文件夹结构:

文件夹结构

我们将我们的通知组件称为 Toast。

让我们创建 Toast 组件

这将是一个简单的组件,它接受一个数组,并根据数组元素是函数还是对象来渲染相同的数组



// src/components/Toast

import React from 'react';

export default function Toast({ toast }) {
  // function to decide how to render the content of the toast
  function renderItem(content) {
    if (typeof content === 'function') {
      return content();
    } else {
      return <pre>{JSON.stringify(content, null, 2)}</pre>;
    }
  }
  return (
    <div className="toast">
      <div className="toast-container">
        {/* Displaying each element of the toast */}
        {toast.map(t => {
          return (
            <div
              className={`toast-container-item ${t.type ? t.type : ''}`}
              key={t.id}
            >
              <span role="img" aria-label="close toast" className="toast-close">
                &times;
              </span>
              {renderItem(t.content)}
            </div>
          );
        })}
      </div>
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

我们将使用它.scss来定义 CSS

注意:请运行npm install --save node-sass编译.scss文件



// styles/base.scss
// base colors
$black: #212121;
$white: #fff;
$gray: #e0e0e0;
$primaryBlue: #1652f0;
$hoverBlue: #154de0;
$red: #d9605a;
// fonts
$code: 'Oxygen Mono', monospace;

// styles/toast.scss
@import './base.scss';
.toast {
  position: fixed;
  top: 50px;
  right: 10px;
  width: 300px;
  max-height: 90vh;
  overflow-y: scroll;
  font-family: $code;
  .toast-container {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    .toast-container-item {
      border: $primaryBlue solid 1px;
      margin: 5px 0px;
      padding: 2px;
      border-radius: 4px;
      width: 100%;
      min-height: 100px;
      word-wrap: break-word;
      background-color: $black;
      box-shadow: 4px 4px 15px 2px rgba(black, 0.75);
      color: $white;
      transition: 0.2s;
      &:not(:first-child) {
        margin-top: -3rem;
      }
      // &:hover,
      // &:focus-within {
      //   transform: translateX(-2rem);
      // }
      &:hover ~ .toast-container-item,
      &:focus-within ~ .toast-container-item {
        transform: translateY(3rem);
      }

      &.info {
        border: $primaryBlue solid 1px;
        background-color: $hoverBlue;
      }
      &.danger {
        border: $red solid 1px;
        background-color: $red;
      }
      .toast-close {
        cursor: pointer;
        position: relative;
        top: 5px;
        font-size: 20px;
        font-weight: 800;
      }
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

我们position: fixed;结合使用顶部和右侧属性,让 Toast 通知出现在屏幕的右上角。

随后,我们使用display: flex;中的属性toast-container来实现灵活的布局

要了解有关 flex 的更多信息,请参阅:Flexbox 完整指南

接下来,让我们定义我们的ToastContext,以便我们可以从应用程序中的任何位置触发组件



// contexts/ToastContext.js

import React, { createContext, useReducer, useContext } from 'react';
import { createPortal } from 'react-dom';
import Toast from '../components/Toast';
import '../styles/toast.scss';

export const ToastContext = createContext();

const initialState = [];

export const ADD = 'ADD';
export const REMOVE = 'REMOVE';
export const REMOVE_ALL = 'REMOVE_ALL';

export const toastReducer = (state, action) => {
  switch (action.type) {
    case ADD:
      return [
        ...state,
        {
          id: +new Date(),
          content: action.payload.content,
          type: action.payload.type
        }
      ];
    case REMOVE:
      return state.filter(t => t.id !== action.payload.id);
    case REMOVE_ALL:
      return initialState;
    default:
      return state;
  }
};

export const ToastProvider = props => {
  const [toast, toastDispatch] = useReducer(toastReducer, initialState);
  const toastData = { toast, toastDispatch };
  return (
    <ToastContext.Provider value={toastData}>
      {props.children}

      {createPortal(<Toast toast={toast} />, document.body)}
    </ToastContext.Provider>
  );
};

export const useToastContext = () => {
  return useContext(ToastContext);
};


Enter fullscreen mode Exit fullscreen mode

让我们分解一下上面的代码。

我们使用初始化一个空的反应上下文React.createContext();,接下来,我们准备通知系统所需的操作,如果应用程序变得更大并且有很多操作(以消除冲突的操作),这些操作可以放在单独的文件中,



export const ADD = 'ADD';
export const REMOVE = 'REMOVE';
export const REMOVE_ALL = 'REMOVE_ALL';


Enter fullscreen mode Exit fullscreen mode

接下来是 Reducer 函数,它将初始状态作为空数组,并根据action.type推送到数组或删除操作返回新状态。

我们还为 toast 数组中的所有新条目提供了一个 id,这使得删除所述目标 toast/通知变得更加容易。

接下来,我们创建一个 Provider 函数,它将值提供给通过创建的空上下文,<Context.Provider>
我们将从钩子返回的 newState 和调度程序函数结合起来useReducer,并通过上下文 API 将它们作为值发送。

我们使用React.createPortal来呈现中的 toast 组件document.body,这提供了更简单/更少冲突的样式和文档流。

最后,我们通过自定义钩子公开了useContext(更易于使用的版本)钩子。<Context.Consumer>

更新 toast 组件以使用useToastContext钩子,以便它可以拥有自己的调度程序来从组件内部关闭 toast/通知



// src/components/Toast.js
import React from 'react';

import { useToastContext, REMOVE } from '../contexts/ToastContext';

export default function Toast({ toast }) {
  const { toastDispatch } = useToastContext();
  function renderItem(content) {
    if (typeof content === 'function') {
      return content();
    } else {
      return <pre>{JSON.stringify(content, null, 2)}</pre>;
    }
  }
  return (
    <div className="toast">
      <div className="toast-container">
        {toast.map(t => {
          return (
            <div
              className={`toast-container-item ${t.type ? t.type : ''}`}
              key={t.id}
            >
              <span
                role="img"
                aria-label="close toast"
                className="toast-close"
                onClick={() =>
                  toastDispatch({ type: REMOVE, payload: { id: t.id } })
                }
              >
                &times;
              </span>
              {renderItem(t.content)}
            </div>
          );
        })}
      </div>
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

为了看到上述内容的实际效果,让我们使用 制作一些基本的路线和导航react-router-dom



$: npm install -s react-router-dom


Enter fullscreen mode Exit fullscreen mode

由于以下内容仅用于展示 Toast 组件的用法,我们将在src/App.js文件中定义每个路由的组件。

定义主页组件



export const Home = () => {
  const { toastDispatch } = useToastContext();
  return (
    <div>
      <button
        onClick={() =>
          toastDispatch({
            type: ADD,
            payload: {
              content: { sucess: 'OK', message: 'Hello World' }
            }
          })
        }
      >
        Show basic notification
      </button>
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode

上面是一个渲染按钮的简单组件,按钮的 onClick 会分派一个带有type: ADD一些内容的动作,还可以选择一种类型,info用于danger渲染 toast/通知的背景颜色。

类似地,我们将定义一些其他组件,只是为了展示各种类型的 Toast 组件用例。

最终scr/App.js文件如下



import React from 'react';
import { BrowserRouter as Router, Switch, Route, Link } from 'react-router-dom';
import './App.css';
import { useToastContext, ADD, REMOVE_ALL } from './contexts/ToastContext';

export const Home = () => {
const { toastDispatch } = useToastContext();
return (
<div>
<button
onClick={() =>
toastDispatch({
type: ADD,
payload: {
content: { sucess: 'OK', message: 'Hello World' }
}
})
}
>
Show basic notification
</button>
</div>
);
};
export const Info = () => {
const { toastDispatch } = useToastContext();
return (
<div>
<button
onClick={() =>
toastDispatch({
type: ADD,
payload: {
content: { sucess: 'OK', message: 'Info message' },
type: 'info'
}
})
}
>
Show Info notification
</button>
</div>
);
};

export const Danger = () => {
const { toastDispatch } = useToastContext();
return (
<div>
<button
onClick={() =>
toastDispatch({
type: ADD,
payload: {
content: { sucess: 'FAIL', message: 'Something nasty!' },
type: 'danger'
}
})
}
>
Show danger notification
</button>
</div>
);
};

export const CutomHTML = () => {
const { toastDispatch } = useToastContext();
return (
<div>
<button
onClick={() =>
toastDispatch({
type: ADD,
payload: {
content: () => {
return (
<div>
<h4>Error</h4>
<p>Something nasty happened!!</p>
</div>
);
},
type: 'danger'
}
})
}
>
Show danger notification with custom HTML
</button>
</div>
);
};

export default function App() {
const { toast, toastDispatch } = useToastContext();
function showClearAll() {
if (toast.length) {
return (
<button
onClick={() =>
toastDispatch({
type: REMOVE_ALL
})
}
>
Clear all notifications
</button>
);
}
}
return (
<div className="App">
<Router>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/info">Info</Link>
</li>
<li>
<Link to="/danger">Danger</Link>
</li>
<li>
<Link to="/custom-html">Custom HTML</Link>
</li>
</ul>
<Switch>
<Route exact path="/">
<Home />
</Route>
<Route exact path="/info">
<Info />
</Route>
<Route exact path="/danger">
<Danger />
</Route>
<Route exact path="/custom-html">
<CutomHTML />
</Route>
</Switch>
</Router>
<br />
{showClearAll()}
</div>
);
}

Enter fullscreen mode Exit fullscreen mode




包起来

上述代码的演示可以在CodeSandbox 链接中找到

以上代码的 Github 仓库位于https://github.com/kevjose/react-notifier。如果您觉得本文有趣,请在 Github 上点个 Star,这会激励我 :)

鏂囩珷鏉ユ簮锛�https://dev.to/kevjose/building-a-reusable-notification-system-with-react-hooks-and-context-api-2phj
PREV
穷人的工具其实很棒!💪 通用着色剂球棒(猫/无替换)fzf
NEXT
JWT 授权和身份验证、Node、Express 和 Vue 后端架构控制器 index.js 前端注册登录注销 MySQL PostreSQL 发送代码进行确认