浏览器同源与非同源的跨页面通讯方案

1,097 阅读7分钟

前言

在浏览器中,我们可以打开多个 tab 页面,但是每个 tab 页面都是相互独立的,即使我们把他们需要属性挂在在 window 上面也不会共享。但是有的时候我们需要做到多个 tab 页面互相之间的数据共享和通讯,就要使用其他的方法来实现这个需求。

页面共享有两种不同的策略,同源非同源

同源的实现方案: BroadcastChannellocalStorageServiceWorkerSharedWorkerIndexedDBwindow.open

非同源的实现方案:iframe + postMessage

BroadcastChannel

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

const bc = new BroadcastChannel("communication")

bc.onmessage = function (e) {
    console.log("onmessage=>", e);
}

bc.postMessage("传递的数据")

我们利用 BroadcastChannel 创建一个标识为 communication 的频道,他的下面会有两个事件给我给我们使用 onmessagepostMessage

postMessage是一个数据传递的事件,当出发这个事件时,就会被 onmessage 监听到,同时抛出一个事件对象。事件对象下面的 data 属性就是你传递过来的数据。

image.png

localStorage

localStorage是前端常用的数据储存方案。他还有一个特性就是,每当 localStorage里面的数据发生变化时,就会触发StorageEvent 事件,我们可以利用这个特性,来实现跨页面通讯,但是注意的是,他只对同域名下的页面才会有效。

代码如下:

btn.onclick = function() {
    console.log("开始传递数据...");
    localStorage.setItem( "store",new Date().getTime().toString() )
}

window.addEventListener( "storage",function( e ) {
    console.log(e);
} )

当我们去触发一个事件的时候,在 localStorage 中设置一个keystore的数据,数据更新时,就会触发storage事件,并抛出一个事件对象:

image.png

但是有一点需要注意的是,StorageEvent是监听整个 localStorage的数据更新,当 localStorage存储的数据越来越多是,就会触发多个事件,例如:

btn.onclick = function() {
    console.log("开始传递数据...");
    localStorage.setItem( "store",new Date().getTime().toString() )
    localStorage.setItem( "store-two",new Date().getTime().toString() )
}

image.png

但是我们可以利用设定的 key 值来判断,只触发你所需要的核心key

window.addEventListener( "storage",function( e ) {
    if( e.key === "store" ) {
        console.log("触发 store 更新事件");
    }
} )

serviceWorker

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

首先我们需要在页面中注册 serviceWorker

if( "serviceWorker" in navigator ) {
    navigator.serviceWorker.register( "./service-worker.js").then( function() {
        console.log('Service Worker 注册成功');
    } ).catch( function() {
        console.log('Service Worker 注册失败');
    } )
}

service-worker.jsserviceWorker 对应的脚本,因为 serviceWorker 本身是不具备跨页面通信的功能,所以我们需要添加些代码,将其改造成消息通讯器。

service-worker.js

self.addEventListener("message", function (e) {
    console.log('serviceWorker 传递的数据', e.data);
    e.waitUntil(
        self.clients.matchAll().then(function (clients) {
            if (!clients || clients.length === 0) {
                return;
            }
            clients.forEach(function (client) {
                client.postMessage(e.data);
            });
        })
    )
})

我们给页面监听了 message 事件,获取页面(client)的发送的信息,然后通过 self.clients.matchAll() 方法注册了该serviceWorker下的所有页面,给每个页面调用 postMessage 方法传递数据,这样就可以把tab 页传递的数据给其他页面共享。

处理完serviceWorker 后,我们还需要监听serviceWorker传过来的数据:

navigator.serviceWorker.addEventListener("message", function (e) {
    console.log("serviceWorker message 事件监听", e);
})

最后你如果需要向某个页面传递数据的时候,可以调用 serviceWorker 下面的 postMessage 方法:

btn.onclick = function() {
    const myData = {
        name:"黑黑的脸蛋",
        age:"23"
    }
    navigator.serviceWorker.controller.postMessage(myData);
}

