摘要
抽屉、对话框这类浮层组件可以在不切换页面的前提下展示更多的信息,但是若多个组件依赖同一个浮层组件的话,该组件的状态管理就会变得复杂,难以维护,本文结合实际工作中的遇到的开发痛点,总结了如何使用Hooks简化浮层组件使用的过程。
背景
目前,在公司从事商业化广告数据报表的开发工作,团队内部沉淀出一套报表数据引擎,利用配置化的方式提供了一套通用性、可扩展的报表接口能力。为了方便数据报表的配置,需要开发一套可视化报表管理后台;
在开发报表引擎配置后台过程中,涉及到一个报表的各层级元素的配置:DashBoard(业务模块)、Panel(数据看板)、Chart(具体报表)、Column(报表数据列),这些元素具有一定的层级拓扑关系,报表页面如下:
在报表引擎管理配置平台时,希望交互中保持这样的层次关系,因此在设计 Chart 配置页面需要这样一种交互:
- 点击一个 Chart 进行配置时,打开一个抽屉(Drawer)作为编辑页;
- 抽屉包含2部分:Chart 层级的配置元数据,以及该 Chart 包含的 Column 列表;
- 点击列表中某一个 Column 的编辑按钮,打开一个二级抽屉,作为该 Column 元数据配置页;
- 一个抽屉承担2种功能:新建、编辑,即同一个抽屉可在不同地方(新建按钮和编辑按钮)打开;
最终的交互形式如下:
本文中简化后的 Demo 交互如下:

