티스토리를 SPA(단일 페이지 어플리케이션) 처럼 만들기

티스토리는 기본적으로 모든 링크를 클릭할 때 마다 페이지를 매번 이동하는 구조로 되어있다. 그런데 적어도 내 스킨에서 각 링크를 클릭할 때마다 바뀌는 부분은 본문 영역 뿐이다. 그래서 티스토리를 SPA(Single Page Application)처럼 단일 페이지에서 별도의 페이지 이동 없이 내용을 동적 로딩하도록 만드는 방법이 있을까 고민해보았다.


처음에는 티스토리 API 에서 글목록, 글내용을 가져와 Ajax 로 뿌려주는 것을 구상했다. 그런데 티스토리 API는 이용자가 항상 토큰을 발급받아야 하고, 토큰 발급 전 티스토리 로그인이 강제되므로 티스토리에 로그인하지 않을 방문자가 대부분인 이 경우에는 적합하지 않았다.


그래서 Ajax 로 html 문서를 호출해서 필요한 부분만 뽑아낸 뒤 동적으로 삽입해봤는데 생각보다 잘 적용되고 속도도 무척이나 빨라서 바로 적용했다. js 와 css 를 로드하고 렌더링하는 과정이 생략되어서 속도가 무척 빠르기 때문에 로딩 중 여부를 표시하는 loader 도 만들어 줄 필요가 없었다.


그리고 본문을 불러오기 전에 내맘대로 컨트롤 할 수 있으므로, 이미지에 대해 lazyload 를 적용할 수 있었다. 현재 첫 접속 시에는 lazyload 인 척만 하고있고, 페이지를 한 번 이동하는 순간부터 제대로 lazyload 하도록 되어있다. 의도한 것은 아니지만 첫페이지에서는 서버 렌더링을 하고 이후에는 동적으로 내용을 받아오는 바람직한 구조가 되었다.


현재 완성 후 대충 테스트를 해봤는데 큰 문제는 없는거같다. 일단 문제가 발생하더라도 에러처리를 해놔서, 문제가 발생하면 그냥 페이지를 이동해버리기 때문에 페이지가 멈춰버리거나 하는 문제는 없다.

기본 동작 흐름

1. 이벤트 바인딩

동적 로딩이 동작해야 하는 경우는 크게 두 경우이다.


