Как стать автором
Обновить

Vue: переворот сознания

Время на прочтение9 мин
Количество просмотров7.6K


В прошлой статье я писал об EasyUI — библиотеке, реализующей графический интерфейс для одно-страничных Web-приложений. Эту библиотеку наша команда использовала при разработке Web-интерфейса для одного маленького, но очень умного устройства. С момента начала реализации проекта прошло довольно много времени, появилось много новых технологий и решений. Об одном из них я и хочу поговорить в этой статье.


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


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


Ещё тогда, читая документацию, я предполагал, что реализация такой таблицы сенсоров при помощи реактивного фреймворка будет простой и элегантной. Оставалось только проверить мои предположения на практике, что я, наконец, и сделал. Для меня, привыкшего к "тяжёлым" проектам вне реактивной парадигмы, потребовался некий переворот сознания, чтобы оценить достоинства Vue. Однако, это стоило того. Ведь всё оказалось гораздо проще, чем я думал...


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



Приложение имеет две вкладки для просмотра таблицы постраничного и при помощи прокрутки. При загрузке активна первая вкладка с постраничным отображением таблицы. В подвале таблицы мы видим общее количество страниц и номер текущей страницы. Нумерация начинается с единицы.


Попробуем растянуть окно браузера вниз.



Размер страницы и количество страниц пересчитались. Полистаем.



Перейдём в конец таблицы.



Всё работает быстро и без видимых багов.


Переключимся на закладку прокрутки.



Потащим за ползунок.



Прокручивает очень быстро.


Давайте пробежимся по коду. Думаю нет смысла объяснять здесь принципы работы Vue. Желающие это сделать найдут огромное количество описаний и примеров, например здесь.


Итак, главный шаблон приложения реализует две закладки, каждая из которых отображает контент (постраничное отображение и прокрутка), описываемый собственным шаблоном. Обратите внимание, что Vue поддерживает однофайловые компоненты (сокращённо SFC, от Single File Component, они же файлы *.vue) — специальный формат файлов, позволяющий собрать в одном файле шаблон, логику и стилизацию компонента Vue.


<template>
  <div class="tab-wrapper">
    <div class="tabs">
      <button
        v-for="tab in tabs" :key="tab"
        :class="['tab-button', { active: active === tab.component }]"
        @click="active = tab.component">
        {{ tab.title }}
      </button>
    </div>
  </div>
  <div class="tab-content">
    <component :is="active"/>
  </div>
</template>
<script>
import PagesTable from './PagesTable.vue'   // шаблон для постраничного просмотра
import ScrollTable from './ScrollTable.vue' // шаблон для прокрутки

export default {
  name: 'test-application',
  data() {
    return {
      active: PagesTable,
      tabs: [{
        component: PagesTable,
        title: 'Pagination'
      },{
        component: ScrollTable,
        title: 'Scrolling'
      }]
    }
  },
  components: {
    PagesTable, ScrollTable
  }
}
</script>
<style scope>
.tab-wrapper {
  position: relative;
  width: 100%;
}
.tabs {
  position: absolute;
  height: 30px;
  width: max-content;
  top: 0;
  bottom: 0;
  z-index: 10;
}
.tab-button {
  padding: 6px 10px;
  border-top-left-radius: 3px;
  border-top-right-radius: 3px;
  border: 1px solid #ccc;
  cursor: pointer;
  background: #E0E0E0;
  margin-bottom: -1px;
  margin-right: 0px;
  height: 30px;
}
.tab-button:hover {
  background: #F0F0F0;
}
.tab-button.active {
  background: #FFFFFF;
  border-bottom: 1px dotted #FFFFFF;
}
.tab-content {
  position: absolute;
  user-select: none;
  top: 39px;
  left: 0;
  bottom: 0;
  right: 0;
  border-top: 1px solid #ccc;
  border-radius: 0 0 6px 6px;
  background-color: white;
  line-height: 1.5em;
  overflow-x: hidden;
  z-index: 5;
}
</style>

Как видно, в шаблонах Vue используется так называемый мусташ-синтаксис. Например, запись {{tab.title}} при отрисовке шаблона будет заменена на значение title текущей переменной tab, то есть на Pagination для первой вкладки, и на Scrolling для второй. Также видно, что за счёт применения директивы обхода v-for количество закладок может быть легко изменено. Механизм выбора закладки очень прост. При активации закладки (то есть по клику соответствующей кнопки) срабатывает обработчик, который присваивает реактивной переменной active значение связанного шаблона (@click="active = tab.component"), и отрисовывается соответствующий компонент (<component :is="active"/>). Всё очень просто и наглядно. Минимум кода. Остаётся только здесь же определить CSS стили, и закладки готовы!


