2015년 10월 15일 목요일

레일스에서 무한 스크롤링 : 실전편

이전 기본 문서에서 우리는 데모 포스트를 설정하고 간단한 pagination 대신에 무한 스크롤을 구현하였다. 이를 달성하기 위해 will_paginate와 javascript를 사용하였다. 

동작하는 데모는 Heroku에서 확인할 수 있다.

그 소스 코드는 GiHub에서 확인할 수 있다.

오늘은 무한 스크롤 대신에 "Load more" 를 구현해 보자. 이 솔루션은 footer 내부에 링크가 존재하고 스크롤링이 모든 레코드가 로딩되는 동안 그 링크를 없애 버리는 경우 편리하다.

이 것이 동작되는 원리를 설명하기 위해 PostsController 다음 변경사항들을 추가하자.

posts_controller.rb

def index
    get_and_show_posts
end

def index_with_button
    get_and_show_posts
end

private

def get_and_show_posts
    @posts = Post.paginate(page: params[:page], per_page: 15).order('created_at DESC')
    respond_to do |format|
        format.html
        format.js
    end
end

route를 추가하자:

config/routes.rb

get '/posts_with_button', to: 'posts#index_with_button', as: 'posts_with_button'

이제 두 컨셉을 설명하는 독립적인 두 페이지가 있다.

index_with_button.html.erb

<div class="page-header">
    <h1>My posts</h1>
</div>

<div id="my-posts">
    <%= render @posts %>
</div>

<div id="with-button">
    <%= will_paginate %>
</div>

<% if @posts.next_page %>
    <div id="load_more_posts" class="btn btn-primary btn-lg">More posts</div>
<% end %>

대부분의 경우, 뷰는 동일하다. 단지 pagination wrapper의 지시자만 변경했고(나중에 그 지시자를 적절한 상태를 쓰기 위해 사용할 것이다) Bootstrap 클래스의 도움을 받아 버튼으로 표시될 #load_more_posts 블록을 추가했다. 우리는 이 버튼이 더 사용가능한 페이지가 있을 때만 보이기 원한다. 오직 블로그에 하나의 포스트만 있는 상황을 상상해보아라.- 왜 "Load more" 버튼을 그릴 필요가 있을까?

이 버튼은 처음에는 보여서는 안된다 - 우리는 자바스크립트로 버튼을 보여줄 것이다. 이 방식은 JS가 disabled 되었있을 때 기본 행위에 대한 대체시스템이다.

application.css.scss

#load_more_posts {
    display: none;
    margin-bottom: 10px; /* Some margin to separate it from the footer */
}

이제 클라이언트 사이드 코드를 수정할 차례이다:

pagination.js.coffee

if $('#with-button').size() > 0
    $('.pagination').hide()
    loading_posts = false

    $('#load_more_posts').show().click ->
      unless loading_posts
        loading_posts = true
        more_posts_url = $('.pagination .next_page a').attr('href')
        $this = $(this)
        $this.html('<img src="/assets/ajax-loader.gif" alt="Loading..." title="Loading..." />').addClass('disabled')
        $.getScript more_posts_url, ->
          $this.text('More posts').removeClass('disabled') if $this
          loading_posts = false
      return


여기서 우리는 "Load more" 버튼을 보여주는 대신에 pagination 블록을 숨기고, click 이벤트 핸들러를 버튼과과 바인딩한다. loading_posts 플래그는 사용자가 버튼을 한번에 여러번 클릭시 다수의 요청을 보내는 것을 방지하는데 사용된다.

이벤트 핸들러 내부에서, 우리는 이전과 동일한 개념을 사용하고 있다: 다음 페이지 URL을 가져온다. "loading" 이미지를 추가하고 버튼을 disable 시킨다. 그리고 AJAX 요청을 서버로 제출하다. 또한 응답이 수신되었을 때 실행될 콜백을 추가하였다. 이 콜백은 버튼을 원래 상태로 복구하고 플래그를 false 상태로 셋팅한다.

그리고 이제 뷰는 다음과 같다:

index_with_button.js.erb

