💫 이전글에서 내부 구현을 간단히 다루었습니다.
https://xionwcfm.tistory.com/422
따라서 이번에는 위 구현방식을 참고해서 제가 필요한 기능만 뽑은
커스텀 useFunnel을 제작해보겠습니다.
사실 그냥 토스의 useFunnel 훅을 갖다쓰고 싶은 마음이 더 컸지만
사용에 꽤 많은 애로사항이 있어서 그냥 필요한 기능만 들어간 커스텀훅을 만들기로 했습니다.
토스의 구현 방식에서 얻을 수 있었던 인사이트를 나열해보면 다음과 같습니다.
1. nextjs의 shallow routing을 통해 뒤로가기를 구현하면서도 상태를 유지할 수 있다.
(pages router에서는 쉘로우라우팅이 잘 지원되는데
앱라우터는 이슈가 많더라구요 앱라우터 쓰시는 분들은 참고하셔야겠습니다.)
2. 뒤로가기의 히스토리 관리는 1을 통해 브라우저 히스토리 스택으로 관리하면 편하다.
3. 합성컴포넌트 패턴을 통해 퍼널의 세부 구현을 아주 잘 감춰줄 수 있다.
4. 제네릭을 통해 합성컴포넌트들의 타입을 적절히 추론하게 하면 개발자 경험이 좋다
더 많은 깨달음을 얻은 분들도 계시겠지만.. 제게 인상깊었던 것은 저 네개였습니다.
이제 저 네개를 참고로하여 필요한 부분을 커스터마이징한 useFunnel을 만들어봅시다.
토스의 원본 구현과 비교하여 제게 필요 없었던 기능은 다음과 같습니다.
1. 퍼널에 대한 정보를 특정 스토리지에 구애받지않고 스토리지에 저장할 수 있는 기능
2. 중첩 퍼널의 고려
3. 히스토리스택 비우기 기능
반대로 필요한 기능은 다음과 같았는데요
1. 언제든 재사용할 수 있는 퍼널 기능과 컴포넌트
2. 다음 스텝으로 넘어갈 수 있는 함수
3. 초기 시작값 설정 기능
4. 쿼리스트링을 기반으로한 뒤로가기 기능
5. 쉘로우라우팅을 통한 이전 상태 유지 기능
제게 필요한 간단한 기능만 구현하고자해보니 생각보다 간단히 구현할 수 있었습니다.
🐯 나만의 useFunnel 만들기
export type NonEmptyArray<T> = readonly [T, ...T[]];
export interface FunnelProps<Steps extends NonEmptyArray<string>> {
steps: Steps;
step: Steps[number];
children:
| Array<React.ReactElement<StepProps<Steps>>>
| React.ReactElement<StepProps<Steps>>;
}
export interface StepProps<Steps extends NonEmptyArray<string>> {
name: Steps[number];
onEnter?: () => void;
children: React.ReactNode;
}
type RouteFunnelProps<Steps extends NonEmptyArray<string>> = Omit<
FunnelProps<Steps>,
'steps' | 'step'
>;
먼저 필요한 타입들을 선언해주겠습니다.
위 타입들에 대한 설명이 궁금하신 분들은 이전 글을 참고해주세요
https://xionwcfm.tistory.com/422
이제 Funnel 컴포넌트를 구현해봅시다.
export const Funnel = <Steps extends NonEmptyArray<string>>({
step,
steps,
children,
}: FunnelProps<Steps>) => {
const validChildren = Children.toArray(children)
.filter(isValidElement)
.filter((item) =>
steps.includes((item.props as Partial<StepProps<Steps>>).name ?? ''),
) as Array<React.ReactElement<StepProps<Steps>>>;
const targetStep = validChildren.find((child) => child.props.name === step);
return <>{targetStep}</>;
};
Funnel 컴포넌트 역시 토스의 원본 구현과 크게 다르지 않습니다.
Funnel 컴포넌트가 하는 일은 그저
타겟이 될 step 프랍과 문자열 배열 steps를 받고
children중에 props.name이 step인 children을 렌더링할뿐인 순수한 컴포넌트입니다.
다음은 Step 컴포넌트를 보겠습니다.
const Step = <Steps extends NonEmptyArray<string>>({
children,
}: StepProps<Steps>) => {
return <>{children}</>;
};
Step 컴포넌트는 더욱 심플한데요
name 과 children을 받으면서 children을 리턴하는 일만 수행합니다.
현재 스텝에 따라 스텝 컴포넌트를 렌더링할지 말지는
Funnel 컴포넌트가 담당하기때문에 Step 컴포넌트는
자기자신을 식별할 수 있는 name과 렌더링할 칠드런만 알고있으면 됩니다.
const useFunnel = <Steps extends NonEmptyArray<string>>(
array: Steps,
option?: {
initialStep: Steps[number];
},
) => {
const [steps, setSteps] = React.useState<Steps>(array);
const router = useRouter();
const step = router.query.step as unknown as string;
const nextStep = (nextQuery: Steps[number]) => {
return () =>
router.push(`${router.pathname}?step=${nextQuery}`, undefined, {
shallow: true,
});
};
useEffect(() => {
if (option?.hasOwnProperty('initialStep')) {
router.replace(`${router.pathname}?step=${option.initialStep}`);
} else {
router.replace(`${router.pathname}?step=${steps[0]}`);
}
}, []);
const FunnelComponent = useMemo(() => {
// eslint-disable-next-line react/display-name
return Object.assign(
(props: RouteFunnelProps<Steps>) => {
return <Funnel<Steps> step={step} steps={steps} {...props} />;
},
{
Step: (props: StepProps<Steps>) => {
return <Step {...props} />;
},
},
);
}, [step, steps]);
return [FunnelComponent, nextStep] as const;
};
다음은 Funnel훅의 구현입니다.
사실 이 훅을 구현하면서 가장 아쉬웠던 부분은
next.js의 next/router 경로의 useRouter 훅에 로직의 중요한 부분들이 강하게 의존하고있기때문에
next.js의 page router를 사용할 때에만 성립이 되는 훅이라는 점입니다.
이 훅의 구현을 위해 필요한것은 쿼리스트링에 포함된 ?step= 의 내부밸류인데요
쿼리스트링의 밸류와 일치하는 컴포넌트를 렌더링하는 것을 통해 퍼널을 구현하고 있기 때문입니다.
step = legend인 경우 name이 legend인 컴포넌트를 렌더링한다.
아무튼 내부 구현은 토스의 useFunnel 훅에 비하면 매우 간소화된 형태로 구현했습니다.
const [steps, setSteps] = React.useState<Steps>(array);
const router = useRouter();
const step = router.query.step as unknown as string;
const nextStep = (nextQuery: Steps[number]) => {
return () =>
router.push(`${router.pathname}?step=${nextQuery}`, undefined, {
shallow: true,
});
};
steps 상태를 만들어주고
useRouter를 통해 쿼리의 step을 뽑아옵니다.
그리고 매번 router.push를 직접작성하는것은 실수의 위험이 있기때문에
쉘로우라우팅과 현재 경로에서 step 로직만 추가하는 함수를 만들어 반환해주었습니다.
const useFunnel = <Steps extends NonEmptyArray<string>>(
array: Steps,
option?: {
initialStep: Steps[number];
},
useEffect(() => {
if (option?.hasOwnProperty('initialStep')) {
router.replace(`${router.pathname}?step=${option.initialStep}`);
} else {
router.replace(`${router.pathname}?step=${steps[0]}`);
}
}, []);
다음은 초기값을 정해주는 일을 수행했는데요
만약 option 객체의 initialStep을 지정해주었다면 지정된 초기값으로 시작하고
따로 지정해주지 않았다면 배열의 첫번째 요소로 시작하도록 지정했습니다.
const FunnelComponent = useMemo(() => {
// eslint-disable-next-line react/display-name
return Object.assign(
(props: RouteFunnelProps<Steps>) => {
return <Funnel<Steps> step={step} steps={steps} {...props} />;
},
{
Step: (props: StepProps<Steps>) => {
return <Step {...props} />;
},
},
);
}, [step, steps]);
return [FunnelComponent, nextStep] as const;
다음으로는 Funnel과 Step을 담고있으면서
제네릭으로 추론이 쉽게 될 수 있는 코드를 작성해야했습니다.
토스의 FunnelComponent 구현에서 인사이트를 얻어
위와 같이 제네릭을 받아 넘겨주는 형태로 Funnel과 Step을 한번 래핑해주었고
step이 바뀔때마다 다시 동작하도록 useMemo의 의존성배열을 추가해줬습니다.
이제 커스텀으로 만든 useFunnel을 사용해보죠!
✨ 이개외데냐
const FunnelExample = ({}: funnelProps) => {
const route = ['iam', 'zzang', 'legend'] as const;
const [Funnel, nextStep] = useFunnel(route);
return (
<div>
<Funnel>
<Funnel.Step name="iam">
<div className="" onClick={nextStep('legend')}>
wow
</div>
</Funnel.Step>
<Funnel.Step name="zzang">
<div className="" onClick={nextStep('iam')}>
hi
</div>
</Funnel.Step>
<Funnel.Step name="legend">
<div className="" onClick={nextStep('zzang')}>
zzang im nida
</div>
</Funnel.Step>
</Funnel>
</div>
);
};
제법 그럴듯하게 사용할 수 있어졌습니다.
prop에 다음 스텝에 대한 정보를 넘겨줄 때에 항상 메인컨테이너에서 확인할 수 있는 구조가 되었고
내부 컴포넌트는 어디로 이동하는지에 대해 알 필요없이 주입받은 경로로 이동만 하는
느슨한 구조를 만들 수 있었습니다.
이정도만 되어도 제가 필요한만큼의 요구사항은 충족하기 때문에
우선 이렇게 쓰면서 필요하다면 기능을 추가해나갈 것 같습니다.
nextRouter에 대한 의존성을 좀 더 느슨하게 만들고 싶지만..
일단은 만족스럽네요!
🐝 마치며
토스에서 만들어둔 useFunnel 훅의 모든 기능을 구현하지는 못했지만
어느정도 열화판으로나마 만들어보면서 타입스크립트를 많이 배운 것 같습니다.
사실 이펙티브 타입스크립트도 다 읽어보고 타입스크립트에 대한 아티클들도 꽤 찾아봐서
어느정도 타입스크립트를 할 줄 안다라고 생각했는데
정말 처음보는 기법들을 많이 배웠네요..
시간날때마다 다른 훅들도 구현을 좀 뜯어봐야겠습니다.
이런 코드를 쓰는 사람들과 같이 일하면 정말 행복할 것 같아요...
'best' 카테고리의 다른 글
useFunnel을 제공하는 라이브러리 만들기 (2) | 2023.12.13 |
---|---|
husky와 commitlint로 jira 이슈번호 자동화 시키기 (2) | 2023.11.27 |
toss/slash의 use-funnel 훅 내부 구현 탐구하고 직접 구현하기(1) (1) | 2023.11.03 |
TypeScript , JavaScript의 접근 제한자 '#' Deep Dive (1) | 2023.08.29 |
자바스크립트의 호이스팅에 Deep Dive 해보자 (3) | 2023.04.11 |