① 블로그 내부 링크에 해당하는 a 태그를 클릭하는 경우 (onclick event)

  • 클릭한 링크가 새 창으로 열도록 되어있는 경우 동적 로딩하지 않도록 한다. (target = "_blank")
  • 기본 이벤트를 막아서 페이지를 이동하지 않도록 한다. (e.preventDefault())
  • 내부 링크인지 외부 링크인지 판단하여, 내부 링크인 경우에만 동작하도록 한다.
  • 내부 링크인지 여부는 주소가 ?(페이징 시), / 으로 시작하는 경우와, 내 도메인 주소인 https://nomo.asia 로 시작하는 경우이다.
    정규표현식을 이용하여 체크한다.
  • 주소는 a 태그의 href 속성으로부터 가져온다.
  • 최종적으로 동적 로딩을 해야하는 경우 window.history.pushState 를 이용하여 주소를 원해 이동해야 했을 주소로 새롭게 갱신한다.
    (탐색할 때 주소의 형태를 해시뱅 (#!) 형태로 바꾸면 구현은 쪼~금 더 편하긴 하지만 기존 티스토리 주소 룰과 다르므로 새로고침 하거나 외부에서 해시뱅 형태의 링크로 접속하는 경우 리다이렉트 해줘야하므로 불편하다.)
  • 최종적으로 동적 로딩을 하지 않아야 하는 경우 window.location.href = 주소 를 이용하여 페이지를 이동한다.

참고로 모든 a 태그에 이벤트를 걸 수도 있으나 이 경우 부작용이 발생할 수 있어서 원하는 엘리먼트의 링크를 클릭했을 때만 동작하도록 이벤트를 바인드 하였다. 예를 들어, 스킨에 유저가 정의하지 않았는데 티스토리에서 자동적으로 만들어내는 링크들이 있고, 이 부분을 클릭했을 때 오동작을 할 수도 있다.


② 뒤로, 앞으로 키를 이용하여 블로그를 탐색하는 경우 (onpopstate event)

  • popstate 이벤트는 뒤로, 앞으로 등을 이용해 사용자가 새로운 상태로 이동할 때 작동한다.
  • 어차피 내부에서 탐색하는 경우에만 동작할 것이므로 내외부 링크에 대한 판단은 하지 않는다.
  • popstate 이벤트가 발동하면, window.location.href 의 값은 이미 이동한 페이지의 값을 갖는다.
    따라서 주소는 window.location.href 로부터 가져온다.

2. Ajax 호출 (동적 로딩)

각 이벤트에서 동적 로딩이 이루어져야 된다고 판단되면 아래와 같은 동작을 실행한다.
  • 이벤트에서 넘긴 주소값을 이용하여 Ajax 호출하여 html 문서를 텍스트 형태로 가져온다.
  • 가져온 html 문서를 DOMParser 를 이용해 쉽게 파싱할 수 있는 구조로 변환한다.
    그냥 jQuery 만도 이용해봤는데 DOMParser 를 한 번 거치는 것이 head 등을 탐색하는데 더욱 안정적이었다.
  • 동적 로딩해야할 본문에 해당하는 element 를 찾아 복사한다.
  • 현재 페이지의 본문에 해당하는 element 를 복사한 element 로 대체한다.
  • 스크롤을 맨 위로 올린다.

추가적인 처리

구조는 생각보다 단순했고 구현도 쉽게 됐는데, 의도하지 않은 방식으로 동작하는 경우가 있어서 추가적인 처리를 할 부분이 많았다.

SyntaxHighlighter 문제

새로 불러올 글에 SyntaxHighlighter 가 적용될 내용이 있는 경우, Ajax로 호출해오면 SyntaxHighlighter 가 적용되지 않은 상태로 삽입됐다.
동적 로드 후, SyntaxHighlighter.highlight(); 를 호출하여 해결했다.

그 외에 $(document).ready 에 선언해준 것중 글이 새롭게 불러와질 때마다 재호출해야 하는 것이 있다면 동적 로드 후 함수를 재호출 해야한다. 내 경우에는 목차를 페이지 로드 후 JavaScript 를 이용해 동적으로 생성해주기 때문에, 동적 로드 후 새로 불러와진 문서에 대하여 목차를 다시 생성해주어야 했다.

목차 탐색 문제

티스토리에서는 목차 기능을 제공하지 않지만, 본 블로그에서는 자동으로 목차를 작성해주는 스크립트를 작성 및 적용하여 PC 화면에서만 적용 중이다. 목차 링크를 클릭하면 주소 맨 끝에 #이 붙어서 동일한 페이지 내에서 스크롤을 이동하게 되며, 이 때 click 이벤트와 popstate 이벤트가 모두 발동한다. 이 경우에는 Ajax 호출을 하면 안 되고, 일반 링크처럼 동작하며 탐색해야한다.


처음에는 단순히 이동할 주소에 #이 포함되어 있다면 ajax 호출을 하지 않도록 했다. 그랬더니 링크 클릭 시에는 문제가 없었지만, 앞뒤로 이동할 때 문제가 발생했다. 예를 들어 목차를 한 번 클릭한 후(주소에 #이 붙는다), 아예 다른 페이지로 이동 후(주소에서 #이 사라진다), 다시 뒤로 이동했을 때는(이전 페이지 주소에 해당하므로 주소에 #이 다시 생긴다) 주소에 #이 포함되어 있더라도 이전 문서의 내용을 다시 불러오기 위하여 Ajax로 내용을 로드해야 한다. 그런데 단순히 이동할 주소에 #이 포함되어 있다면 Ajax 호출을 하지 않도록 한 경우에는 이 때 Ajax 호출을 하지 않아 동적 로딩되지 않고 페이지를 이동해버리거나 멈춰버리는 문제가 있었다.


그래서 Ajax 호출단에서 목차인지 여부를 체크하지 않고, 각 이벤트 시작 단에서 체크하도록 했다.

  • 클릭 이벤트의 경우에는 어차피 목차 링크의 href 값은 #으로 시작하므로, 내부 링크는 / 이나 https://nomo.asia 와 같이 시작해야 하는데 그렇지 않으므로 내부 링크가 아닌 것으로 판단하여 동적 로딩 하지 않는다.
  • popstate 이벤트의 경우에는 페이지 이동 이전 url 을 저장한 뒤, 이전 url 의 # 앞부분이 현재 url 의 # 앞부분과 다른 경우에만 동적 로딩 하도록 한다.
    예를 들어, http://nomo.asia/400#s-5 에서 http://nomo.asia/300 으로 이동했다가 뒤로 이동 시, # 앞의 주소가 다르므로 동적 로딩한다.
    하지만 http://nomo.asia/400#s-5 에서 http://nomo.asia/400#toc-400 으로 이동했다가 뒤로 이동 시, # 앞의 주소가 같으므로 동적 로딩을 하지 않는다.

이로 인해 목차를 클릭하여 이동할 때는 Ajax 호출 없이 일반 링크처럼 동작하도록 하여 해결했다.

타이틀 적용 문제

본문만 가져오면 타이틀은 이전 글의 타이틀 그대로이다.
타이틀도 가져와서 바꾸도록 하여 해결했다.

구글 애드센스 문제

동적으로 본문을 삽입 시, 본문에 삽입한 구글 애드센스가 뜨지 않는 문제가 있었다.



반응형 광고만 그런지는 모르겠지만 애드센스에서는 광고가 삽입될 영역을 체크하는데, 본문에 삽입되기 전 위와 같이 광고가 삽입될 영역이 존재하지 않는다고 판단하여 광고를 삽입하지 않도록 처리된 듯싶었다.


이것은 본문의 애드센스를 삽입할 때 사용하는 코드를 다음과 같이 수정하여 해결했다.


//(adsbygoogle = window.adsbygoogle || []).push({}); // 기존 코드
$(document).ready(function(){
    (adsbygoogle = window.adsbygoogle || []).push({});
});

호환성 문제

작성한 코드에서 DOMParser, window.history.pushState 등의 함수를 사용하는데 브라우저 호환성 문제가 있을 수 있었다.
따라서 저 두가지의 type 이 function 이 아닌 경우 애초에 이벤트를 바인드하지 않도록 했다.
polyfill 등을 적용해볼 수는 있지만, 어차피 이 블로그는 모던 브라우저가 아닌 브라우저들에 대한 호환성을 전부 무시하고 있다.

검색엔진의 봇 문제

useragent 를 체크하여 널리 알려진 검색봇인 경우 이벤트를 바인드하지 않도록 했다. 혹시라도 검색엔진이 탐색을 잘못해서 내용을 잘못 긁어가는 것을 막기위해서이다.

블로그 내부 검색 동작

블로그 내부 검색창을 이용해 검색하는 경우에도 동적 로딩이 가능하도록 html 에서 기존 검색 input 부분의 코드를 수정했다.

기존

    
    

수정

    
    

그 외

ajax 호출로 가져온 html 문서가 존재하지 않거나, 파싱 중 복사해 올 element 가 존재하지 않거나, ajax 호출이 실패하는 경우 페이지를 이동하게 하여 아예 무반응 하는 것을 막는다.

최종 적용 코드

최종적으로 본 티스토리 블로그에 적용한 코드는 아래와 같다.

본인 블로그 스킨에 맞도록 수정이 필요하다.

const DEBUG = false;
const elem_whitelist = [".subject a",".article a",".list_content a","#sidebar a",".searchList a","#footer a"];
const domain_whitelist = /^(\?|\/|http:\/\/nomo.asia|https:\/\/nomo.asia)/;
const crawlerAgentRegex = /bot|google|aolbuild|baidu|bing|msn|duckduckgo|teoma|slurp|yandex|daum|kakao|naver|yeti/i;
const temp_title = "SU의 블로그";
const fadeInOut_elem = "#content";  // 컨텐츠 로드 전 숨길 엘리먼트
const content_elem = "#borderDiv";  // 컨텐츠를 복사해올 엘리먼트
const content_load_after = function(){  // 컨텐츠를 복사해온 후 할 행동
    doc_create($("div.entry"));
    SyntaxHighlighter.highlight();
};

var prev_url = ""; // 이전 url을 저장할 변수를 global 로 선언
function tistory_to_spa_init(){
    if(DEBUG) console.log("tistory_to_spa_init");

    // 검색봇의 경우 패스함
    if (typeof DOMParser !== "function" || typeof window.history.pushState !== "function" || crawlerAgentRegex.test(navigator.userAgent)) {
        return false;
    }

    // 첫 접속 시 url 저장
    prev_url = window.location.href;
    if(DEBUG) console.log("prev_url_init", prev_url);

    // Case1 : 링크 클릭 시 동작
    $(document).on("click", elem_whitelist.join(","), function (e) {
        if($(this).attr("target") === "_blank"){
            return true;
        }
        if(DEBUG) console.log("click 이벤트 동작");
        e.preventDefault();  // 링크 클릭에 의한 기본 이동 막기
        var href = $(this).attr("href");

        // 1. 새창에서 여는것이 아니고
        // 2. domain_whitelist 에서 정규표현식으로 정의된 내부링크가 아닌 경우에만 동작함
        if (href !== null && href !== undefined && href !== "#" && domain_whitelist.test(href)) {
            if(DEBUG) console.log("tistory_to_spa - by click", href, e);
            window.history.pushState("", temp_title, href); // 현재 주소창의 url 변경
            tistory_to_spa(href);
        }
        else{
            if(DEBUG) console.log("클릭 이벤트 예외, href",href,"target",$(this).attr("target"),"domain_whitelist.test(href)",domain_whitelist.test(href));
            window.location.href = href;
        }
    });

    // Case2 : 뒤로가기, 앞으로가기 동작의 경우
    $(window).on("popstate", function (e) {
        if(DEBUG) console.log("popstate 이벤트 동작");
        var href = window.location.href;

        // 1. 이전 url 이 현재 url 과 같지 않고
        // 2. 이전 url 의 # 앞부분이 현재 url 의 # 앞부분과 다른 경우에만 동작
        if (prev_url !== window.location.href && prev_url.split("#").shift() !== window.location.href.split("#").shift()) {
            if(DEBUG) console.log("tistory_to_spa - by popstate", prev_url, href, e);
            tistory_to_spa(href);
        }
        else{
            if(DEBUG) console.log("popstate 예외", prev_url, href);
        }
    });
}

function tistory_to_spa(href) {
    prev_url = window.location.href;    // 이전 url 갱신
    if(DEBUG) console.log("이전 url 갱신", prev_url);

    $.ajax({
        url: href,
        dataType: "html",
        success: function (data) {
            // 탐색 위한 DOMParser 만들기
            var parser = new DOMParser();
            var $temp = $(parser.parseFromString(data, "text/html"));

            if ($temp === undefined || $temp.length === 0){     // 에러처리
                window.location = href;
                return false;
            }

            // 내용 복사해올 elem 찾기
            var $newContents = $temp.find(content_elem);

            if ($newContents.length === 0) {    // 에러처리
                window.location = href;
                return false;
            }
            
            // 복사 전 elem 숨기기 -> 복사 -> elem 보이기 순서
            $newContents.find(fadeInOut_elem).hide();   // 복붙 전 복사 대상에서 엘리먼트 숨기기
            $(fadeInOut_elem).fadeOut();    // 현재 화면에서 엘리먼트 숨기기
            $(content_elem).html($temp.find(content_elem).first().html());  // 복붙하기
            content_load_after();   //  복붙 후 할 동작
            $(fadeInOut_elem).fadeIn(); // 복붙 후 엘리먼트 보이기

            // head 에서 meta, title 복사해오기
            $("head meta, head title").remove();
            $temp.find("head meta, head title").appendTo("head");

            // 주소에 #이 포함되어 있는 경우(목차), 해당 elem 으로 이동
            if (href.indexOf("#") !== -1) {
                var doc_id = "#" + href.split("#").pop();
                if (doc_id.length !== 0) {
                    $("html, body").animate({
                        scrollTop: $(doc_id).offset().top
                    }, "fast");
                } else {
                    //$("html, body").animate({ scrollTop: 0 }, "fast");
                    scrollToTop();
                }
            }
            // 주소에 #이 포함되어 있지 않은 경우, 최상단으로 이동
            else {
                //$("html, body").animate({ scrollTop: 0 }, "fast");
                scrollToTop();
            }
        },
        error: function (err) {
            // ajax 콜 실패 시 그냥 페이지 이동
            if(DEBUG) console.log("error", err);
            window.location.href = href;
        }
    });
}

// 부드럽게 스크롤 Top 까지 올리기
const scrollToTop = () => {
    const c = document.documentElement.scrollTop || document.body.scrollTop;
    if (c > 0) {
        window.requestAnimationFrame(scrollToTop);
        window.scrollTo(0, c - c / 8);
    }
};

tistory_to_spa_init(); // 함수 실행


Leave a comment
≪ Previous : 1 : ··· : 22 : 23 : 24 : 25 : 26 : 27 : 28 : 29 : 30 : ··· : 160 : Next ≫