이번에는 DatePicker를 만든다. 그동안 DatePicker를 쓸 일이 있으면 딱봐도 만들기 귀찮아서 라이브러리를 가져와서 구현을 했는데, 이번에 직접 구현을 해보니 아주 생각대로 귀찮고 나름 구현하는 재미가 있더라요.
날짜 관련한 로직은 나름대로 알고 있다 생각했는데, 이번에 만들면서 꽤나 부족하다는 걸 느꼈고 다 만들고 나니 실력이 확실히 는 것 같은 느낌이 든다! 이 맛에 직접 구현하는 거죠~
import React, { useState, useRef, useEffect, useCallback } from "react";
import { ReactComponent as ArrowIcon } from "@Static/Icons/chevron-left.svg";
import { ReactComponent as CalenderIcon } from "@Static/Icons/calendar_month.svg";
const MemoedArrowIcon = React.memo(ArrowIcon, (p, n) => p.width === n.width);
const MemoedCalenderIcon = React.memo(
CalenderIcon,
(p, n) => p.width === n.width,
);
const DAY_OF_WEEK_NAMES = ["일", "월", "화", "수", "목", "금", "토"];
/* #region 날짜 계산 관련 메소드 */
const getPrevDays = (currentDate: Date) => {
const tmpDate = new Date(currentDate);
tmpDate.setDate(1);
const prevDays = Array(tmpDate.getDay()).fill(null);
for (let index = prevDays.length; index--; ) {
prevDays[index] = new Date(tmpDate.setDate(tmpDate.getDate() - 1));
}
return prevDays as Array<Date>;
};
const getThisDays = (currentDate: Date) => {
const tmpDate = new Date(currentDate);
tmpDate.setDate(1);
const currentMonth = tmpDate.getMonth();
const thisDays = [];
while (tmpDate.getMonth() === currentMonth) {
thisDays.push(new Date(tmpDate));
tmpDate.setDate(tmpDate.getDate() + 1);
}
return thisDays as Array<Date>;
};
const getNextDays = (currentDate: Date, dayCount: number) => {
const tmpDate = new Date(currentDate);
tmpDate.setMonth(tmpDate.getMonth() + 1);
tmpDate.setDate(1);
const nextDays = [];
while (nextDays.length < 42 - dayCount) {
nextDays.push(new Date(tmpDate));
tmpDate.setDate(tmpDate.getDate() + 1);
}
return nextDays as Array<Date>;
};
const calcuateCalenderDays = (date: Date) => {
const prevDays = getPrevDays(date);
const thisDays = getThisDays(date);
const nextDays = getNextDays(date, prevDays.length + thisDays.length);
return [...prevDays, ...thisDays, ...nextDays] as Array<Date>;
};
const getFormattedDateString = (date: Date) => {
return `${date.getFullYear().toString().substring(2)}.${(date.getMonth() + 1)
.toString()
.padStart(2, "0")}.${date.getDate().toString().padStart(2, "0")}`;
};
/* #endregion */
const DayOfWeekHeader = React.memo(() => {
return DAY_OF_WEEK_NAMES.map((month) => (
<span key={month} className="caption-bold text-gray">
{month}
</span>
));
});
type DayItemProp = {
calcuatedDay: Date;
calenderDate: Date;
selectedDate: Date;
onDaySelect: (date: Date) => void;
};
const DayItem = React.memo(
({ calcuatedDay, calenderDate, selectedDate, onDaySelect }: DayItemProp) => {
const textColor =
calcuatedDay.getMonth() !== calenderDate.getMonth()
? "text-gray "
: calcuatedDay.toISOString() === selectedDate.toISOString()
? "bg-black text-white "
: "bg-white text-black hover:bg-lightgray ";
return (
<span
className={`caption-bold flex h-full w-full items-center justify-center p-m14 tablet:p-t14 desktop:p-d14 ${textColor}`}
onClick={() => onDaySelect(calcuatedDay)}
>
{calcuatedDay.getDate()}
</span>
);
},
(p, n) =>
(p.calcuatedDay.toISOString() === p.selectedDate.toISOString()) ===
(n.calcuatedDay.toISOString() === n.selectedDate.toISOString()) &&
p.calcuatedDay.toISOString() === n.calcuatedDay.toISOString(),
);
type CalenderComponentProp = {
selectedDate: Date;
onDaySelected: (date: Date) => void;
};
const CalenderComponent = React.memo(
({ selectedDate, onDaySelected }: CalenderComponentProp) => {
const lastCalenderMonth = useRef("");
const [calenderDate, setCalenderDate] = useState(new Date(selectedDate));
const [calcuatedDays, setCalcuatedDays] = useState(
calcuateCalenderDays(calenderDate),
);
useEffect(() => {
setCalenderDate(new Date(selectedDate));
}, [selectedDate]);
useEffect(() => {
if (lastCalenderMonth.current === calenderDate.getMonth().toString()) {
return;
}
lastCalenderMonth.current = calenderDate.getMonth().toString();
setCalcuatedDays(calcuateCalenderDays(calenderDate));
}, [calenderDate]);
const handlePrevMonthClick = useCallback(() => {
setCalenderDate(
new Date(calenderDate.setMonth(calenderDate.getMonth() - 1)),
);
}, [calenderDate]);
const handleNextMonthClick = useCallback(() => {
setCalenderDate(
new Date(calenderDate.setMonth(calenderDate.getMonth() + 1)),
);
}, [calenderDate]);
return (
<div className="h-full w-full select-none space-y-m16 border p-m24 tablet:space-y-t16 tablet:p-t24 desktop:space-y-d16 desktop:p-d24">
<div className="mx-auto flex w-full flex-row items-center justify-between ">
<MemoedArrowIcon
className="h-m16 w-m16 tablet:h-t16 tablet:w-t16 desktop:h-d16 desktop:w-d16"
onClick={() => handlePrevMonthClick()}
/>
<h6 className="heading6">
{calenderDate.getFullYear()}년 {calenderDate.getMonth() + 1}월
</h6>
<MemoedArrowIcon
className="h-m16 w-m16 rotate-180 tablet:h-t16 tablet:w-t16 desktop:h-d16 desktop:w-d16"
onClick={() => handleNextMonthClick()}
/>
</div>
<div className="mx-auto flex w-[90%] items-center justify-between">
<DayOfWeekHeader />
</div>
<div className="grid h-full w-full grid-cols-7 grid-rows-6">
{calcuatedDays.map((calcuatedDay) => (
<DayItem
key={`${calenderDate.toISOString()}-${calcuatedDay.toISOString()}`}
calcuatedDay={calcuatedDay}
calenderDate={calenderDate}
selectedDate={selectedDate}
onDaySelect={(date) => onDaySelected(date)}
/>
))}
</div>
</div>
);
},
(prevProps, nextProps) =>
prevProps.selectedDate.toISOString() ===
nextProps.selectedDate.toISOString(),
);
type DatePickerProp = {
onSelect: (date: Date) => void;
};
const DatePicker = ({ onSelect }: DatePickerProp) => {
const wasSelected = useRef(false);
const [isOpened, setIsOpened] = useState(false);
const [selectedDate, setSelectedDate] = useState(new Date());
const handleDaySelected = (date: Date) => {
setSelectedDate(date);
setIsOpened(false);
onSelect(date);
if (!wasSelected.current) wasSelected.current = true;
};
return (
<div className="relative w-full">
<div
className={
"flex cursor-pointer select-none items-center justify-between border px-m16 py-m12 tablet:p-t16 desktop:p-d16 " +
`${
isOpened || wasSelected.current
? "border-black "
: "border-lightgray"
}`
}
onClick={() => setIsOpened(!isOpened)}
>
<span
className={
"caption-bold " +
`${wasSelected.current ? "text-black " : "text-gray "}`
}
>
{getFormattedDateString(selectedDate)}
</span>
<MemoedCalenderIcon className="h-m24 w-m24 fill-gray tablet:h-t24 tablet:w-t24 desktop:h-d24 desktop:w-d24" />
</div>
{isOpened && (
<div className="absolute right-0 top-full mt-m8 w-fit tablet:mt-t8 desktop:mt-d8">
<CalenderComponent
selectedDate={selectedDate}
onDaySelected={(date) => handleDaySelected(date)}
/>
</div>
)}
</div>
);
};
export default DatePicker;
이번 UI Component의 코드는 유난히 간데, 그 이유는 이번엔 나름 렌더 최적화랑 코드 분리를 하려고 노력했기 때문. 그런 것 치곤 아직 분리나 최적화할 게 많이 보이긴 하지만,, 일단 넘어가!
function App() {
const [value, setValue] = useState<Date>(new Date());
return (
<>
<div className="mx-auto mt-6 flex h-screen w-[90%] flex-col place-items-center">
<p>현재 선택된 value: {value.toLocaleDateString()}</p>
<DatePicker onSelect={(date) => setValue(date)} />
</div>
</>
);
}
처음에 시도한 설계와 나중에 변경한 코드가 존재한다.
그런데 구현하다보니.. 지난 달의 마지막 요일을 구한다거나, 이번 달의 마지막 요일을 구한다거나, 요일을 선택했을 때 update되는 게 많다던가 하는 식으로 날짜 계산을 위한 변수들이 많았다. 그래서 구현이 좀 빡구현이 된 느낌이 있었다. 곰곰이 생각해보다 각 요일이 Date 객체를 갖는 방식으로 변경하였다.
prevDays[index] = new Date(tmpDate.setDate(tmpDate.getDate() - 1));
while (tmpDate.getMonth() === currentMonth) { ~ }
while (nextDays.length < 42 - dayCount) { ~ }
각 요일이 Date 객체를 가지고 있는 것이 개발하기에 편한 것 같은데 객체 자체를 가지고 있다 보니 메모리가 늘고, 달이 바뀔 때마다 이 객체를 새로 만들어줘야 하는 비용이 꽤나 큰 것 같다.
각 state를 두고 계산할 땐 계산이 빠릿빠릿하긴 했지만 개발 과정이 아주 빡코딩이어서 효율적이진 않았던 것 같다.
그러면 .. 필요할 때만 Date 객체를 만들어주는 것이 최선일 것 같은데, 음! 모르겠다.
const getPrevDays = (currentDate: Date) => {
const tmpDate = new Date(currentDate);
tmpDate.setDate(1);
const prevDays = Array(tmpDate.getDay()).fill(null);
for (let index = prevDays.length; index--; ) {
prevDays[index] = new Date(tmpDate.setDate(tmpDate.getDate() - 1));
}
return prevDays as Array<Date>;
};
const getThisDays = (currentDate: Date) => {
const tmpDate = new Date(currentDate);
tmpDate.setDate(1);
const currentMonth = tmpDate.getMonth();
const thisDays = [];
while (tmpDate.getMonth() === currentMonth) {
thisDays.push(new Date(tmpDate));
tmpDate.setDate(tmpDate.getDate() + 1);
}
return thisDays as Array<Date>;
};
const getNextDays = (currentDate: Date, dayCount: number) => {
const tmpDate = new Date(currentDate);
tmpDate.setMonth(tmpDate.getMonth() + 1);
tmpDate.setDate(1);
const nextDays = [];
while (nextDays.length < 42 - dayCount) {
nextDays.push(new Date(tmpDate));
tmpDate.setDate(tmpDate.getDate() + 1);
}
return nextDays as Array<Date>;
};
const calcuateCalenderDays = (date: Date) => {
const prevDays = getPrevDays(date);
const thisDays = getThisDays(date);
const nextDays = getNextDays(date, prevDays.length + thisDays.length);
return [...prevDays, ...thisDays, ...nextDays] as Array<Date>;
};
달력은 7*6의 그리드로 표현하였다. 왜 6줄이냐면, 시작하는 요일과 마지막 날에 따라서 6줄까지 갈 때가 있기 때문이다. 예를 들어 23년 7월 같은 달.
먼저~ 7개씩 끊어서 표현하는 것보단 분명히 그리드로 표현하는 것이 개발하는 입장에서 편하다고 생각했다. 그냥 아이템들은 쭈욱 나열해놓고, 부모 컨테이너로 레이아웃을 제어해주는 느낌으로다가.
<div className="grid h-full w-full grid-cols-7 grid-rows-6">
{calcuatedDays.map((calcuatedDay) => (
<DayItem
key={`${calenderDate.toISOString()}-${calcuatedDay.toISOString()}`}
calcuatedDay={calcuatedDay}
calenderDate={calenderDate}
selectedDate={selectedDate}
onDaySelect={(date) => onDaySelected(date)}
/>
))}
</div>
gird를 주고, col과 row를 주어 표현해줌으로써 달력 레이아웃은 끝!
각 요일마다 Date 객체가 존재하기 때문에, 한 번 일들을 계산하는 게 꽤나 비싼 비용이다. 그러므로 필요할 때만 필요한 컴포넌트만 rendering 되는 것이 비용을 최대한 줄일 수 있는 방법이라고 생각했다.
원래 Calender 컴포넌트에서 렌더링해줬던 days를 따로 DayItem Component를 만들어주어 같은 Prop이면 렌더링되지 않게 만들어주었다.
<div className="grid h-full w-full grid-cols-7 grid-rows-6">
{prevDays.map((day) => (
<span
key={day}
className="caption-bold flex h-full w-full items-center justify-center p-m14 text-gray hover:bg-lightgray tablet:p-t14 desktop:p-d14"
>
{day}
</span>
))}
{thisDays.map((day) => (
<span
key={day}
className={`caption-bold flex h-full w-full items-center justify-center p-m14 tablet:p-t14 desktop:p-d14 ${
month === selectedDate.getMonth() &&
day === selectedDate.getDate()
? "bg-black text-white "
: "bg-white text-black hover:bg-lightgray "
}`}
onClick={() =>
onDaySelected(
new Date(
`${year}-${(month + 1).toString().padStart(2, "0")}-${day}`,
),
)
}
>
{day}
</span>
))}
{restDays.map((_, index) => (
<span
key={index}
className="caption-bold flex h-full w-full items-center justify-center p-m14 text-gray hover:bg-lightgray tablet:p-t14 desktop:p-d14"
>
{index + 1}
</span>
))}
</div>
ㅋㅋ 진짜 막짰네
DayItem Component
const DayItem = React.memo(
({ calcuatedDay, calenderDate, selectedDate, onDaySelect }: DayItemProp) => {
const textColor =
calcuatedDay.getMonth() !== calenderDate.getMonth()
? "text-gray "
: calcuatedDay.toISOString() === selectedDate.toISOString()
? "bg-black text-white "
: "bg-white text-black hover:bg-lightgray ";
return (
<span
className={`caption-bold flex h-full w-full items-center justify-center p-m14 tablet:p-t14 desktop:p-d14 ${textColor}`}
onClick={() => onDaySelect(calcuatedDay)}
>
{calcuatedDay.getDate()}
</span>
);
},
(p, n) =>
p.calcuatedDay.toISOString() === n.calcuatedDay.toISOString() &&
(p.calcuatedDay.toISOString() === p.selectedDate.toISOString()) ===
(n.calcuatedDay.toISOString() === n.selectedDate.toISOString()),
);
같은 prop이면 리-렌더링이 되지 않도록 React.memo로 HOC해줬다. 이때 비교하는 방법은 다음과 같다.
이 두 가지의 비교를 만족하면 같은 컴포넌트라고 판단한다. 2번째 비교 조건을 뒤로 뺀 이유는 비교 계산하는 비용이 조금 더 크기 때문이다.
Days Rendering
<div className="grid h-full w-full grid-cols-7 grid-rows-6">
{calcuatedDays.map((calcuatedDay) => (
<DayItem
key={`${calenderDate.toISOString()}-${calcuatedDay.toISOString()}`}
calcuatedDay={calcuatedDay}
calenderDate={calenderDate}
selectedDate={selectedDate}
onDaySelect={(date) => onDaySelected(date)}
/>
))}
</div>
그럼 깔끔하게 표현 가능! 물론 tailwind는 빼고^^**,,
DayItem 말고도 여러 컴포넌트라든가, callback도 memorized해주었지만 비슷한 맥락이므로 생략!
평소에도 생각하던 거였긴 했는데, 으음. 예를 들어 이번 상황과 같이 Days를 보여주고 있다고 해보자.
return (
<div>
{
days.map(day => (
<DayItem day={day} />
))
}
</div>
)
그리고 이 days는 month에 의존적이고, re-calcuate될 때 꽤 비용이 든다고 해보자.
const [month, setMonth] = useState(8);
const [days, setDays] = useState([]);
useEffect(() => {
// 비싼 계산
...
setDays(newDays)
}, [month])
그러면~ 사용자가 month를 조작한 행위를 하고 나서 조금의 딜레이가 생긴 뒤 days가 업데이트될 것이다. 이 조금의 딜레이에 대한 피드백이 있어야 조금 더 UX가 높아질 거라고 생각하는데~ 그러려면 내부적으로 계산하는 값과 실제 DOM에 보여지는 값이 달라져야 할 것 같다는 생각이 든다.
이에 대한 게 Fetching Data의 pending status와도 비슷한 비유...인 것 같기도? 데이터를 로드하고 있을 때의 피드백이 필요한 상황이니까.
--> 아~ 찾아보니까 이게 Optimistic update 개념과 얼추 맞아보인다. 내가 하고 싶은 게 실제 계산되는 동안 유저에게 피드백을 해줄 방법을 찾는 거였는데, 요게 좀 그래 보이네.
Optimistic Update
유저가 트리거한 액션을 클라이언트 쪽에서 예상한 기대값으로 일단 업데이트 시켜놓고, 서버에서 응답이 오면 기대값과 비교 후 롤백을 하든 반영을 하든 어쩌든 하는 방식(ㅋㅋ)
UI Component를 개발할 땐 반드시 emotion이나 styled-component 같은 CSS in JS를 사용하자...
감사합니다. 이런 정보를 나눠주셔서 좋아요.