kd-cloud-web / Blog

一群人, 关于前端, 做一些有趣的事儿
13 stars 1 forks source link

几种跨页面通信的方案 #41

Open mhfe123 opened 4 years ago

mhfe123 commented 4 years ago

几种跨页面通信的方案

之前根据浏览器原理的学习可以知道页面的每一个tab都是由单独的进程去控制的,有自己独立的内存空间,存储信息,数据,状态等。

当我们使用场景需要进行页面通讯的时候,比如:我有一个列表页,然后用新开页签打开这个列表中的一项的详情,然后我在列表页修改了一下它的名称,那么详情页展示的名称也应该响应的修改;再比如我做站内通讯,当前页面有2条消息提示,新开一个页面以后,在新开页已读了两条消息,那原先页面的消息也应该变成已读状态。

那么怎么做这种跨页面的通讯呢?

一、同源页面间的跨页面通讯

1.BroadCast Channel

BroadCast Channel 可以帮我们创建一个用于广播的通信频道。当所有页面都监听同一频道的消息时,其中某一个页面通过它发送的消息就会被其他所有页面收到。它的API和用法都非常简单。

首先创建一个BroadcastChannel实例,它只有一个参数,就是命名广播名称的字符串

const channel = new BroadcastChannel('channel');

然后要发送消息时只需要调用它的postMessage方法就可以

channel.postMessage(data);

需要接收消息的页面通过onmessage事件来进行监听

channel.onmessage = function(e) {
    // e.data是发送过来的数据,可以根据数据做一些事情
    ...
}

对错误事件的监听通过onmessageerror事件来进行监听

channel.onmessageerror = function(e) {
    console.warn('error:', e);
    ...
}

如果想要关闭BroadcastChannel可以通过两种方式,一种是将onmessage事件清除掉,但是这种方式只是不再接收信息,实例还是存在的。

另一种是通过它提供的close方法进行关闭。

channel.close();

对浏览器的支持程度 image

2.Service Worker

Service Worker 是一个可以长期运行在后台的 Worker,能够实现与页面的双向通信。多页面共享间的 Service Worker 可以共享,将 Service Worker 作为消息的处理中心(中央站)即可实现广播效果。

首先,需要在页面注册 Service Worker:

navigator.serviceWorker.register('sw.js').then(() => {
    console.log('Service Worker Register Success')
})

sw.js是对应的Service Worker脚本。Service Worker 本身并不自动具备“广播通信”的功能,需要我们添加些代码,将其改造成消息中转站:

self.addEventListener(message, function(e) {
  console.log('service worker receive message', e.data);
  e.waitUntil(self.clients.matchAll().then(function(clients) {
    if (!clients || clients.length === 0) {
      return;
    }
    clients.forEach(function(client) {
      client.postMessage(e.data);
    });
  }));
});

我们在 Service Worker 中监听了message事件,获取页面(从 Service Worker 的角度叫 client)发送的信息。然后通过self.clients.matchAll()获取当前注册了该 Service Worker 的所有页面,通过调用每个client(即页面)的postMessage方法,向页面发送消息。这样就把从一处(某个Tab页面)收到的消息通知给了其他页面。

处理完 Service Worker,我们需要在页面监听 Service Worker 发送来的消息:

navigator.serviceWorker.addEventListener('message', function(e) {
    const data = e.data;
    ...
})
3.LocalStorage

LocalStorage 作为前端最常用的本地存储,大家应该已经非常熟悉了;但StorageEvent这个与它相关的事件有些同学可能会比较陌生。

当 LocalStorage 变化时,会触发storage事件。利用这个特性,我们可以在发送消息时,把消息写入到某个 LocalStorage 中;然后在各个页面内,通过监听storage事件即可收到通知。

window.addEventListener('storage', function(e) {
    // e.newValue表示新设置的值
    // e.key表示新设置的值的键
    ...
});

