时间选择器实现
背景
- 项目架构:React16.13 + webpack5 + ant mobile2 + scss, MPA 架构(嵌在 APP 里面的 H5 页面)
- 业务背景:部门主要负责股票账户资产相关的业务,模块的日活在 80w 以上,相关的页面存在多个需要选择时间范围的功能,原本用的是 ant mobile2 的 DatePicker 组件。
最近产品准备推出一个新的需求,就是在 k 线图上实现框选一定的时间范围,让后能够显示对应账户在该时间范围内的资产情况,所以需要实现一个时间高度自定义的时间选择器。但是产品对于原先的时间范围选择组件意见比较大, 希望时间选择器的样式可以和携程选机票的时间选择器类似(没错,产品就是直接打开携程让我和 UI 照着这个样式去实现),就是那种上下滑动,选择时间范围的那种。同时,由于股票时间的特殊性,时间选择器需要支持非交易日的过滤
方案设计
在这个需求开始,我们调研了市场上的一些第三方组件
在和产品讨论后,发现 ant 的 datePicker 样式交互上已经基本满足需求,但是缺少自定义的功能,如非交易日禁止选中,后期其他项目可能需要在时间的上下位置添加相关文字的描述,如休市,当天盈亏数值,停牌等
刚好我们部门在去年也开始组建了部门内部的组件库(好吧,是我们前端组的老大说部门老大说希望我们能对外输出点什么可以给兄弟部门使用,都是为了绩效啊)
所以在和我的组长沟通后,决定还是自己实现这么一个时间选择器组件,并且将这个组件放到我们内部的组件库中,方便其他项目使用
具体实现
组件数据结构设计
由于我们这个时间选择器设计的目的就是为了支持股票的时间区间选择,所以时间选择器需要有范围限制,即【上市时间】-【退市时间/当前】
type Source = IntervalYearItem[]
type YearItem = {
year: number
months: IntervalMonthItem[]
}
type MonthItem = {
month: number
days: IntervalDayItem[][]
}
type IntervalDayItem = {
day: number
date: string
text: string
}
const source: Source = [
{
year: 2023,
months: [
{
month: 1,
days: [
[
{ day: 0, date: '2023-01-01', text: '1' },
{ day: 1, date: '2023-01-01', text: '1' },
// ...
],
],
},
}
]
import dayjs from 'dayjs'
import { chunk } from 'lodash-es'
import { IntervalDayItem, IntervalMonthItem, IntervalYearItem, Nullable } from './types'
import { DAY_TEXT_ARR, ONE, SEVEN, SIX, TWELVE, ZERO } from './constants'
const emptyDay: IntervalDayItem = {
day: '',
text: '',
date: '',
}
export const getIntervalDateGroup = (from: Nullable<Date>, to: Nullable<Date>): IntervalYearItem[] => {
if (!from || !to || from > to) {
return []
}
const fromYear = from.getFullYear()
const toYear = to.getFullYear()
const toMonth = to.getMonth() + ONE
const years: IntervalYearItem[] = []
for (let year = fromYear; year <= toYear; year++) {
const months: IntervalMonthItem[] = []
const maxMonth = year === toYear ? toMonth : TWELVE
for (let month = ONE; month <= maxMonth; month++) {
const days: IntervalDayItem[] = []
const monthEndDate = dayjs(`${year}-${month}-1`).endOf('month')
for (let day = ONE; day <= +monthEndDate.format('D'); day++) {
const dayInstance = dayjs(`${year}-${month}-${day}`)
days.push({
day,
text: `星期${DAY_TEXT_ARR[dayInstance.day()]}`,
date: dayjs(`${year}-${month}-${day}`).format('YYYYMMDD'),
})
}
const firstDay = dayjs(`${year}-${month}-1`).day()
const lastDay = monthEndDate.day()
// 补齐前面的空格
if (firstDay !== ZERO) {
days.unshift(...Array(firstDay).fill(emptyDay))
}
// 补齐后面的空格
if (lastDay !== SIX) {
days.push(...Array(SIX - lastDay).fill(emptyDay))
}
months.push({
month,
days: chunk(days, SEVEN),
})
}
years.unshift({
year,
months,
})
}
return years
}
实现结果如图:
组件架构设计
数据实现了,接下来就是组件的实现
如下图,我们把组件拆分成以下结构:

import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ONE_HUNDRED } from './common/constants'
import { getIntervalDateGroup } from './common/getIntervalDateGroup'
import { IntervalStore } from './common/store'
import { CalanderListProps, IntervalYearItem, Nullable } from './common/types'
import './index.scss'
import CalanderFooter from './modules/CalanderFooter'
import CalanderHeader from './modules/CalanderHeader'
import DefaultView from './modules/DefaultView'
import YearItem from './modules/YearItem'
export default function CalanderList(props: CalanderListProps) {
const { from, to, children, values = [null, null], onClose, onConfirm, disableDate } = props
const [list, setList] = useState<IntervalYearItem[]>([])
const [showPicker, setShowPicker] = useState(false)
const [selectedDates, setSelectedDates] = useState<[Nullable<Date>, Nullable<Date>]>(values)
useEffect(() => {
const arr = getIntervalDateGroup(from, to)
setList(arr)
}, [from, to])
const onChangePicker = (arr: [Nullable<Date>, Nullable<Date>]) => {
setSelectedDates(arr)
}
const onClosePicker = () => {
setShowPicker(false)
onClose?.()
}
const onClearPicker = () => {
setSelectedDates([null, null])
}
const onConfirmPicker = () => {
setShowPicker(false)
onConfirm?.(values)
props.onChange?.(selectedDates)
}
return (
<>
<div onClick={() => setShowPicker(true)}>{children ? children : <DefaultView />}</div>
{showPicker && (
<div className="whf-calander-list-wapper">
createPortal(
<IntervalStore.Provider
value={{
values: selectedDates,
onChange: onChangePicker,
disableDate,
}}>
<div className="whf-calander-list-wapper">
<CalanderHeader onClose={onClosePicker} />
<div className="whf-calander-list">
{list.map((yearItem) => {
return <YearItem key={yearItem.year} yearItem={yearItem} />
})}
</div>
<CalanderFooter onClear={onClearPicker} onConfirm={onConfirmPicker} />
</div>
</IntervalStore.Provider>, document.body )
</div>
)}
</>
)
}
import { useMemo, useRef } from 'react'
import MonthItem from './MonthItem'
import { IntervalYearItem } from '../common/types'
interface YearItemProps {
yearItem: IntervalYearItem
}
export default function YearItem(props: YearItemProps) {
const { yearItem } = props
const yearItemRef = useRef<HTMLDivElement>(null)
return (
<div ref={yearItemRef}>
<div className="whf-calander-list__year">
{yearItem.months.map((monthItem) => {
return <MonthItem year={yearItem.year} month={monthItem} key={`${yearItem.year}-${monthItem.month}`} />
})}
</div>
</div>
)
}
import { useContext, useMemo } from 'react'
import { IntervalDayItem, IntervalMonthItem } from '../common/types'
import dayjs from 'dayjs'
import { IntervalStore } from '../common/store'
import { ZERO } from '../common/constants'
interface MonthItemProps {
year: number
month: IntervalMonthItem
}
export default function MonthItem(props: MonthItemProps) {
const { month, year } = props
const { values, onChange, disableDate, customRenderDayItem } = useContext(IntervalStore)
const text = useMemo(() => `${year}-${month.month}`, [month.month, year])
const handleClickItem = (text: string, day: number | string) => {
if (!day) {
return
}
const targetDate = dayjs(`${text}-${day}`)
const timestamp = targetDate.valueOf()
const date = targetDate.toDate()
const [start, end] = values
if (!start && !end) {
onChange([date, null])
} else if (start?.getTime() === timestamp) {
onChange([null, end])
} else if (end?.getTime() === timestamp) {
onChange([start, null])
} else if (start) {
onChange(timestamp > start.getTime() ? [start, date] : [date, end || start])
} else if (!start && end) {
onChange(timestamp < end.getTime() ? [date, end] : [end, date])
} else {
onChange([null, null])
}
}
const getDayItemsClass = (dayItem: IntervalDayItem) => {
let classes = 'whf-day-item flexItem'
if (!dayItem.day) {
return classes
}
const [start, end] = values
const date = dayjs(`${text}-${dayItem.day}`)
const timestamp = date.valueOf()
const startTimestamp = start?.getTime() || ZERO
const endTimestamp = end?.getTime() || ZERO
if (disableDate?.(date.toDate())) {
classes += ' whf-day-item-disabled'
}
if (timestamp === startTimestamp) {
classes += ' whf-day-item-start'
} else if (timestamp === endTimestamp) {
classes += ' whf-day-item-end'
} else if (startTimestamp && endTimestamp && startTimestamp < timestamp && timestamp < endTimestamp) {
classes += ' whf-day-item-between'
}
return classes
}
return (
<div className="whf-month-item">
<div className="whf-month-name">{`${year}年${month.month}月`}</div>
<div className="whf-month-days">
{month.days.map((dayItemArr, dayItemArrIndex) => {
return (
<div className="whf-week-item" key={`${text}_${dayItemArrIndex}`}>
{dayItemArr.map((dayItem, dayItemIndex) => {
return (
<div
className={getDayItemsClass(dayItem)}
key={`${text}_${dayItemArrIndex}_${dayItemIndex}`}
onClick={() => handleClickItem(text, dayItem.day)}>
{customRenderDayItem?.(dayItem) || dayItem.day}
</div>
)
})}
</div>
)
})}
</div>
</div>
)
}
import { createContext } from 'react'
import { IIntervalStore } from './types'
export const IntervalStore = createContext<IIntervalStore>({
values: [null, null],
onChange: () => void 0,
})
:root {
--whf-calander-black: #333;
--whf-calander-white: #fff;
--whf-calander-gray: #cfc7c7;
--whf-calander-gray-light: #ccc;
--whf-calander-primary: #f16060;
--whf-calander-primary-active: #ff4134;
--whf-calander-padding-top: 100px;
--whf-calander-header-controller-height: 64px;
--whf-calander-header-tHeader-height: 48px;
--whf-calander-header-height: calc(
var(--whf-calander-header-controller-height) + var(--whf-calander-header-tHeader-height)
);
--whf-calander-footer-height: 64px;
}
html[theme='dark'] {
--whf-calander-black: #fff;
--whf-calander-white: #1e1c1c;
--whf-calander-gray: #524f4f;
--whf-calander-gray-light: #ccc;
--whf-calander-primary: #f16060;
--whf-calander-primary-active: #ff4134;
--whf-calander-padding-top: 100px;
--whf-calander-header-controller-height: 64px;
--whf-calander-header-tHeader-height: 48px;
--whf-calander-header-height: calc(
var(--whf-calander-header-controller-height) + var(--whf-calander-header-tHeader-height)
);
--whf-calander-footer-height: 64px;
}
.flexItem {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
.flexEnd {
justify-content: flex-end !important;
}
@keyframes sliceUp {
0% {
transform: translateY(100%);
}
100% {
transform: translateY(0);
}
}
.whf-calander-list-wapper {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
box-sizing: border-box;
padding-top: var(--whf-calander-padding-top);
background-color: rgba($color: #333, $alpha: 0.6);
color: var(--whf-calander-black);
animation: sliceUp 0.2s ease-in-out;
.whf-calander-header {
height: var(--whf-calander-header-height);
border-radius: 16px 16px 0 0;
overflow: hidden;
.whf-calander-header-controller {
display: flex;
box-sizing: border-box;
align-items: center;
justify-content: space-between;
padding: 16px 8px;
height: var(--whf-calander-header-controller-height);
background-color: var(--whf-calander-white);
.header-controller-item {
@extend .flexItem;
padding: 0 16px;
}
}
.whf-calander-header-tHeader {
display: flex;
align-items: center;
padding: 16px 8px;
box-sizing: border-box;
background-color: var(--whf-calander-white);
height: var(--whf-calander-header-tHeader-height);
}
}
.whf-calander-list {
height: calc(100% - var(--whf-calander-header-height) - var(--whf-calander-footer-height));
width: 100vw;
overflow-y: scroll;
background-color: var(--whf-calander-white);
display: flex;
flex-direction: column-reverse;
.whf-month-item {
.whf-month-name {
display: flex;
align-items: center;
justify-content: center;
padding: 6px 0;
background-color: var(--whf-calander-gray);
}
.whf-week-item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 0 8px;
box-sizing: border-box;
.whf-day-item {
padding: 16px 8px;
position: relative;
}
.whf-day-item-disabled {
color: var(--whf-calander-gray-light);
}
.whf-day-item-start {
border-radius: 8px 0 0 8px;
background-color: var(--whf-calander-primary-active);
color: var(--whf-calander-white);
&::after {
content: '开始';
position: absolute;
top: 0;
left: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transform: scale(0.8);
color: var(--whf-calander-white);
}
}
.whf-day-item-end {
border-radius: 0 8px 8px 0;
background-color: var(--whf-calander-primary-active);
color: var(--whf-calander-white);
&::after {
content: '结束';
position: absolute;
top: 0;
left: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transform: scale(0.8);
color: var(--whf-calander-white);
}
}
.whf-day-item-between {
background-color: var(--whf-calander-primary);
}
}
}
}
.whf-calander-footer {
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
color: var(--whf-calander-black);
background-color: var(--whf-calander-white);
height: var(--whf-calander-footer-height);
padding: 8px 16px;
.whf-calander-footer-btn {
color: var(--whf-calander-primary);
}
}
}
非交易日禁止点击实现
非交易日的限制,我们部门时维护了两个交易日文件,一个 A 股的交易日文件,一个港股的交易日文件,这两个文件都是往 window 上挂载一个变量,以 A 股为例:
// A股
var tradingDay = {
2024: ['0101','0102','0103', .....],
2023: ['0101','0102','0103', .....],
2022: ['0101','0102','0103', .....],
2021: ['0101','0102','0103', .....],
// ...
}
// 港股
var tradingDay_hk = {
2024: ['0101','0102','0103', .....],
2023: ['0101','0102','0103', .....],
2022: ['0101','0102','0103', .....],
2021: ['0101','0102','0103', .....],
// ...
}
所以设置非交易日不可点击的功能可以这么实
const App = ({
stock_code
}) => {
// 公司内部封装的用于区分港股的方法
const isHkStock = useMemo(() => checkoutIsHkStock(stock_code), [stock_code])
const disabledDate = (date: Date) => {
const year = date.getFullYear()
const str = dayjs(date).format('MMDD')
const trade_arr = isHkStock ? tradingDay_hk : tradingDay
return (!trade_arr?.[year]).includes(dayStr)
}
return (
<CalanderList
from={new Date('1980-01-01')}
to={new Date()}
disabledDate={disabledDate}
/>
)
}
黑暗模式实现
修改 css 变量即可,目前效果图
日历组件性能优化
上面代码基本把基本功能都实现了,但是很明显存在性能问题,如下图所示:
开始时间四 1980年,结束时间是 2024年,时间跨度是44年,即 12 * 44 = 528个月,即 528 * 7 * 6 = 23680 个dom节点,节点渲染时间在 1.6s 左右
(redmibook R7-5700u 16g)但实际使用时大部分用户可能只会在近两到三年内进行选择
所以针对上述情况,有进行了优化,当时优化的方案有以下几个:
- 虚拟列表
- 懒加载
一开始想的是,这个时间选择器组件是自己实现的,本来就是想着不依靠第三方控件,所以我们最初是使用了懒加载去实现
即初始化时间选择器时,只加载当前年份,并在列表最后面放置一个高度 5px 的白条,通过 intersectionObserver 监听白条进入可视区间就去加载下一年
懒加载实现
import { useInViewport } from 'ahooks'
function lazyLoad() {
const flagRef = useRef(null)
const [list, setList] = useState<any[]>([])
const [renderList, setRenderList] = useState<any[]>([])
const [inViewport, setInViewport] = useInViewport(flagRef, { rootMargin: '0px 0px 0px 0px' })
useMount(() => {
const arr = getIntervalDateGroup( new Date('1980-01-01'), new Date())
setList(arr)
setRenderList(arr[arr.length - 1])
})
useEffect(() => {
if (inViewport && renderList.length < list.length) {
setRenderList([...renderList, ...list[renderList.length]])
}
}, [inViewport])
return <div>
return <div>
<div ref={flagRef} style={{ height: '5px', backgroundColor: 'white' }}></div>
{/* 日历列表 */}
</div>
}
实际测试结果,初始 dom 加载速度提升飞快,100ms 不到就能渲染出最近一年的日历,滑动加载更多也很湿滑。但是这玩意有个问题就是,当用户一直往前滑的时候,dom 的节点数会一直增加,虽然使用了懒加载,但是渲染的节点数还是不可避免的增加,甚至随着节点数的增加,点击选择区域的时候,由于每个节点的样式都是动态渲染的,导致点击的时候会有明显的卡顿,所以这个方案最终被我们否决了
虚拟列表实现
虚拟列表的实现,前面说了,我们不想使用第三方控件,所以我们实现了一个类似的逻辑,即监听每个 yearItem 组件是否出现在可视区域内,如果在可视区域,正常渲染,否则渲染一个空的元素节点,高度和实际渲染时一直,这样的话时间选择器即每次最多渲染两年的日历,即实际渲染的节点数从 23680 减少到 12 _ 2 _ 7 * 6 = 504 个,初始化时的实际渲染速度和懒加载基本一致
修改后的 yearItem 组件代码如下:
import { useInViewport } from 'ahooks'
import { useMemo, useRef } from 'react'
import MonthItem from './MonthItem'
import { IntervalYearItem } from '../common/types'
interface YearItemProps {
yearItem: IntervalYearItem
}
export default function YearItem(props: YearItemProps) {
const { yearItem } = props
const yearItemRef = useRef<HTMLDivElement>(null)
const [isInViewPort] = useInViewport(yearItemRef)
const totalHeight = useMemo(() => {
return yearItem.months.reduce((acc, monthItem) => {
return acc + monthItem.days.length * 40 + 40
}, 0)
}, [yearItem])
return (
<div ref={yearItemRef} style={{ height: `${totalHeight}px` }}>
{isInViewPort ? (
<div className="whf-calander-list__year">
{yearItem.months.map((monthItem) => {
return <MonthItem year={yearItem.year} month={monthItem} key={`${yearItem.year}-${monthItem.month}`} />
})}
</div>
) : (
<div style={{ height: `${totalHeight}px` }}></div>
)}
</div>
)
}
效果如下:
到这基本组件的实现和优化都结束了,剩下的就是 UI 效果图的细节优化和一些比较小的功能点实现了