手把手帶你用React擼一個日程組件
業(yè)務(wù)背景
先簡單描述一下業(yè)務(wù)場景吧, 就是會調(diào)用用戶在企業(yè)微信或者釘釘這類辦公軟件里面的日程信息, 在web端給安排日程的展示, 如果日程沖突, 就會展示沖突那天的日程, 讓安排人員合理安排時間日程, 避免沖突, 如圖;

使用技術(shù)
- UI框架: React(Hook);
- 插件: moment(對于一個位居18線的懶B碼農(nóng)必備插件, 要不然自己轉(zhuǎn)來轉(zhuǎn)去的太麻煩了);
技術(shù)難點
- API設(shè)計;
- 組件拆分;
- UI和業(yè)務(wù)解耦;
- 開箱即用;
設(shè)計思路
😱一臉懵逼苦
開發(fā)項目的時候, 組件庫用的是千篇一律的antd, 評審過后下意識的去antd找了一下有沒有可以開箱即用的組件.
不巧!!! 還真沒有這種周或者日篩選的組件, 百般惱火, 阿里寫了那么多組件, 為啥偏偏漏了這個呢?
于是轉(zhuǎn)戰(zhàn)萬能的百度, 查有沒有相關(guān)的組件, 后來查到了fullcalendar這個組件, 但是后來連人家的文檔和demo都沒看, 毅然決然的決定自己寫一個!
綜合看來有幾個原因哈:
- 雖然人家的組件寫得好, 但是很多業(yè)務(wù)是千變?nèi)f化的, 不一定能滿足所有的業(yè)務(wù)需求;
- 其次, 新來的開發(fā)人員在不熟悉這個組件的時候, 還需要去閱讀文檔, 增加了維護成本;
- 第三點嘛, 在有限的時間內(nèi)挑戰(zhàn)一下自己;
🙄開始構(gòu)思
其實開始構(gòu)思的時候, 也想過去參考一下優(yōu)秀組件的API設(shè)計, 但是, 現(xiàn)在有些組件寫的真的很難用, 而且不理解他為蛤蟆這么寫, 所以用自己站在一個使用者的角度思考了一下, 還是覺得按照自己作為一個十八流的底層最懶B的程序員的調(diào)用方式去設(shè)計-------開箱即用.
而且還有很重要的一點就是和業(yè)務(wù)解耦, 方便其他的項目直接用起來呀, 何樂而不為呢?
于是自己開始花了一上午青春, 結(jié)合自己的想法, 繪制了一個設(shè)計圖:

這個就是用ProcessOn繪制的, 本人用的不多, 畫得不好哈, 見諒!
🌲目錄結(jié)構(gòu)
└─Calendar
│ data.d.ts 類型定義文件
│ index.tsx 入口文件
│
├─components
│ ├─CalendatrHeader 頭部容器組件
│ │ │ index.less
│ │ │ index.tsx
│ │ │
│ │ └─components
│ │ ├─DailyOptions 頂部切換日期和切換模式狀態(tài)組件
│ │ │ index.less
│ │ │ index.tsx
│ │ │
│ │ └─WeeklyOptions 周模式日期和星期組件
│ │ index.less
│ │ index.tsx
│ │
│ ├─Container 容器組件
│ │ Container.tsx
│ │ index.less
│ │
│ ├─ScheduleCantainer 下半部日程容器
│ │ index.less
│ │ index.tsx
│ │
│ └─ScheduleItem 灰色部分每一條日程組件
│ index.less
│ index.tsx
│
└─utils
index.ts 工具文件
🛠拆分組件
仔細(xì)看圖, 不難看出, 組件大塊上我拆分成了三個部分:
Container容器: 該組件是整個組件的容器, 負(fù)責(zé)UI核心狀態(tài)數(shù)據(jù), 維護兩個狀態(tài):
- targetDay: 當(dāng)前選中日期時間戳(為什么選用時間戳后續(xù)解釋);
- switchWeekAndDay: 保存日和周的狀態(tài);
CalendatrHeader頭部容器組件: Container容器的子組件, 該組件負(fù)責(zé)切換日期, 改變組件周和日的狀態(tài); 該組件內(nèi), 包含日歷組件, 星期組件, 日期篩選組件, 日和周切換組件, 今天按鈕組件, 最后還有一個業(yè)務(wù)組件的容器(businessRender);
ScheduleCantainer日程容器組件: 該組件被25 (因為是從今天0點到次日凌晨0點的區(qū)間) 個scheduleRender組件撐開, 子組件還包括時間刻度組件;
scheduleRender: 特意說一下這個組件, 這個組件接受一個回調(diào), 回調(diào)會返回一個JSX, 這個JSX就是調(diào)用者傳入的自定義樣式的日程組件(具體內(nèi)容在后文講吧);
這就是大致的組件拆分, 文字表達確實欠佳, 可以結(jié)合圖片YY;
接下來就開干吧!!!
代碼實現(xiàn)
先看一下接受的參數(shù)類型定義:
type dataType = {
startTime: DOMTimeStamp; // 開始時間戳
endTime: DOMTimeStamp; // 結(jié)束時間戳
[propsName: string]: any; // 業(yè)務(wù)數(shù)據(jù)
};
type ContainerType = {
data: dataType[]; // 業(yè)務(wù)數(shù)據(jù)
initDay?: DOMTimeStamp; // 初始化時間戳
onChange?: (params: DOMTimeStamp) => void; // 改變?nèi)掌跁r的onChange方法
height?: number; // ScheduleCantainer容器的高度
scheduleRender?: ({
data: dataType,
timestampRange: [DOMTimeStamp, DOMTimeStamp],
}) => JSX.Element; // 傳入的回調(diào), 會接收到當(dāng)前這條數(shù)據(jù)的業(yè)務(wù)數(shù)據(jù), 當(dāng)前業(yè)務(wù)數(shù)據(jù)所在的時間戳范圍;
businessRender?: ({ timestamp: DOMTimeStamp }) => React.ReactNode; // 傳入的業(yè)務(wù)組件, 查詢前端蔡徐坤那個, 看圖, 想起來了嗎?
mode?: 'day' | 'week'; // 初始化展示日和天的模式
};
Container容器組件
代碼:
const Container: React.FC<ContainerType> = ({
initDay,
onChange,
scheduleRender,
businessRender,
data,
height = 560,
mode = 'day',
}) => {
// 當(dāng)前選擇日期時間戳
const [targetDay, setTargetDay] = useState<DOMTimeStamp>(initDay);
// 切換日和周
const [switchWeekandDay, setSwitchWeekandDay] = useState<'day' | 'week'>(mode);
return (
<div className={style.Calendar_Container}>
<CalendatrHeader
targetDay={targetDay}
setTargetDay={(timestamp) => {
onChange(timestamp);
setTargetDay(timestamp);
}}
businessRender={businessRender}
switchWeekandDay={switchWeekandDay}
setSwitchWeekandDay={setSwitchWeekandDay}
/>
<ScheduleCantainer
height={height}
data={data}
targetDay={targetDay}
scheduleRender={scheduleRender}
/>
</div>
);
};
看代碼可以思考一下, 肯定要將全局的狀態(tài)數(shù)據(jù)提升到最高層級去控制, 也符合React的組件設(shè)計哲學(xué);
維護了當(dāng)前時間戳和日/周的狀態(tài), 所有子組件的狀態(tài)都是根據(jù)targetDay去展示的;
CalendatrHeader頭部容器組件
頭部容器我覺得其他的還好, 由于星期是寫死的(主要是參考了一下蘋果的那個日程組件, 蘋果的星期就沒換, 所以參考大廠優(yōu)秀的設(shè)計), 所以比較敲腦殼的就是如何能準(zhǔn)確的展示一周的日期;
其實展示一周的日期我寫了兩種方式:
第一種是以當(dāng)前的日期的星期為基準(zhǔn), 分別向前和向后去計算, 最后輸出一個[29, 30, 31, 1, 2, 3, 4]這樣的List, 如果恰巧今天是1號 或者 2號, 就去拉去上個月最后一天的日期往前遞減;
第二種方式就是下面代碼的方式, 也是拿到當(dāng)前日期的星期去定位, 通過時間戳去動態(tài)計算出來, 只要知道往前減幾天, 往后追加幾天就好了;
其實兩種方式都可以, 最后我用了第二種, 顯然第二種更加簡潔;
如下圖:

