В прошлой статье я писал об 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()">«</button>
<button @click="prev()">‹</button>
<input v-model="num" @change="go()">
<span>of {{pages}}</span>
<button @click="next()">›</button>
<button @click="bottom()">»</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)
4reddy
28.05.2022 00:13-1Тюю.
$mol - вот, что по-настоящему сносит крышу и переворачивает сознание аж на 720 град!nin-jin
28.05.2022 00:184reddy
28.05.2022 13:34+1Сколько берёте за техподдержку?
nin-jin
29.05.2022 00:03+1Я же не многомиллионная корпорация. Я могу себе позволить и бесплатную поддержку.
4reddy
29.05.2022 00:57Тогда вдвойне странно, что с таким тарифом github всё ещё не перешёл на $mol.
nin-jin
29.05.2022 01:05Тогда им пришлось бы уволить половину программистов, чтобы не бездельничали. А у них же семья, ипотека, котики..
4reddy
29.05.2022 12:36Это не фреймворк, а золотая жила просто.
george3
29.05.2022 15:46Есть технология, даже мол не нужен. шлем дынные с сервера на любом языке - получаем GUI автоматом + оповещения что пользователь делает с данными. https://github.com/Claus1/unigui Можно вообще всех фронтеров уволить, но по сути, никому (кроме 2-5) не нужна. слишком непонятная..
nin-jin
29.05.2022 12:51А, да, я ж забыл показать как подобные таблицы делаются на $mol. Тут можно глянуть код, а тут пощупать его в действии.
danilovmy
30.05.2022 14:21Привет. Посмотрел, пощупал. В том виде, в каком это сейчас, не взлетит твой пепелац, какой бы он ни был быстрый верткий или легкий.
Итоговый результат никак не стилизуется под клиента персональным css. В итоге все сидят на одном стиле. Минус 50% клиентов, которые хотят менять стили каждый час по-своему, генеря свои css.
Конечный код страницы не проходит валидацию HTML. Минус клиенты, у кого это стоит в ТЗ. А код не пройдет валидацию, например, если есть одинаковые ID элементов.
Задание стилей: что-то пикселями и css параметрами в "raw text" а что-то функциями типа rem(что то там). Ээ а можно поконкретнее?
Непонятно кому я задаю стиль - где-то простановка тэгу html где-то тэгу с определенным id. Но не очевидно после получения кода страницы HTML - какой сущности сейчас проставляется стиль.
Мусорность конечного кода в браузере прям уух - вероятно, стоит поставить сборщик мусора после отрендера для элементов, не претерпевающих последующую переработку.
Кстати, не понял, как сделать lazy load для строк ниже экрана. Или это уже в коробке?
Как программист - я вижу элегантность некоторых решений в коде. Как продавец софта - продукт воняет неприменимостью на рынке. Как разработчик или дизайнер, которому с этим работать - пока с этим невозможно работать. Как тимлид небольшой команды - я не вижу выигрышных преимуществ у этого фреймворка. Пока без доводов.
nin-jin
30.05.2022 16:00Итоговый результат никак не стилизуется под клиента персональным css.
А код не пройдет валидацию, например, если есть одинаковые ID элементов.
что-то пикселями и css параметрами в "raw text" а что-то функциями типа rem(что то там). Ээ а можно поконкретнее?
Мусорность конечного кода в браузере прям уух - вероятно, стоит поставить сборщик мусора после отрендера для элементов, не претерпевающих последующую переработку.
Не смог распарсить эти фразы. Можно расшифровать?
кому я задаю стиль - где-то простановка тэгу html где-то тэгу с определенным id
Откуда вы всё это берёте-то?
Но не очевидно после получения кода страницы HTML - какой сущности сейчас проставляется стиль.
По именам атрибутов вполне понятно какому компоненту проставляется стиль и почему.
как сделать lazy load для строк ниже экрана
aktuba
Сейчас тебе всё расскажут про $mol))
nin-jin