Одна из моих работ связана с отлаживанием протоколов общения теплосчётчиков для удалённого снятия показаний. Чаще всего теплосчётчик даже не мой, а находится у клиента на объекте, поэтому я подключаю его через GPRS-терминал к какой-либо своей сетевой машине, где поднят TCP сервер откуда посылаю байтики и смотрю, что мне ответит теплосчётчик. Посылать байты с консоли можно, но не очень удобно. Есть ряд приложений с которыми этот процесс упрощается, но я решил сделать себе в помощь специальный TCP сервер в виде Node-приложения с Web-интерфейсом.
Приложение уже готово и, если на компьютере установлена Node, то вы можете запустить его прямо сейчас набрав в терминале командуnpx tcpsrv
, а затем зайти браузером на адрес http://localhost:7000, чтобы попасть в Web-интерфейс сервера.
Если под рукой нет свободных TCP-клиентов, то в целях тестирования можно воспользоваться встроенным эхо-клиентом, который будет отправлять обратно на сервер те же самые байты, которые только что получил от него. Запускается он командой npx tcpsrv --echo
.
Под катом я расскажу в деталях о создании этого приложения. Рассказ будет очень длинным, чашка кофе или бокал пенного лишними не будут.
Маргинальные фреймворки
Не секрет, что в мире Frontend'а что ни день, то новый фреймворк. Но сложилось так, что есть три нерушимых столпа React, Angular и Vue. Мой основной фреймворк в работе - Svelte. Многие не без оснований уже считают его четвертым столпом. У него обширное сообщество, достаточно богатая экосистема - инструменты для разработки, метафреймворки, готовые компоненты и рецепты. Даже вакансии для специалистов со знанием Svelte можно встретить.
Но есть ещё множество, по-своему удобных, но неизвестных, маргинальных фреймворков, знание которых никак не поможет вам на собеседовании, но при этом они могут обладать уникальными преимуществами. Malina.js - один из таких фреймворков, с которым я сильно подружился и стал использовать в некоторых своих pet-проектах.
Однажды, у меня было немного свободного времени, и я изучал, как устроен REPL на сайте Svelte. В то же время я услышал о Malina, которая на тот момент была ещё примерно в стадии PoC, и решил написать REPL для неё, просто ради академического интереса. У этих REPL'ов много общего, поскольку Malina очень сильно напоминает Svelte. Она тоже является компилируемым фреймворком - из деклартивных однофайловых компонентов, собирает императивный нативный JS-код. В бенчмарках Малинка обгоняет Svelte (не говоря уж о большой тройке), при этом бандл получается компактнее.
Если посмотреть на, прямо скажем, небогатую документацию этого фреймворка, то отличий от Svelte можно и не увидеть вовсе. В основном вся разница у них под капотом, в более эффективном методе отрисовки DOM и ином методе отслеживания изменений переменных в компоненте, благодаря которому реактивность работает при любом изменении стейта, даже мутациях. Например в Svelte не будет никакой перерисовки, если мы добавим элемент в массив обычным arr.push('a')
, придется использовать явное присваивание типа arr = [...arr,'a']
. Но концептуально и визуально, Svelte и Malina очень сильно похожи, можно пройти замечательный Учебник по Svelte и тогда вы научитесь делать приложения сразу на обоих фреймворках.
К сожалению, сообщество у Малины пока очень скудное, а экосистемы практически никакой, поэтому в процессе изучения и использования фреймворка в своих pet-проектах часто не хватало какой-либо библиотеки, которые приходилось писать самому. Среди созданных мной библиотек: malinajs-router - маршрутизатор, облегченный порт моей библиотеки tinro для Svelte, malina-ui - приятный UI-кит и storxy - глобальный стор, принципиально похожий на writable-хранилище в Svelte.
В общем, и в этот раз для очередного своего pet-проекта, TCP сервера, я выбрал Малину в качестве фреймворка для web-интерфейса. Проект создаётся командой npm init malina,
выбираем нужный шаблон(нам само-собой нужен Fullstack).
В результате получим костяк будущего приложения с папками src/server
и src/client
, в которых соответственно находятся логика Express-like сервера и клиентские компоненты Malina (которые имеют расширение xht
). Дополнительно я указываю псевдонимы путей в файле jsconfig.json и ставлю специальный esbuild-плагин, что позволяет импортировать компоненты по более лаконичным путям.
Архитектура
Наш сервер должен уметь принимать TCP подключения, в реальном времени передавать данные от клиента в web-интерфейс и обратно через HTTP-сервер. Клиент может присылать данные самостоятельно, без предварительных запросов с нашей стороны, поэтому нам необходимо постоянное подключение к серверу, чтобы такие данные сразу отображались в web-интерфейсе. Обычное дело в таком случае поднимать соединение WebSocket, но есть еще один вариант, который до сих пор почему-то не используется широко - Server Sent Events (SSE). Из названия должно быть ясно, что в отличии от WebSocket'ов, такой канал является однонаправленным, данные идут только от сервера к клиенту, но при этом используется протокол HTTP, что позволяет реализовать такое соединение на любом HTTP-сервере без дополнительных библиотек, а так же не требует особых настроек в случае использования реверс-прокси в продакшене. Данные клиенту из web-интерфейса будем отправлять обычным POST-запросом, поскольку никакого постоянного соединения тут не требуется.
Общение TCP и HTTP серверов будет иметь схожую логику. HTTP сервер сможет подписаться на поступление данных от клиента и сразу же отправлять их по SSE в web-интерфейс. А при поступлении POST- запроса, будет вызываться callback-функция полученная от TCP сервера, которая отправит данные клиенту.
Пара слов о Storxy
Поскольку далее этот пакет сторов будет использоваться как в клиентской, так и в серверной части, то для более продуктивного понимания дальнейшего изложения следует познакомить вас с общими принципами его работы.
Если вы уже знакомы с writable-хранилищами в Svelte или какими-либо другими сторами типа Observable, то работа Storxy покажется очень знакомой. Функция store
из этого пакета создает объект хранилища у которого есть свойство $
и метод subscribe
. При любом изменении значения свойства $
будут вызываться все callback-функции, которые были переданы через метод subscribe(newValue => {...})
в тех местах приложения, которые заинтересованы в получении этих данных.
const myStore = store('hello');
const un = myStore.subscribe(value => console.log(value)); // Console: hello
myStore.$ = 'habr'; // Console: habr
myStore.$ += 'habr'; // Console: habrhabr
myStore.$ = myStore.$.toUpperCase(); // Console: HABRHABR
Метод subscribe
возвращает функцию, при вызове которой происходит отписка от изменений, то есть callback-функция будет удалена из списка подпиcчиков стора.
Также важным в нашем приложении является и второй аргумент функции store
. Если передать туда функцию, то она будет вызвана когда у стора появится первый подписчик. Можно сделать какие-то предварительные действия, например, открыть SSE-соединение с сервером. Если из этой функции вернуть еще одну функцию, то она будет вызвана, когда у стора не останется ни одного подписчика. Здесь можно подчистить концы, например, закрыть соединение открытое ранее.
const myStore = store(0, st => {
console.log(`Первый подписчик!`);
const timer = setInterval( ()=>st.$++, 1000 );
return ()=>{
console.log(`Больше нет подписчиков!`);
clearInterval(timer);
}
});
Таким образом пакет позволяет создавать атомарные сторы, весит считанные байты и в своих Svelte-проектах, при возможности, я предпочитаю пользоваться им взамен встроенных сторов, DX которых отличается в компонентах ("сахарное" изменение значения $mystore=42
) и в JS-коде (значение меняется только методами mystore.set(42)
илиmystore.update()
). У Storxy же в обоих случаях используется mystore.$=42
, что напоминает "сахарный" вариант в Svelte.
Server-side
TCP сервер поднимается методом createServer
из стандартного Node-пакета net
. При подключении нового клиента, для сокета создаётся специальный объект socketObj
, в котором, среди прочего, есть свойство data
со стором, хранящим массив всех принятых и отправленных в этом соединении данных, а также методы send
и close
, позволяющие отправить данные в сокет или закрыть соединение. Этот объект добавляется в массив в сторе socketStore
, который хранит в себе набор всех доступных на данный момент сокетов.
const socketObj = {
...
ip: socket.remoteAddress,
data: store([]), // Стор с массивом всех переданных и полученных данных
send(data){
socket.write(Buffer.from(data));
// Добавляем в стор отправленное собщение
this.data.$.push({type:'out',data});
},
close: ()=>socket.destroy()
};
// Добавляем в стор полученные данные
socket.on('data',data => socketObj.data.$.push({type:'in',data: data.toJSON().data}));
socketStore.add(socketObj);
...
Конструкция вида data.toJSON().data
в обработчике события получения данных нужна, чтобы побайтово преобразовать данные полученные из типа Buffer
в массив целых чисел, поскольку в дальнейшем в приложении используется именно такой формат передачи и хранения байтов. В методе socketObj.send
наоборот, массив байтов из аргумента функции преобразуется в Buffer
, а затем отправляется в сокет.
Таким образом, после запуска TCP сервера, у нас есть стор socketStore
, на который мы можем подписаться, чтобы получать актуальный список сокетов с краткой информацией о каждом. При подключении новых или отключении имеющихся клиентов этот стор будет обновляться и уведомлять всех своих подписчиков. Зная id
нужного сокета, методом socketStore.get
мы можем получить объект socketObj
в котором можем подписаться на свойство socketObj.data
, чтобы получать обновления при появлении новых данных в соединении.
Имея возможность получать обновления по всем нужным нам данным реализация эндпоинтов HTTP-сервера, куда будет подключатья web-интерфейс, кажется уже весьма простой задачей. Так оно и есть.
Предварительно создадим и подключим middleware-функцию, которая будет отвечать на запрос SSE соединения и добавлять в Responce
объект метод res.SSE.send
для отправки событий на клиент. Наверняка, есть ряд готовых подобных пакетов, но это как раз тот случай когда написать своё быстрее, чем найти готовое. Как я уже говорил, работа с SSE очень проста.
GET: /server/info
Незатейлевый эндпоинт, просто отдаем информацию(хост и порт) о запущенном TCP сервере из socketStore.info()
.
SSE: /events/list
Это SSE эндпоинт, который передаёт на клиент актуальный список подключенных сокетов, всякий раз когда он изменяется. Поскольку это SSE запрос, он будет держаться открытым, пока одна из сторон не захочет закрыть его.
app.get('/events/list', (req, res) => {
const un = socketStore.$$(list => {
res.SSE.send('list', list);
});
res.on('close', un);
});
Тут мы просто подписываемся на socketStore($$
- это псевдоним для метода subscribe
в сторе Storxy). Как только список сокетов меняется, на клиент сразу же отправляется событие 'list'
и данные в виде массива с информацией о всех текущих сокетах. Не забываем в случае закрытия SSE канала отписаться от обновлений, чтобы не плодить утечки памяти и лишнюю работу стора.
GET: /socket/:socket_id
При открытии окна с сообщениями сокета в web-интефейсе нужно будет передать туда всю имеющуюся на текущий момент историю полученных и отправленных данных. По предоставленному в параметре socket_id
достаем нужный сокет и отправляем всё содержимое из стора socket.data
.
app.get('/socket/:socket_id', (req, res) => {
const socket = socketStore.get(req.params.socket_id);
if(!socket) return res.error('Unknown socket ID');
res.send(socket.data.$);
});
SSE: /events/socket/:socket_id
После получения списка всей текущей истории обмена данными из прошлого эндпоинта, web-интерфейсом будет открывается SSE соединение. Тут мы подписываемся на стор socket.data
и при его обновлении отсылаем на клиент только последнюю запись.
app.get('/events/socket/:socket_id', (req, res) => {
const socket = socketStore.get(req.params.socket_id);
if(!socket) return res.error('Unknown socket ID');
const un = socket.data.$$(data => {
res.SSE.send('message', data[data.length - 1]);
},true);
res.on('close', un);
});
Внимательный читатель заметит второй аргумент в функции $$
, если установить его в true
, то при подписке на стор сразу же не будет вызвана его callback-функция, только уже при последующих обновлениях. Здесь нам важно отправлять только новые записи в истории сообщений, поэтому необходимо предотвратить отправку последней имеющийся записи при открытии SSE-соединения.
POST: /socket/:socket_id
Получаем данные из web-инитерфейса и из тела POST-запроса извлекаем массив байтов, который затем преобразуемв объект Buffer
и отправляем TCP-клиенту методом socket.send
.
app.post('/socket/:socket_id', (req, res) => {
const socket = socketStore.get(req.params.socket_id);
if(!socket) return res.error('Unknown socket ID');
try {
socket.send(req.body);
res.send({ok:true});
} catch (err) {
res.error(err.message);
}
});
Стоит обернуть отсылку данных в сокет конструкцией try ... catch
, поскольку с сокетом могут случиться разного рода неприятности пока мы будем отправлять данные и такую проблему нужно отловить.
Это все эндпоинты, которые понадобятся в нашем приложении, так что серверная часть получилась не очень сложной.
Client-side
Теперь посмотрим на реализацию web-интерфейса. Для общения с HTTP-сервером напишем пару хелперов.
Первый, api - это простая обёртка вокруг функции fetch()
, которая отправляет GET и POST(если передаются данные) запросы и распарсивает полученый в ответе JSON.
Второй, SSEClient
- поинтереснее, он открывает SSE-соединение с помощью встроенного интерфейса EventSource()
. Затем подписывается на нужные события с сервера, список которых должен быть передан в виде объекта handlers
, где ключи - это названия событий, а значения - callback-функции, которые надо выполнить, когда придёт соответствующее событие.
export function SSEClient(endpoint,handlers){
const source = new EventSource(endpoint);
for(let event in handlers){
source.addEventListener(event,e => handlers[event](JSON.parse(e.data)));
}
return ()=>source.close();
}
Особо приятно, что не нужно заботится о переподключении при потере связи с сервером. Это происходит автоматически.
Конечно же, нам понадобятся сторы. socketList
при появлении первого подписчика подключается к серверу и обновляет значение стора при поступлении актуального списка в SSE-событии list
. Функция SSEClient()
возвращает функцию, при вызове которой соединение прекращается. Она будет вызвана, когда от стора отпишутся все имеющиеся подписчики, поскольку мы возвращаем её из функции первого подписчика(см. раздел о Storxy выше). На самом деле в приложении не возникнет момента, когда от этого стора отпишутся все подписчики, но такую ситуацию предусмотреть стоит.
export const socketsList = store([], st => {
return SSEClient('/events/list',{
list: data => {
st.$ = data;
}
});
});
Ещё есть функция, которая будет создавать стор для нужного соединения. В функции первого подписчика такого стора запрашивается и становится его значением вся текущая история общения с клиентом, а затем поднимается SSE-соединение из которого новые сообщения добавляются к текущему значению стора.
export function makeSocketStore(id){
const socketStore = store([], async st => {
// Получаем список всех данных на текущий момент
const currentData = await api('/socket/'+id);
if(currentData !== null) {
st.$ = currentData;
return SSEClient('/events/socket/'+id,{
message: data => {
// Добавляем данные в массив в сторе
st.$.push(data);
}
});
}
});
// Отправка данных в сокет
socketStore.send = async function(data){
return await api('/socket/'+id,data);
};
socketStore.status = computed(socketsList, list => {
return list && list.find( s => s.id == id );
});
return socketStore;
}
Также добавлены дополнительные методы socketStore.send
для отправки данных в сокет посредством POST-запроса и socketStore.status
который также является стором со значением параметров этого сокета из списка socketsList
. Если клиент отключится, значение этого стора станет равным null
. Функция computed
из пакета Storxy создает стор, значение которого меняется, когда меняется один из сторов, переданных ей в качестве параметра. Аналогично derrived-хранилищу в Svelte.
Интерфейс
Для ускорения создания внешнего вида приложения воспользуемся библиотекой malina-ui, отсюда нам понадобятся кнопки, иконки, управление светлой и тёмной темой и прочее. Компонент Pane отлично подходит для создания прямоугольных интерфейсов, который нам нужен. Создаём им практически всю структуру интерфейса в корневом компоненте App.xht.
Делим приложение на логические части, которые размещаем по соотвествующим компонентам, вроде Header.xht и SocketList.xht, верхний заголовок и список доступных соединений в левой панели. Всегда актуальный список находится в сторе socketsList
, отрисовываем его в цикле each
.
{#each socketsList.$ as socket}
<li>
<a href="/socket/{socket.id}"><span>#{socket.num}</span> {socket.ip}</a>
</li>
{/each}
Как видно, для навигации по приложению мы используем обычные ссылки, маршрутизатор malinajs-router перехватывает клики по ссылкам и показывает содержимое своего компонента <Route>
согласно указанному в свойстве пути.
Структура навигации крайне простая, все адреса которые не соответсвтуют корню или /socket/*
редиректятся в корень, благодаря fallback-маршруту, который активируется, если никто из соседних компонентов не подошел под текущий URL.
<Route path="/">
...
</Route>
<Route path="/socket/:socket_id" force>
{#slot params}
<Socket id={params.socket_id}/>
{/slot}
</Route>
<Route fallback redirect="/" />
Свойство force
на маршруте отображения выбранного соединения необходимо, чтобы содержимое данного компонента перерисовывалось при любой смене URL. Без этого свойства, если адрес меняется с условного /socket/foo
на /socket/bar
полной перерисовки содержимого не произойдет, потому что этот маршрут отображается как в первом случае, так и во втором. Просто в комопнент <Socket>
будет передан новый параметр socket_id
из URL.
Также здесь можно заметить небольшое отличие от Svelte. Свойства переданные в слот получаются при помощи блока {#slot}
, а в Svelte это достигалось бы примением директивы let:params
на компоненте маршрута.
Компонент <Socket> содержит внутри историю обмена сообщениями с клиентом, редактор для ввода байтов для отправки и боковую панель куда мы можем сохранить байты из редактора для дальнейшего повтороного использования.
При отрисовывании этого компонента создается стор socketStore
для указанного socket_id
. Затем выполняются подписки на этот стор где при обновлении мы в переменную requests кладем обновленную историю переписки, а так же на стор socketStore.status
, который возвращает параметры соединения только когда соответствующий сокет открыт на сервере.
const socketStore = makeSocketStore(id);
$onMount(()=>socketStore.status.$$( params => {
online = !!params;
if(online && !info) info = {
ip: params.ip,
num: params.num,
}
}));
$onMount(()=>socketStore.$$( data => {
requests = data;
}));
Встроенная функция $onMount
является аналогом onMount
в Svelte. За исключением того, что в Malina мы можем сразу её использовать, а в Svelte необходимо предварительно импортировать её оператором import {onMount} from 'svelte
'. Обратите внимание, что в обоих случаях callback-функции в $onMount
возвращают функцию отписки от соответствующих сторов. Они будут выполнены, когда компонент будет уничтожаться. Поэтому здесь мы можем не бояться утечек памяти при выборе других соединений в списке, когда компонент <Socket>
будет полностью перерисовываться.
Редактор
Остановимся поподробнее на редакторе, это самый сложный и функционально насыщенный компонент во всём приложении.
Наружу он предоставляет свойство value
, по аналогии с нативными элементами ввода в браузере. Значение этого свойства всегда равно массиву целых чисел, согласно байтам введенным в редакторе. Например для того набора байт, что показаны на картинке выше, в value
мы будем иметь массив [255,160,161,162]
. В таком же виде байты передаются TCP-серверу, который, как я уже упоминал, преобразует их в Buffer
перед отправкой клиенту.
Сами байты могут отображаться в редакторе в трех возможных режимах - Hex, Dec и Ascii - что соответственно означает шестнацетиричные и десятичные числа или символ из ASCII таблицы, соответствующий данному байту. Вне зависимости от того как отображаются байты в редакторе, в массиве байтов в value
всегда будут только целые числа.
Редактор состоит из таблицы по 16 ячеек в ряд, в каждой из которых имеется элемент <input>
. Количество строк динамически изменяется в зависимости от количества введенных байтов.
<td><input type="text" *byteInput={id} value={getByteValue(id)}/></td>
Действие (aka Action), функция жизненого цикла отдельно взятого элемента, запускается при отрисовке этого элемента и первым аргументом получает ссылку на него в DOM дереве. Здесь мы прикрепили такую функцию директивой *byteInput
в которую передали параметр id
- порядковый номер данного байта в массиве value
. Действия в Svelte и Malina, работают абсолютно одинаково, только в Svelte мы бы использовали директиву use:byteInput
.
Задач у функции byteInput сразу несколько:
Она навешивает на поле ввода "слушателей" нажатий клавиш стрелок, Backspace или Enter с целью перемещения фокуса ввода на нужную ячейку, которую легко найти, благодаря тому, что ссылка на каждый элемент
input
помещается в специальный массив. Так при нажатии стрелки влево мы просто выбираем из массива предыдущий элементinputs[id-1]
, а если нужна ячейка выше -inputs[id-16]
. Для простоты, я сделал функцию, которая возвращает сразу все соседние ячейки.Создаёт обработчик события
input
иblur
, где то, что ввел пользователь форматируется под вид байта в текущем режиме редактора. Например, для Hex байтов можно вводить только цифры и буквы от A до F. При этом мы всегда хотим видеть символы в верхнем регистре, а когда пользователь введёт два символа, перевести курсор в следующую ячейку. А если фокус с ячейки пропадет, когда там был введен только один символ, то нужно добавить перед ним '0'. Для Dec и Ascii режимов свои правила. Я вынес все эти требования, а также функции для преобразования в/из целого числа в отдельный файл, что сильно облегчило логику в компоненте. Так что остается при каждом изменении значения просто выполнять переприсваивание значения текущей ячейкеel.value = formatByte(el.value)
, где formatByte занимается тем, что форматирует байт согласно выбранного режима.Записывать текущее значение байта в массив
value
, когда пользователь введет необходимое число символов или нажмет Enter. При удалении символов из ячейки приведет либо к удалению последнего байта, либо к замещению байта на значение 0, в зависимости от того, находится ли эта ячейка в конце или середине набора байтов.
Поскольку свойство value
может быть задано родительским компонентом, за отображение байтов в ячейках отвечает одностороннее привязывание на поле ввода value={getByteValue(id)}
. Функция getByteValue возвращает соответствующее текущему режиму представление байта согласно его положению в массиве value
.
При нажатии кнопки Send, которая находится в компоненте <Socket>
, свойство value
у реадактора очищается (т.е. value=[]
) и набранные байты отправляются клиенту методом socketStore.send()
.
После отправки данных, нам не нужно заботится о том, чтобы они появились в нашем списке истории общения. Благодаря выстроенной архитектуре всё произойдет автоматически, по SSE будет полученно событие, что добавилась исходящая запись, это приведёт к обновлению значения стора socketStore
, что в свою очередь обновит список сообщений в интерфейсе.
Список сообщений
Как я писал выше, в компоненте <Socket>
есть подписка на стор socketStore
, которая обновляет переменную стейта requests
, благодаря чему в ней всегда содержится актуальный список всех данных, которыми обменялись клиент и сервер к текущему моменту. Каждая запись массива представляет с собой объект с полями type
и data
. Первое, это индикатор входящее сообщение или исходящее, а второе собственно массив переданных байтов.
Выводится этот список при помощи блока {#each}
. Компонент <Request> отображает каждое сообщение в табличке по 16 байт на строку с возможностью смены режима отображения - Hex, Dec или Ascii. При этом используются те же правила форматирования, которые мы уже рассмотрели в компоненте редактора. Сообщения от клиента прижимаются к левому краю контейнера, а наши (т.е. отправленные сервером) - к правому. Совсем как диалог в любом мессенджере.
<div *scrollToDown={requests}>
{#each requests as request}
<Request data={request.data} type={request.type}/>
{/each}
</div>
Видим, что сообщения выводятся внутри контейнера, с привязаным действием с параметром *scrollToDown={request}
. Как следует из названия, эта функция должна проматывать весь список сообщений вниз при первой загрузке и при поступлении новых сообщений.
Для этого мы возвращаем из функции метод update
, который будет запускаться всякий раз, когда значение параметра изменится, т.е. в нашем случае в массиве requests
появится новый элемент. Когда это случится, блок плавно прокрутится до конца нативным методом el.scroll()
.
function scrollToDown(el,param){
let preventScroll = false;
function handler(e){
preventScroll = el.scrollHeight > el.scrollTop+el.clientHeight+10;
}
el.addEventListener('scroll',handler);
return {
async update(p){
await $tick();
!preventScroll && el.scroll({top: el.scrollHeight, behavior: 'smooth'})
},
destroy(){
el.removeEventListener('scroll',handler);
}
}
}
Однако, пользователя определенно будет бесить ситуация, когда он решит проскроллить до более ранних сообщений вверху, чтобы изучить их, но внезапно список перемотается вниз, потому что в это время поcтупило новое.
Поэтому, мы добавляем на элемент обработчик события scroll
, где устанавливаем значение переменной preventScroll
в зависимости от того, докрутил ли пользователь прокрутку до конца списка или нет. Таким образом, если у пользователя прокрутка находится выше чем 10 пикселей(небольшой допуск) до конца, preventScroll
будет иметь значение true
и прокрутка в функции update
запускаться не будет.
Панель Пресетов
Справа в интерфейсе есть панель Пресетов куда можно сохранить наборы байтов из редактора для дальнейшего использования. Всё просто, набрали нужные байты и нажали кнопку Add Preset. В списке появится новый пресет, кликом по которому эти байты будут перенесены обратно в редактор. Также пресет можно удалить или отредактировать, изменить название или байты. Останавливаться подробно на этом функционале не будем, там всё довольно тривиально, лучше расскажу о том как организовано хранение этих пресетов.
Пресеты сохраняются в нативный localStorage
и Storxy снова здесь очень помогает. Для списка пресетов был создан стор presets
. В его значении находится список объектов пресетов, такой объект создается функцией makePresetObject() которая принимает в качестве аргумента объект с полями title
и data
(название пресета и массив байтов) и возвращает похожий объект, только с добавленными методами которые позволяют редактировать пресет или вовсе удалить его из стора.
function makePresetObject(preset) {
return {
title: preset.title || 'Preset #' + (presets.$.length + 1),
data: preset.data || [],
rename(title) { this.title = title; },
update(bytes) { this.data = bytes; },
delete() { presets.$ = presets.$.filter(item => item !== this); }
};
}
Стоит заметить, что даже когда метод объекта редактирет поле того же объекта через this
, это все равно будет замечено стором и срабоатет оповещение подписчиков, если этот объект является значением стора.
В стор preset
мы сразу же при старте загружаем сохраненный в localStorage
список пресетов. Затем подписываемся на изменения этого стора и при любых его изменениях сразу же записываем все содержимое стора в localStorage
. Таким образом, весь процесс работы с хранилищем автоматизирован и в нём всегда будут актуальные данные.
Поскольку в localStorage
можно хранить только JSON
, а значением стора у нас является массив объектов, созданных функцией makePresetObject
, то при сохрании нужно перегнать этот массив через метод JSON.stringify
, при этом все несериализуемые свойства и методы объектов удалятся, в нашем случае останутся только title
и data
. А при извлечении, содержимое хранилище прогоняется уже через функцию parse
, которая снова создает массив объектов со всеми нужными методами.
const KEY = 'saved_presets';
export const presets = store( parse(localStorage.getItem(KEY)), st => {
function update(e) {
if(e.key === KEY) {
st.$ = parse(e.newValue);
}
}
window.addEventListener('storage', update);
return () => window.removeEventListener('storage', update);
});
presets.subscribe(list => localStorage.setItem(KEY, JSON.stingify(list)), true);
function parse(json) {
return JSON.parse(json || '[]').map(makePresetObject);
}
Обработка события storage
, нужна на случай если у вас вдруг в браузере открыто несколько вкладок с приложением. Как только в одной вкладке произойдет какое-либо изменение содержимого localStorage
, остальные вкладки получат событие storage
и его новое значение, которое мы тут же устанавливаем стору. Таким образом список пресетов у нас будет везде одинаковым.
Напоследок
Приложение TCP сервера, конечно будет дорабатываться впоследствии. Например, совершенно точно необходима проверка и добавление CRC-байтов. Не помешает и более совершенная система хранения пресетов с категориями.
Надеюсь, мне удалось показать интересные практики реактивного программирования не только на клиенте, но и на сервере. Сторы в данном приложении по существу являются основой работы всего функционала приложения и местами очень упростили архитектуру.
Также, надеюсь, кто-то задумается, что не всегда нужно создавать соединение WebSocket, когда речь заходит о потоке данных с сервера. Для конкретных целей есть более эффективные и простые решения. Делать нотификации на WebSocket'ах не нужно.
Мне нравится работать с фреймворком Malina в своих pet-проектах также, как нравится работать со Svelte на работе. Для меня некоторые вещи в Малинке более интуитивны и требуют меньше "писанины". Не знаю, дорастет ли экосистема Malina когда-нибудь хотя бы до уровня экосистемы Svelte(или в Svelte вдруг появятся "фишки" из Малины), но поиграть с ней иногда никто не запрещает.