在开发过程中,发现有这样几个痛点:
- 痛点1,使用抽屉组件的地方非常多,上面仅描述了报表管理模块的抽屉组件的使用,还有其他模块也有类似的抽屉组件的使用方式,case by case 开发,非常耗费精力;
- 痛点2,为了减少开发量,报表元数据配置的新增页、编辑页复用同一个抽屉,而新增、编辑的按钮可能存在不同的组件中(比如,新增、编辑的按钮分别在 sider 和 content 中,点击按钮弹出同一个抽屉),那抽屉组件应该放在哪个组件中?抽屉组件对应的局部状态数据 state 和 setXXX() 应该放在哪个组件中?
- 痛点3,对于痛点2,一般是找到两个组件的最近公共父组件,将抽屉的局部状态 state 和对状态的操作 setXXX() 通过 props 的方式逐层传递到新增、编辑按钮对应的子组件中。这意味着所有的状态管理都需要在更高级别的组件上,这样做有两个缺点,1)逐级传递导致代码可读性、可维护性变差;2)如果后面需要在第三个组件中加上这个抽屉,且加上这个新的组件后,这三个子组件的最近公共父组件不是原来那个了,那么就需要将抽屉的局部状态 state 和对状态的操作 setXXX() 迁移到新的最近公共父组件,这样又增加代码的维护成本。
问题抽象
为了方便理解,这里我将场景简化,现在实现这样一个简单的报表管理页面:
侧栏(side)和列表(list)是同层级的两个组件,侧栏的新建按钮和列表中的每一个编辑按钮都可以打开同一个抽屉,分别进行新建和编辑操作。
一般实现:
function ChartLayout() {
// 最近公共父节点维护抽屉状态及操作
const [modalVisible, setModalVisible] = useState(false);
const [chart, setChart] = useState(null);
const showChartDrawer = (chart) => {
setModalVisible(true);
setChart(chart)
}
return (
<div className="main-layout">
{/*侧边栏 side,通过 props 传递抽屉状态及操作*/}
<Sider onNewChart={showChartDrawer}/>
{/*列表 list,通过 props 传递抽屉状态及操作*/}
<ChartList onEditChart={chart => showChartDrawer(chart)}/>
{/*最近公共父节点渲染 drawer 组件*/}
<ChartInfoDrawer visible={modalVisible} chart={chart} />
</div>
);
}
这段代码中,将 ChartInfoDrawer 这个抽屉组件定义在了父组件 ChartLayout 中,通过 visible 控制其是否显示。然后再在 Sider 和 ChartList 这两个组件中,用自定义事件来告知父组件,用户点击了某个按钮了,应该显示抽屉。 这样做有两个问题: 第一,组件语义不明确,没有做到低耦合。ChartLayout 应该只做布局的事情,而不应该有其他的业务逻辑。但是这里加入了报表信息处理的逻辑,就让本不相关的两块功能产生了依赖。 第二,难以扩展。现在我们只是在 ChartLayout 下面的两个组件共享了 Drawer 组件,但是如果 和 ChartLayout 同级的组件也要访问这个抽屉呢?是否需要将报表信息处理逻辑的代码上升?一个更底层组件需要这个 Drawer 呢?将 state 和 setXXX() 继续往下传递?
解决方案 EasyDrawer
如何用一个统一的方式去管理 Drawer 这种浮层类的组件(另一个类似的组件是对话框 Modal), 从而让浮层类组件相关的业务逻辑内聚,以便和其他业务逻辑进行解耦? 思路:使用全局状态管理所有的浮层类组件 浮层组件其实是独立于其他界面的一个窗口,用于完成一个独立的功能。从视觉上看,在使用抽屉、对话框这类组件的时候,不需要关心它是从哪个具体的组件中弹出来的,而只会关心对框本身的内容,这种性质决定了在组件层级上,它其实是应该独立于各个组件之外的。 可以给每一个浮层组件定义全局唯一的 ID,通过这个 ID 去显示或者隐藏一个对话框,并且给它传递参数。
API 设计
设计一个 API 去做抽屉的全局管理,这里将这个抽屉的实现命名为 EasyDrawer:
// 通过 create API 创建一个抽屉,id 为 chart-info-drawer
// RealEasyDrawer 为真正的 Drawer 组件,内部使用 antd 的 Drawer
const MyEasyChartInfoDrawer = EasyDrawer.create('chart-info-drawer', RealEasyDrawer)
// 渲染这个组件
<MyEasyChartInfoDrawer />
// 在需要操作的地方引用这个 Drawer 对象
const drawer = useEasyModal('chart-info-drawer');
// 通过 show() 显示这个抽屉,并能够给它传递参数
drawer.show(args);
// 通过 hide() 隐藏这个抽屉
drawer.hide();
如果有这样的 API,那么无论在哪个层级的组件,只要知道某个 Drawer 的 id,那就都可以统一使用这些组件,而不再需要考虑该在哪个层级的组件去定义了,使用起来会更加直观。
基于 Redux 的实现方案
如何管理全局状态? 通过 Redux 实现全局状态管理,创建一个可以处理所有 Drawer 状态的 reducer:
import { useCallback, useMemo, useRef } from "react";
import { Drawer,Modal } from "antd";
import { useSelector, useDispatch } from "react-redux";
const componentCallbacks = {};
export const componentReducer = (state = { hiding: {} }, action) => {
switch (action.type) {
case "easy-drawer/show":
// componentId = true 或者 componentId = args 表示显示该组件
// args 非空的话,作为参数通过 props 传递给该组件
return {
...state,
[action.payload.componentId]: action.payload.args || true,
hiding: {
...state.hiding,
[action.payload.componentId]: false,
},
};
case "easy-drawer/hide":
// force = true,componentId=false,表示删除该组件节点
// force = false,不直接删除该组件节点,而是触发隐藏回调函数,等完全隐藏后,再执行 componentId=false,删除该节点
// 具体实现原理结合下面 hide、EasyDrawer、useEasyDrawer 这三个 api 理解
return action.payload.force
? {
...state,
[action.payload.componentId]: false,
hiding: { [action.payload.componentId]: false },
}
: { ...state, hiding: { [action.payload.componentId]: true } };
default:
return state;
}
};
// 简化显示操作 action 对象的创建
function showDrawer(componentId, args) {
return {
type: "easy-drawer/show",
payload: {
componentId,
args,
},
};
}
// 简化隐藏操作 action 对象的创建
function hideDrawer(componentId, force) {
return {
type: "easy-drawer/hide",
payload: {
componentId,
force,
},
};
}
这段代码的主要思路就是通过 Redux 的 store 去存储每个抽屉状态和参数,并设计了两个 action ,分别用来显示和隐藏组件。要注意的是,这里加入了 hiding 这样一个状态,用来处理抽屉关闭过程的动画,防止组件关闭时的闪退。
定义一个 useEasyDrawer 这样的 Hook,在其内部封装对 store 的操作,实现状态管理的逻辑复用,提高 EasyDrawer 的易用性:
// 基于 Hooks 实现 EasyDrawer
export const useEasyDrawer = (componentId) => {
const dispatch = useDispatch();
const show = useCallback(
(args) => {
return new Promise((resolve) => {
// 将 resolve 方法缓存起来,便于在其他地方调用 resolve() 改变当前 Promise 的状态
componentCallbacks[componentId] = resolve;
// 触发drawer 显示的 action 的下发
dispatch(showDrawer(componentId, args));
});
},
[dispatch, componentId],
);
const resolve = useCallback(
(args) => {
// EasyDrawer 对象的 resolve(value) 方法,使用之前缓存在 componentCallbacks 中的 resolve 方法调用
// 传入的 args,可以在 EasyDrawer.show().then(value => { 处理value }) 中处理
if (componentCallbacks[componentId]) {
componentCallbacks[componentId](args);
// Promise 只能调用一次 resolve
delete componentCallbacks[componentId];
}
},
[componentId],
);
// drawer 对象的隐藏方法
// force 参数用于 drawer 的平滑渲染
// force = true,直接删除 drawer 节点,返回 null
// force = false,将 Drawer 的 open 属性置为 false,让其平滑收起后,调用 hide(true) 删除该节点,返回 null
const hide = useCallback(
(force) => {
// 触发drawer 显示的 action 的下发
dispatch(hideDrawer(componentId, force));
// 组件节点都删除,也就不需要继续缓存对应的 resolve 回调方法了
delete componentCallbacks[componentId];
},
[dispatch, componentId],
);
// 获取传给 drawer 对象的参数,通过 props 传递给该组件
const args = useSelector((s) => s[componentId]);
// 获取 drawer 组件的显示状态
const hiding = useSelector((s) => s.hiding[componentId]);
// 返回 drawer 组件的内部状态以及操作API
return useMemo(
() => ({ args, hiding, visible: !!args, show, hide, resolve }),
[args, hide, show, resolve, hiding],
);
};
实现 EasyDrawer 这样一个组件,去封装 Drawer 的通用的操作逻辑,比如关闭按钮,确定按钮的事件处理:
// EasyDrawer 的实现,内部使用了 antd 的 Drawer
// 通过全局状态控制 Drawer 的显示,全局状态来自于 Redux 的 store
function EasyDrawer({ id, children, ...rest }) {
const drawer = useEasyDrawer(id);
return (
<Drawer
// hide(false),伪关闭,其实调用组件的 open=false,触发其隐藏动画,实例还是在的
onClose={() => drawer.hide()}
// open=false,已经关闭了,afterOpenChange 表示其隐藏动画已经结束了,直接drawer.hide(true),返回 null,节点下线
// 这样做的的好处是,隐藏是平滑的,而不是闪退
afterOpenChange={(open) => !open && drawer.hide(true)}
open={!drawer.hiding}
{...rest}
>
{children}
</Drawer>
);
}
参考容器模式,在抽屉不可见时直接返回 null,不渲染任何内容,这样的话,即使页面上定义了很多个 Drawer,也不会影响性能。
// 创建 EasyDrawer 组件的API
// componentId 为组件的 id,全局唯一,否则会导致状态混乱
// Comp 为实际的组件
// 该方法将组件与 id 绑定
export const createEasyDrawer = (componentId, Comp) => {
return (props) => {
// 通过组件 id 获取该组件的状态
const { visible, args } = useEasyDrawer(componentId);
// 容器模式,visible=false,表示完全关闭,返回 null
if (!visible) return null;
// visible=true,渲染该组件
return <Comp {...args} {...props} />;
};
};
保留浮层组件关闭动画
关闭带动画的浮层组件的一般方法
- drawer.hide(faslse),使组件的显示标记(visible、open)置为 fasle,触发隐藏动画,但组件实例还是存在的;
- 在完全隐藏后,触发隐藏后的回调(afterOpenChange()、Model 中为 afterClose()),调用 drawer.hide(true),返回 null,删除组件实例,参考上文的容器模式;
处理组件交互过程中的返回值
浮层组件关闭时,可能需要返回值给调用者,比如编辑完 Chart 的基本信息后,将新的数据返回给列表组件,用于更新列表。
可以把用户在组件中的操作看成一个异步操作逻辑,在完成了组件中内容的操作之后,就认为异步逻辑完成了,因此可以利用 Promise 来完成这样的逻辑。
useEasyDrawer 这个 Hook 的实现中提供一个 drawer.resolve 方法,能够去 resolve drawer.show 返回的 Promise:
const drawer = useEasyDrawer('my-drawer');
// 实现一个 promise API 来处理返回值
modal.show(args).then(value => {处理 value});
const show = useCallback(
(args) => {
return new Promise((resolve) => {
// 将 resolve 方法缓存起来,便于在其他地方调用 resolve() 改变当前 Promise 的状态
componentCallbacks[componentId] = resolve;
// 触发drawer 显示的 action 的下发
dispatch(showDrawer(componentId, args));
});
},
[dispatch, componentId],
);
核心思路就是将 show 和 resolve 两个函数通过 Promise 联系起来,因为两个函数的调用位置不一样,使用一个局部的临时变量 componentCallbacks 来缓存 resolve 回调函数,在组件交互过程中调用drawer.resolve(value),就会将 value 传递到 drawer.show(args).then(value=>{}) 中,比如,在 EasyDrawer 中编辑完配置信息,关闭的时候调用 drawer.resolve(newChartInfo),就会在列表层得到编辑后 newChartInfo 数据,用于更新列表。
以上面的 Demo 为例,点击新建按钮和列表编辑按钮,都会调用 drawer.show(),并在 .then() 中处理 EasyDrawer 返回的更新后的数据,用于数据更新:
如何使用
EasyDrawer Hook 参考上一节实现方案。
一级抽屉实现
chartInfoDrawer.jsx
import { useCallback } from "react";
import {Button, Form} from "antd";
import FormBuilder from "antd-form-builder";
import EasyDrawer, { createEasyDrawer, useEasyDrawer } from "./EasyDrawer";
// 一级抽屉的 id 为 chart-info-drawer
export default createEasyDrawer("chart-info-drawer", ({ chart }) => {
const [form] = Form.useForm();
const meta = {
initialValues: chart,
fields: [
{ key: "chartId", label: "ID", required: true },
{ key: "chartName", label: "图表名称", required: true },
{ key: "chartType", label: "图表类型", required: true },
],
};
const drawer = useEasyDrawer("chart-info-drawer");
const handleSubmit = useCallback(() => {
form.validateFields().then(() => {
drawer.resolve({ ...chart, ...form.getFieldsValue() });
drawer.hide();
});
}, [drawer, chart, form]);
return (
<EasyDrawer
id="chart-info-drawer"
title={chart ? "Edit Chart" : "New Chart"}
>
<Form form={form}>
<FormBuilder meta={meta} form={form} />
</Form>
<Button onClick={handleSubmit}>
{chart ? "Update" : "Create"}
</Button>
</EasyDrawer>
);
});
ChartLayout.jsx
import { useMemo } from "react";
import {Button, message} from "antd";
import { createStore } from "redux";
import { Provider } from "react-redux";
import { componentReducer, useEasyDrawer } from "./EasyDrawer";
import ChartInfoDrawer from "./ChartInfoDrawer";
import ChartList from "./ChartList";
// redux store
const store = createStore(componentReducer);
function ChartLayout() {
const drawer = useEasyDrawer("chart-info-drawer");
const handleClick = () => {
// .then()中,处理组件内部返回的数据 value
drawer.show().then(value => {
message.info(`新增报表,id=${value.chartId},chartName=${value.chartName}`);
})
}
return (
<div className="demo-charts">
<sider>
<Button type="primary" onClick={handleClick}>
+ New Chart
</Button>
</sider>
<section>
<ChartList />
</section>
{/*自定义的 EasyDrawer 放在当前组件中*/}
<ChartInfoDrawer />
</div>
);
}
export default () => {
return (
// Redux Store
<Provider store={store}>
<ChartLayout />
</Provider>
);
};
ChartList.jsx
import { useMemo, useState } from "react";
import _ from "lodash";
import { Button, Table } from "antd";
import { EditOutlined } from "@ant-design/icons";
import { useEasyDrawer } from "./EasyDrawer";
import data from "./data";
export default () => {
const { show: showDrawer } = useEasyDrawer("chart-info-drawer");
const [charts, setCharts] = useState(data.slice(0, 5));
const columns = useMemo(() => {
return [
{
title: "Id",
dataIndex: "chartId",
},
{
title: "图表名称",
dataIndex: "chartName",
},
{
title: "图表类型",
dataIndex: "chartType",
},
{
title: "Actions",
render(value, chart) {
return (
<Button
type="link"
icon={<EditOutlined />}
onClick={() => {
// .then()中,处理组件内部返回的数据 value,更新到列表
showDrawer({ chart }).then((newChart) => {
setCharts((charts) => {
// 更新列表
const byId = _.keyBy(charts, "chartId");
byId[newChart.chartId] = newChart;
return _.values(byId);
});
});
}}
/>
);
},
},
];
}, [showDrawer]);
return (
<Table
size="small"
pagination={false}
columns={columns}
dataSource={charts}
/>
);
};
二级抽屉实现
实现方案
- 新增一个 id 为 second-column-info-drawer 的 EasyDrawer 作为二级抽屉;
- 将 second-column-info-drawer 组件节点放在一级抽屉组件 chart-info-drawer 中;
- 在 chart-info-drawer 对应的 Drawer 中新增一个按钮,用于打开二级抽屉;

EasyModal 实现
上文的全局管理方案同样适用于对话框(Modal)组件,可以依葫芦画瓢 实现自己的 EasyModal 组件,整体流程与 EasyModal 一致,只是 Modal 的 API 和 Drawer 不太一样,需要微调下:
function NiceModal({ id, children, ...rest }) {
const modal = useNiceModal(id);
return (
<Modal
// 伪关闭,触发隐藏动画
onCancel={() => modal.hide()}
onOk={() => modal.hide()}
// 真正关闭,返回 null,节点下线
afterClose={() => modal.hide(true)}
visible={!modal.hiding}
{...rest}
>
{children}
</Modal>
);
}
export const createNiceModal = (modalId, Comp) => {
return (props) => {
const { visible, args } = useNiceModal(modalId);
// 容器模式
if (!visible) return null;
return <Comp {...args} {...props} />;
};
};