React学习笔记(番外二)——列表多选批量操作复合组件
- 前言
- 〇、Show you the code
- 一、 任务分析及拆解
- 表头行的Checkbox——总开关
- 记录行的Checkbox——行级开关
- 二、 基础实现
- 表头行的文件——header-row.js
- 记录行的文件——record-row.js
- 页面的文件App.js
- 阶段效果
- 三、 完善控制逻辑
- custom-hooks.js——自定义React Hook
- 4处useState
- 4处useCallback
- 1处useMemo
- App.js——部分改造
- header-row.js——部分改造
- record-row.js——部分改造
- 阶段效果
- 四、 最后一步
- 后记
前言
近期有需求需要实现如题的列表多选批量处理复合组件
。因为是两部分组件互相控制,对于初次实现的我来说还是有一定困难的。经过几天的实现、Bugfix,现对这种比较常见的复合组件的实现作以记录。
〇、Show you the code
成熟的程序猿讲究【Talk is cheap, show me the code】,如果你只想看代码:
本篇文章中,最终的完整代码已托管在CodeSandBox,点击前往,查看代码及编译、渲染结果
一、 任务分析及拆解
先来看一下上面说的到底是什么。如下图,当我们有一个比较长的列表时,有时希望一次性多选几行,进行批量操作。
我们可以很轻松地将所需的复合组件拆分为以下几部分子组件:
- 表头行的Checkbox——总开关
- 记录行的Checkbox——行级开关
- 右上方的操作按钮
表头行的Checkbox——总开关
这个Checkbox比较特殊,有3个状态,如下图:
- 未选中状态: 点击后变为
全部选中
状态。此状态下,列表中的记录都是未选中
状态。表头行Checkbox
由于点击达到该状态时所有记录行Checkbox
都变为未选中
状态,所有记录行Checkbox
由于点击都达到未选中
状态时,表头行Checkbox
也受控变为未选中
状态。 - 部分选中状态: 点击后变为
全部选中
状态。此状态下,列表中有部分记录([1, n-1])为选中状态。该状态只能由部分记录行Checkbox
受到点击变为选中
状态而达到。 - 全部选中状态: 点击后变为
未选中
状态。此状态下,列表中的记录都是选中
状态。表头行Checkbox
由于点击达到该状态时所有记录行Checkbox
都变为选中
状态,所有记录行Checkbox
由于点击都达到选中
状态时,表头行Checkbox
也受控变为全部选中
状态。
记录行的Checkbox——行级开关
记录行前的就是常规的Checkbox,只有两种状态,如下图:
- 未选中状态: 点击后变为
选中
状态。此状态下,当前行的记录是未选中
状态。所有记录行Checkbox
都为该状态时表头行Checkbox
受控变为未选中
状态。 - 选中状态: 点击后变为
未选中
状态。此状态下,当前行的记录是选中
状态。部分记录行Checkbox
为该状态时表头行Checkbox
受控变为部分选中
状态。所有记录行Checkbox
都为该状态时表头行Checkbox
受控变为全部选中
状态。
二、 基础实现
我们将一个包含列表的页面分为三个部分:
- 表头行的文件——header-row.js
- 记录行的文件——record-row.js
- 页面的文件——App.js
接下来分别实现三个部分
表头行的文件——header-row.js
import "./index.css";
import React from "react";
import { Checkbox } from "antd";
const HeaderRow = () => {
return (
<div className="header-row">
<div className="column0">
<Checkbox />
</div>
<div className="column1">Id</div>
<div className="column2">Title</div>
<div className="column3">Desc</div>
</div>
);
};
export default HeaderRow;
记录行的文件——record-row.js
import "./index.css";
import React from "react";
import { Checkbox } from "antd";
const RecordRow = (props) => {
let { record } = props;
let { id, title, desc } = record;
return (
<div className="record-row">
<div className="column0">
<Checkbox />
</div>
<div className="column1">{id}</div>
<div className="column2">{title}</div>
<div className="column3">{desc}</div>
</div>
);
};
export default RecordRow;
页面的文件App.js
import "./styles.css";
import React, { useMemo } from "react";
import HeaderRow from "./components/header-row";
import RecordRow from "./components/record-row";
const MainPage = (props) => {
// 生成要显示的记录数据,现实中应该通过后台请求获得
let records = useMemo(() => {
let _records = [];
for (let i = 0; i < 10; i++) {
_records.push({
id: i + 1,
title: `这是测试标题${i + 1}`,
desc: `这是测试描述${i + 1}`
});
}
return _records;
}, []);
// 记录行的组件列表
let recordRowViews = useMemo(() => {
return records.map((item) => {
return <RecordRow key={`record-${item.id}`} record={item} />;
});
}, [records]);
return (
<div>
<HeaderRow />
{recordRowViews}
</div>
);
};
export default MainPage;
阶段效果
基础实现比较简单,当然功能也不完善。我们还没有写Checkbox
之间的控制逻辑,先看看目前的效果:
三、 完善控制逻辑
因为实现中涉及到多个页面具有同样的控件和逻辑需要,因此实现中尽可能地考虑代码的复用性。这里的代码实现拆分为以下几个部分:
- custom-hooks.js——包含几个自定义React Hook,最主要的Checkbox互相控制算法逻辑部分
- App.js——前文中已有的文件,做部分改造
- header-row.js——前文中已有的文件,做部分改造
- record-row.js——前文中已有的文件,做部分改造
custom-hooks.js——自定义React Hook
import { useState, useMemo, useCallback } from "react";
/**
* 列表多选,批量处理的逻辑Hook
* @param showedRecords 经过过滤后页面需要显示的列表项
* @returns {[[string],boolean,boolean,boolean,function,function]}
*/
const useBatchOperation = (allRecords) => {
// 表示部分[1, n-1]行被勾选的状态,仅控制UI
let [partialChecked, setPartialChecked] = useState(false);
// 表示是否所有的条目当前都被选中(即UI表现为表头的checkbox是否勾选)
let [headerChecked, setHeaderChecked] = useState(false);
// 表示当前用户是否主动点击了表头的那一个checkbox,点击后无论状态如何,其他checkbox都要跟随该状态,同headerChecked配合控制使用
let [operateAll, setOperateAll] = useState(false);
// 当前勾选的行所代表的数据单元组成的列表
let [checkedItemList, setCheckedItemList] = useState([]);
// 选中某行记录
let checkItem = useCallback(
(item) => {
let newCheckedItemList = [...checkedItemList];
if (!Array.isArray(item)) {
item = [item];
}
for (let i = 0; i < item.length; i++) {
let index = checkedItemList.indexOf(item[i]);
if (index === -1) {
newCheckedItemList.push(item[i]);
}
}
setCheckedItemList(newCheckedItemList);
return newCheckedItemList;
},
[checkedItemList]
);
// 取消选中某行记录
let uncheckItem = useCallback(
(item, clear = false) => {
let newCheckedItemList = [];
if (!clear) {
if (!Array.isArray(item)) {
item = [item];
}
for (let i = 0; i < checkedItemList.length; i++) {
if (item.indexOf(checkedItemList[i]) === -1) {
newCheckedItemList.push(checkedItemList[i]);
}
}
}
setCheckedItemList(newCheckedItemList);
return newCheckedItemList;
},
[checkedItemList]
);
// 记录行checkbox的onChange事件
let onSingleCheckBoxChange = useCallback(
(checked, record) => {
// 按照判断逻辑,必须放在第一行
setOperateAll(false);
let newCheckedItemList;
if (checked) {
newCheckedItemList = checkItem(record);
} else {
newCheckedItemList = uncheckItem(record);
}
setPartialChecked(
newCheckedItemList.length > 0 &&
newCheckedItemList.length < allRecords.length
);
setHeaderChecked(newCheckedItemList.length === allRecords.length);
},
[checkItem, uncheckItem, allRecords]
);
// 表头行checkbox的onChange事件
let onBatchCheckBoxChange = useCallback(() => {
if (!headerChecked) {
checkItem(allRecords);
} else {
uncheckItem(null, true);
}
setPartialChecked(false);
setHeaderChecked(!headerChecked);
setOperateAll(true);
}, [headerChecked, checkItem, uncheckItem, allRecords]);
// 选中的记录,其Id组成的列表
let checkedIdList = useMemo(() => {
if (checkedItemList.length > 0) {
return checkedItemList.map((item) => {
return item.id;
});
}
return [];
}, [checkedItemList]);
return [
checkedIdList,
partialChecked,
operateAll,
headerChecked,
onSingleCheckBoxChange,
onBatchCheckBoxChange
];
};
export { useBatchOperation };
这里代码量有110行,还是比较多的,我们拆开来讲一下逻辑。一共用了3种React Hook,分别是useState
、useCallback
和useMemo
。其中useState
4处,useCallback
4处,useMemo
1处。
4处useState
// 表示部分[1, n-1]行被勾选的状态,仅控制UI
let [partialChecked, setPartialChecked] = useState(false);
// 表示是否所有的条目当前都被选中(即UI表现为表头的checkbox是否勾选)
let [headerChecked, setHeaderChecked] = useState(false);
// 表示当前用户是否主动点击了表头的那一个checkbox,点击后无论状态如何,其他checkbox都要跟随该状态,同headerChecked配合控制使用
let [operateAll, setOperateAll] = useState(false);
// 当前勾选的行所代表的数据单元组成的列表
let [checkedItemList, setCheckedItemList] = useState([]);
- partialChecked: 标识
表头行Checkbox
当前是否处于部分选中
状态,仅用于控制UI样式,初始值为false
- headerChecked: 标识
表头行Checkbox
当前是否处于全部选中
状态,即是否所有记录行Checkbox
都为选中状态
,初始值为false
- operateAll: 标识当前用户是否主动点击了
表头行Checkbox
,点击后所有记录行Checkbox
必须跟随改变为同样的状态,初始值为false
- checkedItemList: 当前勾选的记录行所代表的数据组成的列表,前文段落页面的文件App.js中
records
数组的子集,初始值为空数组[]
4处useCallback
代码较长,不再复制徒增篇幅。
- checkItem: 勾选某行
记录行Checkbox
后执行的事件方法。实际代码逻辑是给checkedItemList
列表中新增一个记录数据,然后通过setCheckedItemList
更新最新的checkedItemList
,之所以使用useCallback
封装一层,是为了其访问到的checkedItemList
是当前最新的。 - uncheckItem: 和
checkItem
作用相反,取消勾选某行记录行Checkbox
后执行的事件方法。实际代码逻辑是在checkedItemList
列表中找到并删除一个记录数据… - onSingleCheckBoxChange: 当
记录行Checkbox
被点击而发生状态变化时执行的事件方法。代码逻辑包括:根据当前状态是选中
还是未选中
调用checkItem
或uncheckItem
更新checkedItemList
,根据checkedItemList
的length
判断表头行Checkbox
是否应该改为部分选中
、全部选中
或未选中
状态等。 - onBatchCheckBoxChange : 当
表头行Checkbox
被点击而发生状态变化时执行的事件方法。代码逻辑包括:根据当前状态是全部选中
还是未选中
调用checkItem
或uncheckItem
更新checkedItemList
等。
1处useMemo
- checkedIdList: 根据
checkedItemList
实时计算,表示当前被选中的记录的id
组成的列表,暴露出去,向后台发请求时用。
App.js——部分改造
......
const MainPage = (props) => {
......
// 使用自定义hook-useBatchOperation,传入records列表
let [
checkedIdList,
partialChecked,
operateAll,
headerChecked,
onSingleCheckBoxChange,
onBatchCheckBoxChange
] = useBatchOperation(records);
let recordRowViews = useMemo(() => {
return records.map((item) => {
return (
<RecordRow
key={`record-${item.id}`}
record={item}
operateAll={operateAll}
headerChecked={headerChecked}
onSingleCheckBoxChange={onSingleCheckBoxChange}
/>
);
});
}, [records, operateAll, headerChecked, onSingleCheckBoxChange]);
return (
<div>
<HeaderRow
partialChecked={partialChecked}
headerChecked={headerChecked}
onBatchCheckBoxChange={onBatchCheckBoxChange}
/>
{recordRowViews}
</div>
);
};
export default MainPage;
为了不占过多篇幅,用......
代替未发生改变的部分。
主要修改3个部分:
- 创建一个自定义Hook-useBatchOperation的实例,获得暴露出来的6个变量。
RecrodRow
传入operateAll
、headerChecked
和onSingleCheckBoxChange
HeaderRow
传入partialChecked
、headerChecked
、onBatchCheckBoxChange
剩下暂时未用到的
checkedIdList
留给后面的batch-operation-buttons.js
header-row.js——部分改造
......
const HeaderRow = (props) => {
let { partialChecked, headerChecked, onBatchCheckBoxChange } = props;
......
<Checkbox
indeterminate={partialChecked}
checked={headerChecked}
onChange={onBatchCheckBoxChange}
/>
......
);
};
export default HeaderRow;
主要修改2个部分:
- 引入父组件
App.js
传入的partialChecked
、headerChecked
、onBatchCheckBoxChange
- 将上述值和方法传入
Checkbox
控件
record-row.js——部分改造
......
const RecordRow = (props) => {
......
let { record, operateAll, headerChecked, onSingleCheckBoxChange } = props;
let [checked, setChecked] = useState(false);
useEffect(() => {
if (operateAll) {
setChecked(headerChecked);
}
}, [operateAll, headerChecked, setChecked]);
let wrappedOnChange = useCallback(() => {
setChecked(!checked);
onSingleCheckBoxChange(!checked, record);
}, [checked, setChecked, record, onSingleCheckBoxChange]);
return (
......
<Checkbox
checked={checked}
onChange={wrappedOnChange}
onClick={wrappedOnChange}
/>
......
);
};
export default RecordRow;
主要修改4个部分:
- 引入父组件
App.js
传入的operateAll
、headerChecked
、onSingleCheckBoxChange
- 添加一个
useEffect
Hook,如果表头行Checkbox
被点击,跟随其改变状态 - 给
onSingleCheckBoxChange
封装一层,被点击时不仅要更新自身状态,还要触发自定义Hook的数据更新 - 将上述值和方法传入
Checkbox
控件
阶段效果
- 全选 / 取消全选
- 部分选中
四、 最后一步
经过前文中的努力,UI上表现达到预期了,所需的数据(checkedIdList
)也拿到了,距离实现文章开头的效果还差右上角的操作按钮。这里我们新建一个batch-operation-buttons.js
import "./index.css";
import React, { useCallback } from "react";
const BatchOperationButtons = (props) => {
let { checkedIdList } = props;
let handleClick = useCallback(
(func) => {
switch (func) {
case 0:
alert(`批量删除记录,IdList:${JSON.stringify(checkedIdList)}`);
break;
case 1:
alert(`批量下载记录,IdList:${JSON.stringify(checkedIdList)}`);
break;
default:
throw new TypeError(`操作码[${func}]未实现!`);
}
},
[checkedIdList]
);
if (checkedIdList?.length < 1) {
return null;
}
return (
<div className="batch-operation-buttons">
<span className="delete-button" onClick={() => handleClick(0)}>
删除
</span>
<span>/</span>
<span className="download-button" onClick={() => handleClick(1)}>
下载
</span>
</div>
);
};
export default BatchOperationButtons;
注意: 这里的
alert
是为了演示方便,现实情况下应该改为向后台发请求,按照checkedIdList
和操作的func
对选中的记录进行批量操作.
App.js中仅需改动以下代码即可
......
return (
<div>
<div className="buttons">
<BatchOperationButtons checkedIdList={checkedIdList} />
</div>
......
</div>
);
后记
这次的实现中首次学习并使用了自定义Hook
。实现之后发现了它的重要意义,即我们可以通过自定义Hook
真正地将HTML模板和JS数据逻辑拆分为两个独立的文件,一个只管模板内容,一个只管数据的更新。官方一点的说法即:实现了Model(custom-hooks.js)
和View(App.js)
的分离,意义重大。
为了便于理解,你可以想象,在学会使用自定义Hook
之前,App.js
和custom-hooks.js
里的代码统统写在App.js
里,这样一个控件里的代码量将非常大,维护起来比较麻烦。拆分为两个文件后,模板有Bug我们就只改View
文件,数据有Bug我们就只改Model
文件。