'use client'
import moment from 'moment-timezone'
import { useEffect, useState } from 'react'
type Props = {
date: Date
format?: string | undefined
}
const UTCToLocal = ({ date, format = 'YYYY-MM-DD HH:mm:ss' }: Props) => {
const [dateString, setDateString] = useState(moment.utc(date).format(format))
useEffect(() => {
setDateString(moment.utc(date).tz(moment.tz.guess()).format(format))
}, [date, format])
return <>{dateString}</>
}
export default UTCToLocal
UTCToLocal - 범용적인 타임존 처리방법tsx
구글 검색봇이 크롤링하는 시간값의 조건과 클라이언트에서 사용자가 보는 타임존 시간표기를 모두 달성하기 위한 테크닉입니다.
nextjs
timezone
SEO
import { useInfiniteQuery } from '@tanstack/react-query'
import MobileHeader from '../../layouts/NhLayout/MobileHeader'
import { userPostListQueryKey } from '@/queries/user-queries'
import { NH_BIZ, NH_CATEGORY_NEWS } from '../../main'
import { css } from '@emotion/react'
import {
WindowScroller as _WindowScroller,
AutoSizer as _AutoSizer,
List as _List,
CellMeasurer as _CellMeasurer,
CellMeasurerCache,
WindowScrollerProps,
AutoSizerProps,
ListProps,
CellMeasurerProps,
} from 'react-virtualized'
import { FC, useCallback, useEffect } from 'react'
import { SessionStorage } from '@/utils/storage-utils'
import { color, reset } from '@/styles/mixins'
import Link from 'next/link'
import CommonPostItem from './CommonPostItem'
import Loading from '@/components/user/ui-components/Loading'
import { CircularProgress } from '@mui/material'
import { momentKR } from '@/utils/basic-utils'
import DateSeparator from './DateSeparator'
import NewsPostItem from './NewsPostItem'
import NewChip from '../../components/NewChip'
import Router from 'next/router'
import { postService } from '@/services'
const biz = NH_BIZ
const WindowScroller = _WindowScroller as unknown as FC<WindowScrollerProps>
const AutoSizer = _AutoSizer as unknown as FC<AutoSizerProps>
const List = _List as unknown as FC<ListProps>
const CellMeasurer = _CellMeasurer as unknown as FC<CellMeasurerProps>
const cache = new CellMeasurerCache({
fixedWidth: true,
// defaultHeight: 417,
})
const getEnglishLocaleDateStr = (dateStr: string) => {
return momentKR(dateStr).locale('En').format('YYYY.MM.DD ddd').toUpperCase()
}
const styles = {
wrapper: css({ paddingBottom: 30 }),
box: css({}),
}
type Props = {
categorySlug: string
}
const NhPostList = ({ categorySlug }: Props) => {
const pageSize = categorySlug === NH_CATEGORY_NEWS ? 10 : 5
const {
data: infiniteQueryResult,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: [...userPostListQueryKey(biz), 'category-infinite', biz],
queryFn: ({ pageParam }) =>
postService.getPostListByCategoryForUser(biz, {
slug: categorySlug,
pageSize,
pageNumber: pageParam,
}),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (lastPage.result.items.length < pageSize) {
return undefined
}
return allPages.length
},
})
const data = infiniteQueryResult?.pages.map((page) => page.result.items).flat() ?? []
const rowRenderer: ListProps['rowRenderer'] = ({ index, key, parent, style }) => {
const prevIndex = index - 1
const post = data[index]
const prevPost = data[prevIndex]
const postDateStr = getEnglishLocaleDateStr(post.publishedAt)
const prevPostDateStr = prevPost ? getEnglishLocaleDateStr(prevPost.publishedAt) : ''
return (
<CellMeasurer cache={cache} parent={parent} key={key} columnIndex={0} rowIndex={index}>
{({ registerChild }) => (
<div ref={registerChild as any} style={style}>
{categorySlug === NH_CATEGORY_NEWS ? (
<>
{postDateStr !== prevPostDateStr ? <DateSeparator dateStr={postDateStr} /> : null}
<Link href={`/nh/post/view/${post.slug}`} css={[reset.link]}>
<NewsPostItem
post={post}
isNew={index === 0 && momentKR(post.publishedAt).isSame(momentKR(), 'day')}
/>
</Link>
</>
) : (
<div css={{ marginTop: 30 }}>
<Link href={`/nh/post/view/${post.slug}`} css={[reset.link]} data-id={post.id}>
<CommonPostItem post={post} isNew={momentKR(post.publishedAt).isAfter(momentKR().subtract(1, 'd'))} />
</Link>
</div>
)}
</div>
)}
</CellMeasurer>
)
}
const handleList = useCallback((node: _List) => {
if (node) {
cache.clearAll() // 진입시 셀 재 측정을 위해 캐시 삭제
node.forceUpdate()
node.measureAllRows()
window.setTimeout(() => {
window.scrollTo(0, SessionStorage.postScrollY)
}, 0)
}
}, [])
useEffect(() => {
let lastWindowInnerWidth = window.innerWidth
const resizeHandler = () => {
const currentWindowInnerWidth = window.innerWidth
if (currentWindowInnerWidth !== lastWindowInnerWidth) {
cache.clearAll()
lastWindowInnerWidth = currentWindowInnerWidth
}
}
const scrollEventHandler = () => {
//! homee으로 Pop 시에 스크롤 맨 위로가므로 초기에 스크롤 저장하지 않게 하기위함
if (window.scrollY !== 0) {
SessionStorage.postScrollY = window.scrollY
}
}
window.addEventListener('resize', resizeHandler)
window.addEventListener('scroll', scrollEventHandler)
return () => {
window.removeEventListener('resize', resizeHandler)
window.removeEventListener('scroll', scrollEventHandler)
}
}, [])
if (isLoading) return <Loading pageLoading />
return (
<>
<MobileHeader categorySlug={categorySlug} customPrev={() => Router.push('/nh')} />
<WindowScroller>
{({ height, scrollTop, isScrolling, onChildScroll, registerChild }) => (
<div ref={registerChild as any} css={styles.wrapper}>
<AutoSizer disableHeight>
{({ width }) => (
<>
<List
ref={handleList}
autoHeight
height={height}
width={width}
isScrolling={isScrolling}
overscanRowCount={0}
onScroll={onChildScroll}
scrollTop={scrollTop}
rowCount={data.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
defferedMeasurementCache={cache}
//! Hydration 경고 해제용 See: https://github.com/bvaughn/react-virtualized/issues/1737#issuecomment-1219820104
style={{ overflowY: 'auto' }}
/>
</>
)}
</AutoSizer>
{hasNextPage ? (
<button
type="button"
disabled={isFetchingNextPage}
css={[
reset.button,
{
width: '100%',
border: `1px solid ${color.BB30}`,
borderRadius: 10,
padding: `10px 0`,
fontSize: 18,
lineHeight: 1.5,
color: color.BB700,
marginTop: 30,
},
]}
onClick={() => fetchNextPage()}
>
{isFetchingNextPage ? <CircularProgress size="1em" color="inherit" /> : '더 보기'}
</button>
) : null}
</div>
)}
</WindowScroller>
</>
)
}
export default NhPostList
렌더링 최적화 기술 - Virtualizationtsx
Virtualization 이라는 기술을 통해, 뷰포트의 컴포넌트만 렌더링하여 인터렉션 반응속도를 향상시킵니다.
최적화
react
react-virtualized
import { ReactNode } from 'react'
type HeadCell<ListItem> = {
id: Extract<keyof ListItem, string>
label: string
}
type Props<ListItem> = {
headCells: HeadCell<ListItem>[]
items: ListItem[]
renderItem: (item: ListItem) => ReactNode
page?: number
pageSize?: number
}
const Table = <ListItem extends { id: string | number }>({
headCells,
items,
renderItem,
page = 0,
pageSize = 0,
}: Props<ListItem>) => {
return (
<div className="overflow-x-auto">
<table className="table">
{/* head */}
<thead>
<tr>
<th></th>
{headCells.map((headCell) => (
<th key={headCell.id}>{headCell.label}</th>
))}
</tr>
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={item.id}>
<th>{(page - 1) * pageSize + (idx + 1)}</th>
{renderItem(item)}
</tr>
))}
</tbody>
</table>
</div>
)
}
export default Table
Generic 컴포넌트 사용 예시(테이블)tsx
상황에 따라 컴포넌트의 props 타입이 변경되도록 하기위한 테크닉입니다.
typescript
generic
table
function* findIndexAll(str: string, searchElement: string) {
let foundIndex = -1
do {
foundIndex = str.indexOf(searchElement, foundIndex + 1)
if (foundIndex !== -1) {
yield foundIndex
}
} while (foundIndex > -1)
}
[...findIndexAll('/admin/careers/codes', '/')]
Generator로 findIndexAll 구현ts
문자열에서 찾고자 하는 대상의 모든 인덱스를 반환하는 Generator입니다.
es6
generator
import { css } from '@emotion/react'
import { ButtonHTMLAttributes, forwardRef, useCallback, useEffect, useRef } from 'react'
const styles = {
wrapper: css({
'*': {
pointerEvents: 'none',
},
}),
}
export type GAButtonProps = ButtonHTMLAttributes<HTMLButtonElement>
const GAButton = forwardRef<HTMLButtonElement, GAButtonProps>(
({ id, children, 'aria-label': ariaLabel, ...rest }: GAButtonProps, forwardedRef) => {
const inRef = useRef<HTMLButtonElement | null>(null)
const assignRef = useCallback(
(node: HTMLButtonElement) => {
if (forwardedRef) {
if (typeof forwardedRef === 'function') {
forwardedRef(node)
} else {
forwardedRef.current = node
}
}
inRef.current = node
},
[forwardedRef]
)
useEffect(() => {
const button = inRef.current
if (button) {
const slugifiedButtonText = (ariaLabel ?? button?.textContent ?? '')?.trim().replaceAll(' ', '-')
if (!id) {
button.setAttribute('id', `button--${slugifiedButtonText}`)
}
const textColor = window.getComputedStyle(button).color
button.style.setProperty('--text-color', textColor)
}
}, [ariaLabel, children, id])
return (
<button ref={assignRef} id={id} aria-label={ariaLabel} css={styles.wrapper} {...rest}>
{children}
</button>
)
}
)
GAButton.displayName = 'GAButton'
export default GAButton
forward된 ref와 컴포넌트 자체 ref를 동시에 다루는 법tsx
자체 제작한 컴포넌트에 ref prop을 할당하기 위해서는 , forwardRef라는 기술을 사용해야 합니다.
react
forwardRef
import { ReactNode, useEffect, useState } from 'react'
import { Portal } from 'react-portal'
export const PORTAL_ID = 'portal'
const MyPortal = ({ children }: { children: ReactNode }) => {
const [initialized, setInitialized] = useState(false)
useEffect(() => {
setInitialized(true)
}, [])
return initialized ? <Portal node={document && document.getElementById(PORTAL_ID)}>{children}</Portal> : null
}
export default MyPortal
React Portal의 SSR 대응법tsx
react-portal 라이브러리가 Next.js의 서버사이드에서도 동작하도록 하기위한 코드입니다.
typescript
react
react-portal
next.js
// index.tsx
import { rem, typo, color as colorMixin } from '@/styles/mixins'
import { css } from '@emotion/react'
import Slot from './Slot'
const wrapperStyle = css([
typo.HS28,
{
height: '1em',
lineHeight: 1,
fontVariantNumeric: 'tabular-nums',
fontSize: `var(--font-size)`,
color: `var(--color)`,
overflow: 'hidden',
},
])
type Props = {
className?: string
num?: number
fontSize?: number
color?: string
}
const SlotMachine = ({ className, num, fontSize = 28, color = colorMixin.DB1000 }: Props) => {
return (
<div
className={className}
aria-label={`${num}`}
css={wrapperStyle}
style={{ ['--font-size' as any]: rem(fontSize), ['--color' as any]: color }}
>
{num
? num
.toLocaleString()
.split('')
.map((digitStr, index) => {
//! toLocaleString이 Number와 ,만 있다고 가정
if (digitStr === ',') {
return digitStr
}
const digit = Number(digitStr)
return <Slot key={index} digit={digit} index={index} />
})
: null}
명
</div>
)
}
export default SlotMachine
// Slot.tsx
import { keyframes } from '@emotion/react'
import { Fragment } from 'react'
type Props = {
digit: number
index: number
}
const transformNone = keyframes`
to {
transform: none
}
`
const easeOutQuart = 'cubic-bezier(0.25, 1, 0.5, 1)'
const easeInOutQuint = 'cubic-bezier(0.83, 0, 0.17, 1)'
const easeInOutExpo = 'cubic-bezier(0.87, 0, 0.13, 1)'
const Slot = ({ digit, index }: Props) => {
return (
<span
css={{
position: 'relative',
top: `var(--top, 0)`,
display: 'inline-flex',
flexDirection: 'column',
transform: `var(--transform)`,
animation: `${transformNone} 6s ${easeInOutQuint} forwards`,
}}
style={{
['--top' as any]: -1 * digit + -10 * index + 'em', // 도착점
['--transform' as any]: `translateY(calc((100% - ${10 - digit}em)))`, // 0으로 튀고자 함
}}
>
{[...Array(index + 1)].map((_, i) => (
<Fragment key={i}>
<span>0</span>
<span>1</span>
<span>2</span>
<span>3</span>
<span>4</span>
<span>5</span>
<span>6</span>
<span>7</span>
<span>8</span>
<span>9</span>
</Fragment>
))}
</span>
)
}
export default Slot
슬롯머신 컴포넌트tsx
슬롯머신 애니메이션을 구현한 컴포넌트입니다.
react
ui
'use client'
import { THEME_DARK, THEME_LIGHT } from '@/constants'
import { useThemeStore } from '@/stores'
import { ChangeEventHandler, useCallback, useEffect, useRef } from 'react'
const ToggleTheme = () => {
const toggleThemeRef = useRef<HTMLInputElement>(null)
const changeGlobalThemeState = useThemeStore((state) => state.change)
const setToggleStateByTheme = (theme: string) => {
switch (theme) {
case THEME_LIGHT:
toggleThemeRef.current!.checked = true
break
case THEME_DARK:
toggleThemeRef.current!.checked = false
break
default:
toggleThemeRef.current!.checked = true
}
}
const setTheme = useCallback(
(theme: string) => {
document.documentElement.dataset.theme = theme
setToggleStateByTheme(theme)
changeGlobalThemeState(theme)
},
[changeGlobalThemeState],
)
const toggleTheme: ChangeEventHandler<HTMLInputElement> = (e) => {
const checked = e.target.checked
const newTheme = checked ? THEME_LIGHT : THEME_DARK
setTheme(newTheme)
}
useEffect(() => {
setTheme(document.documentElement.dataset.theme ?? THEME_LIGHT)
const colorSchemePreferenceChangeEventHandler = (event: MediaQueryListEvent) => {
const newTheme = event.matches ? THEME_DARK : THEME_LIGHT
setToggleStateByTheme(newTheme)
setTheme(newTheme)
}
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', colorSchemePreferenceChangeEventHandler)
return () => {
window
.matchMedia('(prefers-color-scheme: dark)')
.removeEventListener('change', colorSchemePreferenceChangeEventHandler)
}
}, [setTheme])
return (
<label className="btn btn-square btn-ghost swap swap-rotate">
{/* this hidden checkbox controls the state */}
<input ref={toggleThemeRef} type="checkbox" onChange={toggleTheme} />
{/* sun icon */}
<svg className="swap-on h-6 w-6 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
</svg>
{/* moon icon */}
<svg className="swap-off h-6 w-6 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
</svg>
</label>
)
}
export default ToggleTheme
테마 토글 버튼(Daisy UI)tsx
렌더링 최적화를 포함해, 테마를 토글하기 위한 모든 기능이 래핑되어 있는 컴포넌트입니다.
최적화
theme
toggle
zustand
tailwindCSS
'use client'
import MyPortal from '@/components/MyPortal'
import { LINK_BLOG, LINK_GITHUB, LINK_NOTION } from '@/constants'
import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline'
import Link from 'next/link'
import { ChangeEventHandler, MouseEventHandler, useState } from 'react'
const ToggleMenu = () => {
const [opened, setOpened] = useState(false)
const handleInputChanged: ChangeEventHandler<HTMLInputElement> = (e) => {
setOpened(e.target.checked)
}
const handleMenuClicked: MouseEventHandler<HTMLAnchorElement> = (e) => {
setOpened(false)
}
return (
<>
<div className="flex-none lg:hidden">
<label htmlFor="drawer" aria-label="open sidebar" className="btn btn-square btn-ghost">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="inline-block h-6 w-6 stroke-current"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</label>
</div>
<MyPortal>
<input id="drawer" type="checkbox" className="drawer-toggle" checked={opened} onChange={handleInputChanged} />
<div className="drawer-side z-20">
<label htmlFor="drawer" aria-label="close sidebar" className="drawer-overlay"></label>
<ul className="menu min-h-full w-80 bg-base-200 p-4">
{/* Sidebar content here */}
<li>
<Link href="/careers/intro" onClick={handleMenuClicked}>
Intro
</Link>
</li>
<li>
<Link href="/careers/experiences" onClick={handleMenuClicked}>
Experiences
</Link>
<ul>
<li>
<Link href="/careers/experiences/portfolio" onClick={handleMenuClicked}>
Portfolio
</Link>
</li>
<li>
<Link href="/careers/experiences/codes" onClick={handleMenuClicked}>
Codes
</Link>
</li>
<li>
<a href={LINK_GITHUB} target="_blank" rel="noreferrer noopener nofollow">
Github <ArrowTopRightOnSquareIcon className="h-4 w-4" />
</a>
</li>
<li>
<a href={LINK_BLOG} target="_blank" rel="noreferrer noopener nofollow">
Blog <ArrowTopRightOnSquareIcon className="h-4 w-4" />
</a>
</li>
<li>
<a href={LINK_NOTION} target="_blank" rel="noreferrer noopener nofollow">
Notion <ArrowTopRightOnSquareIcon className="h-4 w-4" />
</a>
</li>
</ul>
</li>
<li>
<Link href="/careers/resume" onClick={handleMenuClicked}>
Resume
</Link>
</li>
<li>
<Link href="/careers/contact" onClick={handleMenuClicked}>
Contact
</Link>
</li>
</ul>
</div>
</MyPortal>
</>
)
}
export default ToggleMenu
Daisy UI Drawer 컴포넌트tsx
Daisy UI 의 Drawer 컴포넌트를 React 버전으로 구현한 컴포넌트입니다.
typescript
react
react-portal
daisy ui
import { useCallback, useEffect, useState } from 'react'
const useHash = () => {
const [hash, setHash] = useState<string>('')
const hashChangeHandler = useCallback(() => {
setHash(window.location.hash)
}, [])
useEffect(() => {
setHash(window.location.hash)
}, [])
useEffect(() => {
window.addEventListener('hashchange', hashChangeHandler)
return () => {
window.removeEventListener('hashchange', hashChangeHandler)
}
}, [hashChangeHandler])
const updateHash = useCallback(
(newHash: string) => {
if (!newHash) {
history.pushState(null, document.title, location.pathname + location.search)
setHash('') // hash 를 아예 없애는 것은 HAshchange 트리거가 안됨
} else if (newHash !== hash) {
window.location.hash = newHash
}
},
[hash]
)
return [hash, updateHash] satisfies [string, (newHash: string) => void]
}
export default useHash
브라우저의 hash를 React로 관리하는 custom hooktsx
브라우저의 hash를 관리하기 위해서는 effect를 사용해야 합니다.
react
DOM API
custom hook