组件库从开发到维护全链路讲解(二)日历组件的核心逻辑与设计
本篇文章为《前端组件库的开发与维护》系列的第二篇文章。本文案例在线文档:calendar.hxkj.vip。GitHub 仓库:github.com/TangSY/vue3…。
今天这章主要从以下几个方面展开讲解:
- 日历面板渲染(动态调整每个月的日期排列)
- 上下月切换(一次性应该渲染几个月的日期)
- 自定义周起始星期(适配不同的周起始星期,例如周日或周一)
- 周月切换(丝滑的切换周/月模式)
- 组件的易用性(API 设计技巧)
- 组件的可扩展性(slot 设计技巧)
- 组件的可维护性(组件之间的设计技巧)
- 改进计划(目前现存的一些问题及重构计划)
日历面板渲染
先看下日历面板由哪些内容构成:
结构还是挺明显的,从上到下分别为:标题栏、星期栏以及日历主体。其中标题栏和星期栏比较简单,着重看看日历主体部分。
这里单纯从展示效果来区分的话,会出现两种情况。第一种情况,当月的起始日期不在面板的第一格,此时需要用上月的日期去填充,如下图:
第二种情况,当月的起始日期在面板的第一格,此时就不用填充上月日期了。
但是,我们在实现的时候,其实是可以按同一种情况来计算的。我们只需要知道当月的第一天在星期几就可以了。若是星期日则填充 0 个上月日期,星期一就填充 1 个上月日期。。。
然后接下来就是我们到底需要渲染几行日期呢?按理说:一个月最多 31 天,每行 7 天的话,我们只需要 5 行就够了。但是,从上面 1 月份那个示例图可以看出来,它是有可能占用 6 行的,而从上面 5 月份的那个示例图看,5 行也确实够用,第六行全是下个月的日期,完全可以舍弃的。
这里其实涉及一个用户体验问题,假设日历的交互方式是通过上下滚动来切换月份,这种交互方式对于每月高度的变化不敏感,为了不给用户展现过多的无效信息,可以设计为:该占多少行就占用多少行,例如 2022 年 1 月占用 5 行,2022 年 5 月占用 6 行。
但是,本案例中日历的交互方式是通过左右滑动来切换月份,这种交互方式对于高度的变化是很敏感的,一会儿 5 行,一会儿 6 行,导致切换时会有明显的上下抖动感。因此,需要统一高度,让其在视觉效果上保持一致性。那这个行数就确定下来了 -- 6行。实现代码如下:
// 日历总天数
const calendarDaysTotalLength = 42;
// 获取月份某一天是星期几
export const getDayOfWeek = (year, month, day) => {
const dayOfMonth = new Date(year, month, day); // 获取当月的第day天
const dayOfWeek = dayOfMonth.getDay(); // 判断第day天是星期几(返回[0-6]中的一个,0代表星期天,1代表星期一)
return dayOfWeek;
};
// 判断是否为闰年
export const isLeap = (year: number) =>
year % 4 === 0 ? (year % 100 !== 0 ? 1 : year % 400 === 0 ? 1 : 0) : 0;
// 获取某个月的总天数
export const daysOfMonth = (year: number) => [
31,
28 + isLeap(year),
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
// 计算每个月的日历
const calculateCalendarOfMonth = (year, month) => {
const calendarOfCurrentMonth = []; // 当月日历数据
const lastMonthYear = month === 0 ? year - 1 : year; // 上个月的年份
const lastMonth = month === 0 ? 11 : month - 1; // 上个月的月份
const nextMonthYear = month === 11 ? year + 1 : year; // 下个月的年份
const nextMonth = month === 11 ? 0 : month + 1; // 下个月的月份
let dayOfWeek = getDayOfWeek(year, month, 1); // 获取月份第一天是星期几
const lastMonthDays = daysOfMonth(year)[lastMonth]; // 上个月的总天数
// 日历数据最前面补齐上个月的日期
for (let i = 0; i < dayOfWeek; i++) {
calendarOfCurrentMonth.push({
year: lastMonthYear,
month: lastMonth,
day: lastMonthDays - (dayOfWeek - 1 - i),
});
}
// 当月日期
for (let i = 0; i < daysOfMonth(year)[month]; i++) {
calendarOfCurrentMonth.push({
year,
month,
day: i + 1,
});
}
// 计算补齐日历面板需要的天数
const fillDays =
calendarDaysTotalLength - calendarOfCurrentMonth.length;
// 在日历后面填充下个月的日期,补齐6行
for (let i = 0; i < fillDays; i++) {
calendarOfCurrentMonth.push({
year: nextMonthYear,
month: nextMonth,
day: i + 1,
});
}
// 返回日期数据。数据结构:[{year: 2022, month: 0, day: 1},{year: 2022, month: 0, day: 2}...]
return calendarOfCurrentMonth;
};
上下月切换
从下图可以看出当从 4 月份往左滑动,将会出现 5 月份日期。同理,如果往右滑动则会展示 3 月份的日期。
因此,可得出一个结论,一次性渲染的最佳月份数量为三个月,分别为:当前月、上个月、下个月。且,每次切换完成之后再重新计算,即可实现用户滑动时无缝衔接。
首先,计算出页面中需要渲染的那三个月,代码实现如下:
const lastMonthYear = 0; // 上个月的年份
const lastMonth = 0; // 上个月的月份
const nextMonthYear = 0; // 下个月的年份
const nextMonth = 0; // 下个月的月份
const calendarOfMonthShow = ref([]);// 用于组件渲染的全部日期数据
// 以当前入参月份为基准,计算三个月份的日历信息
const calculateCalendarOfThreeMonth = (year, month) => {
lastMonthYear = month === 0 ? year - 1 : year; // 上个月的年份
lastMonth = month === 0 ? 11 : month - 1; // 上个月的月份
nextMonthYear = month === 11 ? year + 1 : year; // 下个月的年份
nextMonth = month === 11 ? 0 : month + 1; // 下个月的月份
// 首月数据(上个月)
const firstMonth = calculateCalendarOfMonth(lastMonthYear, lastMonth);
// 第二个月数据(当前展示的月份)
const secondMonth = calculateCalendarOfMonth(year, month);
// 第三个月数据(下个月)
const thirdMonth = calculateCalendarOfMonth(nextMonthYear, nextMonth);
calendarOfMonthShow.value = [firstMonth, secondMonth, thirdMonth];
};
然后,在此基础上编写月份切换方法,代码实现如下:
// 获取上个月日历
const getLastMonth = () => {
calculateCalendarOfThreeMonth(lastMonthYear, lastMonth);
};
// 获取下个月日历
const getNextMonth = () => {
calculateCalendarOfThreeMonth(nextMonthYear, nextMonth);
};
自定义周起始星期
目前市面上存在以下两种类型的日历。一种是以星期一为起始星期,另一种是以星期日为起始星期:
那如果要适配这两种展现形式,对日历数据的生成影响大不大?是不是意味着刚刚那个生成日历数据的方法要大改?其实不然,改动点非常小,只需要对 calculateCalendarOfMonth
方法进行修改即可,插入以下代码:
// 加入起始星期的判断
if (dayOfWeek < props.weekStartIndex) {
dayOfWeek = 7 - props.weekStartIndex + dayOfWeek;
} else {
dayOfWeek -= props.weekStartIndex;
}
插入位置为:
上述代码中 props.weekStartIndex
为组件接收的 props 属性,具体的定义为:0 代表星期天,1 代表星期一,以此类推。这样做的话,不仅支持以星期一和星期日开头,他支持以任意星期开头(虽然没有没见过这样的需求,但是万一有个拥有奇葩思维的产品呢^-^)。如下图所示:
周月切换
这个日历除了支持左右滑动,还支持上下滑动。上下滑动支持切换周/月展示面板。那咱们来具体分析一下这个交互。
总体上来看,分以下三种情况,我们要做的就是隐藏红框中的部分。
这里的实现方案大致上分为两种:
- 在往上滑动时,将数据源由三个月份的数据切换为三个星期的数据(跟月份数据的形式一样,保留上个星期、本星期、下个星期的数据即可)。这种方案在后续左右滑动切换星期时,处理起来会比较方便,但是周月切换的动画效果不连贯。
- 在往上滑动时,保持数据源不变。只要样式上做处理,控制随着手指的滑动,除当前星期之外的布局慢慢消失。这样做的好处是,周月切换的动画效果非常连贯,没有突兀感。只是在数据处理的时候需要花点功夫。
我这边采用的是第二种方案,需要准确的知道当前星期处于日历面板的第几行,然后再隐藏其他行。具体的代码实现如下:
// 解构出当前选中的月份及日期
const { month, day } = currentMonthDay;
// 当前日历面板中所有的day数据
const daysArr: number[] = [];
calendarOfMonth.value[1].forEach((item) => {
daysArr.push(item.day);
});
let dayIndexOfMonth = daysArr.indexOf(day);
// 当day为月底的天数时,有可能在daysArr的前面也存在上一个月对应的日期,所以需要取lastIndexOf
if (day > 15) {
dayIndexOfMonth = daysArr.lastIndexOf(day);
}
// 计算当前日期在第几行
const indexOfLine = Math.ceil((dayIndexOfMonth + 1) / 7);
以上代码仅包含 当前日期在第几行 的计算逻辑,完整的周月切换代码可以前往 GitHub 查看源码.
易用性
什么是易用性?通俗点来讲就是:当某个人看中了你的组件,想要把它引入项目中使用时的难易程度。主要可以通过以下两个方面来降低使用者的上手成本:
第一,详细的文档。那么达到了什么样的标准才叫详细的文档呢,我认为最少应该包含这几点:
- 文档需要列举每个 API 对应的不同使用方法;
- 文档需要附上每种使用方法的 demo 代码;
- 每个 demo 代码需要做到复制即可用。也就是说需要保证 demo 代码的完整性,不能说使用方复制之后还得引入个 XX 组件,还得自行编写 XX 样式代码,或者还得在复制的代码外层包裹 XX 元素之后才能正常使用。
- 每个 demo 示例都需要有对应的渲染效果。
- 需要保证使用方在复制 demo 示例之后的呈现效果,与其在文档上对应的渲染效果完全一致。别搞的货不对版。
第二,合理的 API 设计。那这个的标准是什么呢?简单来说就是让使用者在接入时,所写的代码越少越好,使用的属性越少越好。最理想的状态就是:只需要引入组件,不添加任何额外属性即可完整体验该组件的基本功能。组件所设计的 API 是为了给组件达到锦上添花的效果,是给组件增强功能用的,这样的话,他们可以根据当前的需求选择性的查看对应 API。而不应该一开始就让使用方写一堆的属性配置才能达到可用状态,这会极大的增加使用方的心智负担,看得头都大了,甚至就直接放弃使用了。
可扩展性
这里聊的可扩展性指的是组件可定制化的程度。Vue 组件的可扩展性主要体现在 slot 的设计上,slot 设计的细粒度越小,组件的可扩展性就越高。当然,也并不是越小越好,以我理解的最佳实践就是按组件的最小功能块来划分。此方法不一定业界最佳,欢迎评论区探讨!下面以本文日历组件为例,看看 slot 是如何设计的:
从上图中可以看出,总共设计了 5 个 slot,分别为:1、今天按钮;2、确定按钮;3、整个标题栏(操作栏);4、星期栏;5、日历每一天的日期。
这样做的好处就是,不管使用方要改日历中的哪一块内容都可以轻松实现。例如:本日历并不直接提供农历/节假日的功能,但却可以通过上图中的 5 号插槽来实现。
可维护性
组件的可维护性主要体现在以下四个方面:
- 代码结构清晰。新成员可以快速的理解子组件之间的逻辑;
- 规范化命名。即使不查看的注释的情况下,也能通过名称看出该变量/函数的用途;
- 子组件/函数职责单一。这个能有效的防止代码屎山的形成;
- 完善的单元测试。能够很好的避免“改好一个 bug 的同时,引起以前正常的逻辑无法正确执行”;
接下来,以本案例做一个反例。
改进计划
对于本案例中的日历源码,其实还是有改进空间的,主要体现在可维护性方面。
可以看到,这两个子组件的代码行数都来到了 800 -1000 行,估计冲进去的看源码的小伙伴个个都破口大骂了,哈哈哈。而且里面的逻辑,在我看来还是比较混乱的,有很大的优化空间。主要的问题就是子组件拆分的细粒度不够以及 props 组织的不够好。不过呢,已经在重构了,后续计划分享一下重构的过程以及感悟。
同系列往期文章
总结
到此,日历组件的核心逻辑与设计思路的讲解就完成了,下篇咱们一起来看看如何高效实现的主题换肤。
对此系列感兴趣的,不妨一键三连(点赞 + 关注 + 收藏),方便跟进后续文章。
欢迎在评论区留下大家宝贵的建议!
作者:HashTang
个人空间:hxkj.vip
GitHub 主页:github.com/TangSY
转载自:https://juejin.cn/post/7189796735274156069