[Занимательные задачки, Программирование, Алгоритмы, Node.JS, VueJS] Создаем кэшируемую пагинацию, которая не боится неожиданного добавления данных в БД

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
26-Апр-2021 18:30

Если на вашем сайте присутствует большое количество контента, то для отображения пользователю его приходится так или иначе делить. Все известные мне способы имеют недостатки и я попытался создать систему, которая сможет решить некоторые из них и при этом не будет слишком сложна для реализации.Существующие методы1. Пагинация (разделение на отдельные страницы)
Пример с сайта habr.comПагинация или разделение на отдельные страницы — достаточно старый способ разделения контента, который, в том числе используется на Хабре основным преимуществом является его универсальность и простота реализации как со стороны сервера так и клиентской части. Код запроса данных из бд чаще всего ограничивается парой строк.Тут и далее примеры на языке arangodb aql, я скрыл код сервера т.к там пока ничего интересного.
// Возврат по 20 элементов для каждой страницы.
LET count = 20
LET offset = count * ${page}
FOR post IN posts
  SORT post.date DESC // сортируем от новых к старым
  LIMIT offset, count
  RETURN post
На стороне клиента мы запрашиваем и выводим получившийся результат, я использую vuejs с nuxtjs для примера, но то же самое можно проделать на любом другом стеке, все специфичные для vue моменты я буду подписывать.
# https://example.com/posts?page=3
main.vue
<template> <!-- содержимое template встраивается в body  -->
  <div>
    <template v-for="post in posts"> <!-- отображение каждого элемента  -->
      <div :key="post.id">
        {{ item.title }}
      </div>
    </template>
  </div>
</template>
<script>
export default {
  data() {
    return {
      posts: [], // инициализация массива с постами
    }
  },
  computed: { // свойства объекта this, вычисляемые с помощью функции
    currentPage(){
      // получаем номер страницы из ссылки и приводим к числу с помощью +
      return +this.$route.query.page || 0
    },
  },
  async fetch() { // вызывается перед загрузкой страницы
    const page = this.currentPage
    // запрос к серверу, который выполнит запрос к базе данных
    this.posts = await this.$axios.$get('posts', {params: {page}})
  }
}
</script>
Теперь у нас выводятся все посты на странице, но погодите, как пользователи будут переключаться между страницами? Добавим пару кнопок для перелистывания страниц.
<template> <!-- содержимое template встраивается в body  -->
  <div>
    <div>
      <template v-for="post in posts"> <!-- отображение каждого элемента  -->
        <div :key="post.id">
          {{ item.title }}
        </div>
      </template>
    </div>
    <div>  <!-- Добавляем кнопки  -->
      <button @click="prev">
        Предыдущая страница
      </button>
      <button @click="next">
        Следующая страница
      </button>
    </div>
  </div>
</template>
<script>
export default {
  //... тут код из прошлого примера
  methods: {// добавим функций на страницу
    prev(){
      const page = this.currentPage()
      if(page > 0)
        // переходим на https://example.com/posts?page={page - 1}
        this.$router.push({query: {page: page - 1}})
    },
    next(){
      const page = this.currentPage()
      if(page < 100) // для примера допустим что у нас всегда есть ровно 100 страниц
        // переходим на https://example.com/posts?page={page + 1}
        this.$router.push({query: {page: page + 1}})
    },
  },
}
</script>
Минусы данного способа
  • При достижении конца страницы пользователю нужно переключатся на следующую страницу вручную.
  • Не получится кешировать результаты, т.к посты находящиеся на странице 2, при добавлении новых, непременно сместятся на страницу 3, 4 и так далее, т.е одна и та же операция GET возвращает разные результаты в зависимости от количества постов.
  • Если в момент перелистывания добавятся новые посты, то мы повторно увидим просмотренные элементы на следующей странице и напротив, пропустим если будем листать в обратную сторону.
