블로그 사이트를 개편하면서 ToC(Table of Contents)를 직접 만들어보고 싶었습니다.
글을 쓰기엔 짧은 경험이지만 누군가에게 도움이 될 수도 있으니 구현했던 과정을 공유하고자 합니다.
ToC 디자인은 MDN 사이트의 이쁜 디자인을 참고했습니다.
지금부터 Vanilla JS를 사용해서 ToC 예제를 구현해보겠습니다.
실습하듯이 따라 하셔도 좋고, 눈으로 슥슥 보셔도 됩니다.
제목에는 15분 만에 구현이라 썼지만 개념 설명이 추가되어 글이 다소 깁니다.
상세한 개념 설명은 링크 달아놓겠습니다.
자! 그럼 시작해볼게요.
작업을 크게 4가지로 분류하여 진행하고자 합니다.
마크다운을 사용하면 #이 h1 태그로, ##이 h2 태그로 변환됩니다. (###은 h3로, ####은 h4로)
저의 경우 h1 태그를 제목으로 h2 태그를 목차 제목으로 사용하고 있습니다.
그래서 h1, h2 태그 내용을 ToC에 넣을 예정입니다.
h1, h2 태그를 가져오기 위해 document.querySelectorAll
함수를 사용할 겁니다.
ToC는 ul 태그와 li 태그로 구성하고자 합니다.
h1, h2 태그 내용은 textContent
속성으로 가져올 수 있습니다.
그리고 ToC는 스크롤에 의해 페이지가 내려가도 사이드 쪽에 위치하여 계속 존재하잖아요.
이 부분은 position: sticky
란 CSS 속성을 통해 구현할 겁니다.
ToC 내 목차를 클릭하면 페이지가 이동되도록 click
이벤트 핸들러를 추가하고자 합니다.
페이지 이동은 h1, h2 태그가 가지고 있는 scrollIntoView
함수를 사용할 겁니다.
스크롤 시, 현재 스크린에서 보여지는 컨텐츠에 해당하는 ToC 목차를 하이라이트 되도록 합니다.
이 부분은 IntersectionObserver
API를 통해 구현하고자 합니다.
만약 처음 들어본 API라면 이번 기회에 꼭 익혀보세요.
스크롤 이벤트와 그 짝궁인 스로틀, 바운싱에서 벗어날 수 있습니다!
실습을 마치면 하기 결과물이 만들어집니다.
index.html 파일을 하나 만드셔서 하기 html 내용을 붙여넣으세요.
그리고 웹브라우저에서 index.html 파일을 열어보세요.
VS Code를 쓰시는 경우 Live Server 확장 프로그램을 활용하시면 브라우저에서 html 파일 여는 것이 편합니다.
확장 프로그램 설치 후 VS Code에서 index.html 파일을 열어 놓고, Ctrl+Shift+p를 눌러 Live Server를 입력하여 Live Server: Open With LiveServer 클릭하면 index.html이 반영된 브라우저가 열립니다.
https://marketplace.visualstudio.com/items?itemName=ritwickdey.LiveServer
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ToC Example</title>
<style>
main {
display: flex;
}
article {
width: 70%
}
h1 {
background-color: cadetblue;
}
h2 {
background-color: aquamarine;
}
p {
background-color: beige;
height: 2000px;
}
</style>
</head>
<body>
<main>
<article>
<h1>코끼리 위키백과</h1>
<p>코끼리(elephant, 象)는 장비목에서 유일하게 현존하는 코끼리과를 구성하는 동물들의 총칭이다.</p>
<h2>1. 어원</h2>
<p>‘코끼리’는 ‘코가 긴 것, 코가 긴 짐승’이라는 뜻으로 ‘고ㅎ + 길- + -이’의 구성으로 만들어진 것이다.</p>
<h2>2. 코끼리의 몸</h2>
<h3>다리</h3>
<p>몸 표면에는 굵은 털이 전체에 조잡하게 나 있으며, 꼬리 끝에는 줄모양의 긴 털이 나 있다.</p>
<h3>코</h3>
<p>코끼리의 코는 가장 활용을 많이하는 수단이다.</p>
<h2>3. 생태</h2>
<p>코끼리는 일반적으로 30~40마리씩 무리를 짓고 산다.</p>
<h2>4. 인간과의 관계</h2>
<p>사람들은 수천 년 전부터 코끼리를 길들여 이용했다.</p>
</article>
</main>
</body>
</html>
html에는 코끼리에 대한 내용이 들어있습니다.
h1 태그는 코끼리 위키백과란 제목을 나타내고, h2 태그는 컨텐츠별 목차를 의미합니다.
css쪽을 보시면 main 태그에 display: flex
속성이 부여되어 있습니다.
다음 섹션에서 toc를 만든 다음, main 자손의 article 형제로서 toc를 추가할 예정입니다.
display: flex
는 article과 toc를 정렬시켜줍니다.
flex는 정말 유용하게 잘 쓰이니 꼭 한번 익히시길 추천해 드립니다.
이제 실습 준비는 끝났습니다.
지금부터 ToC 구현해보겠습니다.
브라우저에서 F12를 눌러 개발자 도구를 엽니다.
콘솔창에 하기 JS 코드를 입력합니다.
// h1, h2 태그를 가져와 NodeList에 담아 반환한다.
const tags = document.querySelectorAll('h1, h2')
tags // 출력값 -> NodeList(5) [h1, h2, h2, h2, h2]
tags 변수에는 h1, h2 태그가 고스란히 담긴 NodeList가 할당되었습니다.
이제 이를 활용해 ToC에 넣으면 됩니다.
하기 html을 </aticle>
태그 아래에 넣고, css 소스는 style 태그 안에 넣어주세요.
<!-- ...</article> -->
<aside>
<div class="toc">
<ul>
h1 들어갈 자리
<li>h2 들어갈 자리</li>
<li>h2 들어갈 자리</li>
<li>h2 들어갈 자리</li>
<li>h2 들어갈 자리</li>
</ul>
</div>
</aside>
.toc {
position: sticky;
top: 100px;
}
aside 태그 아래에 toc 클래스를 가진 div 태그를 생성하고, 그 자손으로 ul, li 태그들을 생성했습니다.
toc 클래스에 position: sticky
속성을 주었습니다.
한번 마우스 스크롤을 내려보세요.
페이지가 내려가도 ToC 위치가 잘 유지되나요?
sticky는 자신이 속해있는 부모 안에서 위치가 결정됩니다.
position: sticky
속성을 지정하면 top: 100px
속성은 다음과 같이 동작합니다.
우선 자신의 부모 태그를 찾는데 부모 태그는 block 요소(p, div 등)여야 합니다.
aside 태그는 block 요소이므로 toc의 부모로 여겨지고, aside 태그의 맨위(top)에서 100px 아래로 떨어진 곳에 자신의 위치(toc 위치)를 잡습니다.
그리고 스크롤을 내려도 그 위치에 고정되어 존재하는데 block 요소(p, div 등)를 만나면 그 자리에서 멈추어 스크롤을 내려도 더이상 위치가 내려가지 않습니다.
즉, sticky는 normal flow(absolute, fixed는 normal flow로 동작하지 않음)로 동작하면서 자신의 부모를 지정하여 그 부모 위치에 상대적으로 자신을 위치시킵니다.
normal flow, position에 대한 자세한 내용은 하기 MDN 문서를 참고해주세요.
이제 h1, h2 태그를 담아 놓은 tags
변수에 있는 내용을 ToC에 넣으면 됩니다.
ul, li 태그는 h1, h2 태그에 의해 동적으로 생성할 것이기 때문에 HTML에서 삭제해주세요.
그리고 TOC를 이쁘게 하기 위한 CSS를 추가하겠습니다.
HTML과 CSS를 수정하셨으면, 페이지를 새로고침하고 콘솔 창에 js 코드를 입력해봅니다.
HTML, CSS가 반영된 예시를 보시려면 여기를 클릭해주세요.
<!-- ...</article> -->
<aside>
<div class="toc">
</div>
</aside>
.toc {
position: sticky;
top: 100px;
cursor: pointer;
}
.toc-title {
padding-left: 10px;
font-size: 18px;
font-weight: bold;
color: cadetblue;
}
.toc-item {
padding: 5px 10px;
list-style: none;
font-size: 15px;
color: black;
border-left: 3px solid rgb(171, 171, 171);
}
const tags = document.querySelectorAll('h1,h2')
const toc = document.querySelector('.toc')
tags.forEach(tag => {
if (tag.matches('h1')) {
const ul = document.createElement('ul')
ul.classList.add('toc-title')
ul.textContent = tag.textContent
toc.appendChild(ul)
}
else {
const li = document.createElement('li')
li.classList.add('toc-item')
li.textContent = tag.textContent
toc.firstElementChild.appendChild(li)
}
})
콘솔 창에서 js 코드를 실행하면 ToC에 h1,h2 태그 내용이 반영됩니다.
h1,h2 태그를 리스트로 담고 있는 tags
에 forEach
함수를 사용하여 ToC에 h1,h2 태그 내용 할당 작업을 진행합니다.
forEach
함수 내부의 tag
변수가 h1 태그일 경우 ul 태그를 생성해서 클래스명과 text 내용을 반영하고, appendChild
함수를 통해 toc에 ul 태그를 자손으로 삽입합니다.
tag 변수가 h1 태그가 아닐 경우(여기에선 h2태그) li 태그를 생성해서 클래스명과 text 내용을 반영하고, toc의 첫 자손(위에서 생성한 ul 태그)의 자손으로 li 태그를 삽입합니다.
위 작업을 통해 ToC에 h1,h2 태그 내용이 반영됩니다.
이제 이벤트 처리를 해보겠습니다.
ToC 내 li 태그를 클릭했을 때 (ex. 1. 어원, 2. 코끼리의 몸) 클릭된 li 태그와 매칭되는 h1,h2 태그로 페이지를 이동시켜야 합니다.
click 했을 때 click한 대상이 몇번째 li 태그인지 파악하기 위해 index 값을 li 태그 속성에 저장합니다.
그리고 li 태그에 매칭되는 h1,h2 태그는 tags 변수에 리스트로 잘 담아두었으니, h1,h2 태그 속성에도 index 값을 저장합니다.
위에서 저장한 index 값을 활용하여 페이지를 이동시키면 됩니다.
ToC를 생성한 페이지에서 하기 js 코드를 콘솔 창에 입력해보세요.
// toc-title, toc-item 클래스명을 가진 태그(엘리먼트)의 dataset에 index 값을 저장한다. ex. data-index="0" 형태로 저장됨
const tocItems = document.querySelectorAll('.toc-title, .toc-item')
tocItems.forEach((item, index) => item.dataset.index = index),
// h1, h2 태그들에도 dataset에 indes 값을 저장한다. 다음 섹션인 IntersectionObserver에서 사용할 예정.
tags.forEach((tag, index) => tag.dataset.index = index)
// toc 클래스를 가진 div 태그(ul, li 태그의 조상)에 click 이벤트 핸들러를 추가한다. (이벤트 위임)
// scrollIntoView 이벤트를 통해 해당 태그로 이동한다.
toc.addEventListener('click', e => {
if (e.target.matches('.toc-title') || e.target.matches('.toc-item')) {
tags[e.target.dataset.index].scrollIntoView()
}
})
이제 ToC 안에 있는 목차들을 클릭해보세요.
클릭된 목차에 해당하는 컨텐츠로 페이지가 잘 이동되나요?
document.querySelectorAll
함수를 통해 toc-title, toc-item 클래스 명을 가진 태그들을 가져온 다음 forEach
를 통해 각 태그의 dataset에 index 값을 추가합니다.
item.dataset.index=1
할당문을 수행하면 item의 dataset에 암묵적으로 index가 생성되면서 값 1이 할당됩니다.
할당문을 통해 <li class="toc-item" data-index="1"></li>
이같은 형태가 만들어집니다.
그리고 ul, li 태그의 조상인 div 태그(toc 클래스)에 click 이벤트 핸들러를 추가합니다.
ul, li 태그마다 이벤트 핸들러를 추가하는 것은 메모리 공간을 많이 차지하기에 부모인 div 태그에만 이벤트 핸들러를 추가해줍니다.
자식이 가져야 할 이벤트 핸들러를 부모에게 위임한다고 하여 이 방식을 이벤트 위임이라고 부릅니다.
click 이벤트 핸들러 내부에서 click 이벤트 대상이 toc-title 클래스 혹은 toc-item 클래스 태그인지 확인합니다.
비록 현재 코드 기준에서 부모 태그 밑엔 toc-title, toc-item 클래스 태그만 있더라도 위 조건을 꼭 체크해주어야 합니다.
위 조건이 없다면 나중에 부모 태그 밑에 toc-title, toc-item 클래스가 아닌 다른 요소들이 추가되었을 때, click 이벤트 로직이 수행되어 의도치 않은 동작이 발생할 수 있습니다.
toc-title 클래스 혹은 toc-item 클래스 태그가 맞다면, 해당 태그의 dataset.index
값으로 tags
리스트를 참조하여 페이지가 이동할 태그를 가져옵니다.
그리고 scrollIntoView
를 통해 해당 태그로 페이지를 이동시킵니다.
모든 태그(Element)는 scrollIntoView
함수를 가지고 있습니다.
scrollIntoView
함수 파라미터에 옵션 객체를 넣으면 스크롤 시 효과도 줄 수 있습니다.
tags[e.target.dataset.index].scrollIntoView({ behavior: "smooth", block: "center"})
위 js 코드에 나오듯 scrollIntoView
함수의 파라미터에 { behavior: "smooth", block: "center"}
객체를 추가하여 실행시켜보세요.
화면이 스무스하게 움직이면서 페이지가 맨 위가 아닌 중간으로 이동됩니다.
scrollIntoView
에 대한 자세한 내용은 하기 URL을 참고해주세요.
https://developer.mozilla.org/ko/docs/Web/API/Element/scrollIntoView
이제 click 이벤트 핸들러 추가까지 완성했습니다.
지금까지 콘솔에 입력했던 js 내용은 html 파일 내에서 script 태그로 넣도록 하겠습니다.
<!-- ... -->
<!-- </main> -->
<script>
const tags = document.querySelectorAll('h1,h2')
const toc = document.querySelector('.toc')
tags.forEach(tag => {
if (tag.matches('h1')) {
const ul = document.createElement('ul')
ul.classList.add('toc-title')
ul.textContent = tag.textContent
toc.appendChild(ul)
}
else {
const li = document.createElement('li')
li.classList.add('toc-item')
li.textContent = tag.textContent
toc.firstElementChild.appendChild(li)
}
})
// toc-title, toc-item 클래스명을 가진 엘리먼트의 dataset에 index 값을 추가한다. ex. data-index="0" 형태
const tocItems = document.querySelectorAll('.toc-title, .toc-item')
tocItems.forEach((item, index) => item.dataset.index = index)
// h1, h2 태그들에도 data-index를 부여해준다. IntersectionObserver에서 사용할 예정.
tags.forEach((tag, index) => tag.dataset.index = index)
// toc 클래스를 가진 div 태그(toc 목차들의 조상)에 click 이벤트를 건다. (이벤트 위임)
// scrollIntoView 이벤트를 통해 해당 태그로 이동한다.
toc.addEventListener('click', e => {
if (e.target.matches('.toc-title') || e.target.matches('.toc-item')) {
tags[e.target.dataset.index].scrollIntoView({ behavior: "smooth", block: "center"})
}
})
</script>
이제 스크롤을 내릴 때 ToC 목차에 해당하는 컨텐츠 나오면 해당 목차가 하이라이트 되도록 구현해보겠습니다.
스크롤 이벤트 핸들러를 추가하여 구현하는 방법이 있지만 이 경우 스크롤 이벤트가 엄청나게 자주 발생되기 때문에 스로틀, 디바운싱을 통해 일정시간마다 스크롤 이벤트가 처리되도록 구현해줘야 합니다.
그리고 컨텐츠가 화면(스크린)에 들어왔는지 파악하는 로직도 구현해야하구요.
놀랍게도 IntersectionObserver
API가 이 모든 것을 해결해줍니다.
사용법은 다음과 같습니다.
IntersetionObserver
의 observer 객체를 생성합니다.
const observer = new IntersectionObserver()
이런 식으로요.
그리고 observer
객체가 가지고 있는 observe
메소드를 통해 관찰할 대상을 등록합니다. (여기에선 tags
변수에 있는 h1, h2 태그들이 되겠죠?)
observer.observer(h1태그)
이런 식으로요.
그럼 등록에 대한 할 일은 끝났습니다.
이제 스크롤을 내리거나 올릴 때 관찰하는 대상(tags
변수에 있는 h1, h2 태그들)이 화면에 들어오거나 나갈 때, IntersectionObserver
가 이를 포착하여 우리에게 그 정보를 알려줍니다.
그 정보를 다루려면 IntersectionObserver
를 생성할 때 첫 번째 파라미터에 넣는 callback 함수를 통해 가능합니다.
하기 코드를 통해 설명하겠습니다.
하기 css 코드를 추가하시고 페이지를 열어 콘솔 창에 js 코드는 입력해보세요.
그리고 스크롤을 쭉쭉 내려보세요.
h2 태그가 나올 때 마다 ToC 목차가 하이라이트 되는 게 보이나요?
하기 예시는 css만 추가하였으니, js는 콘솔 창에서 실행시켜주세요.
예시를 보시려면 여기를 클릭해주세요.
.toc-title.selected, .toc-item.selected {
background-color: rgba(95, 158, 160, 0.3);
color: black;
border-left: 3px solid cadetblue;
}
let selectedIndex = 0
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
tocItems[selectedIndex].classList.remove('selected')
selectedIndex = entry.target.dataset.index
tocItems[selectedIndex].classList.add('selected')
}
})
})
tags.forEach(tag => observer.observe(tag))
IntersectionObserver
를 생성할 때 첫 번째 인자로 callback 함수를 넘깁니다.
callback의 첫 번째 파라미터로 리스트 형태인 entries
가 넘어오는데 IntersercionObserver
가 관찰하고 있는 대상 중에 화면에 들어왔거나 나간 대상들이 들어있습니다.
예를 들어 h1, h2 태그를 IntersectionObserver
가 관찰하도록 observe
함수를 통해 등록해놓았다고 합시다.
그리고 스크롤을 내리는 도중 h1, h2 태그가 화면에 나타나지 않았다면 entries
는 비어있는 리스트나 다름없습니다. (비어있다고 표현했지만 사실 콜백 함수가 실행되지 않겠죠)
h1 태그가 화면에 나타나면 entries
리스트에 h1 태그 정보가 담겨서 callback 함수가 실행됩니다.
화면에 나타난 h1 태그 정보에 대해 callback 함수는 딱 한 번 실행됩니다. (기본 옵션인 경우에 그렇습니다. threshold 옵션값에 따라 여러 번 호출될 수 있습니다.)
그리고 h2 태그가 나타나면 entries
에 h2 태그가 담겨 callback 함수가 실행됩니다.
만약 스크롤을 내렸는데 우연히 h1 태그가 화면에서 사라지고, h2 태그가 화면에 나타나면 어떻게 될까요?
그럼 entries
에 h1 태그와 h2 태그가 담기게 됩니다.
entries
리스트에 담긴 entry가 화면에 들어온 건지 나간 건지는 어떻게 알 수 있을까요?
그 내용은 entry
의 isIntersecting
속성에 담겨 있습니다
isIntersecting
값이 true
면 화면에 들어 온거고, false
면 화면에서 나간 겁니다.
js 코드로 다시 돌아가 보면, if(entry.isIntersecting)
을 통해 화면에 관찰하고 있는 태그가 들어왔는지 확인합니다.
만약 들어왔다면 selectedIndex
변수를 통해 기존에 선택된 태그의 selected
클래스를 없애주고, 화면에 들어온 태그의 index
를 가져와, 해당 태그에 매칭되는 ToC 목차에 selected
클래스를 주어 하이라이트를 표시하게끔 합니다.
여기서 한가지 개선해야 할 부분이 있습니다.
페이지 맨 밑에서 스크롤을 위로 올려보면 4.인간과의 관계
에서 3.생태
로 올라갈 때 3.생태
컨텐츠를 읽고 있지만 ToC의 4.인간과의 관계
목차가 하이라이트를 유지하고 있는 현상을 볼 수 있습니다.
이는 Intersection Observer에서 3.생태
태그를 만나야 하이라이트 업데이트가 되어서 나타나는 현상인데요.
태그의 제목과 태그의 컨텐츠를 컨테이너로 감싸서 컨테이너를 Intersection Observer에 등록하면 이를 해결할 수 있습니다.
여기에선 다른 방식으로 구현해보겠습니다.
let selectedIndex = 0
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
tocItems[selectedIndex].classList.remove('selected')
selectedIndex = entry.target.dataset.index
tocItems[selectedIndex].classList.add('selected')
}
else if(entry.boundingClientRect.y > 100) {
tocItems[selectedIndex].classList.remove('selected')
selectedIndex = Number(entry.target.dataset.index) - 1
tocItems[selectedIndex].classList.add('selected')
}
})
})
tags.forEach(tag => observer.observe(tag))
if(entry.boundingClientRect.y > 100)
조건문을 추가했습니다.
boundingClientRect.y
는 현재 entry
의 y
위칫값을 알려줍니다.
화면에서 최상단은 y
가 0
이고, 아래로 갈수록 y
값이 커집니다.
스크롤을 내려서 entry
가 화면 밖으로 나간 거라면 entry
의 위치 y
값은 0
이하 값이 됩니다.
반대로 스크롤을 올려서 entry
가 화면 밖으로 나간 거라면 entry
의 y
값은 화면 세로 사이즈보다 클 것입니다.
화면의 맨 하단보다 아래에 있을 거니까요.
이 조건문에선 러프하게 100px
로 잡았습니다.
스크롤을 내린 건지 올린 건지만 파악하면 되어서요.
스크롤을 위로 올렸기에 하이라이트가 될 목차는 현재 하이라이트된 (화면에서 나간) 목차 index
의 -1
인 index
를 가질겁니다.
그래서 selectedIndex = Number(entry.target.dataset.index) - 1
값으로 selectedIndex
를 할당했습니다.
IntersectionObserver
에는 다양한 기능이 있습니다.
컨텐츠의 25% 부분만 나와도(나갈 땐 75%부분) IntersectionObserver
가 인식하게끔 할 수 있고, 들어왔는지 나갔는지 체크할 범위를 root
로 지정할 수도 있습니다.
자세한 내용은 하기 MDN 사이트를 확인해주세요.
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
이로써 Vanilla JS로 진행한 ToC 구현이 마쳤습니다.
이 포스트가 도움이 되었기를!