多開分頁也能同步?來聊聊跨標籤通訊這件事
2024-12-012.48Front EndReact
在現代 Web 應用裡,跨頁籤同步變得越來越常見。像是使用者登入、修改設定、或刪除一筆資料後,我們經常會希望其他開著的頁籤也能自動反映這些變動,不需要重新整理頁面或手動同步。
這篇就來介紹幾個常見的場景,並一步步實作一個跨頁籤同步的 Todo List。
什麼時候需要跨頁籤同步?
幾個常見的例子:
登入狀態同步:在一個頁籤中登出後,其他頁籤自動跳轉回登入頁。
設定更新:像語言、主題這種偏好變更,希望所有頁籤都能同步。
即時資料刷新:像刪除某筆資料後,其他頁籤的列表也應自動更新。
通知提醒:某一頁產生的通知,希望其他頁籤也能同步顯示。
使用 localStorage
+ storage
事件達成同步
大家熟知 localStorage
可以用來儲存資料,但它還有個「額外功能」是跨頁籤資料同步。只要在同一個網域開啟多個頁籤,當某一頁對 localStorage
做了更新,其他頁籤就能透過 storage
事件得知。
這個事件的特性是:
只有在「其他頁籤」操作 localStorage 時才會觸發,自己頁籤修改不會觸發。
只能用在
localStorage
,sessionStorage
不會觸發這個事件。
範例:基本監聽
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
打印內容如下 :

上述例子意味著我們已經成功完成不同頁籤間的通信,其中,我們只需關注兩個屬性即可 ( key
與 newValue
),爾後可以使用該屬性進行操作
應用 : 搭配 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