使用 React Hooks 和 context API 构建可重用的通知系统
问题
在构建可重复使用的组件时,我们往往会忘记组件本身应该具备的基本功能。
让我解释一下,假设有一个通知/警报/toast 弹出组件,作为一个组件,它应该能够渲染传递给它的任何子组件,并且能够在点击关闭按钮时关闭/隐藏自身(甚至在设定的超时后关闭或隐藏自身)。在最简单的设计中,工程师会使用 prop 钻孔模式,并将 onClose 函数传递给 toast 组件,该函数可以切换承载通知组件的父组件的状态。
这种设计本身并没有错,但是,从开发者体验的角度来看,为什么要让父组件来负责隐藏/关闭通知呢?这个责任应该由组件本身承担。React-notifier 高度可复用的原因在于,任何其他使用它的组件都无需担心通知组件的状态(隐藏/显示或打开/关闭),它只需暴露一个add
andremove
方法即可替你处理这些状态。传统上,这可以通过使用 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>
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 });
}
// ...
}
让我们构建
注意:我们将使用 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/
现在在您最喜欢的代码编辑器中打开创建的项目,并进行src/App.js
编辑
// src/App.js
import React from 'react';
import './App.css';
function App() {
return <div className="App">Hello</div>;
}
export default App;
还编辑src/App.css
了下面的代码。
.App {
text-align: left;
}
接下来,创建如下文件夹结构:
我们将我们的通知组件称为 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">
×
</span>
{renderItem(t.content)}
</div>
);
})}
</div>
</div>
);
}
我们将使用它.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;
}
}
}
}
我们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);
};
让我们分解一下上面的代码。
我们使用初始化一个空的反应上下文React.createContext();
,接下来,我们准备通知系统所需的操作,如果应用程序变得更大并且有很多操作(以消除冲突的操作),这些操作可以放在单独的文件中,
export const ADD = 'ADD';
export const REMOVE = 'REMOVE';
export const REMOVE_ALL = 'REMOVE_ALL';
接下来是 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 } })
}
>
×
</span>
{renderItem(t.content)}
</div>
);
})}
</div>
</div>
);
}
为了看到上述内容的实际效果,让我们使用 制作一些基本的路线和导航react-router-dom
。
$: npm install -s react-router-dom
由于以下内容仅用于展示 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>
);
};
上面是一个渲染按钮的简单组件,按钮的 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>
);
}
包起来
上述代码的演示可以在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