当我们需要发送信息的时候,在对应的页面调用localStorage.setItem方法即可。

localStorage.setItem('newValue', 'new');

这里需要注意一个问题就是storage事件只会在值真正发生改变的时候触发,如果两次修改的值是相同的,则不会触发,所以最好在修改的时候添加一个时间戳作为事件触发的依凭。

4.Shared Worker

Shared Worker 是 Worker 家族的另一个成员。普通的 Worker 之间是独立运行、数据互不相通;而多个 Tab 注册的 Shared Worker 则可以实现数据共享。

Shared Worker 在实现跨页面通信时的问题在于,它无法主动通知所有页面,因此,我们会使用轮询的方式,来拉取最新的数据。思路如下:

让 Shared Worker 支持两种消息。一种是 post,Shared Worker 收到后会将该数据保存下来;另一种是 get,Shared Worker 收到该消息后会将保存的数据通过postMessage传给注册它的页面。也就是让页面通过 get 来主动获取(同步)最新消息。具体实现如下:

首先,我们会在页面中启动一个 Shared Worker,启动方式非常简单:

// 第一个参数为需要注册的脚本的路径,第二个参数为shareworker的名称,可以留空
const shareworker = new ShareWorker(path, name);

然后,在该 Shared Worker 中支持 get 与 post 形式的消息:

let data = null;
self.addEventListener('connect', function(e) {
  console.log(e,'e')
  const port = e.ports[0];
  port.addEventListener('message', function(event) {
    // get 指令返回存储的消息数据
    console.log(event,'ev')
    if (event.data.get) {
      data && port.postMessage(data);
    } else { // 非get指令则存储该消息数据
      data = event.data;
    }
  });
  port.start();
});

之后,页面定时发送 get 指令的消息给 Shared Worker,轮询最新的消息数据,并在页面监听返回信息:

setInterval(() => {
    sharedWorker.port.postMessage({get: true});
}, 1000);
// 监听get消息的返回数据
sharedWorker.port.addEventListener('message', (e) => {
    const data = e.data;
    ...
});
sharedWorker.port.start();

最后,当要跨页面通信时,只需给 Shared Worker postMessage即可:

sharedWorker.port.postMessage(data);

注意,如果使用addEventListener来添加 Shared Worker 的消息监听,需要显式调用MessagePort.start方法,即上文中的sharedWorker.port.start();如果使用onmessage绑定监听则不需要。

5.IndexedDB

除了可以利用 Shared Worker 来共享存储数据,还可以使用其他一些“全局性”(支持跨页面)的存储方案。例如 IndexedDB 或 cookie。

其思路很简单:与 Shared Worker 方案类似,消息发送方将消息存至 IndexedDB 中;接收方(例如所有页面)则通过轮询去获取最新的信息。

首先第一步是创建或打开一个数据库连接:

const request = window.indexedDB.open('test', 1);
request.onupgradeneeded = function(e) {};
request.onsuccess = function(e) {};
request.onerror = function(e) {};

open()方法接收两个参数,第一个是数据库名称,如果数据库名称不存在则会创建一个新的数据库,第二个参数是数据库版本号,不传默认为1,要注意的是它可以是一个非常大的整数。但是,它不能是一个小数,否则它将会被转为最近的整数,同时有可能导致onUpgradeneeded事件不触发(bug)。。

当数据库建立连接时,会返回一个IDBOpenDBRequest对象。

在连接建立成功时,会触发onsuccess事件,其中函数参数eventtarget属性就是request对象。

而在数据库创建或者版本更新时,会触发onupgradeneeded事件。

打开数据库以后要创建一个存储空间:

request.onupgradeneeded = function(e) {
  const db = event.target.result;
  const objectStore = db.createObjectStore('table1', {keyPath: 'id', autoIncrement: true});
  objectStore.createIndex('name', 'name', {unique: false});
};

只能在onupgradeneeded回调函数中创建存储空间,而不能在数据库打开后的success回调函数中创建。

