При создании многоэкранных приложений или приложений, параллельно работающих в нескольких окнах, можно сэкономить много трафика, если все подключенные участники будут использовать одни и те же данные.
Спойлер: Neo.mjs не использует localStorage.
Введение
Представленное здесь демо-приложение специально создано минималистичным. Оно содержит дэшборд с 3 виджетами (табличной, круговой и столбчатой диаграммами), которые можно разделить на отдельные окна браузера.
Интерфейс Data SharedWorker позволяет единоразово загружать данные во все подключенные окна, а благодаря «потоковому режиму» можно получать новые данные 60 раз в секунду.
App SharedWorker обеспечивает повторное использование экземпляров одного и того же компонента даже при смене окон. Это позволяет синхронизировать состояние.
Демо-видео
В идеале это видео надо смотреть на большом экране.
Репозиторий
Репозиторий проекта находится здесь.
Демо-код имеет лицензию MIT (лицензию открытого ПО, разработанную Массачусетским технологическим институтом). Поэтому можете использовать и расширять его по своему усмотрению. Файл readme содержит необходимые шаги для локального запуска приложения.
Бэкенд
Команда Neo.mjs старалась сделать код бэкенда максимально простым.
import Neo from 'neo.mjs/src/Neo.mjs'; import * as core from 'neo.mjs/src/core/_export.mjs'; import express from 'express'; import Instance from 'neo.mjs/src/manager/Instance.mjs'; import ColorService from './ColorService.mjs'; import { WebSocketServer } from 'ws'; const app = express(), wsServer = new WebSocketServer({ noServer: true }); wsServer.on('connection', socket => { socket.on('message', message => { let parsedMessage = JSON.parse(message), data = parsedMessage.data, service = Neo.ns(`Colors.backend.${data.service}`), replyData = service[data.method](...data.params), reply; if (parsedMessage.mId) { reply = { mId : parsedMessage.mId, data: replyData } } else { reply = replyData } socket.send(JSON.stringify(reply)) }) }); const server = app.listen(3001); server.on('upgrade', (request, socket, head) => { wsServer.handleUpgrade(request, socket, head, socket => { wsServer.emit('connection', socket, request) }) })
Использование express
и ws
позволяет создать минималистичный сервер, принимающий сокетные соединения на порт 3001. Ядро neo.mjs
импортируется в код бэкенда Nodejs, чтобы использовать внутренние утилиты и систему конфигурации классов.
import Base from 'neo.mjs/src/core/Base.mjs'; /** * @class Colors.backend.ColorService * @extends Neo.core.Base * @singleton */ class ColorService extends Base { static config = { /** * @member {String} className='Colors.backend.ColorService' * @protected */ className: 'Colors.backend.ColorService', /** * @member {Boolean} singleton=true * @protected */ singleton: true } // ... /** * @param {Object} opts * @param {Number} opts.amountColors * @param {Number} opts.amountColumns * @param {Number} opts.amountRows * @returns {Object} */ read(opts) { let data = this.generateData(opts); return { success: true, data: { summaryData: this.generateSummaryData(data, opts), tableData : data } } } } let instance = Neo.setupClass(ColorService); export default instance;
Полный код здесь: ColorService.mjs.
Service предоставляет только метод read()
, создающий случайные данные на основе 3 параметров, передаваемых как свойства внутри объекта opts:
- amountColors;
- amountColumns;
- amountRows.
Файл определения удаленного вызова процедур (RPC)
{ "namespace": "Colors.backend", "type" : "websocket", "url" : "ws://localhost:3001", "services": { "ColorService": { "methods": { "read": {"params": [{"type": "Object"}]} } } } }
В реальном приложении можно использовать операции CRUD (create, read, update, delete — создание, чтение, обновление, удаление) внутри каждого сервиса и в зависимости от сложности динамически создавать JSON-файл с доступными сервисами. В случае с определенными пользовательскими ролями или правами ситуация может быть другой.
Фронтенд-код будет получать этот файл с помощью метода fetch()
и открывать нужные пространства имен.
let me = this, model = me.getModel(); Colors.backend.ColorService.read({ amountColors : model.getData('amountColors'), amountColumns: model.getData('amountColumns'), amountRows : model.getData('amountRows') }).then(response => { let {data} = response; me.updateTable(data.tableData); me.updateCharts(data.summaryData) })
Конечно, вы можете использовать async/await
, если хотите.
Вся прелесть заключается в возможности напрямую применять метод Service как JavaScript-промис внутри кодовой базы фронтенда.
И это просто замечательный Promise
.
Когда Colors.backend.ColorService.read()
используется внутри SharedWorker
приложения (код фронтенда), neo.mj
отправляет post-сообщение в SharedWorker
. Здесь мы имеем дело с разделением задач. Data-Worker проверяет наличие соединения с сокетом и при необходимости (пере-) подключается. Затем он отправляет сообщение через сокет, а внутри кода бэкенда выполняется ColorService.read()
. Ответ на сообщение о соединении с сокетом будет отправлен обратно App-Worker, который, сопоставив его с первоначальным источником вызова, разрешит Promise
.
Как разработчику, вам не нужно беспокоиться о магии, которая здесь происходит.
Фронтенд
Оболочка приложения автоматически сгенерирована с помощью npx neo-app
.
<!DOCTYPE HTML> <html> <head> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta charset="UTF-8"> <title>Colors</title> </head> <body> <script src="../../src/MicroLoader.mjs" type="module"></script> </body> </html>
Index-файл для neo-приложений включает только модуль MicroLoader, который получает файл neo-config.json
из папки app и запускает главный поток neo. Он создает рабочие установки и после этого динамически загружает файл app.mjs
в папку app.
import Viewport from './view/Viewport.mjs'; export const onStart = () => Neo.app({ mainView: Viewport, name : 'Colors' });
Файл Viewport, который загружается в SharedWorker приложения:
import BaseViewport from '../../../node_modules/neo.mjs/src/container/Viewport.mjs'; import BarChartComponent from './BarChartComponent.mjs'; import HeaderToolbar from './HeaderToolbar.mjs'; import PieChartComponent from './PieChartComponent.mjs'; import TableContainer from './TableContainer.mjs'; import ViewportController from './ViewportController.mjs'; import ViewportModel from './ViewportModel.mjs'; /** * @class Colors.view.Viewport * @extends Neo.container.Viewport */ class Viewport extends BaseViewport { static config = { /** * @member {String} className='Colors.view.Viewport' * @protected */ className: 'Colors.view.Viewport', /** * @member {String[]} cls=['colors-viewport'] */ cls: ['colors-viewport'], /** * @member {Neo.controller.Component} controller=ViewportController */ controller: ViewportController, /** * @member {Object} layout */ layout: {ntype: 'vbox', align: 'stretch'}, /** * @member {Object[]} items */ items: [{ module: HeaderToolbar, flex : 'none' }, { module : TableContainer, reference: 'table' }, { module : PieChartComponent, reference: 'pie-chart' }, { module : BarChartComponent, reference: 'bar-chart' }], /** * @member {Neo.model.Component} model=ViewportModel */ model: ViewportModel } } Neo.setupClass(Viewport); export default Viewport;
3 виджета плюс HeaderToolbar перемещаются непосредственно в массив элементов Viewport. Кроме того, импортируются контроллер представления и модель представления.
import Component from '../../../node_modules/neo.mjs/src/model/Component.mjs'; import ColorsStore from '../store/Colors.mjs'; /** * @class Colors.view.ViewportModel * @extends Neo.model.Component */ class ViewportModel extends Component { static config = { /** * @member {String} className='Colors.view.ViewportModel' * @protected */ className: 'Colors.view.ViewportModel', /** * @member {Object} data */ data: { /** * @member {Number} data.amountColors=10 */ amountColors: 10, /** * @member {Number} data.amountColumns=10 */ amountColumns: 10, /** * @member {Number} data.amountRows=10 */ amountRows: 10, /** * @member {Boolean} data.isUpdating=false */ isUpdating: false, /** * @member {Boolean} data.openWidgetsAsPopups=true */ openWidgetsAsPopups: true }, /** * @member {Object} stores */ stores: { colors: { module: ColorsStore } } } } Neo.setupClass(ViewportModel); export default ViewportModel;
Модель представления в neo — это поставщик состояния (называемый «хранилищем» («store») в других библиотеках/фреймворках), который позволяет дочерним представлениям привязываться к свойствам данных. Можно также привязываться к нескольким свойствам данных в разных моделях представлений внутри родительской цепочки. Важная особенность этого демо-приложения: дерево состояний работает в разных окнах браузера.
Оболочка для дочерних приложений
Как и для основного приложения, для дочерних приложений создается index-файл, содержащий MicroLoader, и файл app.mjs, импортирующий Viewport:
import BaseViewport from '../../../../../node_modules/neo.mjs/src/container/Viewport.mjs'; /** * @class Widget.view.Viewport * @extends Neo.container.Viewport */ class Viewport extends BaseViewport { static config = { /** * @member {String} className='Widget.view.Viewport' * @protected */ className: 'Widget.view.Viewport' } } Neo.setupClass(Viewport); export default Viewport;
Viewport совершенно пуст. Если открыть приложение без загруженного основного приложения, увидим следующую картину:
Перемещение виджетов по окнам браузера
import Component from '../../../node_modules/neo.mjs/src/controller/Component.mjs'; /** * @class Colors.view.ViewportController * @extends Neo.controller.Component */ class ViewportController extends Component { static config = { /** * @member {String} className='Colors.view.ViewportController' * @protected */ className: 'Colors.view.ViewportController' } // ... /** * @param {Object} data * @param {String} data.appName * @param {Number} data.windowId */ async onAppConnect(data) { if (data.appName !== 'Colors') { let me = this, app = Neo.apps[data.appName], mainView = app.mainView, {windowId} = data, url = await Neo.Main.getByPath({path: 'document.URL', windowId}), widgetName = new URL(url).searchParams.get('name'), widget = me.getReference(widgetName), widgetParent = widget.up(); me.connectedApps.push(widgetName); me.getReference(`detach-${widgetName}-button`).disabled = true; widgetParent.remove(widget, false); mainView.add(widget) } } /** * */ onConstructed() { super.onConstructed(); let me = this; Neo.currentWorker.on({ connect : me.onAppConnect, disconnect: me.onAppDisconnect, scope : me }) } // ... } Neo.setupClass(ViewportController); export default ViewportController;
Внутри главного контроллера ViewportController подписываемся на событие connect
, которое срабатывает для каждого окна браузера, подключающегося к SharedWorker приложения.
В случае, если подключающееся окно не является главным окном приложения, вызываем: widgetParent.remove(widget, false);
.
Удаляем нужный виджет (табличную, круговую или столбчатую диаграмму) из Viewport внутри главного окна. Второй параметр false
— флаг для того, чтобы не уничтожать экземпляр компонента.
mainView.add(widget);
Затем добавляем виджет в главное представление → Viewport другого окна браузера. Поскольку повторно используем один и тот же экземпляр компонента, получаем последнее состояние «из коробки».
Все действительно так просто.
Тема повторного использования экземпляров компонентов заслуживает отдельной статьи, поскольку это очень мощная техника для уменьшения риска утечек памяти и повышения производительности во время выполнения.
Проверка приложения с помощью инструментов разработчика Chrome
При создании однооконного приложения в neo фреймворк использует интерфейс Dedicated Workers, который можно проверить прямо в консоли главного окна:
Если вписать "useSharedWorkers”: true
в файл neo-config.json
, фреймворк переключится на интерфейс SharedWorkers, который больше нельзя будет увидеть в консоли окна браузера:
Neo.mjs предоставляет слой абстракции для Workers, поэтому API для разработчиков остается точно таким же. Переключить однострочную конфигурацию можно в любой момент. Чтобы проверить SharedWorkers, нужно открыть: chrome://inspect/#workers
.
Просмотрите SharedWorker приложения и введите в консоль следующий код:
Neo.Main.windowOpen({url:’http://localhost:8080/apps/colors/childapps/widget/index.html?name=bar-chart’, windowName:’_blank’})
Можно ли увеличить производительность?
Производительность демо-приложения, хотя и очень высокая, еще не оптимизирована. Поэтому ответ на вопрос, поставленный в заголовке этого раздела, — однозначное «да!». Предполагается обновление расширения главного потока AmCharts до версии 5.
Можно также будет поэкспериментировать с различными настройками приложения. Например, создавать сокетное подключение непосредственно в App Worker, а не в Data Worker, чтобы уменьшить количество внутренних post-сообщений.
Обновления в проекте neo.mjs
Фреймворк за последние полтора года значительно продвинулся. Самым важным обновлением является то, что команда работает над новым сайтом продукта, который будет включать первую версию раздела самообучения, потребность в котором давно назрела. На завершение этой работы уйдет примерно еще около месяца.
Читайте также:
- Spring Boot, Kafka и WebSocket для отправки сообщений в реальном времени
- Как сделать приложение-чат с Redis, WebSocket и Go
- Как создавать веб-сокеты в Python
Читайте нас в Telegram, VK и Дзен
Перевод статьи Tobias Uhlig: Sharing real-time WebSocket data across multiple browser windows