image.png

image.png

SharedWorker

SharedWorker 不同于 serviceWorker,它无法主动通知页面,所以我们可以采用轮询的方式来拉去最新的数据。

SharedWorker 支持两种信息:

  • postSharedWorker 对收集到的数据进行保存
  • getSharedWorker 对收集到的数据通过 postMessage 传给它注册的页面。

也就是说,我们通过 get 方法来主动获取最新的数据,并同步给页面。

首先我们需要通过 SharedWorker 注册一个 woker来收集数据。

const sharedWorker = new SharedWorker('./shared-worker.js', "sw")

SharedWorker 的第二个参数可以为空,他是 sharedWorker 的名称。

然后我们在 shared-worker.js 实现 getpost 方法:

shared-worker.js

let data = null

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

之后我们在收集数据的页面中创建一个轮询器,用来获取最新的数据,并在 message 事件监听中返回数据:

let resultData = null
const sharedWorker = new SharedWorker('./shared-worker.js', "sw")

setInterval(function () {
    sharedWorker.port.postMessage({ get: true });
}, 1000);
sharedWorker.port.start();

sharedWorker.port.addEventListener('message', (e) => {
    const { data } = e
    
    // 当数据发生变化时,执行下面的流程
    if (JSON.stringify(data) !== JSON.stringify(resultData) || !resultData) {
        resultData = data
        console.log("数据发生变化了=>", resultData, e);
    }
}, false);

最后你如果需要向某个页面传递数据的时候,可以调用 SharedWorker 下面的 postMessage 方法:

btn.onclick = function () {
    const mydata = {
        name: "黑黑的脸蛋",
        age: 23,
        time:new Date().getTime()
    }
    sharedWorker.port.postMessage(mydata);
}

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

IndexedDB

IndexedDB类似于SharedWorker的实现方案,都是对数据进行存储,并轮询查询数据,数据发生变化后,执行后面的逻辑。


// 打开数据库
function openStore() {
    const storeName = 'ctc_conatienr';
    return new Promise(function (resolve, reject) {
        if (!('indexedDB' in window)) {
            return reject('don\'t support indexedDB');
        }
        const request = indexedDB.open('CTC_DB', 1);
        request.onerror = reject;
        request.onsuccess = e => resolve(e.target.result);
        request.onupgradeneeded = function (e) {
            const db = e.srcElement.result;
            if (e.oldVersion === 0 && !db.objectStoreNames.contains(storeName)) {
                const store = db.createObjectStore(storeName, { keyPath: 'tag' });
                store.createIndex(storeName + 'Index', 'tag', { unique: false });
            }
        }
    });
}

// 查询数据
function query(db) {
    const STORE_NAME = 'ctc_conatienr';
    return new Promise(function (resolve, reject) {
        try {
            const tx = db.transaction(STORE_NAME, 'readonly');
            const store = tx.objectStore(STORE_NAME);
            const dbRequest = store.get('ctc_data');
            dbRequest.onsuccess = e => resolve(e.target.result);
            dbRequest.onerror = reject;
        }
        catch (err) {
            reject(err);
        }
    });
}

// 储存数据
function saveData(db, data) {
    return new Promise(function (resolve, reject) {
        try {
            const STORE_NAME = 'ctc_conatienr';
            const tx = db.transaction(STORE_NAME, 'readwrite');
            const store = tx.objectStore(STORE_NAME);
            const request = store.put({ tag: 'ctc_data', data });
            request.onsuccess = () => resolve(db);
            request.onerror = reject;
        } catch (err) {
            reject(err);
        }
    });
}

如果需要给其他页面传递数据时,可以在 indexedDB 中储存数据:

tn.onclick = function () {
    openStore().then(db => saveData(db, null)).then( function( db ) {
        saveData( db,{ 
            name:"黑黑的脸蛋",
            age:23,
            time:new Date().getTime()
        } )
    } )
}

