本文转载自微信公众号「神奇的程序员K」,作者神奇的程序员K 。转载本文请联系神奇的程序员K公众号。
前言
20多天前,遇到一个日程表的业务需求,可以动态增加列、对单元格进行合并,结合公司的jsp项目的已有功能完成单元格的增、删、改操作。进行需求分析整理后,经过了一番查找,发现React版本的antd的表格组件功能很强大,可定制程度很高,可以助我完成这个业务需求的开发。
由于要和jsp进行交互,所以在实现过程中,遇到了一些难题踩了挺多坑,本文就跟大家分享下我从0到1实现这个需求的过程与思路,欢迎各位感兴趣的开发者阅读本文。
环境搭建
因为公司的项目是基于jsp的,antd本想用Vue版本的,无奈它与jsp的一些语法冲突了跑不起来,于是就尝试了react版本的antd,它跑起来了没有发现任何兼容性问题,一切正常。给React点个赞??。
由于要与项目中已有的功能进行交互,没法用脚手架,我只能以cdn的方式引入react,如下所示,按顺序引入react、axios、lodah以及antd所需要的文件。
- <script crossOrigin type="text/javascript" src="lib/react.production.min.js"></script>
- <script crossOrigin type="text/javascript" src="lib/react-dom.production.min.js"></script>
- <script src="lib/babel.min.js"></script>
- <script type="text/javascript" src="lib/moment.min.js"></script>
- <script src="lib/lodash.min.js"></script>
- <script type="text/javascript" src="lib/antd.min.js"></script>
- <script type="text/javascript" src="lib/axios.min.js"></script>
- <link rel="stylesheet" href="lib/antd.min.css">
上述用到的资源文件地址: react-antd-schedule/lib
我们需要把react相关代码写在text/babel标签中,如下所示,我们打印antd和react看看是否有值。
- <script type="text/babel">
- console.log("react");
- console.log(React);
- console.log("antd")
- console.log(antd);
- </script>
打开浏览器控制台,出现下述信息,代表我们的环境已经搭建成功。
image-20201119155715157
接下来,我们写个HelloWord来测试下效果。
- <div id="root" style="width: 94%;overflow: hidden"></div>
- <script type="text/babel">
- // 自定义hook
- const App = () => {
- const onChange = (date, dateString) => {
- console.log(date, dateString);
- }
- return (
- <div>
- React+antd引入成功
- <br />
- <antd.DatePicker onChange={onChange} />
- </div>
- );
- };
- ReactDOM.render(<App />, document.getElementById("root"));
- </script>
执行上述代码,打开浏览器如果看到下述效果,就证明我们的环境已经搭好了。
image-20201119161505912
需要注意的是,CDN引入React和antd,他们是在全局暴露了一个对象,在使用它内部的方法时就需要React.xx、antd.xx来访问了。
需求分析
当我收到需求简述后,我对其进行了整理:
- 表格列要展示的内容:日期、日程内容(接口动态返回),日程内容列用户可以自己手动增加。
- 表格行展示的内容为每一天的数据,每一天的数据分为:上午、下午、晚上三个时间段。
- 日程内容分为天日程和某个时间段的日程两种状态,如果为天日程则需要进行单元格合并。
- 日程内容列的每个单元格有5种状态,需要通过某种方式来区分,让用户一眼就能看出当前日程处于什么状态。
- 日程内容单元格的内容如果为空时,需要将单元格进行合并,显示一个增加图标,点击增加图标后,打开系统的弹窗进行增加操作,操作完成后,渲染内容至刚才点击的单元格。
- 如果内容单元格有内容时,根据不同的状态,打开不同的弹窗进行改、删操作,操作完后,更新结果至对应的单元格。
需求确定后,老板给我分了一个后端,跟后端沟通后开发周期估了1周,我页面估了2天的时间,剩下的3天与后端进行数据对接。
2天后,我把页面弄完了,表格需要的数据格式也定义好了,把数据格式发给后端后,他说好,没问题。
因为没有UI给设计图,所以第一版,我就凭着自己的直觉来弄了,搞出来的东西蛮丑的,下图就是我根据需求实现的页面。
image-20201119172808318
然而,事情没有预想中那么顺利,我页面做好后,到开发周期的最后一天下午,后端把接口给我了,但返回的数据不是我预想的格式,我又进行了二次处理,页面渲染出来后,快到下班时间了,到了预估的开发时间没有完成需求,倒也能理解,毕竟后端那边要处理的数据比较复杂。
本来预估了一周的开发时间,后面需求的不断增加、变更、UI设计效果图,我的页面代码也从一开始的100多行累加到现在的1000多行,这一套折腾下来,直到需求开发完成交给测试,花了20多天的时间。
需求实现
接下来,就跟大家分享下在实现这个需求时,遇到的难点、踩到的一些坑以及我的解决方案。
最后实现的效果如下所示,实现代码请移步:react-antd-schedule/index.html
image-20201119175256753
动态增加列
这个日程表用户可以通过点增加图标来增加一列日程,此时我们就需要往表格头部增加一列数据,一开始我觉得只要往antd的columns和dataSource中添加一条数据就行了,如下所示:
- const App = () => {
- const [columns, setColumns] = React.useState([]);
- const [optRecords, setOptRecords] = React.useState([]);
- //增加按钮函数
- const btnClick = (e) => {
- index++;
- let columnsObj = {
- dataIndex: 'rcnr' + (index),
- title: '日程内容' + index,
- align: 'center',
- onCell: tdSet,
- render: rctd_render,
- }
- // 表格列新增一列
- columns.push(columnsObj)
- setColumns(columns);
- // 处理表格数据
- for (let i = 0; i < optRecords.length; i++) {
- let key = "rcnr"+index;
- // 表格数据新增一条
- optRecords[i][key] = {text:"", code:"0"}
- }
- setOptRecords(optRecords);
- }
- }
当我在浏览器执行看效果时,发现没有生效,于是我下意识的打开了浏览器控制台看看是不是报错了,啪的一下,很快啊~新增加的那一列被渲染上去了,我大E了啊,antd不讲武德啊。
于是,我多试了几次,发现还是不渲染,打开控制台后就奇迹般的渲染上去了,有点摸不着头脑,就求助了下网友,我才恍然大悟,原来是antd没有监听到引用地址的改变,得到了下述解决方案,用一个函数去处理它,让antd监听到引用地址改变,它才会将数据进行渲染。
- const App = () => {
- const [optRecords, setOptRecords] = React.useState([]);
- const [columns, setColumns] = React.useState([]);
- //增加按钮函数
- const btnClick = (e) => {
- if (tableLoadingStatus) {
- alert("表格数据尚未加载完成");
- return false;
- }
- columnsIndex++;
- let columnsObj = {
- dataIndex: "rcnr" + (columnsIndex),
- title: "日程内容" + columnsIndex,
- align: "left",
- className: "rcnrfontSet",
- width: 189.5,
- onCell: tdSet,
- render: rctd_render
- };
- // 表格列新增一列
- setColumns((arr => [...arr, columnsObj]));
- // 处理表格数据
- setOptRecords((arr) => arr.map((item) => {
- return { ...item, ["rcnr" + columnsIndex]: { wz: columnsIndex - 1 } };
- }));
- };
- }
表格列补齐
在后端返回的数据中,如果有不存在的日程,直接连字段都没返回,这就造成了antd在渲染的时候列与表格数据不对应而引发的武发渲染的问题,于是我只能把所有数据遍历一遍,求出最大列长度,然后将列少的数据进行补全,由于添加数据时接口需要传当前点击的是哪一列,刚才补全的数据中是不包含wz字段的,因此我们需要再遍历一次数据,把wz字段加上去,代码如下:
- // 表格数据渲染函数
- const tableDataRendering = function(res) {
- // 获取最大子节点的key数量
- let maxChildLength = Object.keys(defaultData[0].children[0]).length;
- for (let i = 0; i < defaultData.length; i++) {
- for (let j = 0; j < defaultData[i].children.length; j++) {
- const currentObjLength = Object.keys(defaultData[i].children[j]).length;
- if (currentObjLength > maxChildLength) {
- maxChildLength = currentObjLength;
- }
- }
- }
- // 补齐缺少的节点
- for (let i = 0; i < defaultData.length; i++) {
- for (let j = 0; j < defaultData[i].children.length; j++) {
- const currentObjLength = Object.keys(defaultData[i].children[j]).length;
- // 当前节点的长度小于第一个子节点的长度就补齐
- for (let k = currentObjLength; k < maxChildLength; k++) {
- defaultData[i].children[j]["rcnr" + k] = {};
- }
- }
- }
- // 如果存在空对象添加位置字段
- for (let i = 0; i < defaultData.length; i++) {
- for (let j = 0; j < defaultData[i].children.length; j++) {
- // 获取每天的时间段对象
- const item = defaultData[i].children[j];
- // 获取所有的key
- const keys = Object.keys(item);
- // 提取所有的日程字段
- for (let k = 1; k < keys.length; k++) {
- // 日程为空添加wz字段
- if (Object.keys(item[keys[k]]).length <= 1) {
- defaultData[i].children[j][keys[k]].wz = k - 1;
- }
- }
- }
- }
- }
监听子窗口关闭
但点击单元格做完对应的操作后,弹窗关闭,此时我们需要在当前页面监听到子窗口关闭,然后向后台请求接口重新获取数据渲染页面,在打开的弹窗中提供了一个方法,可以调用父页面的方法,但是这个方法必须写在hooks外面他才能获取到。
此时,问题就产生了,如果写在hooks外面,那么就无法拿到antd表格内部的数据做到页面重新渲染,经过一番思考后,想到了可以Proxy来实现,当被代理的对象发生改变时,就触发hooks里的代理函数,实现代码如下:
- <script type="text/babel">
- // 声明代理变量
- let pageStateEngineer;
- // 需要进行代理的对象
- let pageState = { status: false };
- // 监听子页面关闭,弹窗页面在关闭时可调用这个方法,触发页面刷新
- const getSubpageData = (status) => {
- console.log("子页面关闭");
- pageStateEngineer.status = true;
- };
- const App = () => {
- // 代理处理函数
- const pageStateHandler = {
- set: function(recObj, key, value) {
- // 表格状态改为正在加载
- setTableLoadingStatus(true);
- // 重新请求接口,获取最新数据
- axios.post('http://mock-api.com/mnE66LKJ.mock/getTableListData', {
- }).then(function(res) {
- // 数据请求成功,改变表格加载层状态
- setTableLoadingStatus(false);
- if (res.status === 200) {
- // 执行表格数据渲染函数
- tableDataRendering(res);
- } else {
- alert("服务器错误");
- }
- });
- // 修改对象属性
- recObj[key] = value;
- return true;
- }
- };
- // 第一次渲染时,在借口调用成功后创建proxy
- React.useEffect(() => {
- // 调用接口获取表格数据
- axios.post('http://mock-api.com/mnE66LKJ.mock/getTableListData', {
- ls: 0,
- ts: 0
- }).then(function(res) {
- //创建代理,监听pageState对象改变,pageStateHandler处理变更
- pageStateEngineer = new Proxy(pageState, pageStateHandler);
- })
- }
- }
- </script>
重新渲染表格
用户在使用日程表时,他会执行删除某个日程,此时表格渲染函数就要从columns和dataSource中各删除一条数据了,一开始我是直接覆盖其数据,这样做引用地址没变,就引发了动态增加列的那个bug,antd监听不到引用地址改变没有刷新页面。但是我又不知道用户具体删了哪条数据,不好自己写函数去处理。
经过一番求助后,得到了三个解决方案:
- 使用immer来解决这个问题,经过折腾后还是没实现,他返回的数组是只读的,antd无法对数据进行操作,故放弃。
- 使用use-immer来替代React的useState来解决这个问题,这个就比较坑爹了,官方提供了umd的js库,但是通过cdn引入进来后,我硬是没找到它暴露出来的对象是哪个,没法用,故放弃。
- 使用lodash的cloneDeep方法进行深拷贝让其引用地址改变,这样antd就能监听到数据改变,从而触发页面刷新。
三个解决方案,经过验证后,只有第三个是可行的,于是我采取了它,实现代码如下:
- const App = () => {
- // 表格列格式定义
- const defaultColumns = [
- {
- dataIndex: "rq",
- title: "日期",
- align: "center",
- fixed: "left",
- colSpan: 2,
- width: 140.5,
- className: "rqfontSet",
- onCell: dateHandle,
- render: (value, item, index) => {}
- },
- {
- dataIndex: "sjd",
- title: "时间段",
- width: 70,
- colSpan: 0,
- fixed: "left",
- align: "center",
- className: "sjdfontSet",
- render: (value, item, index) => {
- let v1 = value.charAt(0);
- let v2 = value.charAt(1);
- return <div>{v1}<br />{v2}</div>;
- }
- }
- ];
- // 表格数据渲染函数
- const tableDataRendering = function(res) {
- // 根据日程列字段数据赋值表格列的日程字段,rcList中包含sjd所以需要1开始
- for (let i = 1; i < rcList.length; i++) {
- let rcnr = {
- dataIndex: rcList[i],
- title: "日程内容" + i,
- align: "left",
- width: 189.5,
- className: "rcnrfontSet",
- onCell: tdSet,
- render: rctd_render
- };
- defaultColumns.push(rcnr);
- }
- // 渲染表格数据
- handleData(defaultData);
- // 渲染表格列,使用cloneDeep进行深拷贝,触发useState的更新
- setColumns(_.cloneDeep(defaultColumns));
- }
- // 计算要合并的列数
- const handleData = (data) => {
- if (data == null) {
- data = defaultData;
- }
- let newArr = [];
- data.map(item => {
- if (item.children) {
- item.children.forEach((subItem, i) => {
- let obj = { ...item };
- Object.assign(obj, subItem);
- delete obj.children;
- obj.rowLength = item.children.length;
- newArr.push(obj);
- });
- }
- });
- // console.log("处理好的表格数据");
- // console.log(newArr);
- // 将处理好的数据放入optRecords,使用cloneDeep进行深拷贝,触发useState的更新
- setOptRecords(_.cloneDeep(newArr));
- };
- }
还有一种解决方案是使用JSON.parse进行深拷贝,但是这种深拷贝有个问题:但json数据中有函数时,里面的函数会失效没法执行,由于我需要自定义antd的表格,在json数据中包含了函数,因此我不能使用这个方法。
触顶/触底加载数据
由于业务需要,不能使用antd的分页功能,需要实现触顶向前加载30条数据,触底向后加载30条数据。总共只能加载3个月的数据。
实现代码如下:
这里需要比较坑的地方就是如果触顶/触底时,拖动横向滚动也会触发滚动监听,因此我们需要排除横向滚动事件。
- <script type="text/babel">
- // 触顶数据起始条数
- let dataToppingStartNum = 0;
- // 触底数据起始条数
- let dataBottomOutStartNum = 30;
- // 横向/垂直滚动条起始位置
- let levelPosition;
- let verticalPosition;
- // 触底/触顶次数
- let topFrequency = 0;
- let bottomFrequency = 0;
- const App = () => {
- // 横向滚动条位置
- levelPosition = document.querySelector(".ant-table-body").scrollLeft;
- // 纵向滚动条位置
- verticalPosition = document.querySelector(".ant-table-body").scrollTop;
- // 获取表格容器
- let antdTable = document.querySelector(".ant-table-body");
- //页面滚动监听
- antdTable.onscroll = function() {
- // 触底向后加载数据
- if (antdTable.scrollTop + antdTable.clientHeight >= antdTable.scrollHeight) {
- // 判断是否横向滚动
- if (antdTable.scrollLeft !== levelPosition) {
- // 更新位置
- levelPosition = antdTable.scrollLeft;
- return false;
- }
- // 第一次触底不触发数据加载
- if (bottomFrequency === 0) {
- bottomFrequency++;
- return false;
- }
- if (bottomFrequency > 0) {
- bottomFrequency = 0;
- }
- dataBottomOutStartNum += 30;
- // 判断已加载的数据
- if (dataBottomOutStartNum > 90) {
- alert("最多只能向后加载90天的数据");
- return false;
- }
- // 保留向上滑动的天数
- let bottomTS = 0;
- // 页面第一次向上滑动,修改位置
- if (dataToppingStartNum !== 0) {
- bottomTS = -30;
- }
- setTableLoadingStatus(true);
- axios.post('http://mock-api.com/mnE66LKJ.mock/getTableListData', {
- ts: bottomTS,
- ls: dataBottomOutStartNum
- }).then(function(res) {
- // 数据请求成功,改变表格加载层状态
- setTableLoadingStatus(false);
- if (res.status === 200) {
- // 执行表格数据渲染函数
- tableDataRendering(res);
- } else {
- alert("服务器错误");
- }
- });
- }
- // 触顶向前加载数据
- if (antdTable.scrollTop === 0) {
- // 判断是否横向滚动
- if (antdTable.scrollLeft !== levelPosition) {
- // 更新位置
- levelPosition = antdTable.scrollLeft;
- return false;
- }
- // 第一次触顶不触发数据加载
- if (topFrequency === 0) {
- topFrequency++;
- return false;
- }
- if (topFrequency > 0) {
- topFrequency = 0;
- }
- dataBottomOutStartNum += 30;
- if (dataBottomOutStartNum > 90) {
- alert("最多只能向前加载90天的数据");
- return false;
- }
- dataToppingStartNum -= 30;
- setTableLoadingStatus(true);
- axios.post('http://mock-api.com/mnE66LKJ.mock/getTableListData', {
- ts: dataToppingStartNum,
- ls: dataBottomOutStartNum
- }).then(function(res) {
- // 数据请求成功,改变表格加载层状态
- setTableLoadingStatus(false);
- if (res.status === 200) {
- // 执行表格数据渲染函数
- tableDataRendering(res);
- } else {
- alert("服务器错误");
- }
- });
- }
- }
- }
- </script>
这里需要比较坑的地方就是如果触顶/触底时,拖动横向滚动也会触发滚动监听,因此我们需要排除横向滚动事件。