你可能需要的多文件頁面互動方案

熊的貓發表於2023-04-17

前言

歡迎關注同名公眾號《熊的貓》,文章會同步更新!

在日常工作中,面對不同的需求場景,你可能會遇到需要進行多文件頁面間互動的實現,例如在 A 頁面跳轉到 B 頁面進行某些操作後,A 頁面需要針對該操作做出一定的反饋等等,這個看似簡單的功能,卻也需要根據不同場景選擇不同的方案。

79C30119.jpg

這裡所說的場景實際上可分為兩個大方向:同源策略文件載入方式,那麼本篇文章就來探討一下這兩個方面。

同源策略 & 文件載入方式

在正式開始之前,我們還是先簡單聊一下同源策略和頁面載入方式,如果你已經足夠了解了,可以選擇跳過閱讀。

同源策略

基本概念

所謂的 同源策略 實際上是 瀏覽器 的一個重要的 安全策略,主要是用於限制 一個源 的文件 或者 其載入的指令碼 是否可以與 另一個源 的資源進行互動。

注意】這裡的目標是瀏覽器,也就是隻有瀏覽器有同源策略的限制,例如服務端就不存在什麼同源策略,這裡的瀏覽器包括 桌面端瀏覽器移動端瀏覽器微信內建瀏覽器虛擬瀏覽器(虛擬環境中執行的網路瀏覽器) 等。

所謂的 就是我們常說的 協議、主機名(域名)、埠,所以所謂的 同源 也就是指兩個 URL 的 協議、主機名(域名)、埠 等資訊要完全匹配。

主要作用

同源策略 可以用來阻隔惡意文件,減少可能被攻擊的媒介,下面還是透過一個 CSRF 例子講解一下沒有同源限制會發生什麼。

CSRF 攻擊

假設你在 A 網站上進行了登入併成功登入網站後,你發現 A 網站上出現了一個廣告彈窗(寫著:拒絕 huang,拒絕 du,拒絕 pingpangqiu),於是放縱不羈愛自由的你(為了驗證真理)點開了它,發現這個網站居然不講武德,啥也不是...

4488455F.gif

表明平靜如水,背地裡實則已經悄悄向 A 站點伺服器 傳送了請求操作,並且身份驗證資訊用的是你剛剛登入的認證資訊(由於沒有同源限制 cookies 會被自動攜帶在目標請求中),但服務端並不知道這是個假冒者,於是允許了本次操作,結果就是......

文件載入方式

因為這裡是說多頁面互動,所以前提是至少有一個頁面 A 存在,那麼基於 A 頁面來講有以下幾種方式去載入 B 頁面文件:

  • window.location.href
  • <a href="xx" target="xx">
  • window.open
  • iframe

這一部分這裡先簡單提及,更詳細的內容放到最後作為擴充套件去講,也許你會奇怪怎麼沒有 history.pushStatelocation.hash (如 Vue Router、React Router 中的使用),因為它們算屬於在頁面載入之後的路由導航,看起來雖然是頁面切換了,但是切換的是文件的內容,不是整個文件,這一點還是不一樣的。

同源策略下的多文件互動

Web Storage

sessionStorage & localStorage

由於多文件的方式並不適合使用 Vuex/Pinia/React Redux 等全域性狀態管理器,因此 Web Storage 這種應該是我們最先能想到的方式了,而 Web Storage 實際上只包含以下兩種:

  • sessionStorage

    • 為每一個給定的源(given origin)維持一個獨立的儲存區域,該儲存區域在頁面 會話期間 可用,即只要瀏覽器處於開啟狀態,包括頁面重新載入和恢復
  • localStorage

    • 為每一個給定的源(given origin)維持一個獨立的儲存區域,但是在瀏覽器關閉,然後重新開啟後資料仍然存在,即其儲存的資料是 持久化的
有些人會把 IndexedDB 也當做 Web Storage 的一種,這在規範定義上是不夠準確的.

它們最基本的用法這裡就不多說了,總結起來就是:在 B 頁面往 Web Storage 中存入資料 X ,在 A 頁面中讀取資料 X 然後決定需要做什麼。

