引言
以 Bootstrap 为例,使用模态框编写一个简单的消息框:
import { useState } from "react";
import { Modal } from "react-bootstrap";
import Button from "react-bootstrap/Button";
import 'bootstrap/dist/css/bootstrap.min.css';
function App() {
let [show, setShow] = useState(false);
const handleConfirm = () => {
setShow(false);
console.log("confirm");
};
const handleCancel = () => {
setShow(false);
console.log("cancel");
};
return (
<div>
<Button variant="primary" onClick={() => setShow(true)}>弹窗</Button>
<Modal show={show}>
<Modal.Header>
<Modal.Title>我是标题</Modal.Title>
</Modal.Header>
<Modal.Body>
Hello World
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={handleConfirm}>确定</Button>
<Button variant="secondary" onClick={handleCancel}>取消</Button>
</Modal.Footer>
</Modal>
</div>
);
}
export default App;
整段代码十分复杂。
Bootstrap 的模态框使用 show
属性决定是否显示,因此我们不得不创建一个 state 来保存是否展示模态框。然后还得自己手动在按钮的点击事件里控制模态框的展示。
如果你编写过传统桌面软件,弹一个消息框应该是很简单的事情,就像
if (MessageBox.show('我是标题', 'HelloWorld', MessageBox.YesNo) == MessageBox.Yes)
console.log('确定');
else
console.log('取消');
一样。
那么下面我们就朝着这个方向,尝试将上面的 React 代码简化。
0. 简单封装
首先从 HTML 代码开始简化。先封装成一个简单的受控组件:
import React, { useMemo } from "react";
import { useState, createContext, useRef } from "react";
import { Button, Modal } from "react-bootstrap";
/**
* 类 Windows 消息框组件。
* @param {object} props
* @param {string} props.title 消息框标题
* @param {string} props.message 消息框内容
* @param {string} [props.type="ok"] 消息框类型
* @param {boolean} [props.showModal=false] 是否显示消息框
* @param {function} [props.onResult] 消息框结果回调
* @returns {JSX.Element}
*/
function MessageBox(props) {
let title = props.title;
let message = props.message;
let type = props.type || 'ok';
let showModal = props.showModal || false;
let onResult = props.onResult || (() => {});
let buttons = null;
// 处理不同按钮
const handleResult = (result) => {
onResult(result);
};
if (type === 'ok') {
buttons = (
<Button variant="primary" onClick={ () => handleResult('ok') }>确定</Button>
);
}
else if (type === 'yesno') {
buttons = (
<>
<Button variant="secondary" onClick={ () => handleResult('confirm') }>取消</Button>
<Button variant="primary" onClick={ () => handleResult('cancel') }>确定</Button>
</>
)
}
return (
<div>
<Modal show={showModal}>
<Modal.Header>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>{message}</Modal.Body>
<Modal.Footer>
{buttons}
</Modal.Footer>
</Modal>
</div>
);
}
export default MessageBox;
测试:
function App() {
const handleResult = (result) => {
console.log(result);
};
return (
<div>
<MessageBox showModal={true} title="我是标题" message="Hello World" type="ok" onResult={handleResult} />
</div>
);
}
HTML 代码部分简化完成。这下代码短了不少。
现在如果想要正常使用消息框,还需要自己定义 showModal
状态并绑定 onResult
事件控制消息框的显示隐藏。下面我们来简化 JS 调用部分。
1. useContext
首先可以考虑全局都只放一份模态框的代码到某个位置,然后要用的时候都修改这一个模态框即可。这样就不用每次都写一个 <MessageBox ... />
了。
为了能在任意地方都访问到模态框,可以考虑用 Context 进行跨级通信。
把“修改模态框内容 + 处理隐藏”这部分封装成一个函数 show(),然后通过 Context 暴露出去。
import { useState, createContext, useRef, useContext } from "react";
import MessageBoxBase from "./MessageBox";
const MessageBoxContext = createContext(null);
function MessageBoxProvider(props) {
let [showModal, setShowModal] = useState(false);
let [title, setTitle] = useState('');
let [message, setMessage] = useState('');
let [type, setType] = useState(null);
let resolveRef = useRef(null); // 因为与 UI 无关,用 ref 不用 state
const handleResult = (result) => {
resolveRef.current(result);
setShowModal(false);
};
const show = (title, message, type) => {
setTitle(title);
setMessage(message);
setType(type);
setShowModal(true);
return new Promise((resolve, reject) => {
resolveRef.current = resolve;
});
};
return (
<MessageBoxContext.Provider value={show}>
<MessageBoxBase
title={title}
message={message}
type={type}
showModal={showModal}
onResult={handleResult}
/>
{props.children}
</MessageBoxContext.Provider>
);
}
export { MessageBoxProvider, MessageBoxContext };
使用:
index.js
root.render(
<React.StrictMode>
<MessageBoxProvider>
<App />
</MessageBoxProvider>
</React.StrictMode>
);
App.js
function App() {
let msgBox = useContext(MessageBoxContext);
const handleClick = async () => {
let result = await msgBox('我是标题', 'Hello World', 'yesno');
console.log(result);
if (result === 'yes') {
alert('yes');
} else if (result === 'no') {
alert('no');
}
};
return (
<div>
<Button variant="primary" onClick={handleClick}>弹窗1</Button>
</div>
);
}
为了方便使用,可以在 useContext
之上再套一层:
/**
* 以 Context 方式使用 MessageBox。
* @return {(title: string, message: string, type: string) => Promise<string>}
*/
function useMessageBox() {
return useContext(MessageBoxContext);
}
这样封装使用起来是最简单的,只需要 useMessageBox 然后直接调函数即可显示消息框。
但是缺点显而易见,只能同时弹一个消息框,因为所有的消息框都要共享一个模态框。
2. Hook
为了解决上面只能同时弹一个框的问题,我们可以考虑取消全局只有一个对话框的策略,改成每个要用的组件都单独一个对话框,这样就不会出现冲突的问题了。
即将模态框组件和状态以及处理函数都封装到一个 Hook 里,每次调用这个 Hook 都返回一个组件变量和 show 函数,调用方只需要把返回的组件变量渲染出来,然后调用 show 即可。
import React, { useMemo } from "react";
import { useState, createContext, useRef } from "react";
import MessageBoxBase from "./MessageBox";
/**
* 以 Hook 方式使用消息框。
* @returns {[MessageBox, show]} [MessageBox, show]
* @example
* const [MessageBox, show] = useMessageBox();
* return (
* <MessageBox />
* <button onClick={() => show('title', 'message', 'ok')} >show</button>
* );
*/
function useMessageBox() {
let [title, setTitle] = useState('');
let [message, setMessage] = useState('');
let [type, setType] = useState(null);
let [showDialog, setShowDialog] = useState(false);
let resolveRef = useRef(null);
const handleResult = (result) => {
resolveRef.current(result);
setShowDialog(false);
};
const MessageBox = useMemo(() => { // 也可以不用 useMemo 直接赋值 JSX 代码
return (
<MessageBoxBase
title={title}
message={message}
type={type}
showModal={showDialog}
onResult={handleResult}
/>
);
}, [title, message, type, showDialog]);
const show = (title, message, type) => {
setTitle(title);
setMessage(message);
setType(type);
setShowDialog(true);
return new Promise((resolve, reject) => {
resolveRef.current = resolve;
});
};
return [MessageBox, show];
}
export default useMessageBox;
App.js
function App() {
const [MessageBox, show] = useMessageBox();
return (
<div>
{MessageBox}
<button onClick={ () => show('title', 'message', 'ok') }>HookShow1</button>
<button onClick={ () => show('title', 'message', 'yesno') }>HookShow2</button>
</div>
);
}
3. forwardRef + useImperativeHandle
上面我们都是封装成 show() 函数的形式。对于简单的消息框,这种调用方式非常好用。但是如果想要显示复杂的内容(例如 HTML 标签)就有些麻烦了。
这种情况可以考虑不封装 HTML 代码,HTML 代码让调用者手动编写,我们只封装控制部分的 JS 代码,即 showModal
状态和回调函数。
如果是类组件,可以直接添加一个普通的成员方法 show(),然后通过 ref 调用这个方法。但是现在我们用的是函数式组件,函数式组件想要使用 ref 需要使用 forwardRef
和 useImperativeHandle
函数,具体见这里。
import { useImperativeHandle, useRef, useState } from "react";
import MessageBox from "./MessageBox";
import { forwardRef } from "react";
function MessageBoxRef(props, ref) {
let [showModal, setShowModal] = useState(false);
let resolveRef = useRef(null);
function handleResult(result) {
setShowModal(false);
resolveRef.current(result);
}
// ref 引用的对象将会是第二个参数(回调函数)的返回值
useImperativeHandle(ref, () => ({
show() {
setShowModal(true);
return new Promise((resolve, reject) => {
resolveRef.current = resolve;
});
}
}), []); // 第三个参数为依赖,类似于 useEffect()
return <MessageBox {...props} showModal={showModal} onResult={handleResult} />;
}
export default forwardRef(MessageBoxRef);
使用的时候只需要创建一个 ref,然后 ref.current.show() 即可。
App.js
function App() {
const messageBoxRef = useRef();
return (
<div>
<MessageBoxRef ref={messageBoxRef} title="标题" message="内容" />
<button onClick={ () => messageBoxRef.current.show() }>RefShow</button>
</div>
);
}