多開分頁也能同步?來聊聊跨標籤通訊這件事

2024-12-012.48Front EndReact多開分頁也能同步?來聊聊跨標籤通訊這件事

在現代 Web 應用裡,跨頁籤同步變得越來越常見。像是使用者登入、修改設定、或刪除一筆資料後,我們經常會希望其他開著的頁籤也能自動反映這些變動,不需要重新整理頁面或手動同步。

這篇就來介紹幾個常見的場景,並一步步實作一個跨頁籤同步的 Todo List。


什麼時候需要跨頁籤同步?

幾個常見的例子:

  • 登入狀態同步:在一個頁籤中登出後,其他頁籤自動跳轉回登入頁。

  • 設定更新:像語言、主題這種偏好變更,希望所有頁籤都能同步。

  • 即時資料刷新:像刪除某筆資料後,其他頁籤的列表也應自動更新。

  • 通知提醒:某一頁產生的通知,希望其他頁籤也能同步顯示。


使用 localStorage + storage 事件達成同步

大家熟知 localStorage 可以用來儲存資料,但它還有個「額外功能」是跨頁籤資料同步。只要在同一個網域開啟多個頁籤,當某一頁對 localStorage 做了更新,其他頁籤就能透過 storage 事件得知。

這個事件的特性是:

  • 只有在「其他頁籤」操作 localStorage 時才會觸發,自己頁籤修改不會觸發。

  • 只能用在 localStoragesessionStorage 不會觸發這個事件。

範例:基本監聽

