使用Antd表格組件實現日程表
本文轉載自微信公眾號「神奇的程序員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條數據??偣仓荒芗虞d3個月的數據。
實現代碼如下:
這里需要比較坑的地方就是如果觸頂/觸底時,拖動橫向滾動也會觸發滾動監聽,因此我們需要排除橫向滾動事件。
- <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>
這里需要比較坑的地方就是如果觸頂/觸底時,拖動橫向滾動也會觸發滾動監聽,因此我們需要排除橫向滾動事件。