這裡我們可以藉助 document 文件物件的 visibilitychange 事件來監聽當前標籤頁面是否處於 可見狀態,然後再決定是不是要做某些反饋操作。

核心程式碼:

// A 頁面

document.addEventListener('visibilitychange', function () {
  if (document.visibilityState === 'visible') {
    // do something ...
  }
})

演示效果如下:

10.gif

值得注意的是,sessionStorage 在不同標籤頁之間的資料是不能同步,但如果 A 和 B 兩個頁面屬於 同一瀏覽上下文組 可以實現初始化同步(實際算是複製值),後續變化不再同步。

storage 事件

當儲存區域(localStorage | sessionStorage)被修改時,將會觸發 storage 事件,這是 MDN 上的解釋但實際是:

  • 如果當前頁面的 localStorage 值被修改,只會觸發其他頁面的 storage 事件,不會觸發本頁面的 storage 事件
  • window.onstorage 事件只對 localStorage 的修改有效,sessionStorage 的修改不能觸發
  • localStorage 的值必須發生變化,如果設定成相同的值則不會觸發

10.gif

window.onstorage 事件配合 localStorage 很完美,但是唯獨對 sessionStorage 無效,目前沒有發現一個很好且詳細的解釋。

Cookies & IndexdeDB

這兩種和上述的 Web Storage 的實現方式一致,但它們又不屬於一類,因此在這裡還是額外提出來講,不過它們可都是有同源策略的限制的。

既然核心方案一致,這裡就不多說了,來看看它們的一些區別,便於更好的進行選擇:

  • sessionStorage

    • 會話級儲存,最多能夠儲存 5MB 左右,不同瀏覽器限制不同
    • 不同標籤頁之間的資料不能同步,但如果 A 和 B 兩個頁面屬於 同一瀏覽上下文組 可以實現初始化同步(實際算是複製值),後續變化不再同步
    • 不支援 結構化儲存,只能以 字串形式 進行儲存
  • localStorage

    • 持久級儲存,最多能夠儲存 5MB 左右,不同瀏覽器限制不同
    • 只要在 同源 的情況下,無論哪個頁面運算元據都可以一直保持同步到其他頁面
    • 不支援 結構化儲存,只能以 字串形式 進行儲存
  • Cookie

    • 預設是 會話級儲存,若想實現 持久儲存 可以設定 Expires 的值,儲存大小約 4KB 左右,不同瀏覽器限制不同
    • 只要在 同源 的情況下,無論哪個頁面運算元據都可以一直保持同步到其他頁面
    • 不支援 結構化儲存,只能以 字串形式 進行儲存
  • IndexedDB

    • 持久儲存,是一種事務型資料庫系統(即非關係型),儲存大小理論上沒有限制,由使用者的磁碟空間和作業系統來決定
    • 只要在 同源 的情況下,無論哪個頁面運算元據都可以一直保持同步到其他頁面
    • 支援 結構化儲存,包括 檔案/二進位制大型物件(blobs)
同一瀏覽上下文組 可理解為:假設在 A 頁面中以 window.open<a href="x" target="_blank">x</a> 方式 開啟 B 頁面,並且 A 和 B 是 同源 的,那麼此時 A 和 B 就屬於 同一瀏覽上下文組

SharedWorker — 共享 Worker

SharedWorker 介面代表一種特定型別的 worker,不同於普通的 Web Worker,它可以從 幾個瀏覽上下文中 訪問,例如 幾個視窗iframe其他 worker

那麼 SharedWorker 的 Shared 指的是什麼?

從普通的 Web Worker 的使用來看:

  • 主執行緒要例項化 worker 例項:const worker = new Worker('work.js');
  • 主執行緒呼叫 worker 例項的 postMessage() 方法與 worker 執行緒傳送訊息,透過 onmessage 方法用來接收 worker 執行緒響應的結果
  • worker 執行緒(即 'work.js')中也會透過 postMessage() 方法 和 onmessage 方法向主執行緒做相同的事情