2. Бесконечный скроллингЭтом способ решает первую проблему, теперь пользователю не нужно вручную переключаться между страницами. Основная идея заключается в том, что мы получаем следующую страницу при достижении конца прошлой и добавляем новые элементы к существующим.При таком подходе проблема No3 проявляются еще более явно, если раньше мы не могли увидеть 2 похожих элемента рядом, то теперь это станет обычной ситуацией, конечно можно воспользоваться грязным трюком и отфильтровывать элементы с совпадающим id прямо на клиенте, но что если добавится 40 новых элементов за раз? Мы потратим 3 запроса к серверу, чтобы достичь новых результатов, т.к прошлые сместятся на 2 страницы (при условии что на одной странице 20 элементов). Это не мой подход! Как решают эту проблему люди из интернета:
  • Используют описанный мной выше подход, я не искал подтверждение, но я практически уверен в этом, т.к это самое простое решение которое может прийти на ум, его можно использовать для быстрого прототипирования или создания mvp.
  • Создают уникальный идентификатор при первом запросе, и сохраняют результаты запроса на сервере, а затем выдают порционно. Тут сразу напрашивается 2 минуса. Во-первых, это использование лишней памяти сервера для хранения результатов всех пользователей. Во-вторых, более сложная реализация, требующая и логики хранения результатов для каждого пользователя, и логики удаления устаревших запросов. Я уверен, что такие реализации существуют и возможно некоторым удалось решить проблему излишней памяти, но проще система от этого не стала, да и проблему кеширования это не решает, а лишь усугубляет ситуацию.
  • Возможны и другие более или менее изобретательные решения, но то что я хочу вам предложить я пока не встречал. В свое время мне бы очень помогла подобная статья, поэтому я и решил её создать. Думаю что людям с похожей задачей она окажется полезной!
Моя реализацияОсновная идея в том, что нам придется немного изменить логику запроса к базе, при этом не потребуется добавлять новые сущности или добавлять новые параметры в запрос.Обновляем код на сервереДля начала решим проблему кеширования, для этого просто всё перевернем.Теперь последняя страница станет страницей номер 0, а предпоследняя страница номером 1, слово страница (page) сюда уже не вписывается, т.к мы с детства привыкли что в книжках страницы идут с начала, поэтому используем более нейтральное слово offset (смещение).
LET count = 20
LET offset = ${offset}
FOR post IN posts
  SORT post.date ASC // для этого отсортируем всё в обратном порядке
  LIMIT offset, count
  RETURN post