Теперь посмотрим на шаблон для постраничного отображения нашей длинной таблицы. Из главного шаблона видно (import PagesTable from './PagesTable.vue'), что он реализован в файле PagesTable.vue.


PagesTable.vue


<template>
  <div>
    <table>
      <caption>The pagination test for table of ({{rows}} rows)</caption>
      <thead>
        <tr>
          <th v-for="col in head" :key="col">{{col}}</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="row in page" :key="row">
          <td v-for="col in row" :key="col">{{col}}</td>
        </tr>
      </tbody>
    </table>
    <footer>
      <button @click="top()">&laquo;</button>
      <button @click="prev()">&#8249;</button>
      <input v-model="num" @change="go()">
      <span>of {{pages}}</span>
      <button @click="next()">&#8250;</button>
      <button @click="bottom()">&raquo;</button>
    </footer>
  </div>
</template>
<script>
import { Table } from './table.js'

export default {
  data() {
    return {
      head: Table[0],
      page: [],
      page_size: 0,
      pages: 0,
      start: 1,
      num: 0,
      rows: Table.length - 1
    }
  },
  mounted () {
    this.page_size = Math.floor((this.$el.parentElement.clientHeight - 55 - 36) / 27)
    this.page = Table.slice(1, this.page_size)
    window.onresize = () => {
      this.page_size = Math.floor((this.$el.parentElement.clientHeight - 55 - 36) / 27)
    }
  },
  watch: {
    page_size(size) {
      const num = Math.floor(this.start / size)
      this.start = num * this.page_size + 1
      this.num = num + 1
      this.page = Table.slice(this.start, this.start + this.page_size)
      this.pages = Math.floor(this.rows / this.page_size + .5)
    },
  },
  methods: {
    go() {
      console.log('Entered: ' + this.num)
      this.start = (this.num - 1) * this.page_size + 1
      this.page = Table.slice(this.start, this.start + this.page_size)
    },
    prev() {
      if (this.num == 1) return
      --this.num
      this.start -= this.page_size
      this.page = Table.slice(this.start, this.start + this.page_size)
    },
    next() {
      if (this.num == this.pages) return
      ++this.num
      this.start += this.page_size
      this.page = Table.slice(this.start, this.start + this.page_size)
    },
    top() {
      this.start = this.num = 1
      this.page = Table.slice(this.start, this.start + this.page_size)
    },
    bottom() {
      this.num = this.pages
      this.start = (this.num - 1) * this.page_size
      this.page = Table.slice(this.start, this.start + this.page_size)
    }
  }
}
</script>
<style scope>
footer {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 36px;
  border-top: 1px solid lightgray;
  padding: 3px;
  box-sizing: border-box;
  text-align: center;
}
footer button, footer input {
  margin: 3px;
}
footer span {
  font-size: smaller;
}
table {
  margin: 3px;
  width: 100%;
}
input {
  width: 40px;
}
</style>

Здесь кода немного больше, но начнём с шаблона. Он содержит собственно таблицу с заголовком и подвал с кнопками навигации и полем для ввода номера страницы. Обратите внимание, что при отрисовке таблицы Table, которая и содержит весь миллионный набор строк, будут использованы не все её данные, а только часть, загруженная в массив page. Думаю, с шаблоном всё понятно. Перейдём к коду.


Самая сложная часть, это определение количества строк, отображаемых на одной странице, поскольку размер окна браузера может изменяться. Для этого при загрузке экземпляра шаблона (функция mounted) мы определяем текущий размер страницы, заполняем её данными начиная с первой строки и вешаем обработчик изменения размеров окна браузера, который будет пересчитывать размер страницы. А в секции watch прикажем шаблону следить за изменением этого размера и перезагружать страницу новым набором данных, если это необходимо. В секции methods описывается реакция на нажатия клавиш и ввод номера страницы в подвале таблицы.


Перейдём к шаблону, реализующему прокрутку нашей длинной таблицы. В главном шаблоне указано (import ScrollTable from './ScrollTable.vue'), что он реализован в файле ScrollTable.vue.


ScrollTable.vue


