使用Antd表格組件實(shí)現(xiàn)日程表
本文轉(zhuǎn)載自微信公眾號(hào)「神奇的程序員K」,作者神奇的程序員K 。轉(zhuǎn)載本文請(qǐng)聯(lián)系神奇的程序員K公眾號(hào)。
前言
20多天前,遇到一個(gè)日程表的業(yè)務(wù)需求,可以動(dòng)態(tài)增加列、對(duì)單元格進(jìn)行合并,結(jié)合公司的jsp項(xiàng)目的已有功能完成單元格的增、刪、改操作。進(jìn)行需求分析整理后,經(jīng)過(guò)了一番查找,發(fā)現(xiàn)React版本的antd的表格組件功能很強(qiáng)大,可定制程度很高,可以助我完成這個(gè)業(yè)務(wù)需求的開(kāi)發(fā)。
由于要和jsp進(jìn)行交互,所以在實(shí)現(xiàn)過(guò)程中,遇到了一些難題踩了挺多坑,本文就跟大家分享下我從0到1實(shí)現(xiàn)這個(gè)需求的過(guò)程與思路,歡迎各位感興趣的開(kāi)發(fā)者閱讀本文。
環(huán)境搭建
因?yàn)楣镜捻?xiàng)目是基于jsp的,antd本想用Vue版本的,無(wú)奈它與jsp的一些語(yǔ)法沖突了跑不起來(lái),于是就嘗試了react版本的antd,它跑起來(lái)了沒(méi)有發(fā)現(xiàn)任何兼容性問(wèn)題,一切正常。給React點(diǎn)個(gè)贊??。
由于要與項(xiàng)目中已有的功能進(jìn)行交互,沒(méi)法用腳手架,我只能以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相關(guān)代碼寫(xiě)在text/babel標(biāo)簽中,如下所示,我們打印antd和react看看是否有值。
- <script type="text/babel">
- console.log("react");
- console.log(React);
- console.log("antd")
- console.log(antd);
- </script>
打開(kāi)瀏覽器控制臺(tái),出現(xiàn)下述信息,代表我們的環(huán)境已經(jīng)搭建成功。
image-20201119155715157
接下來(lái),我們寫(xiě)個(gè)HelloWord來(lái)測(cè)試下效果。
- <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>
執(zhí)行上述代碼,打開(kāi)瀏覽器如果看到下述效果,就證明我們的環(huán)境已經(jīng)搭好了。
image-20201119161505912
需要注意的是,CDN引入React和antd,他們是在全局暴露了一個(gè)對(duì)象,在使用它內(nèi)部的方法時(shí)就需要React.xx、antd.xx來(lái)訪(fǎng)問(wèn)了。
需求分析
當(dāng)我收到需求簡(jiǎn)述后,我對(duì)其進(jìn)行了整理:
- 表格列要展示的內(nèi)容:日期、日程內(nèi)容(接口動(dòng)態(tài)返回),日程內(nèi)容列用戶(hù)可以自己手動(dòng)增加。
- 表格行展示的內(nèi)容為每一天的數(shù)據(jù),每一天的數(shù)據(jù)分為:上午、下午、晚上三個(gè)時(shí)間段。
- 日程內(nèi)容分為天日程和某個(gè)時(shí)間段的日程兩種狀態(tài),如果為天日程則需要進(jìn)行單元格合并。
- 日程內(nèi)容列的每個(gè)單元格有5種狀態(tài),需要通過(guò)某種方式來(lái)區(qū)分,讓用戶(hù)一眼就能看出當(dāng)前日程處于什么狀態(tài)。
- 日程內(nèi)容單元格的內(nèi)容如果為空時(shí),需要將單元格進(jìn)行合并,顯示一個(gè)增加圖標(biāo),點(diǎn)擊增加圖標(biāo)后,打開(kāi)系統(tǒng)的彈窗進(jìn)行增加操作,操作完成后,渲染內(nèi)容至剛才點(diǎn)擊的單元格。
- 如果內(nèi)容單元格有內(nèi)容時(shí),根據(jù)不同的狀態(tài),打開(kāi)不同的彈窗進(jìn)行改、刪操作,操作完后,更新結(jié)果至對(duì)應(yīng)的單元格。
需求確定后,老板給我分了一個(gè)后端,跟后端溝通后開(kāi)發(fā)周期估了1周,我頁(yè)面估了2天的時(shí)間,剩下的3天與后端進(jìn)行數(shù)據(jù)對(duì)接。
2天后,我把頁(yè)面弄完了,表格需要的數(shù)據(jù)格式也定義好了,把數(shù)據(jù)格式發(fā)給后端后,他說(shuō)好,沒(méi)問(wèn)題。
因?yàn)闆](méi)有UI給設(shè)計(jì)圖,所以第一版,我就憑著自己的直覺(jué)來(lái)弄了,搞出來(lái)的東西蠻丑的,下圖就是我根據(jù)需求實(shí)現(xiàn)的頁(yè)面。
image-20201119172808318
然而,事情沒(méi)有預(yù)想中那么順利,我頁(yè)面做好后,到開(kāi)發(fā)周期的最后一天下午,后端把接口給我了,但返回的數(shù)據(jù)不是我預(yù)想的格式,我又進(jìn)行了二次處理,頁(yè)面渲染出來(lái)后,快到下班時(shí)間了,到了預(yù)估的開(kāi)發(fā)時(shí)間沒(méi)有完成需求,倒也能理解,畢竟后端那邊要處理的數(shù)據(jù)比較復(fù)雜。
本來(lái)預(yù)估了一周的開(kāi)發(fā)時(shí)間,后面需求的不斷增加、變更、UI設(shè)計(jì)效果圖,我的頁(yè)面代碼也從一開(kāi)始的100多行累加到現(xiàn)在的1000多行,這一套折騰下來(lái),直到需求開(kāi)發(fā)完成交給測(cè)試,花了20多天的時(shí)間。
需求實(shí)現(xiàn)
接下來(lái),就跟大家分享下在實(shí)現(xiàn)這個(gè)需求時(shí),遇到的難點(diǎn)、踩到的一些坑以及我的解決方案。
最后實(shí)現(xiàn)的效果如下所示,實(shí)現(xiàn)代碼請(qǐng)移步:react-antd-schedule/index.html
image-20201119175256753
動(dòng)態(tài)增加列
這個(gè)日程表用戶(hù)可以通過(guò)點(diǎn)增加圖標(biāo)來(lái)增加一列日程,此時(shí)我們就需要往表格頭部增加一列數(shù)據(jù),一開(kāi)始我覺(jué)得只要往antd的columns和dataSource中添加一條數(shù)據(jù)就行了,如下所示:
- const App = () => {
- const [columns, setColumns] = React.useState([]);
- const [optRecords, setOptRecords] = React.useState([]);
- //增加按鈕函數(shù)
- const btnClick = (e) => {
- index++;
- let columnsObj = {
- dataIndex: 'rcnr' + (index),
- title: '日程內(nèi)容' + index,
- align: 'center',
- onCell: tdSet,
- render: rctd_render,
- }
- // 表格列新增一列
- columns.push(columnsObj)
- setColumns(columns);
- // 處理表格數(shù)據(jù)
- for (let i = 0; i < optRecords.length; i++) {
- let key = "rcnr"+index;
- // 表格數(shù)據(jù)新增一條
- optRecords[i][key] = {text:"", code:"0"}
- }
- setOptRecords(optRecords);
- }
- }
當(dāng)我在瀏覽器執(zhí)行看效果時(shí),發(fā)現(xiàn)沒(méi)有生效,于是我下意識(shí)的打開(kāi)了瀏覽器控制臺(tái)看看是不是報(bào)錯(cuò)了,啪的一下,很快啊~新增加的那一列被渲染上去了,我大E了啊,antd不講武德啊。
于是,我多試了幾次,發(fā)現(xiàn)還是不渲染,打開(kāi)控制臺(tái)后就奇跡般的渲染上去了,有點(diǎn)摸不著頭腦,就求助了下網(wǎng)友,我才恍然大悟,原來(lái)是antd沒(méi)有監(jiān)聽(tīng)到引用地址的改變,得到了下述解決方案,用一個(gè)函數(shù)去處理它,讓antd監(jiān)聽(tīng)到引用地址改變,它才會(huì)將數(shù)據(jù)進(jìn)行渲染。
- const App = () => {
- const [optRecords, setOptRecords] = React.useState([]);
- const [columns, setColumns] = React.useState([]);
- //增加按鈕函數(shù)
- const btnClick = (e) => {
- if (tableLoadingStatus) {
- alert("表格數(shù)據(jù)尚未加載完成");
- return false;
- }
- columnsIndex++;
- let columnsObj = {
- dataIndex: "rcnr" + (columnsIndex),
- title: "日程內(nèi)容" + columnsIndex,
- align: "left",
- className: "rcnrfontSet",
- width: 189.5,
- onCell: tdSet,
- render: rctd_render
- };
- // 表格列新增一列
- setColumns((arr => [...arr, columnsObj]));
- // 處理表格數(shù)據(jù)
- setOptRecords((arr) => arr.map((item) => {
- return { ...item, ["rcnr" + columnsIndex]: { wz: columnsIndex - 1 } };
- }));
- };
- }
表格列補(bǔ)齊
在后端返回的數(shù)據(jù)中,如果有不存在的日程,直接連字段都沒(méi)返回,這就造成了antd在渲染的時(shí)候列與表格數(shù)據(jù)不對(duì)應(yīng)而引發(fā)的武發(fā)渲染的問(wèn)題,于是我只能把所有數(shù)據(jù)遍歷一遍,求出最大列長(zhǎng)度,然后將列少的數(shù)據(jù)進(jìn)行補(bǔ)全,由于添加數(shù)據(jù)時(shí)接口需要傳當(dāng)前點(diǎn)擊的是哪一列,剛才補(bǔ)全的數(shù)據(jù)中是不包含wz字段的,因此我們需要再遍歷一次數(shù)據(jù),把wz字段加上去,代碼如下:
- // 表格數(shù)據(jù)渲染函數(shù)
- const tableDataRendering = function(res) {
- // 獲取最大子節(jié)點(diǎn)的key數(shù)量
- 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;
- }
- }
- }
- // 補(bǔ)齊缺少的節(jié)點(diǎn)
- 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;
- // 當(dāng)前節(jié)點(diǎn)的長(zhǎng)度小于第一個(gè)子節(jié)點(diǎn)的長(zhǎng)度就補(bǔ)齊
- for (let k = currentObjLength; k < maxChildLength; k++) {
- defaultData[i].children[j]["rcnr" + k] = {};
- }
- }
- }
- // 如果存在空對(duì)象添加位置字段
- for (let i = 0; i < defaultData.length; i++) {
- for (let j = 0; j < defaultData[i].children.length; j++) {
- // 獲取每天的時(shí)間段對(duì)象
- 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;
- }
- }
- }
- }
- }
監(jiān)聽(tīng)子窗口關(guān)閉
但點(diǎn)擊單元格做完對(duì)應(yīng)的操作后,彈窗關(guān)閉,此時(shí)我們需要在當(dāng)前頁(yè)面監(jiān)聽(tīng)到子窗口關(guān)閉,然后向后臺(tái)請(qǐng)求接口重新獲取數(shù)據(jù)渲染頁(yè)面,在打開(kāi)的彈窗中提供了一個(gè)方法,可以調(diào)用父頁(yè)面的方法,但是這個(gè)方法必須寫(xiě)在hooks外面他才能獲取到。
此時(shí),問(wèn)題就產(chǎn)生了,如果寫(xiě)在hooks外面,那么就無(wú)法拿到antd表格內(nèi)部的數(shù)據(jù)做到頁(yè)面重新渲染,經(jīng)過(guò)一番思考后,想到了可以Proxy來(lái)實(shí)現(xiàn),當(dāng)被代理的對(duì)象發(fā)生改變時(shí),就觸發(fā)hooks里的代理函數(shù),實(shí)現(xiàn)代碼如下:
- <script type="text/babel">
- // 聲明代理變量
- let pageStateEngineer;
- // 需要進(jìn)行代理的對(duì)象
- let pageState = { status: false };
- // 監(jiān)聽(tīng)子頁(yè)面關(guān)閉,彈窗頁(yè)面在關(guān)閉時(shí)可調(diào)用這個(gè)方法,觸發(fā)頁(yè)面刷新
- const getSubpageData = (status) => {
- console.log("子頁(yè)面關(guān)閉");
- pageStateEngineer.status = true;
- };
- const App = () => {
- // 代理處理函數(shù)
- const pageStateHandler = {
- set: function(recObj, key, value) {
- // 表格狀態(tài)改為正在加載
- setTableLoadingStatus(true);
- // 重新請(qǐng)求接口,獲取最新數(shù)據(jù)
- axios.post('http://mock-api.com/mnE66LKJ.mock/getTableListData', {
- }).then(function(res) {
- // 數(shù)據(jù)請(qǐng)求成功,改變表格加載層狀態(tài)
- setTableLoadingStatus(false);
- if (res.status === 200) {
- // 執(zhí)行表格數(shù)據(jù)渲染函數(shù)
- tableDataRendering(res);
- } else {
- alert("服務(wù)器錯(cuò)誤");
- }
- });
- // 修改對(duì)象屬性
- recObj[key] = value;
- return true;
- }
- };
- // 第一次渲染時(shí),在借口調(diào)用成功后創(chuàng)建proxy
- React.useEffect(() => {
- // 調(diào)用接口獲取表格數(shù)據(jù)
- axios.post('http://mock-api.com/mnE66LKJ.mock/getTableListData', {
- ls: 0,
- ts: 0
- }).then(function(res) {
- //創(chuàng)建代理,監(jiān)聽(tīng)pageState對(duì)象改變,pageStateHandler處理變更
- pageStateEngineer = new Proxy(pageState, pageStateHandler);
- })
- }
- }
- </script>
重新渲染表格
用戶(hù)在使用日程表時(shí),他會(huì)執(zhí)行刪除某個(gè)日程,此時(shí)表格渲染函數(shù)就要從columns和dataSource中各刪除一條數(shù)據(jù)了,一開(kāi)始我是直接覆蓋其數(shù)據(jù),這樣做引用地址沒(méi)變,就引發(fā)了動(dòng)態(tài)增加列的那個(gè)bug,antd監(jiān)聽(tīng)不到引用地址改變沒(méi)有刷新頁(yè)面。但是我又不知道用戶(hù)具體刪了哪條數(shù)據(jù),不好自己寫(xiě)函數(shù)去處理。
經(jīng)過(guò)一番求助后,得到了三個(gè)解決方案:
- 使用immer來(lái)解決這個(gè)問(wèn)題,經(jīng)過(guò)折騰后還是沒(méi)實(shí)現(xiàn),他返回的數(shù)組是只讀的,antd無(wú)法對(duì)數(shù)據(jù)進(jìn)行操作,故放棄。
- 使用use-immer來(lái)替代React的useState來(lái)解決這個(gè)問(wèn)題,這個(gè)就比較坑爹了,官方提供了umd的js庫(kù),但是通過(guò)cdn引入進(jìn)來(lái)后,我硬是沒(méi)找到它暴露出來(lái)的對(duì)象是哪個(gè),沒(méi)法用,故放棄。
- 使用lodash的cloneDeep方法進(jìn)行深拷貝讓其引用地址改變,這樣antd就能監(jiān)聽(tīng)到數(shù)據(jù)改變,從而觸發(fā)頁(yè)面刷新。
三個(gè)解決方案,經(jīng)過(guò)驗(yàn)證后,只有第三個(gè)是可行的,于是我采取了它,實(shí)現(xiàn)代碼如下:
- 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: "時(shí)間段",
- 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>;
- }
- }
- ];
- // 表格數(shù)據(jù)渲染函數(shù)
- const tableDataRendering = function(res) {
- // 根據(jù)日程列字段數(shù)據(jù)賦值表格列的日程字段,rcList中包含sjd所以需要1開(kāi)始
- for (let i = 1; i < rcList.length; i++) {
- let rcnr = {
- dataIndex: rcList[i],
- title: "日程內(nèi)容" + i,
- align: "left",
- width: 189.5,
- className: "rcnrfontSet",
- onCell: tdSet,
- render: rctd_render
- };
- defaultColumns.push(rcnr);
- }
- // 渲染表格數(shù)據(jù)
- handleData(defaultData);
- // 渲染表格列,使用cloneDeep進(jìn)行深拷貝,觸發(fā)useState的更新
- setColumns(_.cloneDeep(defaultColumns));
- }
- // 計(jì)算要合并的列數(shù)
- 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("處理好的表格數(shù)據(jù)");
- // console.log(newArr);
- // 將處理好的數(shù)據(jù)放入optRecords,使用cloneDeep進(jìn)行深拷貝,觸發(fā)useState的更新
- setOptRecords(_.cloneDeep(newArr));
- };
- }
還有一種解決方案是使用JSON.parse進(jìn)行深拷貝,但是這種深拷貝有個(gè)問(wèn)題:但json數(shù)據(jù)中有函數(shù)時(shí),里面的函數(shù)會(huì)失效沒(méi)法執(zhí)行,由于我需要自定義antd的表格,在json數(shù)據(jù)中包含了函數(shù),因此我不能使用這個(gè)方法。
觸頂/觸底加載數(shù)據(jù)
由于業(yè)務(wù)需要,不能使用antd的分頁(yè)功能,需要實(shí)現(xiàn)觸頂向前加載30條數(shù)據(jù),觸底向后加載30條數(shù)據(jù)??偣仓荒芗虞d3個(gè)月的數(shù)據(jù)。
實(shí)現(xiàn)代碼如下:
這里需要比較坑的地方就是如果觸頂/觸底時(shí),拖動(dòng)橫向滾動(dòng)也會(huì)觸發(fā)滾動(dòng)監(jiān)聽(tīng),因此我們需要排除橫向滾動(dòng)事件。
- <script type="text/babel">
- // 觸頂數(shù)據(jù)起始條數(shù)
- let dataToppingStartNum = 0;
- // 觸底數(shù)據(jù)起始條數(shù)
- let dataBottomOutStartNum = 30;
- // 橫向/垂直滾動(dòng)條起始位置
- let levelPosition;
- let verticalPosition;
- // 觸底/觸頂次數(shù)
- let topFrequency = 0;
- let bottomFrequency = 0;
- const App = () => {
- // 橫向滾動(dòng)條位置
- levelPosition = document.querySelector(".ant-table-body").scrollLeft;
- // 縱向滾動(dòng)條位置
- verticalPosition = document.querySelector(".ant-table-body").scrollTop;
- // 獲取表格容器
- let antdTable = document.querySelector(".ant-table-body");
- //頁(yè)面滾動(dòng)監(jiān)聽(tīng)
- antdTable.onscroll = function() {
- // 觸底向后加載數(shù)據(jù)
- if (antdTable.scrollTop + antdTable.clientHeight >= antdTable.scrollHeight) {
- // 判斷是否橫向滾動(dòng)
- if (antdTable.scrollLeft !== levelPosition) {
- // 更新位置
- levelPosition = antdTable.scrollLeft;
- return false;
- }
- // 第一次觸底不觸發(fā)數(shù)據(jù)加載
- if (bottomFrequency === 0) {
- bottomFrequency++;
- return false;
- }
- if (bottomFrequency > 0) {
- bottomFrequency = 0;
- }
- dataBottomOutStartNum += 30;
- // 判斷已加載的數(shù)據(jù)
- if (dataBottomOutStartNum > 90) {
- alert("最多只能向后加載90天的數(shù)據(jù)");
- return false;
- }
- // 保留向上滑動(dòng)的天數(shù)
- let bottomTS = 0;
- // 頁(yè)面第一次向上滑動(dòng),修改位置
- if (dataToppingStartNum !== 0) {
- bottomTS = -30;
- }
- setTableLoadingStatus(true);
- axios.post('http://mock-api.com/mnE66LKJ.mock/getTableListData', {
- ts: bottomTS,
- ls: dataBottomOutStartNum
- }).then(function(res) {
- // 數(shù)據(jù)請(qǐng)求成功,改變表格加載層狀態(tài)
- setTableLoadingStatus(false);
- if (res.status === 200) {
- // 執(zhí)行表格數(shù)據(jù)渲染函數(shù)
- tableDataRendering(res);
- } else {
- alert("服務(wù)器錯(cuò)誤");
- }
- });
- }
- // 觸頂向前加載數(shù)據(jù)
- if (antdTable.scrollTop === 0) {
- // 判斷是否橫向滾動(dòng)
- if (antdTable.scrollLeft !== levelPosition) {
- // 更新位置
- levelPosition = antdTable.scrollLeft;
- return false;
- }
- // 第一次觸頂不觸發(fā)數(shù)據(jù)加載
- if (topFrequency === 0) {
- topFrequency++;
- return false;
- }
- if (topFrequency > 0) {
- topFrequency = 0;
- }
- dataBottomOutStartNum += 30;
- if (dataBottomOutStartNum > 90) {
- alert("最多只能向前加載90天的數(shù)據(jù)");
- return false;
- }
- dataToppingStartNum -= 30;
- setTableLoadingStatus(true);
- axios.post('http://mock-api.com/mnE66LKJ.mock/getTableListData', {
- ts: dataToppingStartNum,
- ls: dataBottomOutStartNum
- }).then(function(res) {
- // 數(shù)據(jù)請(qǐng)求成功,改變表格加載層狀態(tài)
- setTableLoadingStatus(false);
- if (res.status === 200) {
- // 執(zhí)行表格數(shù)據(jù)渲染函數(shù)
- tableDataRendering(res);
- } else {
- alert("服務(wù)器錯(cuò)誤");
- }
- });
- }
- }
- }
- </script>
這里需要比較坑的地方就是如果觸頂/觸底時(shí),拖動(dòng)橫向滾動(dòng)也會(huì)觸發(fā)滾動(dòng)監(jiān)聽(tīng),因此我們需要排除橫向滾動(dòng)事件。