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

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

Пустой Viewport div

Перемещение виджетов по окнам браузера

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, который можно проверить прямо в консоли главного окна:

Настройка Dedicated Workers

Если вписать "useSharedWorkers”: true в файл neo-config.json, фреймворк переключится на интерфейс SharedWorkers, который больше нельзя будет увидеть в консоли окна браузера:

Интерфейс 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

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Tobias Uhlig: Sharing real-time WebSocket data across multiple browser windows

Предыдущая статьяАтомарный дизайн: структурирование приложений React