В прошлой статье я писал об 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-приложений на встраиваемых системах, занимающихся сбором и анализом большого массива показаний сенсоров либо иных умных устройств.


Комментарии (18)


  1. aktuba
    27.05.2022 18:57
    +8

    Сейчас тебе всё расскажут про $mol))


    1. nin-jin
      28.05.2022 00:14


  1. 4reddy
    28.05.2022 00:13
    -1

    Тюю.
    $mol - вот, что по-настоящему сносит крышу и переворачивает сознание аж на 720 град!


    1. nin-jin
      28.05.2022 00:18


      1. 4reddy
        28.05.2022 13:34
        +1

        Сколько берёте за техподдержку?


        1. nin-jin
          29.05.2022 00:03
          +1

          Я же не многомиллионная корпорация. Я могу себе позволить и бесплатную поддержку.


          1. 4reddy
            29.05.2022 00:57

            Тогда вдвойне странно, что с таким тарифом github всё ещё не перешёл на $mol.


            1. nin-jin
              29.05.2022 01:05

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


              1. 4reddy
                29.05.2022 12:36

                Это не фреймворк, а золотая жила просто.


                1. george3
                  29.05.2022 15:46

                  Есть технология, даже мол не нужен. шлем дынные с сервера на любом языке - получаем GUI автоматом + оповещения что пользователь делает с данными. https://github.com/Claus1/unigui Можно вообще всех фронтеров уволить, но по сути, никому (кроме 2-5) не нужна. слишком непонятная..


                  1. nin-jin
                    29.05.2022 16:12

                    Желаю вам всю жизнь работать с такими интерфейсами через медленное / нестабильное / отсутствующее соединение к серверу под ddos-ом, на который выкатили критический баг.


                    1. aktuba
                      29.05.2022 16:20

                      Ну ты и мудак…


    1. danilovmy
      28.05.2022 13:34
      +4

      720 это два полных оборота. В итоге стоишь на том же месте и смотришь туда же .. но только ещё и голова закружилась. ????


      1. 4reddy
        29.05.2022 00:53
        +1

        Вы первый, кто разгадал этот вираж ????


  1. mSnus
    29.05.2022 04:20

    Когда я вижу - 55 - 36) / 27, я хватаюсь за пистолет


  1. nin-jin
    29.05.2022 12:51

    А, да, я ж забыл показать как подобные таблицы делаются на $mol. Тут можно глянуть код, а тут пощупать его в действии.


    1. danilovmy
      30.05.2022 14:21

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

      Итоговый результат никак не стилизуется под клиента персональным css. В итоге все сидят на одном стиле. Минус 50% клиентов, которые хотят менять стили каждый час по-своему, генеря свои css.

      Конечный код страницы не проходит валидацию HTML. Минус клиенты, у кого это стоит в ТЗ. А код не пройдет валидацию, например, если есть одинаковые ID элементов.

      Задание стилей: что-то пикселями и css параметрами в "raw text" а что-то функциями типа rem(что то там). Ээ а можно поконкретнее?

      Непонятно кому я задаю стиль - где-то простановка тэгу html где-то тэгу с определенным id. Но не очевидно после получения кода страницы HTML - какой сущности сейчас проставляется стиль.

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

      Кстати, не понял, как сделать lazy load для строк ниже экрана. Или это уже в коробке?

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


      1. nin-jin
        30.05.2022 16:00

        Итоговый результат никак не стилизуется под клиента персональным css.

        todomvc.hyoo.ru
        todomvc.hyoo.ru

        А код не пройдет валидацию, например, если есть одинаковые ID элементов.

        Все ID гарантированно уникальны
        Все ID гарантированно уникальны

         что-то пикселями и css параметрами в "raw text" а что-то функциями типа rem(что то там). Ээ а можно поконкретнее?

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

        Не смог распарсить эти фразы. Можно расшифровать?

        кому я задаю стиль - где-то простановка тэгу html где-то тэгу с определенным id

        Откуда вы всё это берёте-то?

        Но не очевидно после получения кода страницы HTML - какой сущности сейчас проставляется стиль.

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

         как сделать lazy load для строк ниже экрана

        Infinite scroll? Тут пример, а тут он в действии.

        $mol_infinite
        $mol_infinite