[译]基于 Web 的多屏应用程序,包括拖放功能

141 阅读21分钟

原文:medium.com/geekculture…

我对这个话题感到非常兴奋,因为我们将要讨论的技术为新一代基于Web的应用程序开辟了道路,这些应用程序可以在多个浏览器窗口之间直接通信,而无需涉及后台。

【题外话】这篇文章很长。如果你只有很短的时间,可以看看第 2 节中的视频,阅读第 10 节中的重点,然后再决定是否要阅读全文。

附录

  1. 上一篇文章:《将单页应用扩展到多个浏览器窗口
  2. 停靠的浏览器窗口

1.导言

如果你想创建一个基于Web的集成开发环境或银行/交易应用程序,并同时在多个屏幕上运行(例如),你将面临几个问题。

当然,你可以创建一个桌面端套壳(例如使用 GitHub Electron),并在全屏模式下在每个屏幕上显示一个浏览器窗口,但一旦多个窗口需要交互时就会变得非常棘手。

这从简单的事情开始:你在左侧屏幕上有一个表格,只要你点击表格的一行,你就希望调整右侧屏幕上图表的内容。

如果要将内容从一个屏幕动态移动到另一个屏幕,情况就会变得复杂。举例说明:你有一个包含导航树和内容视图的屏幕,但你想将导航树移动到一个单独的浏览器窗口,同时保留原有功能。

在多个浏览器窗口之间拖放可能是最困难的部分。想象一下,一个应用程序在两个屏幕上运行(每个屏幕上都有一个全屏浏览器窗口),你可以创建一个应用程序内对话框,然后将其从一个屏幕拖动到另一个屏幕。你可能知道并喜欢大多数操作系统桌面的这一功能,你可以在多个屏幕上轻松拖动程序/视图。那么,为什么不为网络应用程序提供同样的功能呢?

2.演示应用程序

我们正在使用一个简单但功能强大的演示应用程序:

image.png

你可以在浏览器中打开主窗口,然后通过右上角的按钮打开停靠窗口。你可以动态切换第二个窗口的停靠面。

我们可以用左上角的按钮打开一个应用内对话框,然后将其拖入停靠窗口并放置在那里。我们还可以将它从停靠窗口拖回主窗口。

让我们来看看实际操作:

image.png

3.我们是否使用了 HTML5 拖放 API?

你可能对这个很熟悉:

HTML 拖放 API - Web API | MDN

简而言之:我们没有使用它。

API 非常适合简单的用例,比如一个文件上传按钮,可以将 CVS 文件导入应用程序。你可以将项目从桌面拖入浏览器窗口,这一点非常好。另外,当鼠标不悬停在浏览器窗口上时,拖动代理也是可见的。不过,拖动代理只能进行很少的自定义,而且在拖放时只能获得一个数据传输对象。

该应用程序接口实际上并不适合应用程序特定的复杂用例,比如当你想移动一个大的组件树时。

4.我们如何在浏览器窗口之间进行通信?

A: 显然,你可以使用后端来处理这一部分。例如,使用Socket连接 2 个浏览器窗口,然后就可以推送更改。从性能角度来看,这种方法简直就是噩梦。

B: 浏览器窗口可以使用 postMessages 与它创建的弹出窗口(和 iFrames)通信:

window.postMessage - Web API | MDN

这种方法已经比 "A" 好多了,但它也给开发者带来了很多问题:

  1. 业务逻辑放在哪里?
  2. 如何避免代码冗余?
  3. 如果有多个弹出窗口,会发生什么情况? 想象一下,弹出窗口 1 产生了一个新的弹出窗口 2,你想在主窗口和弹出窗口 2 之间进行通信
  4. 当将组件移动到不同窗口时,能否保持组件的 JS 实例不变? 并非不可能,但是......

C: 明智的做法是使用 SharedWorker: SharedWorker - Web API | MDN

