一、写在前面
最近在做一个可视化拖拽搭建 H5 页面的项目,整个项目分为 后台配置应用 和 前台渲染应用。其中一个业务场景是要求配置页面的同时,前台渲染应用能够同步将配置渲染出来。换句话说,你在后台配置这个页面背景色是红色,那么前台应用无须刷新页面等操作背景色就自动变为红色。
这个需求的核心是保持两个前端应用的数据实时同步,换句话说也就是两个前端应用之间的通信。
乍一看这个需求,第一时间想到的是 iframe ,将应用2内嵌到应用1中,然后调用 window.postMessage() 进行数据通信。但是使用 iframe 存在兼容性问题,坑太多。另外对于不存在嵌套关系的两个应用来说,强行进行嵌套会让后续开发者无法理解。
到了这里,我浅薄的知识量不知道还有什么其他方法,选择了直接看答案!之前开发的同事选择了 WebSocket 实现前后台之间的数据同步。彷佛一道闪电,击碎了桎梏,劈开了新世界的大门。(是我知识积累太浅薄了,呜呜呜)。
二、WebSocket是什么
WebSocket 这个关键词,我在平时的工作和学习中已经听过了很多次了,但是对于它的认知还是停留在浅显的表面。所以很有必要重新系统的认识一下它!首先需要弄清楚的就是 WebSocket 是什么。
WebSocket 是一种网络通信协议,和 HTTP 同处于应用层。它的出现解决了 HTTP 的一个缺陷:服务器只能被动发送数据给客户端,不能进行主动推送。WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。
在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就可以建立持久性的连接,并进行双向数据传输。这对于需要连续数据交换的服务,例如网络游戏,实时交易系统等,WebSocket 尤其有用。
这个特点别看简单,但是解决了很多复杂的业务难题,比如刷微博。我们都知道刷微博是一个没有尽头的事情,因为每当你刷完当前的东西,页面 header 区域又出现“你的关注博主有n条新微博”的提示。而且即使你在刷微博的过程中没有刷新页面,这个未读微博的提醒也会出现。这说明了微博服务器在没有收到客户端请求时,主动向客户端推送了一些数据。
下面我们解放一下我们的大脑,做一个假设。如果 WebSocket 不存在的话,我们如果使用 HTTP 实现微博的这个需求呢?
如果使用 HTTP 实现这个需求,想要做到及时获取到服务器上的新数据,无非就是轮询。但是不管是普通轮询还是变种的长轮询(Long Polling),都加重了服务器资源的消耗。因为服务器需要在一定时间内持续地处理客户端的 http 请求。
当数据频繁的时候,这种方式会拖垮服务器,造成后端应用的崩溃,所以不适合作为解决方案。
这个时候再让我们的目光回到 WebSokcet 的身上,它就可以很好解决这个问题。WebSocket 允许服务端主动向客户端推送消息,并且没有同源限制。
三、如何使用 WebSocket
上面介绍了 WebSocket 是什么,以及它的应用场景:服务器主动推送消息。那么下面需要介绍一下 WebSokcet 的使用。
要打开一个 WebSocket 连接,我们需要在 url 中使用特殊的协议 ws 创建 new WebSocket:
- let socket = new WebSocket("ws://javascript.info");
同样也有一个加密的 wss:// 协议。类似于 WebSocket 中的 HTTPS。
一旦 socket 被建立,我们就应该监听 socket 上的事件。一共有 4 个事件:
- open — 连接已建立
- message — 接收到数据
- error — WebSocket 错误
- close — 连接已关闭
……如果我们想发送一些东西,那么可以使用 socket.send(data),这是一个示例:
- let socket = new WebSocket("wss://javascript.info/article/websocket/demo/hello");
- socket.onopen = function(e) {
- alert("[open] Connection established");
- alert("Sending to server");
- socket.send("My name is John");
- };
- socket.onmessage = function(event) {
- alert(`[message] Data received from server: ${event.data}`);
- };
- socket.onclose = function(event) {
- if (event.wasClean) {
- alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
- } else {
- // 例如服务器进程被杀死或网络中断
- // 在这种情况下,event.code 通常为 1006
- alert('[close] Connection died');
- }
- };
- socket.onerror = function(error) {
- alert(`[error] ${error.message}`);
- };
出于演示目的,在上面的示例中,运行着一个用 Node.js 写的小型服务器 server.js(https://zh.javascript.info/article/websocket/demo/server.js)。它响应为 “Hello from server, John”,然后等待 5 秒,关闭连接。
所以你看到的事件顺序为:open → message → close。
这就是 WebSocket,我们已经可以使用 WebSocket 通信了。很简单,不是吗?
四、前后台通讯
1. 应用角色模型
当我们需要在前台应用和后台应用之间进行数据通讯的时候,我们首先需要知道它们两个的角色是什么。
最开始我想当然认为一个是「服务端」,另一个是「客户端」。那么如何确定哪个是服务端呢?另外如果多个应用需要保持数据同步呢?这些问题让我需要重新定位它们的角色。
从更加通用的角度来思考,我们需要一个独立的进程充当服务端的角色,其余的前端应用作为客户端。这样的话无论客户端的数量多少,都可以通过这个服务端为中介来进行应用间的数据同步。架构图如下:
从上图我们可以知道,socket 进程需要在启动其中一个客户端应用时一起启动。然后其余的客户端应用都与这个 socket 进程建立连接,并进行数据的交换。
2. 新的问题:如何启动一个 socket 进程
在知道了架构逻辑之后,新的问题出现了。我们从 socket 示例知道建立一个 socket 连接,需要一个 ws/wss 协议开头的 url 字符串。这个字符串需要从哪里得到呢?
我们首先回归到问题的本质,如何建立一个 socket 连接?
- let socket = new WebSocket("ws://javascript.info");
上面的这行代码就是最简单的建立 socket 连接的方式。但这行代码背后存在了一个隐藏的角色:服务端。
那么我们需要怎么才能启动一个 socket 服务,并且客户端与这个后端服务建立连接呢?这个问题冒出来了之后,我第一时间想找找有没有建好的轮子。果然已经有前辈封装好了包:socket.io(服务端)和 socket.io.client(客户端)。
3. 服务端代码
- // ws/index.js
- const server = require('http').createServer()
- const io = require('socket.io')(server)
- // 绑定事件
- io.on('connect', socket => {
- console.log('服务端建立连接成功!')
- socket.on('disconnet', () => {
- console.log('服务端监测到已断开连接')
- })
- socket.on('transportData', (data) => {
- console.log('服务端监测到数据传输', data)
- io.sockets.emit('updateData', data)
- console.log('服务端emit事件updateData')
- })
- })
- // 启动服务器程序进行监听
- server.listen(8020, () => {
- console.log('==== socket server is running at 1234 port ====')
- })
以上就是服务端 socket 程序的简单代码,要注意的是这里我们使用的是 Socket.io。Socket.io 将 Websocket 和轮询(Polling)机制以及其它的实时通信方式封装成了通用的接口,并且在服务端实现了这些实时机制的相应代码。也就是说,Websocket 仅仅是 Socket.io实现实时通信的一个子集。它很好的解决了不同浏览器的兼容性问题,我们可以将更多精力放在业务逻辑上。
这段代码的核心就是监听(on)和抛出(emit)事件。它 emit 出的事件会被连接的各个客户端监听到的,前提是每个客户端都做了事件监听。
4. 客户端代码
- // client1.vue
- <script>
- import io from 'socket.io-client'
- export default {
- ...
- mounted () {
- const socket = io('http://127.0.0.1:8020')
- socket.on('connect', () => {
- console.log('client1建立连接成功')
- console.log('client1传输数据')
- socket.emit('transportData', {name: 'xuhx'})
- })
- socket.on('updateData', (data) => {
- console.log('client1监听到数据更新', data)
- })
- }
- }
- </script>
上面这段代码是客户端1的代码,主要做的是与 socket 服务端建立连接,然后监听和抛出相应的事件。
- // client2.vue
- <script>
- import io from 'socket.io-client'
- export default {
- ...
- mounted () {
- const socket = io('http://127.0.0.1:8020')
- socket.on('connect', () => {
- console.log('client2建立连接成功')
- })
- socket.on('updateData', (data) => {
- console.log('client2监听到数据更新', data)
- })
- }
- }
- </script>
当 socket 进程抛出了 updateData 事件后,client1 与 client2 都可以监听到了这个事件,并且拿到了传递的数据。
那么通过上面的数据传输图,我们就可以知道客户端可以通过 emit 自定义事件,经过 socket 中转然后被其他客户端监听到事件,从而保持客户端应用之间数据的同步。
五、其他通讯方式
没有哪种解决方案是十全十美的,都是更合适某种业务场景。针对前端不同的业务场景,需要采用不同的解决方案来实现需求。那么除了 WebSocket 之外,还有哪些方案可以用于前端应用之间通讯呢?下面我结合实际开发工作列举一下。
那么首先需要对业务场景进行分类:前端跨页面之间通讯是否是同源页面。这个分类方式很好理解,就是两个通讯的页面是不是属于同一个前端项目。同源页面的通讯有很多方式,我们主要解决非同源页面之间的通信。
1. Cookie
如果应用 A 和应用 B 的根域名相同,只不过子级域名不同,例如:tieba.baidu.com 和 waimai.baidu.com。那么如何在这两个应用页面之间进行数据的传输呢?有些同学会想到 sessionStorage 和 LocalStorage,但是它们的存储的基础是同源的。应用 A 无法访问到应用 B 下的 sessionStorage的。但是对应的 Cookie 就能满足我们的需求,那是因为 Cookie 可以设置存储的 Domain。如果将其 domain 设为根域名,那么应用 A 就可以访问应用 B 下的 Cookie 数据。
Cookie 这种解决方案不适合存储大量数据,因为每次请求都会携带 cookie,容易造成带宽资源的浪费。它很合适某种状态的传递,例如用户在应用 A 中是否领取了会员卡,应用 B 通过 Cookie 读取领取状态进行对应业务逻辑的处理。
2. iframe + window.postMessage()
当应用 A 和应用 B 的根域名相同时,我们可以采用 Cookie 的方式。然而有时候,我们有两个不同域名的产品线,也希望它们下面的所有页面之间能无障碍地通信。那该怎么办呢?
要实现该功能,可以使用一个用户不可见的 iframe 作为“桥”。由于 iframe 与父页面间可以通过指定origin来忽略同源限制,因此可以在页面中嵌入一个 iframe (例如:http://sample.com/bridge.html),而这些 iframe 由于使用的是一个 url,因此属于同源页面。然后使用 window.postMessage() 进行外层应用和内嵌应用之间的通信。
3. Server Sent Events
Server-Sent Events 规范描述了一个内建的类 EventSource,它能保持与服务器的连接,并允许从中接收事件。与 WebSocket 类似,其连接是持久的。
但是两者之间有几个重要的区别:
WebSocket | EventSource |
---|---|
双向:客户端和服务端都能交换消息 | 单向:仅服务端能发送消息 |
二进制和文本数据 | 仅文本数据 |
WebSocket 协议 | 常规 HTTP 协议 |
与 WebSocket 相比,EventSource 是与服务器通信的一种不那么强大的方式。
我们为什么要使用它?
主要原因:简单。在很多应用中,WebSocket 有点大材小用。
我们需要从服务器接收一个数据流:可能是聊天消息或者市场价格等。这正是 EventSource 所擅长的。它还支持自动重新连接,而在 WebSocket 中这个功能需要我们手动实现。此外,它是一个普通的旧的 HTTP,不是一个新协议。
4. 微前端
微前端作为前端的一个前沿技术,我是听说了很久但是一直没有机会去使用。并且听同事的介绍,现在微前端的使用还有不少坑。但是它确实是一个应用间通信、协作的探索方向之一。具体的使用和思路就需要大家自行探索了啊。
上面我列举了3 个其他非同源页面之间的通讯方式。实际上在我搜寻相关资料的时候,发现了同行梳理的很多方式。但我发现很难记住这么多的方式,其中一些方式甚至基本不可能使用,徒增记忆的负担。所以我在这里仅列举了主流好用的方式,一招鲜吃遍天~~
六、小结
两个前端工程之间需要进行数据通讯,可以采取 WebSocket 的方式。项目单独启动一个进程,作为 socket 服务端。需要数据通讯的前端应用在页面中与 socket 进程建立连接,通过 emit 自定义事件和监听相应的事件来实现两个不同应用之间的数据同步。本篇文章写的其实是 WebSocket 的基本使用,结合到具体的业务开发中总结了一下。希望能够对遇到相关需求的朋友有所帮助。
许浩星,微医前端技术部前端工程师。一个认为人生的乐趣一半在静,一半在动的有志青年!