使用antd表格组件实现日程表

3,262 阅读7分钟

前言

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的columnsdataSource中添加一条数据就行了,如下所示:

 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>

重新渲染表格

用户在使用日程表时,他会执行删除某个日程,此时表格渲染函数就要从columnsdataSource中各删除一条数据了,一开始我是直接覆盖其数据,这样做引用地址没变,就引发了动态增加列的那个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>

这里需要比较坑的地方就是如果触顶/触底时,拖动横向滚动也会触发滚动监听,因此我们需要排除横向滚动事件。

写在最后

  • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊
  • 本文首发于掘金,未经许可禁止转载💌