SharedWorkers 也使用 postMessages,因此每个浏览器窗口都可以直接连接到一个 Worker 实例,并可以设置通信。

没有只使用弹出窗口的限制。你也可以使用 "真正的" 浏览器窗口。

image.png

这可能更接近实际使用情况,即使用本地 shell 并在每个屏幕上添加一个 WebView(无头浏览器窗口)。

5.浏览器对 SharedWorkers 的支持如何?

再说一遍:多窗口应用最常见的用例是本地外壳,因此你可以选择浏览器(例如无头 Chromium)。

不过,了解对所有浏览器的支持还是很有帮助的:
developer.mozilla.org/en-US/docs/…

image.png

Google Chrome中的支持非常完美。SharedWorkers 甚至支持 JS 模块。

自从微软 Edge 改用 Chromium 引擎后,它与 Chromium 处于同一水平。

我们有一个关于 Android 上 Chrome 的未结清单:

bugs.chromium.org/p/chromium/…

Firefox支持SharedWorker(我上次检查时不包括 JS modules)。

WebKit (Safari)尚未支持SharedWorker:

bugs.webkit.org/show_bug.cg…

团队至少正在考虑恢复它,因此在票据中添加评论会有所帮助!

6.neo.mjs 框架非常适合!

在创建这个演示应用程序时,我们不想重新发明轮子,只需用很少的代码就能解决这个用例。

neo.mjs 框架和所有演示都是完全开源的(MIT 许可),因此你可以根据自己的需要使用、扩展和定制。

你可以在这里找到该项目: neomjs/neo

简单了解一下Neo的应用架构:

image.png

SharedWorkers 设置正是我们跨窗口拖放演示所需要的。我们只需更改一个框架配置,就能从普通 Workers 设置切换到 SharedWorkers 设置:

useSharedWorkers: true

使用框架的方式保持不变。

那么,这个演示环境的主要优势是什么呢?

  1. 我们可以获得开箱即用的Worker设置和通信 API。
  2. 我们可以在所有连接的浏览器窗口中获得唯一的 DOM ID。
  3. DOM 事件与主线程完全分离。
  4. 所有组件(JS 实例)都位于应用 Worker 中。

7.演示应用程序代码库概览

目前,代码库位于 apps 文件夹中:

github.com/neomjs/neo/…

准确地说,是 /sharedDialog 和 /sharedDialog2。

【题外话】之所以采用这种布局,是因为现在在线示例(Github 页面)的部署方式。如果能将每个示例放到自己的版本库中,并调整部署以单独拉入每个版本库,效果会更好。这样我们还可以为每个示例使用不同的框架版本。在我的待办事项列表中!

描述这两种主要观点相当琐碎:

apps/shareddialog2/view/MainContainer.mjs

import Button                  from '../../../src/button/Base.mjs';
import MainContainerController from './MainContainerController.mjs';
import Toolbar                 from '../../../src/container/Toolbar.mjs';
import Viewport                from '../../../src/container/Viewport.mjs';

/**
 * @class SharedDialog2.view.MainContainer
 * @extends Neo.container.Viewport
 */
class MainContainer extends Viewport {
    static getConfig() {return {
        className : 'SharedDialog2.view.MainContainer',
        autoMount : true,
        controller: MainContainerController,
        layout    : {ntype: 'vbox', align: 'stretch'},
        style     : {padding: '20px'},

        items: [{
            module: Toolbar,
            flex  : 'none',
            items :[{
                module  : Button,
                disabled: true,
                flag    : 'open-dialog-button',
                handler : 'onCreateDialogButtonClick',
                iconCls : 'far fa-window-maximize',
                text    : 'Create Dialog'
            }]
        }, {
            ntype: 'component',
            flex : 1,
            html : '#2',

            style: {
                alignItems    : 'center',
                color         : '#bbb',
                display       : 'flex',
                fontSize      : '200px',
                justifyContent: 'center',
                userSelect    : 'none'
            }
        }]
    }}
}