從上述流程看沒有什麼大問題,但是如果是不同文件去載入執行 const worker = new Worker('work.js'); 就會生成一個新的 worker 例項,而 SharedWorker 區別於 普通 Worker 就在這裡,如果不同的文件載入並執行 const sharedWorker = new SharedWorker('work.js');,那麼除了第一個文件會真正建立 sharedWorker 例項外,其他以相同方式去載入 work.js 的文件就會直接 複用 第一個文件建立的 sharedWorker 例項。

效果演示

10.gif

核心程式碼

>>>>>>>>>>>>>>>>>> pubilc/worker.js <<<<<<<<<<<<<<
// 儲存多個 port 物件
let ports = []

// 每個頁面進行連線時,就會執行一次
self.onconnect = (e) => {
  // 獲取當前 port 物件
  const port = e.ports[0]

  // 監聽訊息
  port.onmessage = ({ data }) => {
    switch (data.type) {
      case 'init': // 初始化頁面資訊
        ports.push({
          port,
          pageId: data.pageId,
        })
        port.postMessage({
          from: 'init',
          data: '當前執行緒 port 資訊初始化已完成',
        })
        break
      case 'send': // 單播 || 廣播
        for (const target of ports) {
          if(target.port === port) continue
          target.port.postMessage({
            from: target.pageId,
            data: data.data,
          })
        }
        break
      case 'close':
        port.close()
        ports = ports.filter(v => data.pageId !== v.pageId)
        break
    }
  }
}
>>>>>>>>>>>>>>>>>> pubilc/worker.js <<<<<<<<<<<<<<

>>>>>>>>>>>>>>>>>> initWorker.ts <<<<<<<<<<<<<<
import { v4 as uuidv4 } from 'uuid'

export default (store) => {
  const pageId = uuidv4()

  const sharedWorker = new SharedWorker('/worker.js', 'testShare')

  store.sharedWorker = sharedWorker

  // 初始化頁面資訊
  sharedWorker.port.postMessage({
    pageId,
    type: 'init'
  })

  // 接收資訊
  sharedWorker.port.onmessage = ({ data }) => {
    if (data.from === 'init') {
      console.log('初始化完成', data)
      return
    }
    store.commit('setShareData', data)
  }

  // 頁面關閉
  window.onbeforeunload = (e) => {
    e = e || window.event
    if (e) {
      e.returnValue = '關閉提示'
    }

    // 清除操作
    sharedWorker.port.postMessage({ type: 'close', pageId })

    return '關閉提示'
  }
}
>>>>>>>>>>>>>>>>>> initWorker.js <<<<<<<<<<<<<<

>>>>>>>>>>>>>>>>>> store/indext.js <<<<<<<<<<<<<<
import { createStore } from 'vuex'
import initWorker from '../initWorker'

const store: any = createStore({
  state: {
    shareData: {}
  },
  getters: {
  },
  mutations: {
    setShareData (state, payload) {
      state.shareData = payload
      console.log('收到的訊息:', payload)
    }
  },
  actions: {
    send (state, data) {
      store.sharedWorker.port.postMessage({
        type: 'send',
        data
      })
      console.log('傳送的訊息:', data)
    }
  },
  modules: {
  }
})

// 初始化 worker
initWorker(store)

export default store
>>>>>>>>>>>>>>>>>> store/indext.js <<<<<<<<<<<<<<

BroadcastChannel

BroadcastChannel 介面代理了一個命名頻道,可以讓指定 origin 下的任意 瀏覽上下文 來訂閱它,並允許 同源 的不同瀏覽器 視窗、Tab 頁、frame/iframe 下的不同文件之間相互通訊,透過觸發一個 message 事件,訊息可以 廣播 到所有監聽了該頻道的 BroadcastChannel 物件。

效果演示

10.gif

核心程式碼

