数10+跨页面通信方案,你用过哪几种?

213 阅读5分钟

「本文已参与低调务实优秀中国好青年前端社群的写作活动」

✨示例代码仓库地址见文章末❤️

BroadcastChannel

BroadcastChannel 接口代理了一个命名频道,可以让指定 origin 下的任意 browsing context 来订阅它。它允许同源的不同浏览器窗口,Tab 页,frame 或者 iframe 下的不同文档之间相互通信。通过触发一个 message 事件,消息可以广播到所有监听了该频道的 BroadcastChannel 对象。

demo1.html 中:

const bc = new BroadcastChannel('Cellinlab-Channel');

bc.onmessage = function(event) {
  const { msg, from } = event.data;
  const text = `[recive] ${msg} -- tab ${from}`;
  console.log('[BroadcastChannel] recive message: ', text);
};

bc.postMessage({
  msg: 'Hello, world!',
  from: 'demo1',
});

demo2.html 中:

const bc = new BroadcastChannel('Cellinlab-Channel');

bc.onmessage = function(event) {
  const { msg, from } = event.data;
  const text = `[recive] ${msg} -- tab ${from}`;
  console.log('[BroadcastChannel] recive message: ', text);
};

bc.postMessage({
  msg: 'Hello, world!',
  from: 'demo2',
});

然后在控制台可以观察到:

Service Worker

Service workers 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。这个 API 旨在创建有效的离线体验,它会拦截网络请求并根据网络是否可用来采取适当的动作、更新来自服务器的的资源。它还提供入口以推送通知和访问后台同步 API。

Service worker 是一个注册在指定源和路径下的事件驱动 worker。它采用 JavaScript 控制关联的页面或者网站,拦截并修改访问和资源请求,细粒度地缓存资源。你可以完全控制应用在特定情形(最常见的情形是网络不可用)下的表现。

Service worker 运行在 worker 上下文,因此它不能访问 DOM。相对于驱动应用的主 JavaScript 线程,它运行在其他线程中,所以不会造成阻塞。它设计为完全异步,同步 API(如 XHR 和 localStorage)不能在 service worker 中使用。

demo1.html 中 注册:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('../Service-Worker/sw.js').then(function(registration) {
    console.log('Service Worker Registered');

    // 监听消息
    navigator.serviceWorker.addEventListener('message', function(event) {
      console.log('[Service Worker] recive message: ', event.data);
    });
  }).catch(function(err) {
    console.log('Service Worker Failed', err);
  });
}

其中, /Service-Worker/sw.js 是对应的 Service Worker 脚本。Service Worker 本身并不自动具备“广播通信”的功能,需要进行简单改造:

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

demo2.html 中 发消息:

if ('serviceWorker' in navigator) {
  // 发送消息
  navigator.serviceWorker.controller.postMessage('Hello, world! There is demo2');
}

然后在控制台可以观察到:

LocalStorage

当 LocalStorage 变化时,会触发 storage 事件,可以通过 event.keyevent.newValue 获取变化的键值对。

demo1.html 中 监听事件:

window.addEventListener('storage', function(event) {
  if (event.key === 'ls-msg') {
    const data = JSON.parse(event.newValue);
    const text = `[recive][${data.timestamp}] ${data.msg} -- tab ${data.from}`;
    console.log('[LocalStorage] recive message: ', text);
  }
});

demo2.html 中 测试发送消息:

const data = {
  msg: 'Hello, world!',
  timestamp: Date.now(),
  from: 'demo2'
};
window.localStorage.setItem('ls-msg', JSON.stringify(data));

const newData = Object.assign({}, data);
newData.timestamp = 2022;
window.localStorage.setItem('ls-msg', JSON.stringify(newData));
window.localStorage.setItem('ls-msg', JSON.stringify(newData));

在控制台可以发现:

奇怪的是,当时间戳值不变时,虽然设置了两次,但是只会触发一次。这是因为,只有在值真正改变的时候,storage 才会触发。

Shared Worker

SharedWorker 接口代表一种特定类型的 worker,可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他 worker。它们实现一个不同于普通 worker 的接口,具有不同的全局作用域, SharedWorkerGlobalScope

SharedWorker 无法主动通知所有页面,需要我们使用轮询的方式,来拉取最新的数据。

demo1.html 中启动一个 SharedWorker,并轮询查看是否有新消息:

// 启动一个 SharedWorker
const sharedWorker = new SharedWorker('../SharedWorker/shared.js', 'cellinlab');