Neo.applyClassConfig(MainContainer);

export {MainContainer as default};

第一个 MainContainer 的代码非常相似,但也包含停靠窗口雷达:

apps/shareddialog/view/MainContainer.mjs

我们在 app1 中创建了 DemoDialog.mjs 视图:

apps/shareddialog/view/DemoDialog.mjs

该文件包含在此处,并不存在于我们的停靠窗口应用程序中。

import Dialog    from '../../../src/dialog/Base.mjs';
import TextField from '../../../src/form/field/Text.mjs';

/**
 * @class SharedDialog.view.DemoDialog
 * @extends Neo.dialog.Base
 */
class DemoDialog extends Dialog {
    static getConfig() {return {
        className: 'SharedDialog.view.DemoWindow',
        title    : 'Drag me across Windows!',

        containerConfig: {
            style: {
                padding: '20px'
            }
        },

        itemDefaults: {
            labelWidth: 70
        },

        items: [{
            module   : TextField,
            flex     : 'none',
            labelText: 'Field 1'
        }, {
            module   : TextField,
            flex     : 'none',
            labelText: 'Field 2'
        }],

        wrapperStyle: {
            height: '40%',
            width : '40%'
        }
    }}
}

Neo.applyClassConfig(DemoDialog);

export {DemoDialog as default};

接下来让我们看看第二个 MainContainerController: apps/shareddialog2/view/MainContainerController.mjs

import ComponentController from '../../../src/controller/Component.mjs';

/**
 * @class SharedDialog2.view.MainContainerController
 * @extends Neo.controller.Component
 */
class MainContainerController extends ComponentController {
    static getConfig() {return {
        /**
         * @member {String} className='SharedDialog2.view.MainContainerController'
         * @protected
         */
        className: 'SharedDialog2.view.MainContainerController'
    }}

    /**
     *
     * @param {Object} data
     */
    onCreateDialogButtonClick(data) {
        let app = Neo.apps['SharedDialog'];

        if (app) {
            app.mainViewInstance.controller.createDialog(data, this.view.appName);
        }
    }
}

Neo.applyClassConfig(MainContainerController);

export {MainContainerController as default};

仔细想想,这已经很了不起了!

停靠窗口应用程序本身不包含任何逻辑,因此存在冗余。

onCreateDialogButtonClick()

上述方法希望主应用程序存在,而主应用程序在此上下文中是公平的。我们将直接在主应用程序的视图控制器中触发一个方法。我们也可以在该主视图实例上触发一个事件,并对其进行订阅。

因此,主应用程序的 MainContainerController 包含了所有相关的业务逻辑,只需 600行代码即可完成:

apps/shareddialog/view/MainContainerController.mjs

在深入探讨之前,让我们先介绍一下拖放的基本知识,以便我们能够站在同一起跑线上。

8.拖放概念

当我们拖动应用程序中的对话框时,我们拖动的不是真正的 DOM 节点,而是一个所谓的 proxyEl。

proxyEl 应该是真实元素的轻量级克隆。对于对话框来说,它包含页眉和空主体,以减少浏览器的回流(想象一下包含网格/表格的复杂组件树)。

对话框类仍在开发中,你可以在此处找到它:

src/dialog/Base.mjs

onDragStart()我们将淡出真正的对话框 DOM(降低不透明度),并移动 proxyEl。

这种方法的一个好处是,我们可以很容易地对其进行扩展和增强。例如,我们可以实现一个 ESC 键监听器,以便在任何时候取消当前的拖动操作(销毁 proxyEl,淡出对话框)。

对话框类使用的是 draggable.DragZone.EI 的一个实例:

src/draggable/DragZone.mjs

它可以将 proxyEl 动作直接分配给可选的主线程插件:

src/main/addon/DragDrop.mjs

=> 对于简单的用例,我们不需要将每个 drag:move 事件都推送给应用程序 Worker,而且从性能角度来看,将动作保留在 main 中也是合理的。

简而言之: 对话框类已具备在其所在应用(浏览器窗口)中拖动对话框的逻辑,因此我们无需在演示应用中实现这部分功能。

9.多窗口拖放逻辑

如 7.中所述,你可以在此处找到完整的逻辑:

apps/shareddialog/view/MainContainerController.mjs

首先,让我们看看 createDialog()

/**
 *
 * @param {Object} data
 * @param {String} appName
 */
createDialog(data, appName) {
    let me = this;

    me.enableOpenDialogButtons(false);

    me.dialog = Neo.create(DemoDialog, {
        animateTargetId    : data.component.id,
        appName            : appName,
        boundaryContainerId: null,
        cls                : [me.currentTheme, 'neo-dialog', 'neo-panel', 'neo-container'],

        dragZoneConfig: {
            alwaysFireDragMove: true
        },

        listeners: {
            close          : me.onDialogClose,
            dragZoneCreated: me.onDragZoneCreated,
            scope          : me
        }
    });
}

这两个应用程序都有一个 "创建对话框" 按钮,点击它将创建一个新的对话框实例。我们也可以在关闭时取消挂载对话框,然后在点击按钮时再次挂载。这取决于你的应用程序的使用情况(是要保留经常使用的对话框,还是像登录表单这样(大多数情况下......)只使用一次的对话框?)

启用或禁用 "创建对话框 "按钮非常简单,因为所有组件都位于应用程序 Worker 中。因此,你可以使用 manager.Component 查找实例,即使它们的 DOM 位于不同的浏览器窗口中:

/**
 *
 * @param {Boolean} enable
 */
enableOpenDialogButtons(enable) {
    this.getOpenDialogButtons().forEach(button => {
        button.disabled = !enable;
    });
}

/**
 *
 */
getOpenDialogButtons() {
    return ComponentManager.find({
        flag: 'open-dialog-button'
    });
}

button.disabled是一个框架配置,因此只要为其分配一个新值,就会触发一个设置器,并自动为你更新 UI。

我们在对话框实例配置中添加了 dragZoneCreated 监听器,因此我们可以监听相关事件:

/**
 *
 * @param {Object} data
 */
onDragZoneCreated(data) {
    let me = this;

    data.dragZone.on({
        dragEnd  : me.onDragEnd,
        dragMove : me.onDragMove,
        dragStart: me.onDragStart,
        scope    : me
    });
}

DragZone 本身将在对话框类中 onDragStart() 创建(如果它还不存在),因此我们需要这个自定义事件钩子。

/**
 *
 * @param {Object} data
 */
onDragStart(data) {
    if (this.hasDockedWindow()) {
        let me               = this,
            appName          = me.view.appName,
            dockedHorizontal = me.dockedWindowSide === 'left' || me.dockedWindowSide === 'right';

        me.dialogRect = data.dragElementRect;

        for (let item of data.eventData.path) {
            if (item.tagName === 'body') {
                me.dragStartWindowRect = item.rect;
                break;
            }
        }

        if (me.hasDockedWindow()) {
            Neo.Main.getWindowData({
                appName: me.dialog.appName === appName ? me.dockedWindowAppName : appName
            }).then(data => {
                me.targetWindowSize = dockedHorizontal ? data.innerWidth : data.innerHeight;
            });
        }
    }
}

我们只在存在第二个浏览器窗口的情况下才执行 onDragStart() 操作。

我们假设在拖动操作过程中无法调整对话框或浏览器窗口的大小(也许可以使用某些操作系统快捷键,但不能使用鼠标)。

我们存储对话框矩形,其大小与 proxyEl 相同。

我们存储 dragStartWindowRect 。重要:这是我们开始拖动的窗口的 document.body DOMRect,因此可以是主窗口或停靠窗口。

我们会在 targetWindowSize 中存储未开始拖动的窗口的大小。

/**
 *
 * @param {Object} data
 */
onDragMove(data) {
    if (this.hasDockedWindow()) {
        let me                  = this,
            dialogRect          = me.dialogRect,
            dockedWindowAppName = me.dockedWindowAppName,
            dragStartWindowRect = me.dragStartWindowRect,
            proxyRect           = Rectangle.moveTo(dialogRect, data.clientX - data.offsetX, data.clientY - data.offsetY),
            side                = me.dockedWindowSide,
            proxyPosition, vdom;

        // in case we trigger the drag:start inside the docked window,
        // we can keep the same logic with just flipping the side.
        if (me.dialog.appName === dockedWindowAppName) {
            dockedWindowAppName = me.view.appName;
            side                = me.getOppositeSide(me.dockedWindowSide);
        }

        if (Rectangle.leavesSide(dragStartWindowRect, proxyRect, side)) {
            proxyPosition = me.getProxyPosition(proxyRect, side);

            if (!me.dockedWindowProxy) {
                vdom = Neo.clone(me.dialog.dragZone.dragProxy.vdom, true);

                delete vdom.id;

                Object.assign(vdom.style, {
                    ...proxyPosition,
                    transform         : 'none',
                    transitionProperty: 'none'
                });

                me.dockedWindowProxy = Neo.create({
                    module    : Component,
                    appName   : dockedWindowAppName,
                    autoMount : true,
                    autoRender: true,
                    cls       : ['neo-dialog-wrapper'],
                    renderTo  : 'document.body',
                    vdom      : vdom
                });
            } else {
                me.updateDockedWindowProxyStyle({
                    ...proxyPosition,
                    visibility: null
                });
            }
        } else {
            me.updateDockedWindowProxyStyle({visibility: 'hidden'});
        }
    }
}

对于 drag:move,我们使用的是矩形实用工具类:

src/util/Rectangle.mjs

(当我们添加浮动菜单时,我们可以进一步增强这个功能)。

如果还没有 proxyEl,我们将创建它(包括当前位置),否则我们将把它移动到与鼠标光标相匹配的位置。

我们将隐藏非 drag:start 窗口的 proxyEl,以防 proxyEl 在 drag:start 窗口内完全可见(否则会产生副作用,因为拖动速度非常快)。

这里的线索是,如果我们在停靠窗口内开始拖动,我们可以假装它是主窗口,在另一侧有一个停靠窗口 => 重新使用相同的业务逻辑

/**
 *
 * @param {Object} data
 */
onDragEnd(data) {
    if (this.hasDockedWindow()) {
        let me                  = this,
            dialog              = me.dialog,
            dragStartWindowRect = me.dragStartWindowRect,
            proxyRect           = Rectangle.moveTo(me.dialogRect, data.clientX - data.offsetX, data.clientY - data.offsetY),
            side                = me.dockedWindowSide;

        if (dialog.appName === me.dockedWindowAppName) {
            side = me.getOppositeSide(me.dockedWindowSide);
        }

        if (Rectangle.leavesSide(dragStartWindowRect, proxyRect, side)) {
            if (Rectangle.excludes(dragStartWindowRect, proxyRect)) {
                me.mountDialogInOtherWindow({
                    proxyRect: proxyRect
                });
            } else {
                me.dropDialogBetweenWindows(proxyRect);
            }
        }
    }
}

如果我们将对话框完全拖放到拖动:开始窗口内,我们不需要做任何事情,因为对话框 DragZone 会处理它。

如果对话框完全拖放到另一个窗口内,或者确实拖放到两个窗口之间,我们就需要切换逻辑。

10.将对话框放到另一个窗口中

这是本文的重点。

/**
 *
 * @param {Object} data
 * @param {Object} data.proxyRect
 * @param {Boolean} [data.fullyIncludeIntoWindow]
 */
mountDialogInOtherWindow(data) {
    let me                   = this,
        appName              = me.view.appName,
        dialog               = me.dialog,
        dragEndWindowAppName = me.dockedWindowAppName,
        side                 = me.dockedWindowSide,
        proxyPosition, wrapperStyle;

    if (dialog.appName === dragEndWindowAppName) {
        dragEndWindowAppName = me.view.appName;
        side                 = me.getOppositeSide(me.dockedWindowSide);
    }

    proxyPosition = me.getProxyPosition(data.proxyRect, side, data.fullyIncludeIntoWindow);

    dialog.unmount();

    // we need a delay to ensure dialog.Base: onDragEnd() is done.
    // we could use the dragEnd event of the dragZone instead.
    setTimeout(() => {
        dialog.appName = dialog.appName === dragEndWindowAppName ? appName : dragEndWindowAppName;

        me.getOpenDialogButtons().forEach(button => {
            if (button.appName === dialog.appName) {
                dialog.animateTargetId = button.id;
            }
        });

        wrapperStyle = dialog.wrapperStyle;

        wrapperStyle.left = proxyPosition.left;
        wrapperStyle.top  = proxyPosition.top;

        dialog.wrapperStyle = wrapperStyle;

        me.destroyDockedWindowProxy();

        dialog.mount();
    }, 70);
}

我们基本上可以将代码简化为

dialog.unmount();  
  
dialog.appName = 'SharedDialog2'; // 另一个窗口应用程序的名称
  
dialog.mount();

现在你很可能会问自己

"这到底是怎么做到的?"

这又会让我们回到:

那么,[neo.mjs]在这个演示上下文中的主要优势是什么?

  1. 我们可以获得开箱即用的 Worker 设置和通信 API。
  2. 我们可以在所有连接的浏览器窗口中获得唯一的 DOM ID。
  3. DOM 事件与主线程完全分离。
  4. 所有组件(JS 实例)都位于应用 Worker 中。

我们可以保留相同的对话框 JS 实例,因为它位于(共享)应用 Worker 中。

我们只需在另一个浏览器窗口中 mount() 即可,因为我们知道不会有任何冲突的 DOM id。

去耦合 DOM 事件意味着对话框的整个业务逻辑仍将正常工作。将对话框拖放到另一个窗口后,你仍然可以关闭它、调整它的大小或再次拖动它。

框架知道哪个应用程序位于哪个窗口中,因此我们只需调整 appName 即可。

11. 在线演示

我已将演示添加到 neo.mjs 在线示例中: neomjs.github.io/pages/

请在 desktop 版本的 Google Chrome 中打开演示。

dist/production/apps/shareddialog/index.html

dist/prod 版本也可在 Firefox 中运行,但尚未完善。停靠窗口的位置需要调整,CSS 也存在一些问题。拖放逻辑本身运行良好。

apps/shareddialog/index.html

开发模式版本只能在 Chrome 和 Edge 中运行,因为其他浏览器还不支持SharedWorker范围内的 JS 模块。在此版本中,你可以看到真正的代码,因为它无需任何构建即可运行。

要调试 SharedWorkers,你需要输入以下 URL:

chrome://inspect/#workers

image.png

然后点击应用程序 Worker,就会出现一个新的控制台窗口。

在开发模式下,你将获得真正的代码(无sourcemap):

image.png

12.我们能做得更多吗?

答案显然是"是! "。

  1. 我们可以为 Firefox 全面打磨演示。
  2. 使逻辑更加通用。我们可以扩展 dialog.Base 或为SharedWorker上下文创建一个新的 DragZone 实现。这将使其更易于使用。
  3. 添加跨窗口动画。

虽然我们可以轻松更改对话框的动画目标(animationTarget),这很贴心:

image.png

如果我们能将锚节点保留在不同的窗口中,那就更好了。理论上:我们可以同时在两个窗口中以不同的位置启动相同的动画(例如,将 proxyEl 从窗口外移动到一个窗口中的按钮,然后将其从对话框移动到另一个窗口可见区域外的目标位置)。

  1. 在窗口之间丢弃对话框是一个非常有趣的话题。

