[译]跨多个浏览器窗口共享实时 WebSocket 数据

222 阅读4分钟

原文:itnext.io/sharing-rea…

在创建多屏应用程序或在多个窗口内并行运行的应用程序时,当所有连接的参与者共享相同数据时,可以节省大量流量。

剧透:我们没有使用 LocalStorage。

1. 简介

我们的演示应用程序非常简洁。它包含一个带有 3 个部件(表格、饼图和条形图)的仪表盘,我们可以将它们分离到单独的浏览器窗口中。

我们使用一个数据 SharedWorker 只为所有连接的窗口加载一次数据,并且我们有一个 "流模式",在该模式下我们每秒拉取 60x 的新数据。

我们还使用了应用程序 SharedWorker,它使我们能够在更换窗口时重复使用相同的组件实例。这将保持状态同步。

image.png 一个浏览器窗口内的所有部件

image.png 将仪表板部件移到自己的窗口中

2.演示视频

image.png

3. 仓库

你可以在这里找到资源库:

github.com/neomjs/mult…

演示代码采用 MIT 许可,因此欢迎你随意使用和扩展。自述文件包含在本地运行应用程序所需的步骤。

如果你希望看到部署版本以直接在线测试,请提前通知我们!

4.后台

我们希望后台代码尽可能简单。

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

我们的服务将只公开 read() 方法,该方法将根据我们作为 opts 对象内部属性传递的 3 个参数为我们创建随机数据:

  1. 金额颜色
  2. 金额列
  3. 行数

5.RPC 定义文件

{
    "namespace": "Colors.backend",
    "type"     : "websocket",
    "url"      : "ws://localhost:3001",

    "services": {
        "ColorService": {
            "methods": {
                "read": {"params":  [{"type":  "Object"}]}
            }
        }
    }
}

在现实世界的应用程序中,你将在每个服务内进行 CRUD,并根据复杂程度,动态创建包含可用服务的 JSON 文件。对于特定的用户角色或权限,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

这样做的好处在于,你可以在前端代码库中直接将服务方法用作 JavaScript 承诺。

这就是 Promise 的地狱:)

image.png

如果我们在应用程序 SharedWorker (前端代码)中使用 Colors.backend.ColorService.read() ,neo.mjs 将向数据 SharedWorker 发送一条 post 消息。关注点分离。数据 Worker 将检查我们的套接字连接是否已经存在,并在需要时(重新)连接。然后,它将通过套接字发送我们的消息,并在我们的后端代码中执行 ColorService.read() 。特定套接字连接消息的响应将被发回给我们的 App-Worker。在映射到初始调用者后,它将解析我们的 Promise 。

作为开发人员,你无需担心这里发生的神奇事情。

6.前台

我们使用 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>

neo 应用程序的索引文件将只包含 MicroLoader 模块,它会获取我们应用程序文件夹中的 neo-config.json 文件,并启动 neo 主线程。它将为我们创建 Worker 设置,并在完成后动态加载应用程序文件夹中的 app.mjs 文件。

import Viewport from './view/Viewport.mjs';

export const onStart = () => Neo.app({
    mainView: Viewport,
    name    : 'Colors'
});

我们的视口文件将加载到应用程序的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 的 items 数组中。我们还导入了视图控制器和视图模型。

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 中,视图模型是一个状态提供器(或在其他库/框架中称为 "存储"),它允许子视图绑定到数据属性。我们还可以绑定父链中不同视图模型的多个数据属性。本演示应用程序的重要部分:我们的状态树可以跨浏览器窗口运行。

7.子应用程序的外壳

与主应用程序一样,我们有一个包含 MicroLoader 的索引文件和一个导入 Viewport 的 app.mjs 文件:

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;

视口是完全空的,因此如果我们在没有加载主应用程序的情况下打开应用程序,它看起来会是这样:

image.png 空视口 div

8.在浏览器窗口中移动小工具

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);

我们将从主窗口内的视口中移除所需的部件(表格、饼图或条形图)。第二个参数 false 是一个不销毁组件实例的标志。

mainView.add(widget);

然后,我们将部件添加到主视图 → 不同浏览器窗口的视口中。由于我们重复使用的是同一个组件实例,因此我们可以获得最新的状态。

就是这么简单。

重用组件实例这一主题值得我们专门撰写一篇博文,因为它是降低内存泄漏和运行时性能的一项非常强大的技术。

9.使用 Chrome 开发工具检查我们的应用程序

在 neo 中创建单窗口应用程序时,框架会使用 Dedicated Workers,我们可以直接在主窗口控制台中对其进行检查:

image.png 专职 Worker 设置

一旦我们在 "useSharedWorkers": true 文件中写入 neo-config.json 后,框架就会切换到 SharedWorkers,而我们在浏览器窗口控制台中再也看不到 SharedWorkers:

image.png 在主窗口控制台中看不到 SharedWorker

Neo.mjs 为 Worker 提供了一个抽象层,因此开发人员使用的 API 完全不变。我们可以在任何时候切换单行配置。要检查 SharedWorkers,你需要打开:

chrome://inspect/#workers

image.png

检查应用程序 SharedWorker,并在控制台输入以下代码:

Neo.Main.windowOpen({url:’http://localhost:8080/apps/colors/childapps/widget/index.html?name=bar-chart', windowName:’_blank’})

image.png

10.能否进一步提高性能?

image.png

虽然演示应用程序感觉非常快,但它尚未进行性能优化。因此,这个问题的答案显然是 "是的!"。我们仍需将 AmCharts 主线程插件升级(重写)到第 5 版。请给我们提个醒,以防这将成为优先事项。

我们还可以使用不同的应用程序设置。例如,直接在应用程序 Worker 内而不是在数据 Worker 内创建 Socket 连接,以减少内部发布消息的数量。

11.更新 neo.mjs 项目

这是我1年半来的第一篇博文。希望你喜欢,也欢迎你提出问题。

在此期间,框架本身也取得了很大进展。最重要的更新是,我们正在制作一个新的产品网站,其中将包括逾期已久的自学学习部分的第一个版本。粗略估计,完成这项工作还需要一个月左右的时间。

我们仍在每周四(中欧标准时间)17:30 举办免费研讨会。如果你想加入我们,请在 Slack 频道内与我们联系。

如果贵公司需要更多帮助,neo.mjs 核心团队现在提供专业讲师指导培训(40 小时,6-12 人参加)和专业服务(例如,帮助你创建下一代应用程序)。

github.com/neomjs/neo

欢迎反馈意见!

致以最诚挚的问候,祝你编码愉快、
托比亚斯