Разбиение на страницы

12

На данный момент переведено

В этой главе вы:

  • Узнаете больше о подписках Meteor, и как мы можем их использовать для контроля данных.
  • Создадите страницу с подгружаемыми данными по мере прокрутки страницы.
  • Используете пакет `iron-router-progress` для создания индикатора загрузки в стиле iOS.
  • Создадите особенную подписку для прямых ссылок на страницу постов.
  • Наше приложение Microscope продвигается ударными темпами, и оно определенно станет хитом когда мы его запустим.

    По этой причине стоит задуматься о быстродействии приложения, и о том как поток запросов повлияет на производительность, когда сотни и тысячи пользователей ринутся создавать новые посты.

    Ранее мы говорили о том, как коллекция на клиенте должна хранить только часть данных, доступных на сервере. Мы даже создали такие коллекции для уведомлений и комментариев.

    Не смотря на это, мы все еще публикуем все наши посты за раз, для всех подключенных пользователей. Когда количество постов станет измеряться тысячами, это станет большой проблемой. Чтобы избежать ее, нам надо начать разбивать посты на отдельные страницы.

    Добавляем больше постов

    Для начала давайте создадим больше тестовых постов, чтобы было что разбивать на страницы.

    // Fixture data
    if (Posts.find().count() === 0) {
    
      //...
    
      Posts.insert({
        title: 'The Meteor Book',
        userId: tom._id,
        author: tom.profile.name,
        url: 'http://themeteorbook.com',
        submitted: now - 12 * 3600 * 1000,
        commentsCount: 0
      });
    
      for (var i = 0; i < 10; i++) {
        Posts.insert({
          title: 'Test post #' + i,
          author: sacha.profile.name,
          userId: sacha._id,
          url: 'http://google.com/?q=test-' + i,
          submitted: now - i * 3600 * 1000,
          commentsCount: 0
        });
      }
    }
    
    server/fixtures.js

    После запуска команды meteor reset вы должны получить примерно такую картину:

    Displaying dummy data.
    Displaying dummy data.

    Коммит 12-1

    Добавили достаточно постов чтобы их можно было разбивать …

    Бесконечные страницы

    В лучших традициях современных веб-приложений мы создадим механизм, который будет подгружать новые посты по мере прокрутки страницы вниз. Для начала мы загрузим, скажем, 10 постов, а внизу высветим ссылку “Загрузить еще”. По щелчку на этой ссылке мы подгрузим еще 10 постов, и так до бесконечности. Таким образом мы сможем контролировать всю нашу систему разбиения данных на страницы с помощью одного единственного параметра, означающего количество постов, единовременно выводимых на экран.

    Нам надо придумать способ сообщить об этом параметре серверу, чтобы тот знал, сколько постов посылать клиенту. У нас уже есть подписка на публикацию posts на маршрутизаторе. Мы воспользуемся ей, чтобы дать маршрутизатору возможность управлять нашими страницами.

    Самый простой способ передать параметр на сервер будет через URL. Например, в таком формате - http://localhost:3000/25 - здесь мы передаем значение 25, про которое сервер догадается, что оно означает количество постов. Дополнительной фишкой будет то, что если пользователь случайно (или намеренно) перезагрузит страницу в браузере, он снова получит то же самое количество постов, что и ранее.

    Для этого нам понадобится изменить способ подписки на посты. Точно так же, как и в главе Комментарии, мы переместим код подписки с уровня маршрутизатора на уровень маршрута.

    Если вы уже запутались - не пугайтесь. Сейчас все станет яснее, когда мы начнем писать код.

    Сначала мы уберем подписку на публикацию posts в блоке Router.configure(). Удалите Meteor.subscribe('posts') и оставьте только подписку на уведомления - notifications:

    Router.configure({
      layoutTemplate: 'layout',
      loadingTemplate: 'loading',
      waitOn: function() {
        return [Meteor.subscribe('notifications')]
      }
    });
    
    lib/router.js

    Затем мы добавим параметр postsLimit в адрес маршрута. Символ ? означает, что параметр необязательный. Таким образом наш маршрут будет совпадать не только с http://localhost:3000/50, но и с обычным http://localhost:3000.

    Router.map(function() {
      //...
    
      this.route('postsList', {
        path: '/:postsLimit?'
      });
    });
    
    lib/router.js

    Стоит особенно отметить что маршрут в виде /:parameter? будет совпадать со всеми возможными маршрутами. Так как каждый маршрут будет последовательно проверен на совпадение с текущим адресом, стоит уделить особенное внимание объявлению маршрутов в порядке уменьшения конкретности.

    Другими словами, более точные маршруты вроде /posts/:id должны быть объявлены в начале, а наш маршрут postsList стоит переместить ближе к концу файла, так как он будет совпадать практически с любым адресом.

    Настало время бросить вызов серьезной проблеме подписки и нахождения верных данных. Определим значение по-умолчанию для случая когда параметр postsLimit отсутствует. Пусть это будет “5” - такое значение позволит нам сгенерировать множество страниц для списка постов.

    Router.map(function() {
      //..
    
      this.route('postsList', {
        path: '/:postsLimit?',
        waitOn: function() {
          var postsLimit = parseInt(this.params.postsLimit) || 5;
          return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: postsLimit});
        }
      });
    });
    
    lib/router.js

    Обратите внимание на то, как мы передаем JavaScript объект {limit: postsLimit} вместе с именем нашей публикации posts. Этот объект послужит параметром options, когда сервер вызовет Posts.find() чтобы получить порцию постов. Давайте переключимся на код сервера и воплотим это:

    Meteor.publish('posts', function(options) {
      return Posts.find({}, options);
    });
    
    Meteor.publish('comments', function(postId) {
      return Comments.find({postId: postId});
    });
    
    Meteor.publish('notifications', function() {
      return Notifications.find({userId: this.userId});
    });
    
    server/publications.js

    Передаем параметры

    Наш код публикаций в свою очередь сообщает серверу, что он может доверять всем объектам JavaScript, которые посылает клиент (в нашем случае, {limit: postsLimit}). Доверие сервера настолько велико, что он может использовать этот объект в качестве параметра вызова find(). Это позволяет пользователям посылать любые опции запроса через консоль браузера.

    В нашем случае это вполне безобидно, так как все что пользователь может сделать это поменять посты местами, или изменить значение параметра limit (чего мы и добиваемся).

    Подобного подхода стоит избегать, когда у объектов есть секретные неопубликованные поля с данными частного характера. Пользователь запросто сможет получить данные из этих полей, слегка подправив содержимое объекта fields. По той же причине объект запроса не стоит использовать напрямую как параметр вызова find().

    Безопаснее будет передавать отдельные параметры вместо целого объекта - чтобы избежать передачи ненужных полей:

    Meteor.publish('posts', function(sort, limit) {
      return Posts.find({}, {sort: sort, limit: limit});
    });
    

    Теперь, когда мы подписываемся на данные на уровне маршрутизатора, стоит установить контекст данных. Мы слегка изменим наш традиционный подход, заставив функцию data вернуть объект JavaScript вместо курсора на данные в Mongo. Это позволит создать именной контекст данных, который мы назовем posts.

    Традиционно контекст данных был доступен как this внутри шаблона, но теперь он будет доступен через posts. В остальном, следующий код должен быть уже знаком:

    Router.map(function() {
      this.route('postsList', {
        path: '/:postsLimit?',
        waitOn: function() {
          var limit = parseInt(this.params.postsLimit) || 5;
          return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
        },
        data: function() {
          var limit = parseInt(this.params.postsLimit) || 5;
          return {
            posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
          };
        }
      });
    
      //..
    });
    
    lib/router.js

    Теперь когда мы задаем контекст данных на уровне маршрутизатора, можно окончательно избавиться от метода шаблона posts в файле posts_list.js. И так как мы назвали наш контекст posts (точно так же, как и метод), нам даже не нужно трогать шаблон postsList.

    Файл маршрутизатора router.js теперь должен выглядеть так:

    Router.configure({
      layoutTemplate: 'layout',
      loadingTemplate: 'loading',
      waitOn: function() {
        return [Meteor.subscribe('notifications')]
      }
    });
    
    Router.map(function() {
      //...
    
      this.route('postsList', {
        path: '/:postsLimit?',
        waitOn: function() {
          var limit = parseInt(this.params.postsLimit) || 5;
          return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
        },
        data: function() {
          var limit = parseInt(this.params.postsLimit) || 5;
          return {
            posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
          };
        }
      });
    });
    
    lib/router.js

    Коммит 12-2

    Поправили маршрут postsList чтобы он принимал параметр li…

    Давайте опробуем нашу новенькую систему разбития результатов на страницы в действии. Изменяя параметр в URL мы можем задавать количество постов, выводимых на главную страницу. Попробуйте открыть http://localhost:3000/3. Вы должны увидеть что-то вроде такого:

    Контролируем количество постов на главной странице.
    Контролируем количество постов на главной странице.

    Почему не отдельные страницы?

    Почему мы решили подгружать новые посты по мере прокрутки, а не отдельные страницы по 10 постов на каждой? Ведь так, например, делает Google. Все дело в природе реального времени, на которой построен Meteor.

    Давайте представим что мы разбиваем коллекцию Posts на страницы, как это делает Google с результатами поиска. Мы перешли на вторую страницу, которая высвечивает посты с 10 по 20. Что произойдет, если другой пользователь удалит любой из предыдущих 10 постов?

    Так как наше приложение работает в реальном времени, наши данные тут же изменятся. Пост номер 10 превратится в 9 и пропадет со страницы, в то время как пост номер 11 займет его место. В конце-концов результатом окажется то, что на глазах у пользователя посты поменяют места без видимой на то причины.

    Даже если бы наш UX дизайн перетерпел бы подобное поведение интерфейса, традиционное разбиение на страницы вовсе нетривиально в техническом воплощении.

    Вернемся к предыдущему примеру. Мы опубликовали посты от 10 до 20 из коллекции Posts, но как же вы найдете эти посты на клиенте? Вы не можете выбрать посты от 10 до 20, так как их всего 10 в коллекции на клиенте.

    Одним из решений было бы опубликовать эти 10 постов на сервере, и затем вызвать Posts.find() на клиенте чтобы выбрать для отображения все опубликованные посты.

    Это сработает только если у вас одна единственная подписка. Но что если у вас появится больше одной подписки на посты, как у нас вскоре и произойдет?

    Представим что одна подписка запрашивает посты от 10 до 20, а вторая от 30 до 40. Теперь у вас загружено 20 постов на клиенте, и ни малейшего представления какой из постов принадлежит которой подписке.

    Из-за всех этих причин традиционный способ разбиения коллекций на страницы плохо работает вместе с Meteor.

    Создаем Контроллер для Маршрутизатора

    Вы могли заметить что мы дважды повторили линию кода var limit = parseInt(this.params.postsLimit) || 5;. Вдобавок, использование предопределенных величин в коде, вроде этого числа “5”, является плохой практикой. Мир от этого не рухнет, но код стоит немного реорганизовать согласно принципам DRY - Don’t Repeat Yourself - “Не повторяйтесь”.

    Открываем новую сторону маршрутизатора Iron Router - Контроллер Маршрутизатора - Route Controller. Это удобный способ сгруппировать несколько фишек маршрутизатора в один пакет, который легко используется другими маршрутами. В этот раз мы используем его для одного единственного маршрута, но уже в следующей главе вы увидите, насколько он облегчит нам жизнь.

    PostsListController = RouteController.extend({
      template: 'postsList',
      increment: 5,
      limit: function() {
        return parseInt(this.params.postsLimit) || this.increment;
      },
      findOptions: function() {
        return {sort: {submitted: -1}, limit: this.limit()};
      },
      waitOn: function() {
        return Meteor.subscribe('posts', this.findOptions());
      },
      data: function() {
        return {posts: Posts.find({}, this.findOptions())};
      }
    });
    
    Router.map(function() {
      //...
    
      this.route('postsList', {
        path: '/:postsLimit?',
        controller: PostsListController
      });
    });
    
    lib/router.js

    Рассмотрим код. Сначала мы создали контроллер наследуя его от RouteController. Затем был инициализирован параметр template, а также новый параметр - increment.

    Дальше мы задали функцию limit которая вернет значение текущего ограничения на количество постов на странице. Функция findOptions возвращает объект с параметрами поиска options. Возможно, сейчас она вам покажется лишней, но уже скоро она нам понадобится.

    Затем мы определили функции waitOn и data - теперь они используют нашу функцию findOptions.

    Напоследок через параметр controller мы сообщили маршруту postsList использовать наш новый контроллер.

    Коммит 12-3

    Изменили маршрут postsLists чтобы он перенаправлял на кон…

    Добавляем ссылку “Загрузить еще”

    Разбиение на страницы работает, и наш код выглядит просто отлично. Осталась одна проблема - переход по страницам пока что работает только, если вы вручную будете менять параметр количества постов в адресной строке. Давайте сделаем все чуть проще и приятнее в использовании.

    Нам понадобится кнопка в конце списка с постами - “Загрузить еще постов”. Каждый раз когда пользователь ее нажмет, количество постов на странице увеличится на 5. Если наш текущий URL http://localhost:3000/5, нажатие на кнопке должно изменить его на http://localhost:3000/10.

    Как и ранее, мы добавим логику разбиения на страницы в маршрут. Помните как мы передали контекст данных именной переменной, вместо того чтобы использовать анонимный курсор? Точно так же не существует правила, по которому функция data может передавать одни курсоры. Мы воспользуемся той же техникой чтобы сгенерировать URL для кнопки “Загрузить еще постов”.

    PostsListController = RouteController.extend({
      template: 'postsList',
      increment: 5,
      limit: function() {
        return parseInt(this.params.postsLimit) || this.increment;
      },
      findOptions: function() {
        return {sort: {submitted: -1}, limit: this.limit()};
      },
      waitOn: function() {
        return Meteor.subscribe('posts', this.findOptions());
      },
      posts: function() {
        return Posts.find({}, this.findOptions());
      },
      data: function() {
        var hasMore = this.posts().fetch().length === this.limit();
        var nextPath = this.route.path({postsLimit: this.limit() + this.increment});
        return {
          posts: this.posts(),
          nextPath: hasMore ? nextPath : null
        };
      }
    });
    
    lib/router.js

    Давайте внимательнее взглянем на это волшебство в маршрутизаторе. Как вы помните, маршрут postsList (который наследуется от контроллера PostsListController, над которым мы как раз работаем) принимает параметр postsLimit.

    Когда мы передаем объект {postsLimit: this.limit() + this.increment} вызову функции this.route.path(), мы говорим маршруту postsList создать новый путь, используя этот объект JavaScript как контекст данных.

    Другими словами, это то же самое что и использование метода Handlebars {{pathFor 'postsList'}}, за исключением того, что мы заменяем непосредственное this на наш собственный контекст данных.

    Мы берем этот новый путь и добавляем его в контекст данных шаблона, но только если еще остались посты. Как это работает на практике?

    Вызов this.limit() возвращает количество постов, отображаемых на странице. Это будет либо значение в текущем URL, либо значение по-умолчанию (5) - если в URL нет этого параметра.

    С другой стороны this.posts ссылается на текущий курсор базы данных, и this.posts.count() сосчитает количество постов в этом курсоре.

    Теперь, если мы запросим n количество постов и получим ровно столько постов, можно оставить кнопку “Загрузить еще постов” на странице. Но если мы запросим n постов, а получим меньше чем n в ответ, значит посты закончились и кнопку “Загрузить еще” можно спрятать.

    Однако, есть еще один момент. Что если количество постов в базе данных равно n? В этом случае клиент запросит n постов, получит в ответ ровно n, и кнопка “Загрузить еще” останется на виду.

    К сожалению здесь нет простого решения, и на данный момент мы оставим все как есть.

    Все что осталось это добавить саму ссылку “Загрузить еще” после списка постов на странице, и добавить немного логики чтобы эта ссылка отображалась только, если еще остались посты:

    <template name="postsList">
      <div class="posts">
        {{#each posts}}
          {{> postItem}}
        {{/each}}
    
        {{#if nextPath}}
          <a class="load-more" href="{{nextPath}}">Load more</a>
        {{/if}}
      </div>
    </template>
    
    client/views/posts/posts_list.html

    Теперь список постов должен выглядеть так:

    Кнопка “Загрузить еще” на странице
    Кнопка “Загрузить еще” на странице

    Коммит 12-4

    Добавили nextPath() в контроллер и используем его для раз…

    Улучшаем индикатор прогресса

    Разбиение на страницы работает, но одна проблема сильно портит впечатление. Каждый раз когда кто-то нажимает ссылку “Загрузить еще” и маршрутизатор запрашивает больше постов, приложение переходит на шаблон loading пока данные запрашиваются. В результате страница перепрыгивает на самый верх, и приходится прокручивать вниз чтобы продолжить чтение постов.

    Было бы гораздо лучше, если бы мы оставались на той же странице пока грузятся данные, одновременно показывая какой-либо индикатор что данные действительно грузятся. Именно для этих целей и существует пакет iron-router-progress.

    Этот пакет позволит нам добавить индикатор загрузки наверху экрана, в стиле браузера Safari на iOS или сайтов вроде Medium и YouTube. Все что нужно сделать это установить сам пакет:

    meteor add mrt:iron-router-progress
    
    Командная консоль bash

    Благодаря волшебству умных пакетов smart packages, индикатор прогресса тут же заработает в нашем приложении. Он будет активирован для каждого маршрута, и автоматически спрятан как только все данные для маршрута будут загружены.

    Из-за того что мы хотим показать список постов даже если мы переходим между страницами, мы не хотим, чтобы триггер загрузка запускался для наших страниц PostListController – мы можем это добиться если не будем ждать подписок:

    PostsListController = RouteController.extend({
      // ...
    
      onBeforeAction: function() {
        this.postsSub = Meteor.subscribe('posts', this.findOptions());
      },
      posts: function() {
        return Posts.find({}, this.findOptions());
      },
      data: function() {
        var hasMore = this.posts().count() === this.limit();
        return {
          posts: this.posts(),
          ready: this.postsSub.ready,
          nextPath: hasMore ? this.nextPath() : null
        };
      }
    });
    
    lib/router.js

    Вместо подписки в waitOn блоке, мы подписываемся в onBeforeAction, держа ссылку на подписку (subscription handle) в this.postsSub, таким образом мы сообщаем о “готовности” шаблону.

    Затем, в шаблоне, мы можем показать бегунок загрузки в конце списка постов, когда мы подгружаем новый набор постов. Для этого мы проверяем состояние “готовности”, которые мы передали ранее:

    <template name="postsList">
      <div class="posts">
        {{#each postsWithRank}}
          {{> postItem}}
        {{/each}}
    
        {{#if nextPath}}
          <a class="load-more" href="{{nextPath}}">Load more</a>
        {{else}}
          {{#unless ready}}
            {{> spinner}}
          {{/unless}}
        {{/if}}
      </div>
    </template>
    
    client/views/posts/posts_list.html

    Сделаем еще одну небольшую микро-поправку. Отключим iron-router-progress для маршрута postSubmit, так как ему не нужно ждать никаких данных (в конце-концов это просто пустая форма на станице):

    Router.map(function() {
    
      //...
    
      this.route('postSubmit', {
        path: '/submit',
        disableProgress: true
      });
    });
    
    lib/router.js

    Коммит 12-5

    Используем пакет iron-router-progress для индикатора прог…

    Доступ к любому посту

    На данный момент по-умолчанию мы загружаем пять самых новых постов. Но что произойдет если кто-то откроет страницу одного из постов?

    Пустой шаблон.
    Пустой шаблон.

    Если вы попробуете открыть один из постов, приложение нарисует шаблон с пустым постом. Здесь есть определенный смысл: мы сообщили маршрутизатору подписаться на публикации posts когда загружается маршрут postsList. Но мы не сообщили ему что делать с маршрутом postPage.

    Пока что мы умеем подписываться только на лист из n последних постов. Как запросить у сервера один конкретный пост? Оказывается, здесь есть один секрет: у коллекции может быть одновременно несколько публикаций!

    Чтобы вернуть наши потерявшиеся посты, мы добавим новую публикацию singlePost, которая будет публиковать один пост согласно запрошенному параметру _id.

    Meteor.publish('posts', function(options) {
      return Posts.find({}, options);
    });
    
    Meteor.publish('singlePost', function(id) {
      return id && Posts.find(id);
    });
    
    server/publications.js

    Теперь мы можем подписаться на посты на клиенте. У нас уже есть подписка на публикацию comments в функции waitOn маршрута postPage. Мы просто добавим здесь еще одну подписку на singlePost. Не забудьте добавить подписку в маршрут postEdit, ведь ему потребуются те же самые данные:

    Router.map(function() {
    
      //...
    
      this.route('postPage', {
        path: '/posts/:_id',
        waitOn: function() {
          return [
            Meteor.subscribe('singlePost', this.params._id),
            Meteor.subscribe('comments', this.params._id)
          ];
        },
        data: function() { return Posts.findOne(this.params._id); }
      });
    
      this.route('postEdit', {
        path: '/posts/:_id/edit',
        waitOn: function() {
          return Meteor.subscribe('singlePost', this.params._id);
        },
        data: function() { return Posts.findOne(this.params._id); }
      });
    
      /...
    
    });
    
    lib/router.js

    Коммит 12-6

    Подписываемся на посты по-отдельности, чтобы можно было з…

    Теперь, когда у нас работает разбиение гигантского количества постов на страницы, у приложения не будет проблем с обилием данных, и пользователи смогут запостить еще больше ссылок. Было бы здорово как-то сортировать все эти ссылки и дать пользователям возможность голосовать за лучшие из них. Об этом и будет следующая глава.