當(dāng)前一周就要輸出[12, 13, 14, 15, 16, 17, 18]
下面是上述難點具體實現(xiàn)的代碼:
const calcWeekDayList: (params: number) => WeekType = (params) => {
const result = [];
for (let i = 1; i < weekDay(params); i++) {
result.unshift(params - 3600 * 1000 * 24 * i);
}
for (let i = 0; i < 7 - weekDay(params) + 1; i++) {
result.push(params + 3600 * 1000 * 24 * i);
}
return [...result] as WeekType;
};
代碼:
const CalendatrHeader: React.FC<CalendatrHeaderType> = ({
targetDay,
setTargetDay,
switchWeekandDay,
businessRender,
setSwitchWeekandDay,
}) => {
// 當(dāng)前一周的日期
const [dateTextList, setDateTextList] = useState<WeekType | []>([]);
// 這個狀態(tài)是在切換周的時候, 直接增加或者減少一周的時間戳, 下一周或者上一周的日期就會被自動算出來;
const [currTime, setCurrTime] = useState<number>(targetDay);
useEffect(() => {
setDateTextList(calcWeekDayList(targetDay));
}, [targetDay]);
// 根據(jù)當(dāng)前時間戳, 計算之前和之后天數(shù)的日期, 由于星期是固定不變的, 所以只計算當(dāng)前一周的日期就好了
const calcWeekDayList: (params: number) => WeekType = (params) => {
const result = [];
for (let i = 1; i < weekDay(params); i++) {
result.unshift(params - 3600 * 1000 * 24 * i);
}
for (let i = 0; i < 7 - weekDay(params) + 1; i++) {
result.push(params + 3600 * 1000 * 24 * i);
}
return [...result] as WeekType;
};
const onChangeWeek: (type: 'prevWeek' | 'nextWeek', switchWay: 'week' | 'day') => void = (
type,
switchWay,
) => {
if (switchWay === 'week') {
const calcWeekTime =
type === 'prevWeek' ? currTime - 3600 * 1000 * 24 * 7 : currTime + 3600 * 1000 * 24 * 7;
setCurrTime(calcWeekTime);
setDateTextList([...calcWeekDayList(calcWeekTime)]);
}
if (switchWay === 'day') {
const calcWeekTime =
type === 'prevWeek' ? targetDay - 3600 * 1000 * 24 : targetDay + 3600 * 1000 * 24;
setCurrTime(calcWeekTime);
setTargetDay(calcWeekTime);
}
};
return (
<div className={style.Calendar_Header}>
<DailyOptions
targetDay={targetDay}
setCurrTime={setCurrTime}
setTargetDay={setTargetDay}
dateTextList={dateTextList}
switchWeekandDay={switchWeekandDay}
setSwitchWeekandDay={(value) => {
setSwitchWeekandDay(value);
if (value === 'week') {
setDateTextList(calcWeekDayList(targetDay));
}
}}
onChangeWeek={(type) => onChangeWeek(type, switchWeekandDay)}
/>
{switchWeekandDay === 'week' && (
<WeeklyOptions
targetDay={targetDay}
setTargetDay={setTargetDay}
dateTextList={dateTextList}
/>
)}
<div className={style.Calendar_Header_businessRender}>
<div className={style.Calendar_Header_Zone}>GMT+8</div>
{businessRender({ timestamp: targetDay })}
</div>
</div>
);
};
DailyOptions : 其實就是頭部切換"一周日期" & "日和周模式" & "今天"的組件的容器;
WeeklyOptions : 這個是下面展示當(dāng)前一周星期幾和日期的組件, 如果切換為day的話不展示: 如圖:

businessRender: 這個就是肖戰(zhàn)哥哥那一欄用戶傳入的業(yè)務(wù)組件;
ScheduleCantainer詳細(xì)日程容器
也就是圖片這部分:

其實這部分代碼比較多, 就不方便全都貼出來了; 我根據(jù)功能點貼出來部分片段吧;
左側(cè)刻度
左側(cè)刻度其實就是寫死的 從00:00 - 01:00 ---> 23:00 - 00:00, 但是在寫的時候有一個小的問題, 就是這個組件是浮動到左側(cè)的, 而且他要隨著右側(cè)條目的滾動而滾動, 其實一開始我寫到一個盒子里了, 滾動容器整體就一起滾動了, 但是遇到了一個小問題, 由于右側(cè)條目會變得超寬, 就會出現(xiàn)橫向滾動條, 如果橫滾整個容器的話, 左側(cè)的時間刻度就會被滾動出可視區(qū)域.
所以還是絕對定位之后, 監(jiān)聽右側(cè)日程條目的滾動事件, 動態(tài)的改變左側(cè)的style的top值, 反向賦值就好了, 由于是向下滾動的, 所以左側(cè)的時間刻度需要向上滾動, 所以top值取反就會達到同步的效果; 真是個小機靈鬼吧, 嘿嘿; 這個代碼就不占用篇幅了, 大家自由發(fā)揮吧, 如果有更好的方式, 歡迎評論區(qū)留言.
ScheduleItem日程容器條目
先看下這個組件的代碼:
const ScheduleItem: React.FC<ScheduleItemType> = ({
timestampRange,
dataItem,
scheduleRender,
width,
dataItemLength,
}) => {
// 計算容器高度
const calcHeight: (timestampList: [number, number]) => number = (timestampList) =>
timestampList.length > 1 ? (timestampList[1] - timestampList[0]) / 1000 / 60 / 2 : 30;
const calcTop: (startTime: number) => number = (startTime) => moment(startTime).minute() / 2;
// 計算 ScheduleItem 寬度
const calcWidth: (w: number, d: number) => string = (w, d) =>
width === 0 || dataItemLength * width < 347 ? '100%' : `${d * w}px`;
return (
<div style={{ position: 'relative' }} className={style.Calendar_ScheduleItem_Fath}>
<div
className={style.Calendar_ScheduleItem}
style={{ width: calcWidth(width, dataItemLength) }}
>
{dataItem.map((data, index) => {
return (
<Fragment key={index}>
{data.startTime >= timestampRange[0] && data.startTime < timestampRange[1] && (
<div
className={`${style.Calendar_ScheduleItem_container} Calendar_ScheduleItem_container`}
style={{
height: `${calcHeight([data.startTime, data.endTime]) || 30}px`,
top: calcTop(data.startTime),
}}
>
{scheduleRender({ data, timestampRange })}
</div>
)}
</Fragment>
);
})}
</div>
</div>
);
};
這一部分呢(就是下面灰色一條一條的部分), 為什么要單獨出一個組件呢? 可以先思考一下......
好了, 不賣關(guān)子了, 其實就是為了好定位用戶的日程數(shù)據(jù), 例如今天的10:00 -- 11:00, 定位到哪里的問題.
還記得這個API嗎?
scheduleRender?: ({
data: dataType,
timestampRange: [DOMTimeStamp, DOMTimeStamp],
}) => JSX.Element;
這個組件內(nèi)會有[DOMTimeStamp, DOMTimeStamp] 這樣的一個參數(shù)(DOMTimeStamp時間戳的意思), 這兩個時間戳其實就是當(dāng)前時段的 10:00 -- 11:00 的其實和截至?xí)r間戳, 由于我們接受的startTime和endTime也是時間戳, 通過比較大小是否在這個范圍, 就可以控制展示和隱藏, 這回明白為什么采用時間戳了吧, 直接比較數(shù)字大小就好了;
我們再說一下這個東東的樣式問題:
其實這個東東我我寫死了30px, 原因呢就是因為一小時是60分鐘, 如果60px的話太高了, 所以寫了30px, 方便定位嘛, 畢竟我懶, 不想太復(fù)雜的計算;
所以定位計算也就一行代碼: const calcTop: (startTime: number) => number = (startTime) => moment(startTime).minute() / 2; 高度定位問題結(jié)了! 哈哈~~
接下來呢, 還有一個問題就是高度問題, 如圖:

高度計算其實也不難, 主要根據(jù)當(dāng)前起止時間的區(qū)間范圍去計算( 1px 兩分鐘 ), 具體實現(xiàn)看代碼:
const calcHeight: (timestampList: [number, number]) => number = (timestampList) =>
timestampList.length > 1 ? (timestampList[1] - timestampList[0]) / 1000 / 60 / 2 : 30;
首先會判斷入?yún)⒌臅r間戳是不是只有一個時間, 如果只有開始時間, 沒有結(jié)束時間, 寫死30px, 如果有起止時間, 就去轉(zhuǎn)成分鐘動態(tài)計算一下;
最后還有一個問題, 業(yè)務(wù)數(shù)據(jù)是怎么傳進去是如何渲染到組件的:
先看一下我們傳入data字段的JSON:
[
{
startTime: 1626057075000, // 開始時間
endTime: 1626070875000, // 結(jié)束時間
value: 'any', // 業(yè)務(wù)數(shù)據(jù)
},
{
startTime: 1626057075000,
endTime: 1626070875000,
value: 'any',
},
{
startTime: 1626057075000,
endTime: 1626070875000,
value: 'any',
},
{
startTime: 1626057075000,
endTime: 1626070875000,
value: 'any',
},
];
其實我們在循環(huán)渲染這個ScheduleItem組件的時候, 用那個寫死的24h的list去循環(huán), 之后, 循環(huán)的時候, 動態(tài)的去業(yè)務(wù)數(shù)據(jù)中去查找符合當(dāng)次循環(huán)的時間范圍內(nèi)的業(yè)務(wù)數(shù)據(jù), 把這個數(shù)據(jù)塞到組件內(nèi); 大致代碼如下:
for (let i = 0; i < HoursList.length; i++) {
resule.push({
timestampRange: [todayTime + i * 3600 * 1000, todayTime + (i + 1) * 3600 * 1000],
dataItem: [ // 由于當(dāng)前一個時間段, 日程可能沖突, 所以要有一個list傳入組件
...data.filter((item) => {
return (
item.startTime >= todayTime + i * 3600 * 1000 &&
item.startTime < todayTime + (i + 1) * 3600 * 1000
);
}),
],
});
}
總結(jié)
以上就是這個組件大部分的實現(xiàn), 從接到需求, 到設(shè)計組件, 最后到實現(xiàn)細(xì)節(jié), 說的不一定面面俱到, 但也算是基本的實現(xiàn)思想.
也羅列了一下技術(shù)難點的實現(xiàn)細(xì)節(jié), 其實看起來也并不難, 只要稍稍動動腦就可以了.
我的實現(xiàn)方式也不一定很好, 程序的實現(xiàn)方式千萬種, 我只是表達了一下自己的設(shè)計思想, 與大家共同學(xué)習(xí), 如果有什么不好的地方, 歡迎大家評論區(qū)指出, 我們共同進步.
到此這篇關(guān)于手把手帶你用React擼一個日程組件的文章就介紹到這了,更多相關(guān)React日程組件內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
將React+Next.js的項目部署到服務(wù)器的方法
本文詳細(xì)介紹了將React+Next.js項目部署到服務(wù)器的步驟,包括服務(wù)器環(huán)境準(zhǔn)備、項目配置與構(gòu)建、啟動服務(wù)、配置Nginx反向代理、HTTPS配置、驗證與監(jiān)控以及高級優(yōu)化,感興趣的朋友一起看看吧2025-03-03
使用VScode 插件debugger for chrome 調(diào)試react源碼的方法
這篇文章主要介紹了使用VScode 插件debugger for chrome 調(diào)試react源碼,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2019-09-09
React?中使用?react-i18next?國際化的過程(react-i18next?的基本用法)
i18next?是一款強大的國際化框架,react-i18next?是基于?i18next?適用于?React?的框架,本文介紹了?react-i18next?的基本用法,如果更特殊的需求,文章開頭的官方地址可以找到答案2023-01-01
詳解React中的useMemo和useCallback的區(qū)別
React中的useMemo和useCallback是兩個重要的Hooks。常常被用于優(yōu)化組件的性能。雖然這兩個Hooks看起來很相似,但它們彼此之間還是有很大的區(qū)別的,隨著小編一起來學(xué)習(xí)吧2023-04-04
React Antd中如何設(shè)置表單只輸入數(shù)字
這篇文章主要介紹了React Antd中如何設(shè)置表單只輸入數(shù)字問題,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-06-06

