只要是應用程式,都有可能會被開啟了一個以上的實體,而當應用程式的設計當中包含了持久性資料(persistent data,即使應用程式關閉之後仍會保存的資料)的時候,我們就會需要去考慮有多個實體同時去存取持久性資料的一些細節。本篇當中來分享 BP Studio 在這部份的處理方法。

應用情境

BP Studio 的持久性資料主要有下面三類:

  1. 資源檔案的快取:存在 CacheStorage 當中;這部份是由 Service Worker(可參見本系列第二篇)統一管理的,直接自動就是跨實體的,沒有什麼需要煩惱的地方,所以本篇不多討論。
  2. 工作階段狀態:已經開啟的專案檔案與其狀態(當前的鏡頭視角、編輯歷史狀態等等)、以及這些檔案對應的 FileSystemFileHandle(如果有的話),以便應用程式下次開啟時可以繼續編輯上次開啟的專案。除了 FileSystemFileHandle 是存在 IndexedDB 當中之外(可參見本系列第十篇),其餘的資料都是存在 localStorage 裡面的。
  3. 各種使用者設定值:包括多國語系、深淺色主題以及其它的各種選項。這也是存在 localStorage 裡面的。

其中後兩者的行為在面對複數個實體的時候,會有稍微不太一樣的行為。使用者設定值的方面,希望的是所有的實體之間可以連動:所有的實體都可以去寫入新的設定,而當寫入發生時,其餘的實體應該要能獲得通知、並且去更新自身的設定以呈現出一致的行為。而工作階段狀態的部份則比較複雜一點。這邊我稍微參考了一下 VS Code 的設計,規定「如果有複數個實體存在,則只有其中最早被開啟的那一個能夠去寫入工作階段狀態」,我稱之為「擁有工作階段存取權」的實體;而也只有那個實體會在開啟的時候重新載入儲存的工作階段狀態,其餘的實體在開啟時只會顯示歡迎畫面、不會載入任何東西。底下就來針對這兩種不同行為進一步討論。

設定同步

BP Studio 的各種使用者設定是存在於一個叫 settings 的巢狀物件當中,並且將它包裝成了 Vue 當中的 reactive 物件;而我對它設置了一個 deep 的 watch,使得裡面任何深層的值被修改的時候,就會立刻觸發存檔機制、也就是把其內容轉換成 JSON 字串,並且寫入到 localStorage"settings" 欄位當中。

接下來要做的就是當有這種寫入發生的時候,其它的實體必須要能接到通知、且更新自身持有的 settings 物件的內容。這個部份剛好有一個很方便的 StorageEvent 可以監聽:

window.addEventListener("storage", e => {
    // 確定寫入發生的位置是 localStorage,
    // 以及寫入的欄位是 "settings"
    if(e.storageArea === localStorage && e.key === "settings") {
        // 讀取 localStorage 並且把新的資料寫入到 settings 物件當中
        ...
    }
});

注意到這個事件是只會在別的實體上面觸發,進行寫入操作的那一個實體自己是不會觸發的。然而,這邊還有一個要注意的小細節:記得我剛才提到過,我設置了一個 watch 去自動在 settings 物件當中任何資料改變的時候寫入 localStorage,此時我們必須把上面的那種寫入視為例外,否則其它實體在同步完設定了之後又會再次寫入 localStorage,這樣就沒完沒了了。所以比較完整的形式會是如下所示:

// 設置一個旗標來表示目前是否正在處理同步
let syncing: boolean = false;

window.addEventListener("storage", e => {
    if(e.storageArea === localStorage && e.key === "settings") {
        syncing = true;
        // 讀入新的設定
        const json = localStorage.getItem("settings");
        const newSettings = JSON.parse(json);
        // 因為 settings 是 reactive 物件,必須用複製欄位的方式來寫入
        // 這樣做一樣會觸發底下的 watch
        for(const key in settings) {
            settings[key] = newSettings[key];
        }
    }
});

watch(settings, () => {
    if(!syncing) {
        // 只有不是正在進行同步的時候才真的寫入
        const json = JSON.stringify(settings);
        localStorage.setItem("settings", json);
    }
    syncing = false; // 把旗標還原
}, { deep: true });

工作階段存取權

回顧一下我對存取權的規則是:所有開啟的實體當中最早被開啟的那一個具有存取權。注意到在這個定義之下,對每個實體而言,「是否具有存取權」這件事並不是一個常數:假如原本最老的那個實體被關閉了,那麼就會變成第二老的那一個擁有存取權,依此類推。

