前言
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>
这里需要比较坑的地方就是如果触顶/触底时,拖动横向滚动也会触发滚动监听,因此我们需要排除横向滚动事件。
写在最后
- 公众号无法外链,如果文中有链接,可点击下方阅读原文查看😊