// 定时轮询
const timer = setInterval(() => {
  // 发送消息,获取数据
  sharedWorker.port.postMessage({
    type: 'get',
    message: 'Hello from SharedWorker Demo1',
    from: 'demo1',
  });
}, 1000);

// 接收消息
sharedWorker.port.addEventListener('message', (e) => {
  const data = e.data;
  const text = `[recive] ${data.message} -- From ${data.from}`;
  console.log(text);
}, false);
sharedWorker.port.start();

其中 shared.js 中的代码如下:

let data = null;
self.addEventListener('connect', function (e) {
  const port = e.ports[0];
  port.addEventListener('message', function (event) {
    if (event.data.type === 'get') {
      data && port.postMessage(data);
      data = null;
    } else {
      data = event.data;
    }
  });
  port.start();
});

然后,就可以在 demo2.html 中发送消息:

// 启动一个 SharedWorker
const sharedWorker = new SharedWorker('../SharedWorker/shared.js', 'cellinlab');
// 发送消息
sharedWorker.port.postMessage({
  type: 'post',
  message: 'Hello from SharedWorker Demo2',
  from: 'demo2',
});

在控制台可以观察到:

IndexedDB

还可以使用一些其他“全局性”(支持跨页面)的存储方案,例如 IndexedDB 和 Cookie。

思路是,消息发送者将消息存到 IndexedDB 中,接收方则通过轮询去拉取消息。

首先,对于 IndexedDB 的基础操作进行下简单封装:

samples/IndexedDB/util.js:

function connectDB (dbName = 'my_idb', version = 1, storeName = 'my_store') {
  return new Promise(function (resolve, reject) {
    if (!('indexedDB' in window)) {
      reject(new Error('IndexedDB is not supported'))
    }
    const request = indexedDB.open(dbName, version);
    request.onerror = reject;
    request.onsuccess = function (event) {
      resolve(event.target.result);
    }
    request.onupgradeneeded = function (event) {
      const db = event.target.result;
      if (event.oldVersion === 0 
        && !db.objectStoreNames.contains(storeName)) {
        const store = db.createObjectStore(storeName, { keyPath: 'mid' });
        store.createIndex(`${storeName}_Index`, 'mid', { unique: true });
      }
    };
  });
}

function saveData (db, store, key, data) {
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(store, 'readwrite');
    const objectStore = transaction.objectStore(store);
    const request = objectStore.put({ mid: key, data: data});
    request.onsuccess = () => resolve(db);
    request.onerror = reject;
  })
}

function queryData (db, store, key) {
  return new Promise((resolve, reject) => {
    try {
      const transaction = db.transaction(store, 'readonly');
      const objectStore = transaction.objectStore(store);
      const request = objectStore.get(key);
      request.onsuccess = (e) => resolve(e.target.result);
      request.onerror = reject;
    } catch (error) {
      reject(error);
    }
  });
}

然后在 Demo1 中 轮询查看是否有新消息:

const STORE_NAME = 'my_store';
const DATA_KEY = 'my_key';

const data = {
  msg: 'Hello, World!',
  from: 'Demo1'
};
connectDB().then(db => {
  saveData(db, STORE_NAME, DATA_KEY, null)
    .then(db => {
      setInterval(() => {
        queryData(db, STORE_NAME, DATA_KEY)
        .then(res =>{
          if (!res || !res.data) {
            return;
          }
          const data = res.data;
          const text = `[recive] ${data.msg} -- tab ${data.from}`;
          console.log(`[Sorage Demo1] recive message `, text);
        });
      }, 1000);
    });
});

在 Demo2 中,将消息存入 IndexedDB:

const STORE_NAME = 'my_store';
const DATA_KEY = 'my_key';

connectDB().then(db => {
  saveData(db, STORE_NAME, DATA_KEY, null)
    .then(db => {
      setInterval(() => {
        queryData(db, STORE_NAME, DATA_KEY)
        .then(res =>{
          if (!res || !res.data) {
            return;
          }
          const data = res.data;
          const text = `[recive] ${data.msg} -- tab ${data.from}`;
          console.log(`[Sorage Demo2] recive message `, text);
        });
      }, 1000);
    });
  const data = {
    msg: 'Hello, World!',
    from: 'Demo2'
  };
  saveData(db, STORE_NAME, DATA_KEY, data);
});

可以在 Demo1 的控制台中看到:

Cookie

使用 Cookie 实现思路和 IndexedDB 类似,这里不再赘述。

window.open & window.opener

在使用 window.open 打开新窗口时,会返回新窗口对象的引用 WindowObjectReference, 如果父子窗口满足“同源策略”,你可以通过这个引用访问新窗口的属性或方法。