然而這邊卻有一個小麻煩:並不存在一個可靠的方法、可以在一個實體關閉的時候去執行特定的程式碼。現在的規範是強烈地建議不要使用網頁的 unload 事件、也不要在 beforeunload 事件裡面做任何除了「提示使用者可能的資料遺失」之外的事情。也就是說,我們不能夠等到最老的實體要被關閉的時候,才由它去發出某種通知、告訴其它實體說「我要把存取權讓出來了喔」這樣;此路是不通的。

幸好,現代的瀏覽器中有一個 Web Locks API 可以很有效地解決這種需求。當我們用這個 API 請求了一個 lock 之後,這個 lock 其實有三種情況會被釋放:

  1. 我們傳入該方法的 callback 函數執行完畢(同步情形)、或是該函數傳回的 Promise 執行完畢(非同步情形);
  2. 這個 lock 被另外一個設定了 steal: true 的請求搶走了;
  3. 以及最重要的,如果請求這個 lock 的實體根本被關掉了的話。

我們這邊就是要利用第三個特性來有效地掌握存取權的釋出。基本的形式只要幾行程式碼就可以完成了:

// 其餘的程式碼會根據這個變數的值來判斷是否有存取權
let hasSession = false;

navigator.locks.request("bps_session",
    // 注意到這個 Promise 的 resolve 永遠不會被呼叫,
    // 所以取得的 lock 一直到實體被關閉為止都不會釋出
    () => new Promise<void>(resolve => {
        hasSession = true;
    })
);

如此一來,最先被打開的實體就會直接拿到存取權,而若有更多實體被打開,最初的那一個關閉的瞬間就會讓排隊中的第一個拿到存取權,依此類推。

實體重新整理

本來上述的基本形式已經足以滿足大部分的需求,但是這邊我有點自找麻煩地追加了一個隱藏的規定:如果一個擁有存取權的實體被按下了「網頁重新整理」的按鈕,那重新整理之後它應該還是繼續擁有存取權。當然,實務上這個狀況應該很少會發生才對,但是我覺得這是合理的行為,所以有點頑固地堅持要實現之。

首先的第一個重點當然在於讓一個實體在重新整理之後還能保有某些記憶。相信很多讀者應該會立刻想到 sessionStorage;確實它就是用來做這件事的沒錯。我只要在取得存取權的時候在裡面寫入某種旗標,然後重新整理的時候(存取權會在那一瞬間轉移給第二個實體)如果看到這個旗標,就利用 steal 的機制把第二個實體的存取權搶回來即可。

可是這邊有一個小細節需要注意:如果使用者是按下頁籤右鍵選單中的「複製」功能來製造出新的頁籤的話,sessionStorage 裡面的資料是會全部被複製一份到新的頁籤當中的,這麼一來我們設置的旗標也會被複製過去,使得新的頁籤誤以為自己有存取權;這當然是不行的。因此,我們必須要去偵測當前的頁面真的是重新整理出來的、而不是被複製出來的。這可以透過如下的方法檢查:

const isReload =
    (performance.getEntriesByType("navigation")[0]
        as PerformanceNavigationTiming)?.type == "reload" ??
    performance.navigation.type == 1;

if(!isReload) sessionStorage.clear(); // 避免資料被複製到新的頁籤

之所以這個檢查的咒語有一點複雜,是因為這是兼併了新的 PerformanceNavigationTiming 與舊版的 performance.navigation 寫法的結果;這樣做可以在最多的環境中獲得支援 1

確定 sessionStorage 乾淨了之後,我們上面的基本版就可以升級如下:

let hasSession = false;

function request(steal: boolean): void {
    navigator.locks.request("bps_session", { steal },
        () => new Promise<void>(resolve => {
            hasSession = true;
            sessionStorage.setItem("hasSession", "true"); // 寫入旗標
        })
    )
    .catch(() => {
        // 如果 lock 被搶走的話,會進到這裡來
        hasSession = false;
        sessionStorage.clear();
        setTimeout(request, 1000); // 被搶的話要記得重新排隊
    });
}

const steal = Boolean(sessionStorage.getItem("hasSession")); // 讀取旗標
request(steal);