import { useEffect } from "react"; export default function Page() { // 標籤 1 監聽到了事件 useEffect(() => { const handler = (event: StorageEvent) => { console.log(event); }; window.addEventListener("storage", handler); return () => { window.removeEventListener("storage", handler); }; }, []); return ( <div> <button onClick={() => { // 由標籤 2 觸發點擊事件 const updatedTime = new Date().toISOString(); sessionStorage.setItem("test", updatedTime); }} > Set LocalStorage </button> </div> ); }

查看 標籤1 打印內容如下 :

文章圖片

上述例子意味著我們已經成功完成不同頁籤間的通信,其中,我們只需關注兩個屬性即可 ( keynewValue ),爾後可以使用該屬性進行操作

應用 : 搭配 React Query 製作跨標籤同步 TodoList

我們理解了跨標籤同步的原理,現在來實作一個簡單 todo list 釋例,本例使用 React Query 發送 API、使用 localstorage 模擬 API 儲存結果,這邊再使用 shadcn 進行美化,呈現畫面如下 

文章圖片

API 請求處理

fetchTodos 模擬 API , 將 todo list 結果持久化在 localStorage

updateTodos 模擬 API ,可帶入新的 todo list 結果回存至 localStorage ,另外我們在更新 API

 資料時,發出 todo-update 信號通知其他頁籤資料已更新,這邊的 value 使用當前時間確保每次更新都設定新值,其他頁籤才得以監聽

我們再使用 useQuery 、 useMutation 處理請求

const fetchTodos = async () => { const todos = JSON.parse(localStorage.getItem("todos") || "[]"); return todos; }; const updateTodos = async (newTodos: Task[]) => { localStorage.setItem("todos", JSON.stringify(newTodos)); // 發送跨標籤通信的訊號 localStorage.setItem("todo-update", Date.now().toString()); }; const { data: todos = [], isLoading } = useQuery({ queryKey: ["todos"], queryFn: fetchTodos, }); const mutation = useMutation({ mutationFn: updateTodos, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["todos"] }); }, });

新增、刪除事件函數

const addTodo = (todo: string) => { mutation.mutate([...todos, { id: Date.now(), text: todo }]); }; const removeTodo = (id: number) => { mutation.mutate(todos.filter((todo: Task) => todo.id !== id)); };

監聽 storage事件

我們使用 useEffect 監聽 storage 事件,並且確認事件 key 值為 todo-update 時觸發更新,然後搭配queryClient.invalidateQueries 重新獲取 todo 列表

useEffect(() => { const handleStorageChange = (event: StorageEvent) => { if (event.key === "todo-update") { queryClient.invalidateQueries({ queryKey: ["todos"] }); } }; window.addEventListener("storage", handleStorageChange); return () => window.removeEventListener("storage", handleStorageChange); }, [queryClient]);

整體程式碼如下

import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient, } from "@tanstack/react-query"; import { useEffect } from "react"; import { Input } from "~/components/ui/input"; import { Card, CardHeader, CardTitle, CardContent, CardFooter, } from "~/components/ui/card"; import { Badge } from "~/components/ui/badge"; import { Separator } from "~/components/ui/separator"; type Task = { id: number; text: string; }; // 初始化 QueryClient const queryClient = new QueryClient(); // 模擬 API const fetchTodos = async () => { const todos = JSON.parse(localStorage.getItem("todos") || "[]"); return todos; }; const updateTodos = async (newTodos: Task[]) => { localStorage.setItem("todos", JSON.stringify(newTodos)); // 發送跨標籤通信的訊號 localStorage.setItem("todo-update", Date.now().toString()); }; // Todo List 顯示 const TodoList = () => { const queryClient = useQueryClient(); // 取得 Todo 資料 const { data: todos = [], isLoading } = useQuery({ queryKey: ["todos"], queryFn: fetchTodos, }); // 更新 Todo 資料 const mutation = useMutation({ mutationFn: updateTodos, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["todos"] }); }, }); // 新增 Todo 項目 const addTodo = (todo: string) => { mutation.mutate([...todos, { id: Date.now(), text: todo }]); }; // 移除 Todo 項目 const removeTodo = (id: number) => { mutation.mutate(todos.filter((todo: Task) => todo.id !== id)); }; useEffect(() => { // 監聽 localStorage 變化,觸發重新獲取資料 const handleStorageChange = (event: StorageEvent) => { if (event.key === "todo-update") { queryClient.invalidateQueries({ queryKey: ["todos"] }); } }; window.addEventListener("storage", handleStorageChange); return () => window.removeEventListener("storage", handleStorageChange); }, [queryClient]); if (isLoading) return <div>Loading...</div>; return ( <Card className="max-w-lg mx-auto mt-10 shadow-lg"> <CardHeader> <CardTitle className="text-center text-2xl font-bold"> Todo List </CardTitle> <Separator /> </CardHeader> <CardContent> <ul className="space-y-2"> {todos.map((todo: Task) => ( <li key={todo.id} className="flex items-center justify-between p-2 border rounded-md" > <span className="text-sm font-medium">{todo.text}</span> <Badge variant="destructive" className="cursor-pointer" onClick={() => removeTodo(todo.id)} > Delete </Badge> </li> ))} </ul> </CardContent> <CardFooter> <Input type="text" placeholder="Add new task..." className="w-full" onKeyDown={(e) => { if (e.key === "Enter" && e.currentTarget.value) { addTodo(e.currentTarget.value); e.currentTarget.value = ""; } }} /> </CardFooter> </Card> ); }; const Page = () => { return ( <QueryClientProvider client={queryClient}> <TodoList /> </QueryClientProvider> ); }; export default Page;

結語

此時我們完成了一個非常成功的跨頁籤操作,但該方法仍有缺點,例如 : localStorage 有 5MB 限制、更複雜的使用場景可能不適用,如果需要更細緻的操作可以透過另依原生 API BroadcastChannel ,至於用法,日後我再做一篇文章來整理吧 :)

參考

https://muki.tw/broadcastchannel-message-to-sessionstorage/#google_vignette

https://medium.com/i-am-mike/javascript-broadcastchannel-api-%E5%A6%82%E4%BD%95%E5%81%9A%E5%88%B0%E8%B7%A8%E8%A6%96%E7%AA%97%E6%BA%9D%E9%80%9A-0d7c150c4a2c