// A.html
<body>
    <h1>A 頁面</h1>
    <a href="/b" target="_blank">開啟 B 頁面</a>
    <br />
    <button onclick="send()">傳送訊息給 B 頁面</button>
    <h3>
      收到 B 頁面的訊息:
      <small id="small"></small>
    </h3>

    <script>
      const bc = new BroadcastChannel('test_broadcast_hannel')

      // 向 B 頁面傳送訊息
      function send() {
        console.log('A 頁面已傳送訊息')
        bc.postMessage('你好呀!')
      }

      // 監聽來著 A 頁面的訊息
      bc.onmessage = ({ data }) => {
        document.querySelector('#small').innerHTML = event.data
      }
    </script>
  </body>

// B.html
<body>
    <h1>B 頁面</h1>
    <button onclick="send()">傳送訊息給 B 頁面</button>
    <h3>
      收到 A 頁面的訊息:
      <small id="small"></small>
    </h3>

    <script>
      const bc = new BroadcastChannel('test_broadcast_hannel')

      // 向 A 頁面傳送訊息
      function send() {
        console.log('B 頁面已傳送訊息')
        bc.postMessage('還不錯呦~')
      }

      // 監聽來著 A 頁面的訊息
      bc.onmessage = ({ data }) => {
        document.querySelector('#small').innerHTML = event.data
      }
    </script>
  </body>

HTTP 長輪詢

HTTP 長輪詢 相信大家應該非常的熟悉了,也許你 過去/現在 正在做的 掃碼登入 就是用的長輪詢。

由於 HTTP1.1 協議並不支援服務端主動向客戶端傳送資料訊息,那麼基於這種 請求-響應 模型,如果我們需要服務端的訊息資料,就必須先向服務端傳送對應的查詢請求,因此只要每隔一段時間向伺服器發起查詢請求,在根據響應結果決定是繼續下一步操作,還是繼續發起查詢。

核心很像 Web Storage 方案,只不過中間者不同:

  • Web Storage 的中間者是 瀏覽器,一個頁面 存/改 資料,其他頁面讀取再執行後續操作
  • 長輪詢 的中間者是 伺服器,一個頁面提交請求把目標資料提交到服務端,其他頁面透過輪詢的方式去讀取資料再決定後續操作

由於這種方案比較常見,這裡就不再額外演示。

6397E0B3.png

非同源的多文件互動

window.postMessage

通常對於兩個不同頁面的指令碼,只有當執行它們的頁面具有:

  • 相同協議(通常為 https
  • 相同埠號443https 的預設值)
  • 相同主機 (兩個頁面的 Document.domain設定為相同的值)

時,這兩個指令碼才能相互通訊。

window.postMessage() 方法可以 安全 地實現 跨源通訊,這個方法提供了一種 受控機制 來規避此限制,本質就是自己註冊監聽事件,自己派發事件。

window.postMessage() 允許 一個視窗 可以獲得對 另一個視窗 的引用(比如 targetWindow = window.opener)的方式,然後在視窗上呼叫 targetWindow.postMessage() 方法分發一個 MessageEvent 訊息。

語法如下,詳細解釋可見 MDN

targetWindow.postMessage(message, targetOrigin, [transfer]);

63719F00.jpg

window.open() 和 window.postMessage()

10.gif

核心程式碼

// A.html
<body>
    <h1>A 頁面</h1>
    <button onclick="openAction()">開啟 B 頁面</button>
    <button onclick="send()">傳送訊息給 B 頁面</button>

    <script>
      let targetWin = null
      const targetOrigin = 'http://127.0.0.1:8082/'

      // 開啟 B 頁面
      function openAction() {
        targetWin = window.open(targetOrigin)
      }

      // 向 B 頁面傳送訊息
      function send() {
        if (!targetWin) return
        console.log('A 頁面已傳送訊息')
        targetWin.postMessage('你好呀!', targetOrigin)
      }

      // 監聽來著 B 頁面的訊息
      window.onmessage = (event) => {
        console.log('收到 B 頁面的訊息:', event.data)
      }
    </script>
  </body>
  
 // B.html
 <body>
    <h1>B 頁面</h1>

    <button onclick="send()">傳送訊息給 A 頁面</button>

    <script>
      const targetWin = window.opener
      const targetOrigin = 'http://127.0.0.1:8081/'
      
      // 監聽來著 A 頁面的訊息
      window.onmessage = (event) => {
          console.log("收到 A 頁面的訊息:", event.data)
      }

      // 向 B 頁面傳送訊息
      function send() {
        if (!targetWin) return
        console.log('B 頁面已傳送訊息')
        targetWin.postMessage('還不錯喲~', targetOrigin)
      }

    </script>
  </body>