只不過,這樣的作法會跟我們前面的規定有一點點小衝突:注意到存取權被搶走的實體必須重新排隊,這會使得它的順位被排到了最後面、即便它其實是第二個被開啟的實體;而等一下如果第一個實體真的被關閉了,會拿到存取權的將是第三個實體。雖然有這個小小的不協調,但是因為用 Web Locks API 除此之外用起來是真的很簡單方便,所以我就將就一下吧;反正這個情境本來就是少見的。

向下相容

到這邊本來應該是很美滿的,只剩下一個問題:Web Locks API 的瀏覽器版本需求是高過我設定的瀏覽器支援度不少的(可以參見本系列第 14 篇)。Safari 甚至一直到 15.4 版才加入其支援,而 Firefox 也是到了 96 版才有。所以對於那些較舊的瀏覽器,就得想辦法加以 polyfill 了。

關於這個部份,網路上是有一些現成的 polyfill 存在(例如 navigator.locks),但是我發現那些 polyfill 實際上執行起來的行為都有明顯的 bug,我稍微解釋一下為什麼。

稍早我提到,我們這邊整個機制的一大重點就是要偵測「實體被關閉掉」的這件事,而且一個前提是不能指望透過 beforeunload 或甚至 unload 事件來進行通知。那不然要怎麼辦呢?一個很標準的想法是透過心跳(heartbeat)的機制、來讓實體持續地(例如使用 setInterval() 方法)回報「我還活著喔」這樣,而只要發現持有存取權的實體最後一次心跳的時間超過設定的間隔,那就可以合理當作它已經被關掉了,從而把存取權轉移給下一個實體。網路上對於 Web Locks API 的既有 polyfill 就我所見,全部都是基於這套想法在做的。

可是或許他們忘了一件很重要的事:setInterval() 這一類定時觸發的方法全都有一個特性,就是當網頁頁籤處於背景狀態時,它們的觸發可能會非常緩慢,乃至於太慢的心跳造成了假死的誤判。結果就是,那些 polyfill 在切換頁籤的時候都很容易發生「明明持有存取權的實體還沒被關閉、存取權就已經讓出來了」的 bug。

這有辦法修正嗎?唔,是有一個辦法可以保證 setInterval() 不會被減速,就是改成用 Worker 來執行,但如此一來又有了新的問題:Worker 是沒有辦法讀寫 localStorage 的,如此一來它要回報心跳就要嘛得打回 UI 執行緒去進行、要嘛就得回報給某個 Worker 可以存取的儲存體(例如 IndexedDB)上頭,但無論哪一種作法都複雜得太過不必要了。

因此,基本上我們可以放棄掉「由實體主動給出心跳」這樣的路線。退而求其次地,就是讓實體被動地接受來自其它實體的詢問,也就是說,改成由位於前景的排隊中實體去輪詢持有存取權的實體是否還活著。這個反轉的最大差異就是輪詢所使用的 setInterval() 是由前景實體發動的,因此不會被減速(如果排隊中的實體退回到背景當中,那它也不急著拿到存取權,所以被減速也沒差)。

然而這個詢問要怎麼實作呢?對於互相不確定對方存在的實體之間,可用的往返溝通手段是很有限的,尤其在這邊 Broadcast channel 也是不能用的,因為 Safari 也是從 15.4 版才開始支援它。雖然 broadcast channel 可以用 localStorage 配合 StorageEvent 來 polyfill,但是就算如此,要用這種方法來正確實作往返溝通仍舊是相當複雜的一件事,尤其要多久才算是 timeout 是很難拿捏的。

不過剛好因為 BPS 有用到 service worker,這邊倒是存在一個簡單得多的解法,其核心概念是 service worker 可以利用 clients.get() 方法來檢查一個實體是否還活著,如此一來就不需要實體之間的往返溝通也可以進行輪詢了。

我們先來看 service worker 的部份:

// 因為 service worker 不能保證變數的存活,我們要使用 IndexedDB 來暫存資料。
// 雖然到頭來我還是用了 IndexedDB,但起碼整體的架構簡單多了。
import * as idbKeyval from "idb-keyval";

self.addEventListener("message", event => {
    event.waitUntil(message(event)); // 保險起見
});