<template>
  <div
    @scroll.prevent
    class="wheel"
  >
    <table>
      <caption>The scroll test for table of ({{rows}} rows)</caption>
      <thead>
        <tr>
          <th v-for="col in head" :key="col">{{col}}</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="row in page" :key="row">
          <td v-for="col in row" :key="col">{{col}}</td>
        </tr>
      </tbody>
    </table>
    <div ref="scroller" class="scroller">
      <div
        class="handle"
        :style="{top:htop+'px'}"
        @mousedown="start_handle"
      ></div>
    </div>
  </div>
</template>
<script>
import { Table } from './table.js'

export default {
  data() {
    return {
      head: Table[0],
      page: [],
      page_size: 0,
      hndle: false,
      htop: 0,
      start: 1,
      rows: Table.length - 1
    }
  },
  mounted () {
    this.page_size = Math.floor((this.$el.parentElement.clientHeight - 62) / 27)
    this.page = Table.slice(1, this.page_size)
    window.onresize = () => {
      this.page_size = Math.floor((this.$el.parentElement.clientHeight - 62) / 27)
    }
    window.onwheel = (e) => {
      if (e.target.closest('div').classList.contains('wheel')) {
        this.mousewheel(e)
      }
    }
    window.onmousemove = this.handle
    window.onmouseup = this.stop_handle
  },
  watch: {
    page_size(size) {
      const num = Math.floor(this.start / size)
      this.start = num * this.page_size + 1
      this.num = num + 1
      this.page = Table.slice(this.start, this.start + this.page_size)
      this.pages = Math.floor(this.rows / this.page_size + .5)
    }
  },
  methods: {
    mousewheel(e) {
      let move = Math.floor(e.deltaY / 114);
      this.start += move
      if (this.start < 0) this.start = 0;
      if (this.start > this.rows - this.page_size)
        this.start = this.rows - this.page_size;
      this.page = Table.slice(this.start, this.start + this.page_size)
      const h = this.$refs.scroller.clientHeight - 8
      this.htop = Math.floor(h / this.rows * this.start + .5)
    },
    start_handle() {
      if(this.hndle) return
      this.hndle = true
    },
    stop_handle() {
      if(!this.hndle) return
      this.hndle = false
      this.page = Table.slice(this.start, this.start + this.page_size)
    },
    handle(e) {
      if(!this.hndle) return
      let top = this.htop + e.movementY
      const h = this.$refs.scroller.clientHeight - 8
      if (top < 0) top = 0
      else if (top > h) top = h
      this.htop = top
      this.start = Math.floor(this.rows / h * this.htop + .5)
      this.page = Table.slice(this.start, this.start + this.page_size)
    }
  }
}
</script>
<style scope>
table {
  margin: 3px;
  width: 100%;
}
.wheel {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  user-select: none;
}
.scroller {
  position: absolute;
  top: 58px;
  bottom: 14px;
  right: 8px;
  width: 4px;
  background: #eee;
}
.handle {
  position: absolute;
  width: 10px;
  height: 8px;
  top: 0;
  border: 1px solid #CCC;
  border-radius: 3px;
  left: -4px;
  background: #FFF;
}
</style>

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


В шаблоне вместо подвала с кнопками определены два вложенных div-а, которые и реализуют ползунок. Положение ползунка определяется inline-стилем :style="{top:htop+'px'}", связанным с переменной htop, и может изменяться при его перетаскивании мышкой вверх или вниз, а также при прокрутке таблицы при помощи колёсика мыши. Изменение положения ползунка при его перетаскивании влияет на позицию начала страницы в таблице, а изменение позиции начала страницы при использовании колёсика мыши изменяет позицию ползунка.


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


И никакого громоздкого кода, связанного с обновлением DOM!


Бросающийся в глаза недостаток — это повторение одного и того же кода в секции watch обоих шаблонов. Но в Vue можно использовать так называемые примеси (mixins), которые позволяют избежать подобных повторений.


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


Подводя итоги могу сказать, что Vue 3 очень мощный и удобный фреймворк для создания Web-приложений на встраиваемых системах, занимающихся сбором и анализом большого массива показаний сенсоров либо иных умных устройств.


Теги:
Хабы:
Всего голосов 9: ↑8 и ↓1+7
Комментарии32

Публикации

Информация

Сайт
hr.auriga.ru
Дата регистрации
Дата основания
Численность
501–1 000 человек
Местоположение
Россия