我最初采用的方法是找出与对话框有较大交集的窗口,然后将对话框移动到该窗口:

/**
 *
 * @param {Object} proxyRect
 */
dropDialogBetweenWindows(proxyRect) {
    let me           = this,
        dialog       = me.dialog,
        intersection = Rectangle.getIntersectionDetails(me.dragStartWindowRect, proxyRect),
        side         = me.dockedWindowSide,
        size         = proxyRect.height * proxyRect.width,
        wrapperStyle;

    if (intersection.area > size / 2) { // drop the dialog fully into the dragStart window
        me.destroyDockedWindowProxy();

        wrapperStyle = dialog.wrapperStyle;

        if (dialog.appName === me.dockedWindowAppName) {
            side = me.getOppositeSide(side);
        }

        switch (side) {
            case 'bottom':
                wrapperStyle.top = `${me.dragStartWindowRect.height - proxyRect.height}px`;
                break;
            case 'left':
                wrapperStyle.left = '0px';
                break;
            case 'right':
                wrapperStyle.left = `${me.dragStartWindowRect.width - proxyRect.width}px`;
                break;
            case 'top':
                wrapperStyle.top = '0px';
                break;
        }

        dialog.wrapperStyle = wrapperStyle;
    } else { // drop the dialog fully into the dragEnd window
        me.mountDialogInOtherWindow({
            fullyIncludeIntoWindow: true,
            proxyRect             : proxyRect
        });
    }
}

平心而论,这对于大多数使用情况来说已经足够了。

image.png

不过,你可以在屏幕之间移动基于操作系统的应用程序,它们仍能保持完整的功能。那么,为什么不能让基于网络的应用程序也实现同样的功能呢?

我想到的第一个办法是克隆对话框 JS 实例,在每个窗口中都有一个实例。这种方法会带来很多副作用和自定义逻辑。如果拖动真实实例会发生什么情况?更糟糕的是,如果拖动克隆的实例会发生什么?我们需要为用户能与之交互的每个组件定制逻辑,并保持它们同步(例如,在你向 TextField 输入时)。

有了 neo.mjs 设置,我们可以做得更好!

我们只需将对话框的 DOM 放到两个窗口中,逻辑仍可正常工作。我最初的想法是保留 1 个 vdom(新状态)对象和 2 个 vnode 对象(当前状态),但这需要进行两次 delta 更新计算。

由于对话框应该是同步的(位置样式值除外),我们甚至不需要这样做。

我的最新想法是在应用程序 Worker 中添加一个标记,以防它的组件(对话框)同时存在于一个以上的浏览器窗口中。

如果是这样,我们就可以调整组件的 appName 配置,以支持一系列应用程序。如果我们更改了不止一次挂载的对话框中组件的 vdom,它可以只向所有相关的主线程发送 delta 更新。

这仍然是用户生成更改的问题,例如,当你在文本字段中输入内容时,我们只需更新 vdom 和 vnode,对于单页面应用程序来说就没有什么可做的了。或者在滚动网格/表格时,也不需要滚动同步。

因此,有几个地方需要框架触发新的 delta 更新,最好不要为非SharedWorker上下文添加无关代码。

这是我非常希望进一步推动的一个主题!

  1. 一旦这个逻辑到位,我们还可以做一些事情,比如调整对话框的大小,使其能够在一个以上的浏览器窗口中运行。

  2. 我们可以支持 2 个以上的窗口。例如,上面有 2 个屏幕,下面有 2 个屏幕 => 一个对话框可以同时拖入 4 个窗口。我知道,这是边缘情况的边缘情况......:)

  3. 我们可以在 DragZone 中添加一个钩子,以防止在设置了标记的情况下发生拖放逻辑(例如,如果我们将对话框拖放到其他窗口,就没有必要将其拖放到原窗口(调用 setTimeout() 的原因))。

13.neo.mjs 框架的下一步是什么?

同样,你也可以在这里找到完全 MIT 许可的项目:

neomjs/neo

我的内部路线图长得令人难以置信。

实话实说,在实现拖放功能的过程中,我就有了这个技术演示的想法,并有动力去完成它,只是想感受一下是否真的有这个需求。

如果你正在开发基于网络的多屏应用程序的业务用例,请务必在这里发表评论!

据我所知,目前使用该框架的客户都专注于 SPA:

image.png

我将把重点放在框架核心和添加更多组件上,并首先进一步完善现有组件。

有客户要求视图模型和数据绑定(虚拟机配置到组件配置),这是一项艰巨的任务。

一旦 webpack(Acorn)支持添加公共类字段,简化组件配置 => 是我的首要任务。这也是第一批突破性改动之一,因此这将成为 neo.mjs v2。

14.最后的思考:行动号召!

如此规模的开源项目需要大量的工作和热爱。

这不是指这个技术演示本身,而是指整个 neo.mjs 框架范围。

要达到可持续发展的水平,它们需要一个由贡献者或赞助者组成的活跃社区。最好两者兼备。

遗憾的是,neo.mjs 仍然是一颗隐藏在野外的宝石,还没有足够多的人注意到它的存在。

我认为,当该框架最初在 GitHub 上发布时(2019 年 11 月),其概念过于超前。从那时起,已经发生了很多变化(GA 发布至今已提交 4700 多条)。我真心希望现在能有更多开发者准备好一试。

如果你想支持这个项目,请告诉你的朋友或分享这篇文章。

这对我来说意义重大!

如果你拥有 JS 技能,我们也非常欢迎你的参与。

我知道,虽然使用 neo.mjs 相当简单,但为它做贡献却是另一回事,需要专家级的水平。

我仍然愿意帮助更多的开发人员提高开发速度!

目前,我将把更多精力放在帮助客户项目将其第一个 neo.mjs 应用程序投入生产上,以免在财务方面耗尽精力。不用担心,我会在业余时间继续尽我所能推动这个项目。

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

附录

1.上一篇文章:《将单页应用扩展到多个浏览器窗口

这个演示应用程序有意保持尽可能简单。

如果你想了解更复杂的使用案例(不包括跨窗口拖放),请点击此处:

image.png

你可以在这里找到相关文章:

将单页应用程序扩展到多个浏览器窗口 | 作者:Tobias Uhlig

虽然这篇文章已经有点过时,但 Covid 应用程序本身仍然是如何在 SharedWorkers 上下文中进行通信的绝佳范例。

2.停靠式浏览器窗口

目前的实现更像是一个附带产品,因为它方便测试不同的拖放方向。

不过,我已经得到反馈,认为这对一般的多窗口应用很有用。

尤其是在用户使用带鱼屏幕的情况下。

你知道,就像这样:

image.png

在 neo.mjs 代码库中,我们有一个新的(可选的)主线程插件来支持这一点:
src/main/addon/WindowPosition.mjs

你只需将其放入 index.html 文件(mainThreadAddons)以及 buildScripts/webpack/json/myApps.json 中即可。

image.png

有两个主要限制:

  1. 你只能修改通过 window.open() 创建的浏览器窗口,这意味着它仅限于弹出窗口。因此,你可以调整弹出窗口的大小和位置,但不能调整主窗口本身。
  2. 有一个报错(安全功能)阻止你通过编程将弹出窗口移动到不同的屏幕上。例如:在第二个屏幕上打开主窗口。打开一个停靠窗口。将主窗口拖到第一个屏幕上。停靠窗口将停留在第二个屏幕上,尽管位置是正确的。如果你手动将弹出窗口拖到第一个屏幕上,它就会重新工作。

我没有深入研究这两个问题,可能有浏览器标志允许这样做。