async function message(event: ExtendableMessageEvent): Promise<void> {
    const port = event.ports[0];
    if(port) {
        // 讀取已知的客戶端 id 清單
        const clientList = await idbKeyval.get<string[]>("clients") || [];
        const sourceId = (event.source as Client).id;
        // 會傳來的指令分成 request, check, query, steal 四種
        if(event.data == "request") {
            // 請求 lock
            clientList.push(sourceId);
            port.postMessage(await check(clientList, sourceId));
        } else if(event.data == "check") {
            // 單純檢查排隊排到了沒有
            port.postMessage(await check(clientList, sourceId));
        } else if(event.data == "query") {
            // 查看有多少人在排隊
            port.postMessage(clientList.length);
            return; // 這個情況中提早返回,不用寫入
        } else if(event.data == "steal") {
            // 處理搶 lock 的動作
            if(clientList.length) {
                const client = await self.clients.get(clientList[0]);
                // 通知對應的客戶端存取權被搶了
                if(client) client.postMessage("steal");
                clientList.shift();
            }
            clientList.unshift(sourceId);
            port.postMessage(true);
        }
        // 寫入新的 id 清單
        await idbKeyval.set("clients", clientList);
    }
}

async function check(clientList: string[], id: string): Promise<boolean> {
    let client: Client | undefined;
    while(clientList[0] !== id && !client) {
        // 檢查 id 對應實體是否還活著,沒有就從清單中移除
        client = await self.clients.get(clientList[0]);
        if(!client) clientList.shift();
    }
    return clientList[0] === id;
}

然後是 UI 執行緒當中的 polyfill;首先我們有一些呼叫 worker 的公用方法(其中 callWorker 方法跟本系列第五篇當中介紹過的是類似的,只是細節更完整一點):

function callWorker<T = unknown>(
    worker: Worker | ServiceWorker, data: unknown
): Promise<T> {
    return new Promise((resolve, reject) => {
        const channel = new MessageChannel();
        channel.port1.onmessage = event => {
            resolve(event.data);
            channel.port1.onmessage = null; // GC
        };
        try {
            worker.postMessage(data, [channel.port2]);
        } catch(e) {
            reject(e);
        }
    });
}

function callService<T = unknown>(data: unknown): Promise<T> {
    return new Promise((resolve, reject) => {
        navigator.serviceWorker.ready.then(reg => {
            if(!reg || !reg.active) return reject();
            callWorker<T>(reg.active, data).then(resolve, reject);
        }, reject);
    });
}

type Writeable<T> = { -readonly [P in keyof T]: T[P] };

然後是主體的程式:

if(!("locks" in navigator)) {
    const CHECK_INTERVAL = 500;
    const nav = navigator as Writeable<Navigator>; // 這是為了應付 TS 型別檢查

    function check(): Promise<void> {
        return new Promise(resolve => {
            // 持續檢查,直到排隊排到為止。如同前述,這邊不用擔心減速的問題。
            const interval = setInterval(async () => {
                const success = await callService("check");
                if(success) {
                    clearInterval(interval);
                    resolve();
                }
            }, CHECK_INTERVAL);
        });
    }

    nav.locks = {
        async request(
            key: string, // 在這邊忽略此值
            options: LockOptions,
            callback: () => Promise<void>
        ): Promise<void> {
            const success = await callService<boolean>(
                options.steal ? "steal" : "request"
            );
            if(!success) await check(); // 沒有立刻拿到的話就等待
            callback(); // 拿到存取權了
            return new Promise((resolve, reject) => {
                // 拿到存取權之後,接到被槍的通知就要進行 reject
                nav.serviceWorker.onmessage = event => {
                    if(event.data == "steal") reject();
                };
            });
        },
        async query() {
            const count = await callService<number>("query");
            // 傳回一個符合 API 格式的結果就好,這邊不多講究
            return { held: count ? [{ name: "bps_session" }] : [] };
        },
    } as LockManager;
}

結語

Web Locks API 是一個滿奇特的 API,因為我找不到一個現成的 polyfill 是既有在維護、實作也完整、運作起來又沒有 bug 的。當然我上面的 polyfill 也是遠遠沒有完整實作、而是只針對我會用到的功能去做而已,但是或許其核心想法可以對各位有些幫助。

我個人有點懷疑那些現成的 polyfill 之所以會有顯然的 bug,可能是因為沒有真的拿到舊版瀏覽器上面充分實測的關係吧。這部份真的又要再次感謝 BrowserStack 贊助的測試資源了;沒有他們的話上面的解法根本不可能開發得出來的。


  1. 原則上後者是已經棄用的寫法,但是其瀏覽器支援卻比前者要廣;Safari 一直到 15 版才支援前者。 


分享此頁至:
最後修改日期: 2023/04/20

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。您的留言可能會在審核之後才出現在頁面上。