본문 바로가기

송송 DEV

송송은 뚠뚠 오늘도 뚠뚠 열심히 ~ 코딩 하네 뚠뚠

게시물 Table Of Contents 자동 목차 만들기

포스팅 게시글 TOC 자동 목차 만들기 그런데 이제 Value와 Reference 개념을 곁들인...

개요

현재 블로그 게시글 페이지처럼 글 구조를 보여주는 목차를 페이지 한편에 띄우려 한다.
이름하야 Table of Contents, 목차다. 보통 한 쪽에 글의 소제목을 나열하고, 현재 읽고 있는 부분이 어디인지 알려주거나 클릭했을 때 해당 파트로 이동할 수 있는 기능을 말한다.
페이지 상의 글 구조를 분석 후 리스트를 만들어 원하는 위치에 꽂아주려 한다.

 

구현 사항

  • 글의 구조를 분석하고 리스트로 만든다
  • 해당 제목을 클릭했을 때 해당 위치로 이동한다

 

구현 방법

  1. 페이지에서 DOM을 분석하여 글의 리스트를 추출하고, 이것을 조합하여 중첩된 ol엘리먼트로 만든다
  2. 만든 엘리먼트를 원하는 위치에 꽂아준다
  3. 페이지에 알맞게 스타일링을 해준다

이번 글에서는 페이지에서 리스트 추출해서 화면에 붙이는 것까지만 작성할 예정이다.

 

사실 작년에 jQuery로 만들어 현재 블로그에 적용하여 사용중이었다. 그러나 이제 IE 지원을 할 필요가 없으니 이번에 소스 코드를 업데이트하려 한다. 기존 소스와 비교하여 설명할 예정!

 

글 제목 리스트 추출하기

TOC 구현한 것을 보면 H1~H6 등의 헤딩 깊이는 스타일을 통해 표현하고, 실제 구조는 그냥 쭉 나열한 것을 많이 보았다. 나는 중첩 구조로 표현하고 싶어서 좀 복잡하게 구현했었다.

 

구버전 : 레벨에 따라 텍스트 수정

소스가 엉망진창이라 일부만 가져왔다. 실제 사용하기 어려우니 전체 흐름만 참고하시라.

$(function(){
    // 티스토리 스킨이라 필요한 부분
  // entry-content : 게시글 내용 출력되는 div 박스
    // 해당 클래스가 있을 때(=게시글 출력 페이지일 때) toc 생성
    if (document.querySelector(".entry-content") !== null) {
    tocInit();
  }
});