被打开页面可以通过 window.opener,获取父窗口的引用。

demo1.html 中,添加创建子窗口和发送消息给子窗口的逻辑,并监听来自其他窗口的消息

// 创建和收集 子窗口
let childWins = [];
document.getElementById('btn-open-window').addEventListener('click', () => {
  const childWin = window.open('demo2.html', '_blank');
  childWins.push(childWin);
});

// 给子窗口发消息
document.getElementById('btn-send-msg').addEventListener('click', () => {
  childWins = childWins.filter(win => !win.closed);
  if (childWins.length > 0) {
    childWins.forEach(win => {
      win.postMessage({
        type: 'message',
        data: {
          fromOpener: true,
          msg: 'hello from parent'
        }
      }, '*');
    });
  }
});

// 接收来自父窗口或子窗口的消息,并发给非发送者的父窗口或子窗口
window.addEventListener('message', (e) => {
  const { type, data } = e.data;
  if (type == 'message') {
    const text = `[recive] ${data.msg}`;
    console.log(`[Window] recive message `, text);

    // 避免向上回传
    if (window.opener && !window.opener.closed && data.fromOpener) {
      window.opener.postMessage(e.data, '*');
    }

    childWins = childWins.filter(win => !win.closed);
    // 避免向下回传
    if (childWins.length > 0 && !data.fromOpener) {
      childWins.forEach(win => {
        win.postMessage(e.data, '*');
      });
    }
  }
});

demo2.html 中,添加给父窗口发送消息的逻辑,并监听来自其他窗口的消息:

let childWins = [];
document.getElementById("btn-send-msg").addEventListener("click", () => {
  if (window.opener && !window.opener.closed) {
    window.opener.postMessage({
      type: "message",
      data: {
        fromOpener: false,
        msg: "hello from child"
      }
    }, "*");
  }
});

// 接收来自父窗口或子窗口的消息,并发给非发送者的父窗口或子窗口
window.addEventListener('message', (e) => {
  const { type, data } = e.data;
  if (type == 'message') {
    const text = `[recive] ${data.msg}`;
    console.log(`[Window] recive message `, text);

    // 避免向上回传
    if (window.opener && !window.opener.closed && data.fromOpener) {
      window.opener.postMessage(e.data, '*');
    }

    childWins = childWins.filter(win => !win.closed);
    // 避免向下回传
    if (childWins.length > 0 && !data.fromOpener) {
      childWins.forEach(win => {
        win.postMessage(e.data, '*');
      });
    }
  }
});

在 控制台 可以观察到:

iframe

在非同源的情况下,可以使用 iframe 作为“桥”,来实现跨页面通信。

demo1.html 中,创建 iframe,并发送消息给 iframe:

// 通过 iframe 给其他页面发送信息
document.getElementById("btn-send-msg").addEventListener("click", function() {
  window.frames[0].postMessage({
    type: 'msg',
    msg: "hello from demo1",
    time: new Date().getTime()
  }, "*");
});

iframe 收到消息后,进行转发,传递给其他与 iframe 同域的 iframe:

const bc = new BroadcastChannel('cellinlab-iframe-channel');

// 收到来自页面的信息后,在 iframe 之间发送信息
window.addEventListener('message', function(e) {
  bc.postMessage(e.data);
});

// 收到来自 iframe 之间的信息后,将消息发送给页面
bc.onmessage = function(e) {
  window.parent.postMessage(e.data, '*');
};

位于其他跨域页面中的 iframe 接收来自同域 iframe 的消息后,再同步给其所在的页面:

// 监听来自 iframe 的信息
window.addEventListener('message', function(e) {
  const {type, msg} = e.data;
  if (type === 'msg') {
    console.log('received message from iframe:', msg);
  }
});

服务端推送

服务端推送是利用服务端作为中转站,去实现消息的互通,宏观上看,类似于 QQ、微信的消息通信机制。

从技术实现上讲,常见的方法有:

简单轮询

顾名思义,就是在前端创建定时器,定期查询服务端是否有新的消息。

COMET

  • 基于 HTTP 的长轮询(long-polling)
  • 基于 iframe 的长连接流(stream)模式

SSE (Server-Sent Events)

使用 server-sent 事件,服务器可以在任何时刻向我们的 Web 页面推送数据和信息。这些被推送进来的信息可以在这个页面上作为 Events + data 的形式来处理。

WebSocket

WebSocket 对象提供了用于创建和管理 WebSocket 连接,以及可以通过该连接发送和接收数据的 API。

示例代码

Github | Ways-to-Communicate-Across-Browser-Tabs

参考