iframe 和 window.postMessage()

眼前的限制

<iframe> 載入的方式有些限制,只能父頁面向子頁面傳送訊息,子頁面不能向父頁面傳送訊息,本質原因是在父頁面中我們可以透過 document.querySelector('#iframe').contentWindow 的方式獲取到子頁面 window 物件的引用,但是子頁面卻不能像 window.open() 的方式透過 window.opener 的方式獲取父頁面 window 物件的引用。

原本想透過 postMessage 將父頁面的 window 的代理物件傳遞過去,但丟擲如下異常:

image.png

主要原因是 postMessage 是不允許將 Window、Element 等物件進行復制傳遞,即使可以傳遞到了子頁面中也是無法使用的,因為能傳遞過去說明你用了深克隆,但深克隆之後已經和原來的父頁面無關了。

window.parent 屬性

以上思考是在沒完全沒有想到 window.parent 時的方向,也感謝評論區掘友的提醒,完全可以使用這個 window.parent 化繁為簡來獲取父頁面的 window 物件引用:

  • 如果一個視窗沒有父視窗,則它的 parent 屬性為 自身的引用
  • 如果當前視窗是一個 <iframe><object><frame> 的載入的內容,那麼它的父視窗就是 <iframe><object><frame> 所在的那個視窗

10.gif

核心程式碼

 // A.html
 <body>
    <h1>A 頁面</h1>
    <button onclick="send()">傳送訊息給 B 頁面</button>
    <h3>
      收到 B 頁面的訊息:
      <small id="small"></small>
    </h3>
    <iframe
      id="subwin"
      height="200"
      src="http://127.0.0.1:8082/"
      onload="load()"
    ></iframe>

    <script>
      let targetWin = null
      const targetOrigin = 'http://127.0.0.1:8082/'

      // B 頁面載入完成
      function load() {
        // 獲取子頁面 window 物件的引用
        let subwin = document.querySelector('#subwin')
        targetWin = subwin.contentWindow
      }

      // 向 B 頁面傳送訊息
      function send() {
        if (!targetWin) return
        console.log('A 頁面已傳送訊息')
        targetWin.postMessage('你好呀!', targetOrigin)
      }

      // 監聽來著 A 頁面的訊息
      window.onmessage = ({ data }) => {
        document.querySelector('#small').innerHTML = event.data
      }
    </script>
  </body>
  
 // B.html
<body>
    <h1>B 頁面</h1>
    <button onclick="send()">傳送訊息給 B 頁面</button>
    <h3>
      收到 A 頁面的訊息:
      <small id="small"></small>
    </h3>

    <script>
      const targetWin = window.parent
      const targetOrigin = 'http://127.0.0.1:8081/'

      // 向 A 頁面傳送訊息
      function send() {
        if (targetWin === window) return
        console.log('B 頁面已傳送訊息')
        targetWin.postMessage('還不錯呦~', targetOrigin)
      }
      
      // 監聽來著 A 頁面的訊息
      window.onmessage = ({ data }) => {
        document.querySelector('#small').innerHTML = event.data
      }
    </script>
  </body>

websocket

早期 HTTP(超文字傳輸協議)主要目的就是傳輸超文字,因為當時網路上絕大多數的資源都是純文字,許多通訊協議也都使用純文字,因此 HTTP 在設計上不可避免地受到了時代的限制,即 HTTP 沒有完全的利用 TCP 協議的 全雙工通訊 能力,這就是為什麼 HTTP 是 半雙工通訊 的原因。

由於 HTTP 存在早期設計上的限制,但隨著網際網路的不斷髮展,越來越需要這種 全雙工通訊 的功能,因此需要一種新的基於 TCP 實現 全雙工通訊 的協議,而這個協議就是 WebSocket