Теперь сколько постов мы бы не добавили, GET "/?offset=0" всегда будет возвращать один и тот же результат.Получать первую страницу стало немного сложнее, поэтому совместим оба выше приведенных способа, для этого перейдем с уровня запроса к базе на уровень сервера (язык nodejs):
async getPosts({offset}) {
  const isOffset = offset !== undefined
  if (isOffset && isNaN(+offset)) throw new BadRequestException()
  const count = 20
  // Смещение должно быть кратно количеству элементов, чтобы результаты не пересекались
  if (offset % count !== 0) throw new BadRequestException()
  const sort = isOffset ? `
    SORT post.date DESC
    LIMIT ${+offset}, ${count}
  ` : `
    SORT post.date ASC
    LIMIT 0, ${count * 2} // Возвращаем больше чем нужно если это первая страница*
  `
  const q = {
    query: `
      FOR post IN posts
      ${sort}
      RETURN post
    `,
    bindVars: {}
  }
  // получаем результат запроса вместе с общим количеством найденных элементов
  const cursor = await this.db.query(q, {fullCount: true, count: isOffset})
  const fullCount = cursor.extra.stats.fullCount
  /*
    *Если общее число элементов в базе не кратно count{20} то в начальном запросе приходит 2 страницы [21-39] элементов
    В таком случае вторую страницу нужно пропустить т.к она уже входит в первую
    Если общее число делится на 20 то в первом запросе приходит 1-я страница c count{20} элементов
  */
  let data;
  if (isOffset) {
    // отсекаем попытку получить вторую страницу если она встроена в первую
    const allow = offset <= fullCount - cursor.count - count
    if (!allow) throw new NotFoundException()
    // возвращаем нормальный порядок постам, т.к в запросе к базе мы всё перевернули
    data = (await cursor.all()).reverse()
  } else {
    const all = await cursor.all()
    if (fullCount % count === 0) {
      // отрезаем лишние 20 элементов, это можно сделать как тут, так и в запросе к бд, вопрос лишь в оптимизации
      data = all.slice(0, count)
    } else {
      /* Тут посложней, если ранее мы могли иметь на последней странице 0-20 элементов,
      то теперь там нам всегда возвращается 20 элементов и недостачу нужно компенсировать,
      для этого на первую страницу добавляются дополнительные 0-20 элементов к имеющимся,
      в запросе к бд для первой страницы мы возвращаем с запасом 40 элементов
      и затем здесь отрезаем лишние
      */
      const pagesCountUp = Math.ceil(fullCount / count)
      const resultCount = fullCount - pagesCountUp * count + count * 2
      data = all.slice(0, resultCount)
    }
  }
  if (!data.length) throw new NotFoundException()
  return { fullCount, count: data.length, data }
}
Чего мы этим добились:
  • Теперь перекрытия id после добавления новых элементов стали невозможны.
  • Запросы теперь статичны и легко поддаются кешированию, единственным плавающим по количеству элементов и их id остался запрос без параметра offset.
  • Наш код на клиенте теперь не работает(.
Минусы моего способа:
  • Вопрос что делать при удалении все ещё открыт, это не частая операция, поэтому можно каждый раз полностью сбрасывать кэш, либо возвращать null вместо отсутствующего элемента, это не плохое решение, т.к. зачастую реального удаления с сервера не происходит, элемент лишь помечается как удаленный, если таких "null-зомби" станет много, то можно удалить все null-зомби из выдачи и сбросить кэш для всех запросов.
Обновляем код на клиентеЗаодно я покажу как сделать бесконечную прокрутку из пункта №2.
<template>
  <div>
    <div ref='posts'>
      <template v-for="post in posts">
        <div :key="post.id" style="height: 200px"> <!-- добавим высоту элементу, чтобы все работало хорошо  -->
          {{ item.title }}
        </div>
      </template>
    </div>
    <div> <!-- убираем переход на следующую т.к теперь работает автоподгрузка  -->
      <button @click="prev" v-if="currentPage > 1">
        Предыдущая страница
      </button>
    </div>
  </div>
</template>
<script>
const count = 20
export default {
  data() {
    return {
      posts: [],
      fullCount: 0,
      pagesCount: 0,
      dataLoading: true,
      offset: undefined,
    }
  },
  async fetch() {
    const offset = this.$route.query?.offset
    this.offset = offset
    this.posts = await this.loadData(offset)
    setTimeout(() => this.dataLoading = false)
  },
  computed: {
    currentPage() {
      return this.offset === undefined ? 1 : this.pageFromOffset(this.offset)
    }
  },
  methods: {
     // используем код для преобразования смещения в страницы и обратно
    pageFromOffset(offset) {
      return offset === undefined ? 1 : this.pagesCount - offset / count
    },
    offsetFromPage(page) {
      return page === 1 ? undefined : this.pagesCount * count - count * page
    },
    prev() {
      const offset = this.offsetFromPage(this.currentPage - 1)
      this.$router.push({query: {offset}})
    },
    async loadData(offset) {
      try {
        const data = await this.$axios.$get('posts', {params: {offset}})
        this.fullCount = data.fullCount
        this.pagesCount = Math.ceil(data.fullCount / count)
        // Удаляем вторую страницу если она приходит вместе с первой
        if (this.fullCount % count !== 0)
          this.pagesCount -= 1
        return data.data
      } catch (e) {
        //... обработка 404 и других ошибок сервера
        return []
      }
    },
    onScroll() {
      // за 1000 пикселей до конца запрашиваем новые элементы
      const load = this.$refs.posts.getBoundingClientRect().bottom - window.innerHeight < 1000
      const nextPage = this.pageFromOffset(this.offset) + 1
      const nextOffset = this.offsetFromPage(nextPage)
      if (!this.dataLoading && load && nextPage <= this.pagesCount) {
        this.dataLoading = true
        this.offset = nextOffset
        this.loadData(nextOffset).then(async (data) => {
          const top = window.scrollY
          // добавляем новые элементы и изменяем адресную строку
          this.posts.push(...data)
          await this.$router.replace({query: {offset: nextOffset}})
          this.$nextTick(() => {
            // Исключаем ситуации когда viewport браузера остается внизу страницы после подгрузки
            window.scrollTo({top});
            this.dataLoading = false
          })
        })
      }
    }
  },
  mounted() {
    window.addEventListener('scroll', this.onScroll)
  },
  beforeDestroy() {
    window.removeEventListener('scroll', this.onScroll)
  },
}
</script>
Теперь у нас есть полностью лишенная обозначенных недостатков реализация. Несомненно присутствуют моменты которые можно сделать лучше, я хотел показать сам подход, реализация может быть у каждого разной.Бонус: Добавляем гибкую систему перехода по страницамВ данный момент мы можем перемещаться лишь на 1 страницу вперед или назад, добавим возможность перейти на любую страницу, элемент управления может выглядеть примерно так (в квадратных скобках текущая страница):< 1 ... 26 [27] 28 ... 255 >< [1] 2 3 4 5 ... 255 >< 1 ... 251 252 253 254 [255] >Основа метода для генерации пагинации взята из этого обсуждения: https://gist.github.com/kottenator/9d936eb3e4e3c3e02598#gistcomment-3238804 и скрещена с моим решением.Показать продолжение бонусаВ начале вам нужно добавить этот вспомогательный метод внутрь тега <script>
const getRange = (start, end) => Array(end - start + 1).fill().map((v, i) => i + start)
const pagination = (currentPage, pagesCount, count = 4) => {
  const isFirst = currentPage === 1
  const isLast = currentPage === pagesCount
  let delta
  if (pagesCount <= 7 + count) {
    // delta === 7: [1 2 3 4 5 6 7]
    delta = 7 + count
  } else {
    // delta === 2: [1 ... 4 5 6 ... 10]
    // delta === 4: [1 2 3 4 5 ... 10]
    delta = currentPage > count + 1 && currentPage < pagesCount - (count - 1) ? 2 : 4
    delta += count
    delta -= (!isFirst + !isLast)
  }
  const range = {
    start: Math.round(currentPage - delta / 2),
    end: Math.round(currentPage + delta / 2)
  }
  if (range.start - 1 === 1 || range.end + 1 === pagesCount) {
    range.start += 1
    range.end += 1
  }
  let pages = currentPage > delta
    ? getRange(Math.min(range.start, pagesCount - delta), Math.min(range.end, pagesCount))
    : getRange(1, Math.min(pagesCount, delta + 1))
  const withDots = (value, pair) => (pages.length + 1 !== pagesCount ? pair : [value])
  if (pages[0] !== 1) {
    pages = withDots(1, [1, '...']).concat(pages)
  }
  if (pages[pages.length - 1] < pagesCount) {
    pages = pages.concat(withDots(pagesCount, ['...', pagesCount]))
  }
  if (!isFirst) pages.unshift('<')
  if (!isLast) pages.push('>')
  return pages
}
Добавляем недостающие методы
<template>
  <div ref='posts'>
    <div>
      <div v-for="post in posts" :key="item.id">{{ post.title }}</div>
    </div>
    <div style="position: fixed; bottom: 0;"> <!-- Прикрепим пагинацию к окну браузера -->
      <template v-for="(i, key) in pagination">
        <button v-if="i === '...'" :key="key + i" @click="selectPage()">{{ i }}</button>
        <button :key="i" v-else :disabled="currentPage === i" @click="loadPage(pagePaginationOffset(i))">{{ i }}</button>
      </template>
    </div>
  </div>
</template>
<script>
export default {
  data() {
    return {
      posts: [],
      fullCount: 0,
      pagesCount: 0,
      interval: null,
      dataLoading: true,
      offset: undefined,
    }
  },
  async fetch() {/* без изменений */},
  computed: {
    currentPage()  {/* без изменений */},
    // генерирует пагинацию на основе текущей страницы и общего количества страниц
    pagination() {
      return this.pagesCount ? pagination(this.currentPage, this.pagesCount) : []
    },
  },
  methods: {
    pageFromOffset(offset) {/* без изменений */},
    offsetFromPage(page) {/* без изменений */},
    async loadData(offset) {/* без изменений */},
    onScroll() {/* без изменений */},
    //  метод для перехода на нужную страницу
    loadPage(offset) {
      window.scrollTo({top: 0})
      this.dataLoading = true
      this.loadData(offset).then((data) => {
        this.offset = offset
        this.posts = data
        this.$nextTick(() => {
          this.dataLoading = false
        })
      })
    },
    // преобразуем элементы пагинации в смещения
    pagePaginationOffset(item) {
      if (item === '...') return undefined
      let page = isNaN(item) ? this.currentPage + (item === '>') - (item === '<') : item
      return page <= 1 ? undefined : this.offsetFromPage(page)
    },
    // Добавим простой метод для выбора любой страницы
    selectPage() {
      const page = +prompt("Введите номер существующей страницы");
      this.loadPage(this.offsetFromPage(page))
    },
  },
  mounted() {
    window.addEventListener('scroll', this.onScroll)
  },
  beforeDestroy() {
    window.removeEventListener('scroll', this.onScroll)
  },
}
</script>
Теперь, при необходимости, можно перейти на нужную страницу.
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_zanimatelnye_zadachki (Занимательные задачки), #_programmirovanie (Программирование), #_algoritmy (Алгоритмы), #_node.js, #_vuejs, #_paginatsija (пагинация), #_paginatsija_stranits (пагинация страниц), #_node.js, #_arangodb, #_nuxtjs, #_vuejs, #_nestjs, #_webprogrammirovanie (web-программирование), #_zanimatelnye_zadachki (
Занимательные задачки
)
, #_programmirovanie (
Программирование
)
, #_algoritmy (
Алгоритмы
)
, #_node.js, #_vuejs
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 25-Ноя 17:52
Часовой пояс: UTC + 5