在获取数据的页面中,对 indexedDB 数据库进行轮播查询,如果数据发生更改,执行后面的逻辑:

let resultData = null

function getData(db, fn) {
    query(db).then(function (res) {
        if (!res || !res.data) {
            return;
        }
        const data = res.data;
        fn && fn(data)
    })
}

// 收集数据
openStore().then(db => saveData(db, null)).then(function (db) {
    setInterval(() => getData(db, data => {
        // 当数据发生变化时,执行下面的流程
        if (JSON.stringify(data) !== JSON.stringify(resultData) || !resultData) {
            resultData = data
            console.log("数据发生变化了=>", resultData);
        }
    }), 1000)
});

window.open

Window 接口的 open() 方法,是用指定的名称将指定的资源加载到浏览器上下文(窗口 window ,内嵌框架 iframe 或者标签 tab )。如果没有指定名称,则一个新的窗口会被打开并且指定的资源会被加载进这个窗口的浏览器上下文中。

我们利用 window.open 打开新页面的 window 对象收集起来:

let childWin = []

btn.onclick = function() {
    const win = window.open('http://127.0.0.1:5500/open/open.html');
    childWin.push(win);
}

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

btnsetData.onclick = function() {
    const data = { name:"黑黑的脸蛋",age:23 }
    childWin = childWin.filter( win => !win.closed )
    childWin.forEach( win => win.postMessage(data) )
}

注意,我这里先用.closed属性过滤掉已经被关闭的 Tab 窗口。这样,作为消息发送方的任务就完成了。

在收集方中,我们可以监听message事件,来获取数据:

window.addEventListener("message", function (e) {
    console.log("收集到的数据=>",e.data);
})

window.open 还有一个有意思的地方就是,可以直接访问打开或被它打开的tab 页的 window 对象:

<button id="btn"> 利用 open 打开一个页面 </button>
<button id="btnsetData"> 向新页面传递数据 </button>

<script>
    
    // 获取当前时间戳,给打开的 tab 页
    window.context = function() {
        return {
            time:new Date().getTime()
        }
    }
    
    // 调用 window.open 时,收集打开 tab 页的 window 对象
    btn.onclick = function() {
        window.opener = window.open("http://127.0.0.1:5500/open/open.html")
    }
	
    // 直接调用打开 tab 页的 getResult 方法,给它传递数据
    btnsetData.onclick = function() {
        window.opener.getResult( {name:"黑黑的脸蛋"} )
    }
</script>

打开的 tab 页:

<button id="getOpenerName"> 获取上级页面数据 </button>

<script>
    
    // 当这个事件被调用的时候,说明触发了传递数据的事件。
    window.getResult = function( data ) {
        console.log("获取传递过来的数据=>",data);
        
        // ...  执行更新视图的逻辑部分
    }

    getOpenerName.onclick = function() {
        
        // 调用打开它的 tab 页先来的 context 函数,获取当前时间戳。
        console.log( window.opener.context() );
    }
</script>

值的注意的是,当你的主页面刷新是,收起起来的 opener 对象将会丢失,此时你就失去了对其他 tab 页传递数据的对象。

iframe + postMessage非同源传递数据

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

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

// 获取 iframe 传递过来的数据
window.postMessage( "message",function( e ) {
    // ... 执行视图更新
} )


// 给 iframe 传递数据
window.frames[0].window.postMessage( data,"*" )

其中为了简便此处将postMessage的第二个参数设为了'*',你也可以设为 iframeURLiframe 收到消息后,会使用某种跨页面消息通信技术在所有 iframe 间同步消息,例如下面使用的 Broadcast Channel

iframe 里面的代码:

const bc = new BroadcastChannel('AlienZHOU');
// 收到来自页面的消息后,在 iframe 间进行广播
window.addEventListener('message', function (e) {
    bc.postMessage(e.data);
});

// 对于收到的(iframe)广播消息,通知给所属的业务页面
bc.onmessage = function (e) {
    window.parent.postMessage(e.data, '*');
};