自我介绍
大家好,我是 思哲Lee 一名前端开发人员,从业快两年,主要在公司写vue
前言声明
由于我的经验和水平有限,可能这篇文章对于你不会有太大帮助,可能不是因为这篇文章水,而是你的能力已经远超了我的水平极限请理性讨论,不要带有言语攻击,谢谢
为什么会复刻小米日历
出于对技术栈的探索,我又重拾了react,之前在学习阶段写过一个管理系统项目(那个时候还是类组件流行的时候,现在已经变为函数组件了) , 然后又因光神的react小册中日历组件启发,不满足简单的实现,想让日历变得高大上起来,正巧看到了小米的日历效果还不错,因此便进行了一些拙劣的模仿,在技术上是基本完成了日历组件的功能,但和原本小米日历还是有一些功能差距(比如记忆时间点时间段的指定安排等);因为是重拾原因,会留有我一些简单的学习记录,废话不多说,让我们开始进入正题吧
正文
精彩效果预览

你将了解到什么
- 了解一个日历组件是如何完成的
- 了解日历月和年的切换是如何进行的
- 了解日历的每日详细信息是如何获取的(剧透一下,使用lunar库)
- 得到笔者的源码一份(调皮一下haha)
技术栈
使用主要技术栈如下:
- react (version:18.2.0)
- typescript (version:18.2.0)
- lunar-typescript (version:1.7.1)
- tailwindCss
react
-
介绍
react是前端主流框架之一,是我们今天的主角,他和vue有着类似的响应架构,通过响应状态的改变来驱动视图更新,同时在细节上存在功能的映射,比如生命周期,响应状态驱动,虚拟dom,插槽,整体渲染流程等,这里就不展开了,如果你已经是react&vue的高手,应该已经完成了整体的回顾,为了便于大家对未知知识的不足,附上官网链接: react.docschina.org/
typescript
-
介绍
ts是js的上层封装,让只能在运行状态显示和执行的语言变成了和java等一众强语言类型一样拥有了编译时特性,即类型约束(下面我会对类型约束展开,说说我对它的理解),是一个在编码阶段即可发现程序存在错误问题可能性的有效工具, 附上官网链接:www.tslang.cn/index.html
-
ts的优秀约束特性
继承了java的优秀特性,如接口,泛型,抽象类,类属性和方法的访问约束
扩展了具有js特性的类型,如 字面量约束 例如: const str1:'1' | '2' = '1' (表示这个类型只能指向这两个常量)
提供了具有ts风格的工具类型函数
....
-
类型约束的秘密
这里简单说一下类型约束是什么,类型约束指一个变量在程序编写时允许指向的数据类型,这里说一下我的理解:每一个变量名可以看做是一个指针(或是大篮子),类型约束就是约束这个指针允许指向的数据类型(或是大篮子允许存放的物品),在程序层面,这个指针可以指向它所有有关联的子类实例对象(如果指向了不允许存储的约束类型则会进行报错),根据需要使用指向实例的不同属性和方法的访问权限来决定类型约束范围,如果想使用指向实例的所有属性和方法那么就用对应子类实例的类型进行约束,否则就使用对应的父类类型进行约束,比如以下场景
typescript
// 以下是一个简单例子,实际场景可以使用类的不同特性完成对应的封装工作,简单封装可以使用函数搞定,如果是高复杂度的,那么就应该使用类了,如果类里面还有重复可以使用 类的继承特性完成上层封装行为 , 这里也简单说一下类的第三个特性,多态,多态指调用不同实例的相同方法达到的执行行为不同,多态特性如果需要良好的运用需要引入抽象类或者接口以便更好的管理程序,保证整洁性
class Father{
public fatherName:string
constructor(name){
this.fatherName = name
}
sayFather(){
console.log('i am father of children')
}
}
class Child1 extends Father{
public childName:string
constructor(childName,fatherName){
super(fatherName)
this.childName = childName
}
play(){
console.log('i have ability of play')
}
}
class Child2 extends Father{
public childName:string
constructor(childName,fatherName){
super(fatherName)
this.childName = childName
}
coding(){
console.log('i have ability of coding')
}
}
// 由于Child1继承Father所以Father可以引用这个有关联的子类实例,但由于类型访问的限制,只能使用这个实例对象的部分属性和方法,即Father和其继承链上的属性和方法
const father:Father = new Child1('child1','father1')
// 创建了 Child2类的实例对象 并使用Child2进行约束,此时child2可以使用Child2类即父类上的属性和方法
const child2:Child2 = new Child2('child2','father2')
// 结论
// 创建了一个子类实例的属性和方法后,如果想使用全部的属性和方法则使用对应的子类实例类型约束即可(在ts中可以不约束,因为其会自动推断,在没有预设值的时候,变量的约束类型就为第一次指向实例的类型,如果手动约束了那么就不会进行类型推断),如果只想使用部分属性和方法,那么使用对应的类型进行约束控制即可,因此类型约束就相当于是限制了访问一个指向实例的数据范围,根据想使用的属性和方法的范围大小,使用对应的类型进行约束即可(在程序正式运行的时候,属性和方法的使用还是会从当前子类实例开始查找的)
lunar
-
介绍
lunar是本次文章的主角之一,用于获取当前日期的所有信息,包括阴历,阳历等等的信息,相当于日期信息的集合,通过这个库我们可以知道日期已知的全部信息,如今天是多少号,是农历的什么日历,有什么节日,是否休息,是否调休等等,包含你想要的关于日期的一切,官网如下 6tail.cn/calendar/ap...
tailwindcss
-
介绍
tailwindcss 是一个原子化的css库,这个库对所有css进行对应类的封装,这个时候我们只需要使用对应类名的组合即可达到预期样式效果,是一个提高编程效率的有效扩展工具 ; 官网如下 www.tailwindcss.cn/
核心问题解决
-
如何得到当月的天数,和上一个月的天数
使用moment的api可以得到当前月有多少天,当前月的第一天是星期几,基于此可以完成空余天数的补足
使用 原始的Date Api可以拿到距离当前月份的上一个月的倒数天数
以下为对应的获取例子和区分当月和非当月的日期补全策略代码
tsxfunction buildDetailDayInfoParams(timeInfo: TimeInfo & { offset: number, timeInfo: TimeInfo }) { const { year, offset, day, month } = timeInfo; if (timeInfo.viewMode == ViewMode.YEAR) { return new Date( year + offset, month, day) } if (timeInfo.viewMode == ViewMode.MONTH) { return new Date( year, month + offset, day) } } function getMonthList(timeInfo: TimeInfo, dayPosition: number): DayInfo[] { const { yearOnView, viewMode, monthOnView } = timeInfo; let curDate = null if (viewMode == ViewMode.YEAR) { curDate = moment(`${yearOnView + dayPosition}-${monthOnView} `) } if (viewMode == ViewMode.MONTH) { curDate = moment(`${yearOnView}-${monthOnView + dayPosition} `) } if (viewMode == ViewMode.MONTH) { if (monthOnView + dayPosition == 0) { curDate = moment(`${yearOnView - 1}-${12} `) } else if (monthOnView + dayPosition == 13) { curDate = moment(`${yearOnView + 1}-${1} `) } } const daysOnCurMonth = curDate.daysInMonth(); const startDayOnCurMonth = parseInt(curDate.startOf('month').format('d')) let dayList = [] let i = 0 for (i = 0; i < daysOnCurMonth + startDayOnCurMonth; i++) { if (!dayList.length || dayList[dayList.length - 1]) { dayList = dayList.concat(new Array(7).fill(null)) } const day = i - startDayOnCurMonth + 1 dayList[i] = getDetailDayInfo( buildDetailDayInfoParams({ year: yearOnView, month: monthOnView - 1, day, offset: dayPosition, viewMode: timeInfo.viewMode, timeInfo, }) , timeInfo) } let index = dayList.findIndex(dayInfo => !dayInfo) if (index !== -1) { for (index; index < dayList.length; index++) { const day = index - startDayOnCurMonth + 1 dayList[index] = getDetailDayInfo( buildDetailDayInfoParams({ year: yearOnView, month: monthOnView - 1, day, offset: dayPosition, viewMode: timeInfo.viewMode, timeInfo, }) , timeInfo) } } return dayList }
以下是lunar库的基本数据获取操作
tsx/** * @description: 用于组装日历详细信息,包含对应日期和节气 * @param {Date} date * @return {*} */ export function getDetailDayInfo(date: Date, timeInfo: TimeInfo): DayInfo { const lunar = Lunar.fromDate(date) const solar = Solar.fromDate(date) const dateForMoment = moment(date) const weekDay = dateForMoment.format('dddd') const { year, month, day, yearOnView, monthOnView, dayOnView } = timeInfo const dayInfo: DayInfo = { day: Solar.fromDate(date).getDay(), chineseDay: lunar.getDayInChinese(), isWeekend: weekDay == "Saturday" || weekDay == "Sunday", weekDay, fullDate: dateForMoment.format('YYYY-MM-DD'), dateFromTheMonth: date.getMonth() + 1 == timeInfo.monthOnView, isToday: dateForMoment.isSame(moment(`${year}-${month}-${day}`), 'day'), isSelected: dateForMoment.isSame(moment(`${yearOnView}-${monthOnView}-${dayOnView}`), 'day'), yiList: lunar.getDayYi(), jiList: lunar.getDayJi(), chineseDateName: '农历' + lunar.getMonthInChinese() + '月' + lunar.getDayInChinese(), chineseYearName: lunar.getYearInGanZhi() + lunar.getShengxiao() + '年', chineseMonthName: lunar.getMonthInGanZhi() + '月', chineseDayName: lunar.getDayInGanZhi() + '日', } if (dayInfo.chineseDateName.includes('腊月廿三')) { dayInfo.chineseDay = '北方小年' } else if (dayInfo.chineseDateName.includes('腊月廿四')) { dayInfo.chineseDay = '南方小年' } // 用于区分法定节假日和调休 const holiday = HolidayUtil.getHoliday(dateForMoment.format('YYYY-MM-DD')) if (holiday) { dayInfo.isRestDay = !holiday.isWork() dayInfo.isWorkDay = holiday.isWork() } const season = lunar.getJieQi() const festivalList = [] const festivalsForLunar = lunar.getFestivals() const festivalsForSolar = solar.getFestivals() festivalList.push(...festivalsForSolar, ...festivalsForLunar) dayInfo.festivalList = festivalList /** * 中文名规则,如果当前包含节气,月初,法定节价值的,优先响应 */ if (festivalList.length && festivalList[0].length < 4) { dayInfo.chineseDay = festivalList[0] } else if (season) { dayInfo.chineseDay = season } else if (lunar.getDay() == 1) { dayInfo.chineseDay = lunar.getMonthInChinese() + '月' } return dayInfo }
-
如何处理滑动块动画
在处理滑块时我采用的策略为 启用是三个视图的形式,即上个月视图(-1)本月视图(0)下个月视图(1)进行分类,基于主视口进行定位,并以此进行逻辑编写,动画部分使用transform:translateX() 完成页面横向滚动,且滑动的是主视图,当主视图变化的时候由于定位其他视图也会跟着变化,同时制定了策略,当只有满足滑动要求的行为才会完成月切换,具体代码如下
tsxexport default function CalendarDetail() { const timeInfo = useContext<TimeInfoContextType>(TimeInfoContext); // 获取日历上下文 const [innerContextHeight, setInnerContextHeightState] = useState<number>(0) const [divEleTranslateX, setDivEleTranslateXState] = useState<number>(0) const divEleRef = useRef<HTMLDivElement>(null); // 创建一个ref来获取日历元素 const DateDetailDivEleRef = useRef<HTMLDivElement>(null); // 监听页面滚动 useEffect(() => { let timeOnTouchStart = 0 let touchInfoOnTouchStart: any = {} let timeOnTouchEnd = 0 const notifyTimeInfoContextUpdate = (slideDistance) => { if (timeInfo.viewMode == ViewMode.YEAR) { handleSlideForYear(slideDistance) } else if (timeInfo.viewMode == ViewMode.MONTH) { handleSlideForMonth(slideDistance) } } const handleSlideForYear = (slideDistance) => { const yearOnView = timeInfo.yearOnView + (slideDistance > 0 ? -1 : 1) timeInfo.setTimeInfoState({ ...timeInfo, yearOnView, selectDateDetailInfoOnView: getDetailDayInfo(new Date(yearOnView, timeInfo.monthOnView - 1, timeInfo.dayOnView), timeInfo) }) } const handleSlideForMonth = (slideDistance) => { const monthOnView = timeInfo.monthOnView + (slideDistance > 0 ? -1 : 1) if (monthOnView == 0) { timeInfo.setTimeInfoState({ ...timeInfo, monthOnView: 12, yearOnView: timeInfo.yearOnView - 1, selectDateDetailInfoOnView: getDetailDayInfo(new Date(timeInfo.yearOnView - 1, 11, timeInfo.dayOnView), timeInfo) }) } else if (monthOnView == 13) { timeInfo.setTimeInfoState({ ...timeInfo, monthOnView: 1, yearOnView: timeInfo.yearOnView + 1, selectDateDetailInfoOnView: getDetailDayInfo(new Date(timeInfo.yearOnView + 1, 0, timeInfo.dayOnView), timeInfo) }) } else { const totalDays = moment(`${timeInfo.yearOnView}-${monthOnView}`).daysInMonth() // 得到现在的dayOnView值,如果比最新月份的最大值大则dayOnView变为当前月份最大值 const newState = { ...timeInfo, monthOnView, } if (totalDays < timeInfo.dayOnView) { newState.dayOnView = totalDays } newState.selectDateDetailInfoOnView = getDetailDayInfo(new Date(timeInfo.yearOnView, monthOnView - 1, newState.dayOnView), timeInfo) timeInfo.setTimeInfoState(newState) } } const initCacheTouchParams = () => { timeOnTouchStart = 0 touchInfoOnTouchStart = {} timeOnTouchEnd = 0 } const divEleRefTouchStartEvent = (e: TouchEvent) => { touchInfoOnTouchStart = e.changedTouches[0] timeOnTouchStart = e.timeStamp } const divEleRefTouchMoveEvent = (e: TouchEvent) => { const touchInfo = e.changedTouches[0] const xOffset = touchInfo.pageX - touchInfoOnTouchStart.pageX setDivEleTranslateXState(xOffset) } const divEleRefTouchEndEvent = (e: TouchEvent) => { // 处理是否进行滚动操作(如果小于1s的滑动则进行日历切换,否则只有超过了屏幕的一半的距离时我才进行对应的滚动) timeOnTouchEnd = e.timeStamp const touchInfo = e.changedTouches[0] // 如果是正值,说明需要回到上一个月份,否则反之 const slideDistance = touchInfo.pageX - touchInfoOnTouchStart.pageX // 碰到闭包引用导致状态无法正常更新问题,解决方法一:依赖对应的响应数据,使当前timeInfo指向永远为最新,解决方法二:使用函数设置方式,永远获取最新的响应数据 if (timeOnTouchEnd - timeOnTouchStart < 500 && Math.abs(slideDistance) > 20) { notifyTimeInfoContextUpdate(slideDistance) } else if (Math.abs(slideDistance) > window.innerWidth / 2) { notifyTimeInfoContextUpdate(slideDistance) } // 还原初始状态 setDivEleTranslateXState(0) initCacheTouchParams() } setInnerContextHeightState(DateDetailDivEleRef.current?.clientHeight) divEleRef.current?.addEventListener('touchstart', divEleRefTouchStartEvent, { passive: true }) divEleRef.current?.addEventListener('touchmove', divEleRefTouchMoveEvent, { passive: true }) divEleRef.current?.addEventListener('touchend', divEleRefTouchEndEvent, { passive: true }) return () => { divEleRef.current?.removeEventListener('touchstart', divEleRefTouchStartEvent) divEleRef.current?.removeEventListener('touchmove', divEleRefTouchMoveEvent) divEleRef.current?.removeEventListener('touchstart', divEleRefTouchEndEvent) } }, [timeInfo]) return <div className="my-2 flex-1 "> {(timeInfo.viewMode == ViewMode.MONTH || timeInfo.viewMode == ViewMode.WEEK) && <WeekHeader />} <div className=" relative " ref={divEleRef} style={{ transform: `translateX(${divEleTranslateX}px)`, height: innerContextHeight }}> <DateDetail dayPosition={-1} /> <DateDetail dayPosition={0} ref={DateDetailDivEleRef} /> <DateDetail dayPosition={1} /> </div> {timeInfo.viewMode == ViewMode.MONTH && <SelectDayItemDetailInfo />} </div> } const DateDetail = forwardRef(function (props: any, ref: any) { const timeInfo = useContext<TimeInfoContextType>(TimeInfoContext); // 获取日历上下文 const dayPosition = props.dayPosition; const dayList: DayInfo[] | DayInfo[][] = getDayList(timeInfo, dayPosition) const getLeftForStyle = () => { if (dayPosition == -1) { return '-100vw' } if (dayPosition == 0) { return '0' } if (dayPosition == 1) { return '100vw' } } if (timeInfo.viewMode == ViewMode.MONTH || timeInfo.viewMode == ViewMode.WEEK) { return <div className=" w-full grid grid-cols-7 gap-2 my-4 absolute" style={{ left: getLeftForStyle() }} ref={ref}> { dayList.map((dayInfo, i) => <DayItem dayInfo={dayInfo} key={i} />) } </div> } if (timeInfo.viewMode == ViewMode.YEAR) { return <div className=" w-full grid grid-cols-3 gap-4 my-4 absolute" style={{ left: getLeftForStyle() }} > { dayList.map((dayInfo, i) => <MonthItem dayInfo={dayInfo as DayInfo[]} month={i + 1} key={i} />) } </div> } return <></> })
实现效果

源码地址
we-del/react-xiaomi-calendar (github.com)
最后
感谢你能看到这里,笔者能力有限在文章存在的逻辑或表达问题请多包涵,如果对某个细节点存在疑问欢迎评论区讨论,如果文章对你有帮助请用点赞和收藏回应我 :)