具體使用這裡不再單獨介紹,如果你想了解更多,可以檢視往期文章《HTTP,WebSocket 和 聊天室》.

63721876.gif

不過這裡還是簡單介紹一下,實現的核心就是 不同的頁面同一個 websocket 服務 建立連線,多個頁面間的通訊在 websocket 服務 中進行轉發,即頁面傳送訊息到 websocket 服務 根據標識進行 單博廣播 的形式下發到其他指定頁面

image.png

不同文件載入方式

前面提到的不同的文件載入方式如下:

  • window.location.href
  • <a href="x" target="x">
  • window.open
  • <iframe>

上面已經列舉了最常見的方案,跨源方案最全能,這是毋庸置疑的,關於不同文件載入方式也在某些層面上與上述方案掛鉤,下面主要講一些不同文件載入方式的異同點。

window.location.href 和 <a href="x" target="_self">

最常的用法就是透過 window.location.href = x 將當前的文件的 url 進行替換,但其實它是有一些規則的:

  • 有效 url
  • hash 形式
  • 其他形式

x = 有效 url 時,當前文件的內容會被新的 url 指向的內容替換:

10.gif

x = hash 形式 時,會將當前文件的 hash 部分直接替換為 x 指向的內容:

10.gif

x = 其他形式 時,會將 x 的內容作為當前文件 url子路徑 進行替換:

19.gif

以上三種形式與 <a href="x" target="_self"> 的表現一致。

window.open 和 <a href="x" target="_blank">

window.open(x)<a href="x" target="_blank"> 的方式都會新開啟一個標籤頁,然後去載入 x 指向的資源,當然其中 x 的載入形式同上。

window.open() 的缺點

瀏覽器出於安全的考慮,會攔截掉 非使用者操作 開啟的新頁面,也就是指如果我們想在某個非同步操作之後自動透過 window.open(x) 的形式開啟新頁面就會失敗,例如:

fetch(url,option).then(res=>{    
    window.open('http://www.test.com') // 開啟失敗
})

setTimeout(() => {
    window.open('http://www.test.com') // 開啟失敗
}, 1000)

image.png

解決辦法

  • 將 window.open() 方法放在使用者事件中

    • 如在非同步操作結束後彈窗提供按鈕,讓使用者手動點選
  • 直接提供 <a> 標籤的形式進行跳轉

    • 不要妄想透過自動建立 a 標籤,然後再透過 a.click() 的方式實現跳轉,你能想到瀏覽器安全限制中也能考慮到
  • window.open() 配合 window.location.href

    • 如下的 clickHandle 本質還是需要用在 使用者事件 中,直接自動執行該函式還是會失效,因為畢竟不是由使用者動作產生的結果

      const clickHandle = () => {
        const newWin = window.open('about:blank')
        ajax().then(res => {
          newWin.location.href = 'http://www.baidu.com'
        }).catch(() => {
          newWin.close()
        })
      }

    <iframe>

    <iframe> 能夠將另一個 HTML 頁面嵌入到 當前頁面 中,每個嵌入的 瀏覽上下文 都有自己的 會話歷史記錄DOM 樹

包含嵌入內容的瀏覽上下文稱為 父級瀏覽上下文頂級瀏覽上下文(沒有父級)通常是由 Window 物件表示的瀏覽器視窗。

多餘的東西在這也不展開了,上面我們使用過的 contentWindow 屬性只在 <iframe> 元素上存在,它返回的是當前 iframe 元素HTMLIFrameElement 所載入文件的 Window 物件的引用。

contentWindow 屬性是 可讀屬性,它所指向的 Window 物件可以去訪問這個 iframe 的文件和它內部的 DOM

10.gif

最後

歡迎關注同名公眾號《熊的貓》,文章會同步更新!

以上就是本文的全部內容了,縱觀文中原本各個看似零散的知識點,在一個需求場景下都被聯絡起來了,所以有些東西確實學了不一定立刻就會用到,但是真的到需要用到的時候你會發現很多知識點其實都是聯絡在一起的,並且它們的表現或原理何其相似。

希望本文對你有所幫助!!!

63F21AE8.gif

相關文章