function tocInit() {
    var contentWrap = document.querySelector(".entry-content");

    // entry-content 내부의 헤딩 요소를 가져온다
    var headings = contentWrap
      ? contentWrap.querySelectorAll("h1, h2, h3, h4")
      : [];

    // querySelectorAll은 노드리스트 형태로 반환된다.
    // 노드리스트는 배열과 비슷하지만 배열은 아니다.
    // Array.prototype.forEach.call을 이용해 배열로 만들어주고
    // 거기에 관련 데이터를 넣어 html 엘리먼트를 만들고자 한다.
    var newHeadings = [];
    Array.prototype.forEach.call(headings, function (item, index, headings) {
        newHeadings.push({
        name: item.localName,
        index: parseInt(item.localName.substr(1)),
        text: item.innerText,
        id: item.id,
      });
    }
}
function renderToc() {
  // ul li 중첩된 html 구조 만들기
  var listHtml = "";
  newHeadings.forEach(function (item, idx, newHeadings) {
    var html = "";
    if (idx > 0) {
      var prevLevel = newHeadings[idx - 1].index;
    }
    var currentLevel = item.index;
        // 레벨 차이를 계산해서 닫는 태그 추가
    if (idx == 0 || prevLevel - currentLevel == -1) {
      html += '<ul class="lv-' + currentLevel + '">';
    } else if (prevLevel - currentLevel == -2) {
      html += "<ul><li>";
      html += '<ul class="lv-' + currentLevel + '">';
    } else if (prevLevel - currentLevel == -3) {
      html += "<ul><li><ul><li>";
      html += '<ul class="lv-' + currentLevel + '">';
    } else if (prevLevel - currentLevel == 1) {
      html += "</li></ul>";
    } else if (prevLevel - currentLevel == 2) {
      html += "</li></ul>";
      html += "</li></ul>";
    } else if (prevLevel - currentLevel == 3) {
      html += "</li></ul>";
      html += "</li></ul>";
      html += "</li></ul>";
    }
    html +=
      '<li><a class="toc-item" href="#' +
      item.id +
      '" target-idx =' +
      idx +
      " >" +
      item.text +
      "</a>";
    var arrayLen = newHeadings.length - 1;
    if (idx == arrayLen) {
      if (currentLevel == 1) {
        html += "</li></ul>";
      } else if (currentLevel == 2) {
        html += "</li></ul></li></ul>";
      } else if (currentLevel == 3) {
        html += "</li></ul></li></ul></li></ul>";
      } else if (currentLevel == 4) {
        html += "</li></ul></li></ul></li></ul>";
      }
    }
    listHtml += html;
  });

  // 실제 엘리먼트를 만들고 위에서 만든 listHtml 내용으로 채움
  var newNode = document.createElement("nav");
  newNode.classList.add(navName);
  newNode.innerHTML = listHtml;
  mainWrap.insertBefore(newNode, null);
}

자, 위의 소스를 보면 왜그렇게 고치고 싶어 했나 알 수 있을 것이다.
여기서 구현할 때 이용한 전략(?)은 다음과 같다.

  1. 헤딩 태그 이름을 이용해 개별 아이템의 깊이를 지정한다.
  2. 이전 아이템의 깊이와 현재 깊이를 비교하여 깊이 차를 계산하고 거기에 맞춰 알맞은 개수의 태그 꺾쇠를 추가한다.

뭐 돌아는 가는데… innerHTML쓰는 것도 별로고 코드가 썩 예쁘진 않다 😗😗😗
오류도 있었다. 어차피 나만 쓰는 거라 별 문제는 없었는데, 만약 첫 번째 헤딩 요소가 h1이 아니라면 전체를 감싸는 ul이 아예 생성되지 않는다.

 

대충 이런 이상한 꼬라지가 나온다는 뜻이다.
열린 적이 없는 ul태그를 닫았으니 오류가 났을텐데, 이건 브라우저에서 알아서 고쳐준 것 같고 깊이가 2인 소제목 2와 깊이가 1인 소제목 1이 ul 태그도 없이 nav 하위에 나란히 나열된 것을 볼 수 있다.

 

새로운 방법 : 스택 자료구조 활용

이리 저리 궁리해 보다가 스택 자료구조를 활용한 좋은 방법이 있기에 공유한다.

출처링크(stack overflow)

const headings = document.querySelector('.entry-content').querySelectorAll('h1,h2,h3,h4,h5,h6');
const stack = [{text: 'root', level: 0, items:[]}];

[...headings].forEach(h => {
    const self = {
        text: h.textContent.trim(),
        level: +h.nodeName.replace('H', ''),
        items: [],
    };

        // 현재 요소 레벨이 스택의 마지막 요소보다 작거나 같으면
        // 스택의 마지막 요소를 꺼낸다
    while (self.level <= stack[stack.length-1].level) {
        stack.pop();
    }
        // 스택 배열의 마지막 아이템의 items 배열 하위 요소로 push
    stack[stack.length-1].items.push(self);
        // 스택 배열에 push
    stack.push(self);

});

console.log(stack[0].items)

먼저 이게 어떻게 해서 돌아가는 건지 살펴보자

  1. headingsquerySelectorAll로 헤딩 태그를 모두 가져오고, 전체 헤딩 데이터를 저장할 stack배열을 만든다.
  2. headings의 개별 헤딩요소를 순회하면서 textlevel 정보를 가진 self요소를 만든다.
  3. 현재 self 아이템의 레벨과 기존 stack에 저장되어 있는 마지막 요소를 비교하여 pop
  4. 마지막 아이템의 items 배열 하위 요소로 push 하고 그리고 스택 배열에 push

4번을 좀 더 풀어서 설명해 보자.

초기구성은 위 이미지와 같다. 헤딩 요소를 가져온 headings 배열에서 forEach를 돌릴 예정이다.
stack배열에는 기본적으로 text:"root" 값을 가진 레벨 0의 오브젝트를 추가해 둔 상태이다.
이제 headings[0] 아이템인 text:"헤딩1번" 아이템을 이용해 self오브젝트를 생성한다. 생성한 self를 stack 배열의 가장 마지막 요소(여기서는 root)의 items 배열 안에 push하고 stack배열에도 push 해준다. 이렇게 넣은 요소를 h1이라고 하겠다.

이제 다음 루프로 넘어와서 headings[1] 아이템인 text:"헤딩 2번"을 넣어줄 차례이다.
마찬가지로 아이템을 가져와 self를 만든다. 그리고 stack 배열의 마지막 요소(h1)의 items 배열에 넣어주고, stack 배열에도 넣어준다. 이제 이 아이템을 H2라고 하자.

소스에서 명시적으로 push 한 형태는 다음과 같다. 그러나 실제로는

root의 items에 넣어두었던 h1에도 h2 아이템이 push 된다.
어떻게 이런 일이 가능할까?
간단하게 말하자면 오브젝트이기 때문에 자바스크립트에서 주소값을 참조(reference) 하기 때문이다.

 

value(값) vs reference(참조)

코드에서 뭔가를 만들어내면 컴퓨터 메모리에 저장을 해야 한다. 메모리 어딘가에 저장을 했으니 메모리 주소값을 갖게 될 텐데, 임의로 위 이미지와 같다고 치자.
stack배열은 @ABCD001, root아이템은 @ABCD002에 저장되어 있다.
그리고 self객체를 만든다. 이때 만든 self가 저장된 메모리의 주소값은 @ABCD003이다.
그렇게 이해하고 있지만 실제로는 메모리 주소 @ABCD001에 PUSH 하라는 걸로 이해해야 한다.

따라서 여기서도 @ABCD003@ABCD004를 PUSH 한다고 생각하면 root내부에 있는 당연히 H1에도 추가가 되는 것이다.

인터넷 쇼핑몰에서 택배를 시킨다고 했을 때 쇼핑몰 사이트에 유저가 다른 이름으로 등록한 배송지가 서로 다른 이름(변수 이름)으로 여러 가지가 있어도 실제 주소(메모리 주소)가 같다면 같은 곳으로 배송되는 걸 떠올리면 좀 쉬울까..?
또한 추가적으로 자료를 찾고 싶다면 보통 value(값) vs reference(참조), 그리고 여기서 더 확장하여 Mutable Object(가변객체) vs (Immutable Object) 불변객체, Shallow Copy(얕은 복사) vs Deep Copy(깊은 복사) 등의 키워드가 있으니 같이 찾아보면 좋을 듯하다.

 

오류 수정하기

하지만 이것도 완벽하진 않다. H1하위에 H2가 아닌 H3이 오면 구조가 제대로 잡히지 않는다. 헤딩요소는 차례대로 오는 게 옳은 사용법이지만, 어디 세상 일이 그렇게 마음대로 되나? 여하간 이를 해결하려면, self 대신 비어 있는 임의의 오브젝트를 하나 만들어 넣어주면 된다.

const headings = document.querySelector('.entry-content').querySelectorAll('h1,h2,h3,h4,h5,h6');
const stack = [{text: 'root', level: 0, items:[]}];

[...headings].forEach(h => {
    const self = {
        text: h.textContent.trim(),
        level: +h.nodeName.replace('H', ''),
        items: [],
    };

    while (self.level <= stack[stack.length-1].level) {
        stack.pop();
    }

    while(self.level - stack[stack.length-1].level >  1) {
                // 비어있는 객체 만들기
                // 레벨이 안 맞을 때마다 주소가 서로 다른 empty객체를
                // 하나씩 새로 만들어야 하므로 여기서 생성해주어야 한다
        const empty = {text:'',items:[], level : stack[stack.length-1].level +1}
        stack[stack.length-1].items.push(empty);
        stack.push(empty);
    }

    stack[stack.length-1].items.push(self);
    stack.push(self);
});

console.log(stack[0].items)

여기서 주의해야 하는 건 empty객체를 만드는 위치다. 만약 while문 바깥쪽에 만든다면 같은 for문 내부에서 empty의 주소는 다 동일한 곳을 가리키게 되므로 레벨 차이가 2보다 더 높은 경우 제대로 동작하지 않는다.
이제 stack[0].items에 헤딩 요소가 예쁘게 중첩 구조로 들어가 있을 것이다.
이걸 이용해 실제 마크업을 추가하려면 아래와 같이 하면 된다.

// 마크업 생성 함수
// 재귀 활용하여 모든 요소를 createElement로 만들어 붙임
const createMarkup = (items) => {
  const ol = document.createElement('ol');

  items.forEach(item => {
    if (item.text !== '') {
      const li = document.createElement('li');
      li.textContent = item.text;
      ol.appendChild(li);
    }

    if (item.items.length > 0) {
      const li = document.createElement('li');
      const childOl = createMarkup(item.items);
      li.appendChild(childOl);
      ol.appendChild(li);
    }
  });

  return ol;
}

// toc 마크업 생성
const tocElement = createMarkup(stack[0].items);
console.log(tocElement);

// 원하는 위치에 append 하기
const navElem = document.querySelector('.toc-list');
navElem.appendChild(tocElement);

글에서 heading을 가져오고 이를 가공하여 html 요소로 만들어 붙이는 것까지만 간략화한 것이기 때문에 실제로 이 블로그에서 사용하고 있는 소스와 동일하지 않다. 실제로 쓰는 건 어떤 소스인지 궁금하다면 개발자도구에서 소스탭을 살펴보시길.

 

후기

블로그에 붙이고 싶은 기능이 많이 있었고 목차 기능도 그중 하나였다. 중첩된 구조로 야무지게 가져오고 싶은데 제대로 된 답이 나오지 않아 고민을 정말 많이 했었다. 그러다 스택오버플로 답변을 찾았을 때 벅차올라서 가슴이 다 두근거리지 뭔가.
분명 값과 참조도 알고 스택 개념도 잘 아는데, 왜 그전에는 떠올리지 못했을까? 개념을 이해하는 것과 그 특성을 영리하게 사용하는 건 또 다른 이야기가 아닌가 싶다.

 

참고 자료

코어자바스크립트(정재남)

값 vs참조 설명글 링크

관련 링크(스택 오버플로)

TOC 만들기(wbluke)