通过createObjectStore能够创建一个存储空间。接受两个参数:

  1. 第一个参数,存储空间的名称,即我们上面的customers
  2. 第二个参数,指定存储的keyPath值为存储对象的某个属性,这个属性能够在获取存储空间数据的时候当做key值使用。autoIncrement指定了key值是否自增(当key值为默认的从1开始到2^53的整数时)。

createIndex能够给当前的存储空间设置一个索引。它接受三个参数:

  1. 第一个参数,索引的名称。
  2. 第二个参数,指定根据存储数据的哪一个属性来构建索引。
  3. 第三个属性, options对象,其中属性unique的值为true表示不允许索引值相等。

然后对数据库进行操作:

IndexedDB中,我们也能够使用事务来进行数据库的操作。事务有三个模式(常量已经弃用):

我们创建一个事务时,需要从上面选择一种模式,如果不指定的话,则默认为只读模式。具体示例如下:

const transaction = db.transaction(['customers'], 'readwrite');

事务函数transaction的第一个参数为需要关联的存储空间,第二个可选参数为事务模式。与上面类似,事务成功时也会触发onsuccess函数,失败时触发onerror函数。

const objectStore = transaction.objectStore('table1');
// 获取索引
const index = objectStore.index('name');
// 增加一条数据
objectStore.add({name: 'a', age: 10});
// 获取一条数据
objectStore.get(1);
// 修改一条数据
objectStore.put(data);
// 删除一条数据
objectStore.delete(name);

我们做页面通讯的方式就是在要发送信息的页面修改indexedDB中的某条数据的值,然后在需要接收通讯的页面通过定时获取的方式来获得改变的值。

6.window.open + window.opener

当我们使用window.open打开页面时,方法会返回一个被打开页面window的引用。而在未显示指定noopener时,被打开的页面可以通过window.opener获取到打开它的页面的引用 —— 通过这种方式我们就将这些页面建立起了联系(一种树形结构)。

首先,我们把window.open打开的页面的window对象收集起来:

// 通过类似全局变量的东西存储打开的window对象
let $childWins = [];
const win = window.open(url);
$childWins.push(win);

当我们需要发送消息的时候,作为消息的发起方,一个页面需要同时通知它打开的页面与打开它的页面:

// 过滤掉已经被关闭的窗口
childWins = $childWins.filter(w => !w.closed);
if (childWins.length > 0) {
    let message = {msg: '我发消息了!'};
    childWins.forEach(w => w.postMessage(message));
}

作为接收方需要通过监听message事件来进行相应的处理:

window.addEventListener('message', function(e) {
    // 发送的数据
    const data = e.data;
    ...
});
7.visibilitychange
小结

同源网站的跨页面通信主要通过两种形式,一种是广播的形式,将发送的信息交给一个处理中心,然后由处理中心发给对应的页面。另一种是通过共享存储的方式,将信息存到公共空间,然后再有需要的页面通过定时获取的方式来得到信息的改变。

二、非同源页面之间的通信

随着业务的发展,我们可能会有两个不同域名的产品线,他们之间做通信要通过什么方式呢?

要实现该功能,可以使用一个用户不可见的 iframe 作为“桥”。由于 iframe 与父页面间可以通过指定origin来忽略同源限制,因此可以在每个页面中嵌入一个 iframe (例如:http://sample.com/bridge.html),而这些 iframe 由于使用的是一个 url,因此属于同源页面,其通信方式可以复用上面第一部分提到的各种方式。

页面与 iframe 通信非常简单,首先需要在页面中监听 iframe 发来的消息,做相应的业务处理:

window.addEventListener('message', function(e) {
  ...
});

然后,当页面要与其他的同源或非同源页面通信时,会先给 iframe 发送消息:

window.frames[0].window.postMessage(message, '*');

这个方法有两个参数,一个是要发送的消息,第二个参数为指定的url,*表示所有页面。

image