$('#my-posts').append('<%= j render @posts %>');
<% if @posts.next_page %>
    $('.pagination').replaceWith('<%= j will_paginate @posts %>');
    $('.pagination').hide();
<% else %>
    $('.pagination, #load_more_posts').remove();
<% end %>

다시 페이지에 새로운 포스트들을 추가한다. 만약 포스트가 더 존재한다면, 새로운 pagination이 그려지고 숨겨진다. 존재하지 않으면 pagination 버튼은 제거된다.

Link to a Paricular Page

이제 전통적인 pagination 대신에 무한 스크롤이나 "Load more" 버튼을 어떻게 만드는지 알게 되었다. 아마도 당신이 고려해야 하는 한가지는 어떻게 사용자가 특정 페이지에 대한 링크를 공유할 수 있느냐 이다. 현재로서는, 이를 수행할 방법이 없다. 왜냐하면 우리는 새로운 페이지를 로드할 때 URL을 변경하지 않기 때문이다.

이제 URL에 있는 search 부분을 자바스크립트를 사용해 변경함으로써 이를 달성해보자.(? 심볼로 시작하는)

window.location.search = 'page' + page_number

불행히도, 이는 즉각적으로 페이지를 릴로드한다. 이는 우리가 원하는 것이 아니다. 우리의 두번째 시도에서, hash 부분을 변경해보자(#심볼로 시작하는 hash). 실제로 이는 잘 동작한다. 이 페이지는 다시 릴로드되지 않는다. 그러나 세번째 더욱 우아한 솔루션이 있다 - History API. 이 API를 가지고  우리는 브라우저의 히스토리를 바로 조작할 수 있다.

이 특정 케이스에서, 우리는 pushState 메소드를 이용해 히스토리에 몇몇 엔트리를 추가하기 원한다.

먼저, Benjamin Arthur Lupton이 작성한 HTML5 History/State API를 위한 cross-browser를 지원을 제공하는 History.js 라이브러리를 다운로드 받자. jQuery를 위해 아마도 scripts/bundled/html4+html5/jquery.history.js 를 사용하기 원할 것이다.

이제, $.getScript가 리소스 로딩을 완료한 이후에 실행될 간단한 함수를 작성해보자.

pagination.js.coffee

page_regexp = /\d+$/

pushPage = (page) ->
    History.pushState null, "InfiniteScrolling | Page " + page, "?page=" + page
    return

$.getScript more_posts_url, ->
    # ...
    pushPage(more_posts_url.match(page_regexp)[0])

more_posts_url 은 다음 페이지에 대한 링크를 포함하고 있다는 것을 잊지말자. 거기서 페이지 넘버를 가져온다. pushPage 함수 내에서 우리는 브라우저의 히스토리를 조작하기 위해 History.js 를 사용하고 기본적으로 URL을 변경한다(마지막 파라미터를 가지고). 두번째 파라미터는 윈도우의 타이틀을 변경한다. 필요하면 첫 파라미터(null)는 몇몇 추가적인 데이터를 저장하는데 사용될 수 있다. URL이 변경된 뒤에는 사용자는 이전 페이지로 이동하기 위해 브라우저에 있는 "Back" 버튼을 클릭할 수 있다. 메우 좋다.

마지막으로 걱정할 것은 legacy 브라우저이다: IE 9과 소수의 특정 브라우저들, 그것들은 History API를 지원하지 않는다. 이들 고대의 가축들에는, 결과 URL이 다음과 같이 보일 것이다:
http://example.com?page=2 대신에 http://example.com#http://example.com?page=2. 그래서 우리는 이 경우를 위한 지원을 추가해야 한다.

pagination.js.coffee

[...]

hash = window.location.hash
  if hash.match(/page=\d+/i)
    window.location.hash = '' # Otherwise the hash will remain after the page reload
    window.location.search = '?page=' + hash.match(/page=(\d+)/i)[1]

[...]

이 코드 블록은 페이지에 적제된 상태에서 실행된다. 여기서 page= 를 위한 url hash를 스캔한다. 만약 URL의 search 부분이 대응하는 페이지 번호로 업데이트 되면 그 페이지가 리로드된다.

뷰를 가볍게 수정해서 pagination이 다음페이지가 사용가능할 때에만 보여주는 것은 좋은 아이디어이다("Load more" 버튼을 가지고 했던 것처럼). 반면에 사용자가 마지막 페이지로 직접 이동하기 위해 URL을 입력하였을 때 pagination은 여전히 디스플레이되고 javascript 이벤트 핸들러는 여전히 발견될 것이다.

index.html.erb

<% if @posts.next_page %>
    <div id="infinite-scrolling">
<%= will_paginate %>
    </div>
<% end %>

그러나 이 솔루션은 사용자가 이전 포스트들을 로딩할 수 없는 상황에서 문제점을 야기한다. 당신은 "Load previous" 버튼을 가지고 더 복잡한 솔루션을 구현하거나 "Go to the first page" 링크를 표시할 수 있다.

다른 방법은 기본 pagination을 조합하는 것이다. 무한 스크롤링과 함께 페이지의 제일 위에 표시한다. 이는 다른 문제점을 해결해준다: 방문자가 마지막 페이지로 이동하고자 한다면? 즉 31페이지로 이동하고자 하면 스크롤을 내리고 내려야(또는 "Load more"버튼을 30번)하는데 매우 짜증나는 일이다. 우리는 원하는 페이지로 점프하거나 몇몇 필터를 구현하는 방법으로 제공할 수 있다.(by date, category, view count 등)

Pagination and Infinite Scrolling

"조합된" 솔루션을 구현해보자. 무한 스크롤과 기본적인 pagination을 조합한다. 이는 또한 javascript가 disable된 곳에서도 잘 동작할 것이다. 사용자는 단지 두 곳에서 pagination을 볼 것이다. 이것은 나쁘지 않다.

먼저, view 블록에 다른 pagination 블록을 추가한다(다음 절에서, 우리는 static-pagination wrapper를 사용할 것이다)

index.html.erb 그리고 index_with_button.html.erb

<div class="page-header">
  <h1>My posts</h1>
</div>
<div id="static-pagination">
  <%= will_paginate %>
</div>
[...]

이후, 우리는 오직 하나의 pagination block 만 참조되도록 스크립트를 약간 수정해야 한다(수정된 라인 근처에 코맨트를 달아놓았다):

pagination.js.coffee

[...]

if $('#infinite-scrolling').size() > 0
    $(window).bindWithDelay 'scroll', ->
      more_posts_url = $('#infinite-scrolling .next_page a').attr('href') # <--------
      if more_posts_url && $(window).scrollTop() > $(document).height() - $(window).height() - 60
        $('#infinite-scrolling .pagination').html( # <--------
          '<img src="/assets/ajax-loader.gif" alt="Loading..." title="Loading..." />') # <--------
        $.getScript more_posts_url, ->
          window.location.hash = more_posts_url.match(page_regexp)[0]
      return
    , 100

  if $('#with-button').size() < 0
    # Replace pagination
    $('#with-button .pagination').hide() # <--------
    loading_posts = false

    $('#load_more_posts').show().click -<
      unless loading_posts
        loading_posts = true
        more_posts_url = $('#with-button .next_page a').attr('href') # <--------
        if more_posts_url
          $this = $(this)
          $this.html('<img src="/assets/ajax-loader.gif" alt="Loading..." title="Loading..." />').addClass('disabled')
          $.getScript more_posts_url, ->
            $this.text('More posts').removeClass('disabled') if $this
            window.location.hash = more_posts_url.match(page_regexp)[0]
            loading_posts = false
      return

[...]

index.js.erb

$('#my-posts').append('<%= j render @posts %>');
$('.pagination').replaceWith('<%= j will_paginate @posts %>');
<% unless @posts.next_page %>
    $(window).unbind('scroll');
    $('#infinite-scrolling .pagination').remove(); // <--------
<% end %>

index.js.erb 내에서 두 곳 모두에서 pagination이 업데이트 되길 원하므로 2번째 라인은 수정하지 않았다.

index_with_button.js.erb

$('#my-posts').append('<%= j render @posts %>');
$('.pagination').replaceWith('<%= j will_paginate @posts %>');
<% if @posts.next_page %>
    $('#with-button .pagination').hide(); // &lt--------
<% else %>
    $('#with-button .pagination, #load_more_posts').remove(); // <--------
<% end %>

여기에 동일한 컨셉이 적용되었다. 또한 두 경우 모두 replaceWith 를 조건문 밖으로 이동시켰다는 것을 주목해라. 이 지점에서 우리는 우리의 pagination이 다음 페이지가 열릴때마다 재작성되기를 원할 것이다. 만약 우리가 이 변경사항을 적용하지 않으면 사용자가 마지막 페이지를 열었을때 꼭대기의 pagination은 교체되지 않을 것이다.  - 오직 아래 있는 pagination 만이 제거될 것이다.

Spy the Scrolling!

이제 우리는 마지막에 도달했다. 이 부분은 아마도 가장 교묘한 부분일 것이다. 이 부분에서 우리는 사용자가 스크롤을 내리고 더 많은 포스트가 로딩될 때 URL을 업데이트하고 현재 페이지를 하이라이트한다. 그러나 사용자가 스크롤 백(맨 위쪽으로)하기로 결정했다면? 물론 URL 과 pagination 도 업데이트되지 않을 것이고 다소 혼란스러울수 있을 것이다.

이는 스크롤 스파잉을 구현함으로써 해결될 수 있다. 우리의 계획은 다음과 같다: 다른 페이지로부터의 포스트들 사이에 구분자를 추가하자(이들 구분자들은 페이지 넘버를 포함할 것이다) 그리고 사용자가 이들 구분자 만큼 스크롤할 때마다 이벤트를 발생시킨다. 이벤트 내에서 그가 현재 어떤 페이지를 보고있는지 체크하고 그에 대응하여 URL과 pagination을 업데이트하자.

구분자부터 시작해보자.

index.html.erb 그리고 index_with_button.html.erb

[...]

<div id="my-posts">
<div class="page-delimiter first-page" data-page="<%= params[:page] || 1 %>">
</div>
<%= render @posts %>
</div>
[...]

여기 data-page는 실제 페이지 넘버를 포함하고 있다. 우리는 GET 파라미터로부터 그것을 가져오거나 페이지 넘버가 제공되지 않으면 1로 셋팅한다. 우리가 사용하는 first-page 클래스는 짧게 사용한다는 것을 주목하라.

우리는 또한 스크립트를 업데이트할 것이다.

index.js.erb 그리고 inde_with_button.js.erb

var delimiter = $('<div class="page-delimiter" data-page="<%= params[:page] %>"></div>');
$('#my-posts').append(delimiter);
$('#my-posts').append('<%= j render @posts %>');

[...]

당장 이들 구분자들은 사용자들에게 보이지 않을 것이다.

마지막으로, 실제 스크롤 스파잉을 구현해라. Caleb Troughton이 만든 jQuery를 위한 Waypoints 라이브러리를 사용할 수 있다. 유사한 기능을 제공하는 다른 라이브러리들이 있지만 Waypoints는 사용자가 스크롤 업 하든 다운하든 트래킹할 수 있으며 우리 케이스에 더 편리하다.

다음 함수는 구분자에 사용자가 스크롤할 때마다 실행될 이벤트 핸들러를 붙일 것이다. 불행히도 우리의 구분자들은 동적으로 추가되기 때문에 우리는 이 이벤트 핸들러들을 각각 붙여야 할것이다. 그렇지 않으면 동작하지 않을 것이다.

pagination.js.coffee

jQuery ->
  page_regexp = /\d+$/

  window.preparePagination = (el) ->
    el.waypoint (direction) ->
      $this = $(this)
      unless $this.hasClass('first-page') && direction is 'up'
        page = parseInt($this.data('page'), 10)
        page -= 1 if direction is 'up'
        page_el = $($('#static-pagination li').get(page))
        unless page_el.hasClass('active')
          $('#static-pagination .active').removeClass('active')
          pushPage(page)
          page_el.addClass('active')

    return

  [...]

이제 코드는 사용자가 스크롤 업하고 있고 첫 페이지에 도달하지 않았는지 체크한다. 그러면 코드는 data-page로부터 페이지 넘버를 받아와서 만약 방향이 업이면 1만큼 감소시킨다. 이는 우리의 구분자들이 대응되는 페이지로부터의 포스트 앞에 놓여있기 때문이다. 그래서 사용자가 스크롤 업하고 이 구분자만큼 이동할 때 실제로 이 페이지를 떠나고 이전 페이지로 간다.

#static-pagination 셀렉터는 기본 pagination을 가진 블록을 가리킨다. 셀렉터는 현재 페이지 넘버를 가진 li 앨리먼트를 리턴하고 그것에 active 클래스를 지정한다. $('#static-pagination li')의 페이지는 0부터 시작하는 반면에 페이지 숫자는 1부터 시작한다는 것에 주의해라. 아직 우리는 1 만큼 page를 감소시키지 않는다. 이는 pagination 블록에 있는 첫 li는 항상 "Previous page" 링크를 갖고 있기 때문이다. 그래서 우리는 그것을 건너 뛴다. 마지막으로 또한 우리는 URL에 있는 hash를 변경한다.

preparePagination 함수는 window에 붙는다는 것을 명심해라. 그래서 우리는 이 함수를 이 파일 내에서 뿐만아니라 우리의 *.js.erb 뷰에서도 잘 호출한다. CoffeeScript는 전체 스코프를 오염시키는 것을 방지하기 위해 self-invoing anonymous 함수로 각 파일 내에서 코드를 래핑한다(이는 실제로 좋은 것이다). 그럼에도 불구하고 이 경우에는 우리가 함수를 window에 붙이지 않으면, 함수는 외부에서 보이지 않을 것이다.

이제 우리는 실제 적용할 수 있다.

pagination.js.coffee

[...]

if $('#infinite-scrolling').size() > 0
    preparePagination($('.page-delimiter'))

[...]

if $('#with-button').size() > 0
    preparePagination($('.page-delimiter'))

[...]

index.js.erb 그리고 index_with_button.js.erb

var delimiter = $('<div class="page-delimiter" data-page="<%= params[:page] %>"></div>');
$('#my-posts').append(delimiter);
$('#my-posts').append('<%= j render @posts %>');
$('.pagination').replaceWith('<%= j will_paginate @posts %>');
preparePagination(delimiter);

[...]

마지막으로 중요한 것은 index.js.erb로부터 $(window).unbind('scroll'); 을 제거하는 것이다. 왜냐하면 Waypoints는 이 이벤트에 의지하며 우리는 항상 그것에 귀기울이고 있어야 하기 때문이다.

또한 사용자가 현재 페이지를 체크할 수 있도록 기본 pagination에 고정된 위치를 지정하고 싶을 것이다. 간단한 스타일을 적용해보자.

#static-pagination {
  position: fixed;
  top: 30px;
  opacity: 0.7;
  &:hover {
    opacity: 1;
  }
}

이제 pagination 블록은 항상 꼭대기에 디스플레이 될 것이고 약간 불투명할 것이다. 사용자가 이 앨리먼트에 마우스를 갖다대면 불투명도가 1로 셋팅될 것이다.

Conclustion

이제 이 문서의 끝에 도달했다. 나는 독자들이 이 문서를 읽으며 유용한 팁을 발견했기를 바란다. 제시된 솔루션은 이상적이지 않다. 그러나 어떻게 업무가 달성될 수 있는지 이해를 제공했을 것이다. 이전 포스트를 로딩하는 문제를 해결하는 방법과 함께 이 문서에 대한 당신의 생각을 당신의 웹사이트에 공유해주기 바란다.


출처 <a href=" http:="" infinite-scrolling-rails-practice="" www.sitepoint.com="">http://www.sitepoint.com/infinite-scrolling-rails-practice/

댓글 없음: