如何让Web应用程序支持多个浏览器窗口
动机
我们在开发单页应用时,通常只定义其在单个浏览器窗口中的行为,而即使在多个浏览器窗口上打开同一个应用,大多数情况下也只是与本地存储同步,各个窗口内各个应用的状态并不是实时同步的(除非服务器同步),它们孤立地运行,相对独立。
然而,这意味着更多的浏览器窗口将生成越来越多的独立应用程序实例,这些实例可能具有不同的 UI 状态,并且往往不可避免地具有相同的网络请求或 WebSocket 连接,这也可能意味着糟糕的用户体验(用户可能已经习惯了)和服务器资源的过度使用。
那么支持多个浏览器窗口的应用程序意味着什么呢?
- 应用程序实例共享:代码共享、本地存储共享、状态共享等
- 降低服务器资源使用率
- 更好的用户一致性体验
- 更流畅的 Web 应用程序
但让大型 Web 应用程序保持平稳运行并不容易。
Web 应用程序仍然主要使用 JavaScript 构建,而 JavaScript 是一种单线程编程语言,运行缓慢的 JavaScript 代码会阻碍浏览器的渲染。好消息是,主流浏览器正在逐渐支持更多不同类型的 Worker,尤其是 Service Worker,它们用于实现 PWA(渐进式 Web 应用),从而大幅提升用户体验。最新的现代浏览器也提供了 Web Worker 和 Shared Worker。随着 IE 在今年被弃用,这些 Worker 得到了良好的支持。目前,在现代浏览器中,只有Safari 缺少对 Shared Worker 的支持。
那么对于Web应用来说,通过Worker实现“多线程”到底意味着什么呢?
“ 2021 年 Web Worker 现状”这篇文章涵盖了许多难以预测的性能问题。有了这些浏览器 Worker,我们或许能够更好地处理计算复杂且运行缓慢的 JS 代码,从而确保 Web 应用程序的流畅运行。
是时候重新思考为什么我们不能让 Web 应用支持多浏览器窗口,并提升 Web 应用的性能了。新的架构需求带来了新的框架需求,而这样的应用我们称之为Shared Web Apps
。
共享 Web 应用程序
尽管我们希望用户打开尽可能少的应用程序窗口,但事实是许多用户会在多个浏览器窗口中打开同一个应用程序。
共享 Web 应用程序支持在多个浏览器窗口中运行 Web 应用程序。
它拥有一个唯一的服务器线程来共享共享 Web 应用,无论是代码共享、本地存储共享还是状态共享等等。无论打开多少个浏览器窗口,共享 Web 应用始终只有一个服务器应用实例供多个客户端应用共享。我们都知道 DOM 操作开销很大。在共享 Web 应用中,客户端应用实例仅负责渲染,除了状态同步之外,客户端应用将变得非常轻量,几乎所有业务逻辑都将在服务器应用中运行。
- 客户端应用仅渲染 UI,更好地利用设备的多个核心,确保客户端应用运行流畅
- 解决多浏览器窗口引起的问题
- 更好地分离关注点
reactant-share - 构建共享 Web 应用的框架
reactant-share 仓库:reactant
为了构建这样的共享 Web 应用程序,reactant-share
reactant-share 应运而生。它基于reactant
框架和react
库,支持以下功能。
- 依赖注入
- 不可变状态管理
- 查看模块
- Redux插件模块
- 单元测试和集成测试的测试平台
- 路由模块
- 持久性模块
- 模块动态
- 共享 Web 应用程序支持多个浏览器窗口
- 共享选项卡
- 共享工作者
- 服务工作者
- 浏览器扩展
- 独立窗户
- 内嵌框架
reactant-share
使用起来非常简单,你可以用它快速构建一个共享的Web应用程序。它大大降低了支持多浏览器窗口应用程序架构的复杂性。
工作原理
当 reactant-share 启动时,它会在浏览器中创建一个服务器应用实例和多个客户端应用实例(每个浏览器窗口一个),但真正完整运行的唯一实例是服务器应用实例,它负责几乎所有的应用程序逻辑,而多个客户端应用实例只是简单地同步状态和渲染。reactant-share 的状态模型使用不可变状态,而 reactant 基于 Redux,因此我们通过 Redux 的 触发从服务器应用到客户端应用的状态同步dispatch
。
- 用户通过 DOM 事件触发客户端应用代理方法
- 此代理方法在服务器应用程序上执行。
- 服务器应用程序状态同步回客户端应用程序。
例子
reactant-share 的整体工作流程如下图所示。这是一个 shared-worker 类型的计数器应用的示例。
- 首先,我们在中定义一个计数器应用程序模块和视图模块
app.view.tsx
import React from "react";
import {
ViewModule,
createApp,
injectable,
useConnector,
action,
state,
spawn,
} from "reactant-share";
@injectable({ name: "counter" })
class Counter {
@state
count = 0;
@action
increase() {
this.count += 1;
}
}
@injectable()
export class AppView extends ViewModule {
constructor(public counter: Counter) {
super();
}
component() {
const count = useConnector(() => this.counter.count);
return (
<button type="button" onClick={() => spawn(this.counter, "increase", [])}>
{count}
</button>
);
}
}
- 接下来,我们使用
createSharedApp()
创建客户端应用程序,其选项必须包含workerURL
,将创建共享工作者的工作者 URL(如果尚未创建)。
import { render } from "reactant-web";
import { createSharedApp } from "reactant-share";
import { AppView } from "./app.view";
createSharedApp({
modules: [],
main: AppView,
render,
share: {
name: "SharedWorkerApp",
port: "client",
type: "SharedWorker",
workerURL: "worker.bundle.js",
},
}).then((app) => {
// render only
app.bootstrap(document.getElementById("app"));
});
- 最后,我们只需创建工作文件并按照选项
worker.tsx
进行构建。worker.bundle.js
workerURL
import { createSharedApp } from "reactant-share";
import { AppView } from "./app.view";
createSharedApp({
modules: [],
main: AppView,
render: () => {
//
},
share: {
name: "SharedWorkerApp",
port: "server",
type: "SharedWorker",
},
}).then((app) => {
// render less
});
具体的工作流程increase
是这样的。
- 用户单击客户端应用程序中的按钮。
spawn(this.counter, "increase", [])
将会执行,它将有关代理执行的参数传递给服务器应用程序。- 服务器应用程序将执行
this.counter.increase()
,并将更新的状态同步回每个客户端应用程序。
spawn()
reactant-share 中的灵感来自于actor模型。
反应物共享框架
多种模式
- 共享标签页 - 适用于不支持 SharedWorker/ServiceWorker 的浏览器。服务器应用是一个带有渲染功能的实例,也在浏览器窗口中运行。在多个浏览器窗口中,也只有一个服务器应用,关闭或刷新该应用后,其他客户端应用的一个实例将转换为服务器应用。
- SharedWorker - 如果没有浏览器兼容性要求,强烈建议 reactant-share 使用此模式,并且 reactant-share 也会进行优雅降级,因此如果浏览器不支持 SharedWorker 则应用程序将以 Shared-Tab 模式运行。
- ServiceWorker - 如果共享 Web 应用程序旨在成为 PWA(渐进式 Web 应用程序),那么使用此模式将是理想的,并且它还支持自动优雅降级到共享选项卡模式。
- 浏览器扩展 - 浏览器扩展允许后台线程,reactant-share 的服务器应用程序可以在这个后台线程中运行,而 UI 可以在客户端应用程序中运行。
- 分离窗口 - reactant-share 允许子应用程序作为分离窗口运行或快速合并到更完整的应用程序中。
- iframe - reactant-share 允许每个子应用程序在 iframe 上运行。
示例 repo:SharedWorker/Detached window/iframe
用户体验
由于reactant-share的多个实例是逻辑共享和状态共享的,因此当用户在多个浏览器窗口中打开同一个reactant-share应用时,真正完整运行的唯一实例是服务器应用。
仅渲染的客户端应用程序将非常流畅,几乎不会因为 JS 代码而冻结,并且一致的应用程序状态将允许用户在多个浏览器窗口之间切换而无需担心。
开发经历
reactant-share 提供 CLI 和对 Typescript 的全面支持,以及对 Shared-Tab、SharedWorker、ServiceWorker 和浏览器扩展以及其他不同类型的开箱即用运行时模式的支持。内置用于模块测试、路由和持久性模块的测试平台,以及对 reactant-share 应用程序延迟加载的模块动态支持。
服务发现/通信
由于 reactant-share 使用了data-transport,所以 reactant-share 支持几乎所有 data-transport 支持的传输方式。客户端应用和服务端应用,无论哪个先加载,客户端应用都会等待服务端应用启动完成,并从中获取所有的初始应用状态。
使用客户端应用程序中的actor模型来设计spawn(),我们可以spawn(counterModule, 'increase', [])
让服务器应用程序代理模块方法的执行,并将状态和结果响应并同步回客户端应用程序。
但是如果我们需要客户端应用程序和服务器应用程序之间的直接通信,那么我们需要使用该PortDetector
模块。
class Counter {
constructor(public portDetector: PortDetector) {
this.portDetector.onServer(async (transport) => {
const result = await transport.emit("test", 42);
// result should be `hello, 42`
});
this.portDetector.onClient((transport) => {
transport.listen("test", (num) => `hello, ${num}`);
});
}
}
跟踪/调试
由于 reactant-share 基于 Redux,因此它完全支持 Redux DevTools,并且 Redux 带来的不可变时间旅行将使调试变得容易。
容错/数据一致性
由于客户端应用每次使用后获取服务器应用代理执行的状态同步,spawn()
可能会因为各种原因在边缘情况下导致其乱序,因此 reactant-share 集成了reactant-last-action
,它提供了序列标记来保持如果客户端应用收到检查序列中是否存在异常的同步操作,则客户端应用将启动完整的状态同步来纠正操作序列。
另外,当浏览器不支持Worker API时,reactant-share会进行优雅降级(例如SharedWorker模式->Shared-Tab模式->SPA模式)。
隔离
无论是Shared-Tab、SharedWorker还是ServiceWorker等模式,每个应用实例都是孤立运行的,它们之间的基本交互只能通过spawn()
同步状态来触发。
配置
reactant-share 提供了命令行界面,你只需要运行npx reactant-cli init shared-worker-example -t shared-worker
即可获得一个使用 SharedWorker 模式的 reactant-share 项目。如果你想更改其模式,只需更改 的配置即可createSharedApp()
。
createSharedApp({
modules: [],
main: AppView,
render,
share: {
name: 'ReactantExampleApp',
port: 'client',
- type: 'SharedWorker',
+ type: 'ServiceWorker',
workerURL: 'worker.bundle.js',
},
}).then((app) => {
app.bootstrap(document.getElementById('app'));
});
这样,我们就可以快速将SharedWorker模式转换为ServiceWorker模式。
传输/性能
由于客户端应用仅渲染和接收同步状态。因此,当每次调度更新状态的大小不超过 50M 时,客户端应用可以保持平稳运行。Reactant 使用Immer patch进行更新,通常这个 patch 会非常小,并且 Reactant 还会进行 DEV 检查以最小化 patch 更新。事实上,在大多数情况下,patch 不会那么大。
更新状态大小 | 数据量 | 反序列化 |
---|---|---|
30 数组 * 1,000 个项目 | 1.4米 | 14毫秒 |
30 数组 * 1,0000 个项目 | 14米 | 130毫秒 |
1000 个数组 * 1,000 个项目 | 46米 | 380毫秒 |
笔记本电脑:1 GHz Intel Core M / 8 GB 1600 MHz DDR3
使用派生数据缓存对 reactant-share 模块进行基准测试
模块和状态的数量 | 州总数 | 各州更新 |
---|---|---|
100个模块*20个状态 | 2,000 | 3毫秒 |
200个模块*30个状态 | 6,000 | 9毫秒 |
300个模块*100个状态 | 3万 | 44毫秒 |
笔记本电脑:1 GHz Intel Core M / 8 GB 1600 MHz DDR3
因此,reactant-share 在大型项目中仍然表现良好。
复杂
无论是实践清晰架构 (Clean Architecture)、DDD、OOP 还是 FP,reactant-share 都拥有更高的开放性,可以随心所欲地构建高度复杂的项目。reactant-share 提供了一些可选功能,但唯一不容错过的是 DI。reactant-share 的 DI 灵感来自 Angular,并且与 Angular 的 DI 非常相似。架构设计带来的编码复杂性通常由实践的最终规范决定,但 reactant-share 希望在框架层面帮助这种复杂的架构设计。
安全
对于 Reactant-share 应用来说,服务器/客户端之间的通信仅对状态和参数进行序列化和反序列化,因此几乎不可能发生框架级别的安全问题。当然,对于任何重视前端安全的项目来说,启用 https 和使用Subresource Integrity都是必要的,我们也应该关注React 文档中关于XSS 安全性的问题。
测试
reactant-share 提供testBed()
方便模块测试的功能。例如,
const { instance } = testBed({
main: Counter,
modules: [],
});
对于服务器应用程序/客户端应用程序交互的集成测试,reactant-share 还提供mockPairTransports()
模拟传输。
const transports = mockPairTransports();
createSharedApp({
modules: [],
main: AppView,
render,
share: {
name: "SharedWorkerApp",
port: "client",
type: "SharedWorker",
transports: {
client: transports[0],
},
},
}).then((app) => {
const clientApp = app;
// render only
app.bootstrap(document.getElementById("app"));
});
createSharedApp({
modules: [],
main: AppView,
render: () => {
//
},
share: {
name: "SharedWorkerApp",
port: "server",
type: "SharedWorker",
transports: {
client: transports[1],
},
},
}).then((app) => {
const serverApp = app;
// render less
});
经过这样的模拟传输,clientApp
可以serverApp
轻松进行集成测试。
蜜蜂
@injectable()
您可以使用@injectable()
来装饰一个可注入的模块,然后使用emitDecoratorMetadata
TypeScript,或者@inject()
注入依赖项。
@state
@state
用于装饰将为 Redux 创建 reducer 的类属性。
@action
它通过类方法使用突变来更新 redux 状态。
class Todo {
@state
list: { text: string }[] = [];
@action
addTodo(text: string) {
this.list.push({ text });
}
}
ViewModule
/useConnector()
ViewModule
是一个带有组件的视图模块,它与 React 类组件完全不同。 的组件ViewModule
是一个函数组件,用于模块与 UI 之间的状态连接(使用useConnector()
)以及应用程序视图引导。
spawn()
spawn()
将类方法的执行从客户端应用转移到服务端应用,并将状态同步到所有客户端应用。它受到 Actor 模型的启发,但与其他 Actor 模型不同,reactant-sharespawn()
不会创建新线程。
createSharedApp()
reactant-share 支持多种模式,您可以使用它createSharedApp()
来创建多个不同的共享 Web 应用程序,这些应用程序通过传输 API 相互交互。
问答
- reactant-share 能彻底解决架构的复杂性吗?
虽然 reactant-share 试图在框架层面降低一些复杂性,但大型应用程序的复杂性并不完全取决于框架本身,因此即使使用 reactant-share 构建大型项目也不能完全保证其绝对简洁、高效、可维护。这其中涉及到测试策略、代码规范、CI/CD、开发流程、模块设计等诸多方面。
但就模块模型和共享模型而言,reactant-share 已经提供了尽可能简洁的设计。如果你对reactant-share感兴趣,可以快速尝试一下。
- reactant-share 真的没有任何缺点吗?使用上有什么限制吗?
reactant-share 是一个用于构建共享 Web 应用的框架。但这种模式并非免费,而且在数据传输方面存在性能问题(SharedArrayBuffer 的高维护成本也迫使我们暂时放弃了它。实际上,这个问题是由于 JS 的多线程无法高效共享内存造成的)。
虽然共享 Web 应用允许客户端应用在仅渲染的客户端线程中运行,但它引入了同步状态传输的额外开销。我们必须确保它足够轻量且高效。虽然 reactant-share 基于 Immer 进行状态更新,但始终很难确保每个更新都最小化。
reactant-share 提供了一个开发选项enablePatchesChecker
。在开发模式下,该选项默认启用。任何无效的变更操作都会收到警报,通常会消除警报,并且 reactant-share 会尝试将更新大小保持在尽可能小的范围内。
结论
前端框架和架构始终在不断发展。随着现代浏览器对 Worker 的全面支持以及多核 CPU 设备的日益普及,我们对一些多线程运行 Web App 的探索已经达到了成熟的阶段。我们有理由相信,未来的 Web App 将以更低的复杂度进行设计,并以多线程的方式流畅运行。它能够充分利用用户的设备资源,为用户带来良好的体验,而开发者也无需承担过多的多线程编程负担。
这就是 reactant-share 想要尝试和努力的方向。
如果您认为 reactant-share 很有趣,请随意给它一颗星。
回购:反应物
文章来源:https://dev.to/unadlib/how-to-make-web-application-support-multiple-browser-windows-28pa