오랜만에 ToC 컴포넌트 npm 패키지를 하나 만들었다. 전에 한번 만들고 자신감이 생겨서 그런지 수월했던 것 같다. 3일정도 걸렸고, 그간 발생한 오류들과 해결법, 알게된 것들, rollup 이 아니라 ESBuild 로 빌드한 이야기를 기록하고자 글을 쓴다.
Table of Content 의 약어로, 목차를 만들 때 주로 사용하는 컴포넌트이다. h1,h2,h3 같은 레벨이 있는 태그를 기반으로 목차를 자동 생성하는 기능, 스크롤이 발생할 때마다 해당하는 목차 태그가 진하게 변경되는 기능, 목차 태그를 클릭하면 이동하는 기능 또한 존재한다.
scroll 동작과 관련있기 때문에 Intersection Observer API 나 scroll event 사용이 필요했다. 둘 중 어떤걸 쓸까 고민하다가 Intersection Observer API는 문제가 있어서 사용하지 못했고, scroll event 를 사용했다. (왜 못했는지는 뒤에 나올 예정..)
| 기술 | 장점 | 단점 |
|---|---|---|
| Intersection Observer API | 비동기적으로 동작하여 CPU 사용률이 낮음 | 구형 브라우저에서 폴리필이 필요할 수 있음 |
| 특정 요소의 가시성을 쉽게 추적 가능 | ||
| 지연 로드에 유용 | ||
| Scroll Event | 실시간으로 스크롤 위치를 기반으로 애니메이션이나 동작을 제어 가능 | 빈번한 이벤트 호출로 CPU 사용률이 높아질 수 있으며, 성능 최적화가 필요 |
| 거의 모든 브라우저에서 지원 | ||
| 스크롤 위치를 픽셀 단위로 정밀하게 제어 가능 |
1. getBoundingClientRect()
HTML 요소의 크기와 위치 정보를 제공하는 JavaScript 함수이다. 이 함수는 DOM 요소의 경계 박스를 계산하여, 현재 뷰포트 내에서 요소의 위치와 크기에 대한 정보를 포함하는 객체를 반환한다.
const element = document.getElementById('div');
const rect = element.getBoundingClientRect();
// 이외 width, height, x(left 와 동일), y(top 과 동일) 를 구할 수 있다.
console.log(rect.top); // 요소의 상단이 뷰포트의 상단에서 얼마나 떨어져 있는지 출력
console.log(rect.left); // 요소의 왼쪽이 뷰포트의 왼쪽에서 얼마나 떨어져 있는지 출력
console.log(rect.width); // 요소의 가로 길이 출력
console.log(rect.height); // 요소의 세로 길이 출력
2. scroll 위치 확인 함수
window.pageYOffset
브라우저 창의 스크롤 위치를 픽셀 단위로 반환하는 함수로, 페이지 상단에서부터 얼마나 스크롤되었는지를 알려준다.
document.documentElement.scrollTop
표준 모드에서는 이 속성을 통해 현재 문서의 세로 스크롤 위치를 얻을 수 있다.
document.body.scrollTop
쿼크 모드에서 사용하는 방법으로, 대부분의 최신 브라우저에서는 document.documentElement.scrollTop 이 더 표준이다.
3. getComputedStyle(element)
요소의 스타일 속성을 계산된 값으로 가져올 수 있는 함수이다. (margin..)
const element = document.querySelector('h1'); // h1 태그를 선택
const computedStyle = window.getComputedStyle(element);
const marginTop = parseFloat(computedStyle.marginTop);
const marginBottom = parseFloat(computedStyle.marginBottom);
const marginLeft = parseFloat(computedStyle.marginLeft);
const marginRight = parseFloat(computedStyle.marginRight);
React Functional Component의 줄임말로, TypeScript에서 함수형 컴포넌트를 정의할 때 사용하는 타입이다.
Props 타입 자동 설정: React.FC를 사용하면 컴포넌트의 props 타입을 자동으로 설정할 수 있다.
children 타입 포함: React.FC를 사용하면 자동으로 children 타입이 포함된다. 언제든지 children의 타입 지정 없이 전달이 가능하기 때문에 타입이 명확하지 않다는 단점이 있다.
기본 반환 타입 설정: React.FC를 사용하면 반환 타입이 자동으로 ReactElement로 설정되므로 반환 타입을 명시적으로 설정하지 않아도 된다.
1. Intersection Observer API 쓰지 못한 이유
처음에는 Intersection Observer API를 사용해서 구현했는데, 스크롤을 빠르게 내리면 TOC 목차의 속성이 변하지 않는 이슈가 발생했다!!! 왜그럴까 하면서 찾아보니까, Intersection Observer API의 동작원리(?) 때문이었다.
Intersection Observer API는 관찰된 DOM 요소의 위치를 확인하는 루프에서 비동기 함수를 실행한다. 브라우저의 렌더링 주기와 결합되어 있으며 매우 빠르게 처리하지만 스크롤바를 더 빨리 움직이면 IO API가 일부 가시성 변경을 감지하지 못할 수 있다는 이유였다.
그래서 scroll event 를 활용해서 개발을 했고, 위와 같은 문제는 해결되었다!!
2. TOC 에서 클릭한 태그가 bold 되지 않는 이슈
TOC 에서 클릭한 태그가 bold되지 않는 이슈가 발생한 이유를 처음에는 click event 와 useEffect 가 겹쳐서 발생한 문제로 생각했다. 그래서 setTimeout 을 걸어서 delay 를 추가도 해보고, click event 와 useEffect 의 정의한 함수에 파라미터로 넘겨서 처리도 해보고... 여러 방법을 썼지만 문제가 해결되지 않았다.
그 때, click event 에서 사용하는 함수와 useEffect 에서 사용하는 함수, 즉 handleScroll 함수에서 가장 가까운 태그를 찾는 조건이 좀 잘못된 것 같아서 수정했더니 허무하게 문제가 해결되었다!!!!!!
(역시 근본적인 원인을 수정하는게 좋다는 걸 또 깨닫는다...)
전에는 rollup으로 빌드를 했고, 이번에는 설정이 굉장히 간단하다는 ESBuild 로 빌드를 해보았다. 소문대로 간단하고 빠르게 빌드할 수 있었고, 그 방법을 소개하려한다!
npm install esbuild typescript --save-dev
npx tsc --init
{
"compilerOptions": {
"target": "es6",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"declaration": true, // 타입 정의 파일 생성
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"jsx": "react-jsx"
},
"include": [
"src/**/*"
],
"exclude": ["node_modules", "dist"]
}
const esbuild = require('esbuild');
// Build script
esbuild.build({
entryPoints: ['./src/index.tsx'], // 엔트리 파일
bundle: true,
minify: true, // 압축
sourcemap: true, // 소스 맵 생성
platform: 'browser', // 브라우저용 번들
target: ['es6'], // 타겟 환경
outdir: 'dist', // 출력 폴더
external: ['react', 'react-dom'], // 외부 모듈로 처리할 라이브러리
format: 'esm', // ES 모듈 포맷
jsxFactory: 'React.createElement', // JSX 변환을 위한 팩토리
jsxFragment: 'React.Fragment', // JSX Fragment 변환
}).catch(() => process.exit(1));
...
"scripts": {
"build": "node build.js",
"watch": "node build.js --watch"
},
"devDependencies": {
"esbuild": "^0.14.0",
"typescript": "^4.4.3"
},
...