別タブで開いているページを、リロードすることなく更新したい(実装編)
概要
拙著 別タブで開いているページを、リロードすることなく更新したい(理論編) にて、以下を実装する際の技術課題を確認しました。
- 目的のページを既に別タブで開いている場合、このタブを表示する
- タブを表示する際、リロードを行わずに画面のデータを更新する
- 画面のデータ : 上記例で言うと、選択されたチャプター動画の再生位置)
本記事では、サンプルコードを作成し、期待した動作が実現できることを確認します。
前提
Next.js にてサンプルコードを実装します。
初期状態
ファイル構成
.
└── app
├── fonts
├── intro
└── page.tsx
└── video
└── page.tsx
"use client";
import { useSearchParams } from "next/navigation";
import { FC } from "react";
export const chapterMap = {
"01": "開始",
"02": "メイン",
"03": "終了",
};
type TypeChapterId = keyof typeof chapterMap;
// Chapter 情報を受け取って処理したいこと
const ChapterComponent: FC = () => {
const currentChapterId = useSearchParams().get("chapter") as TypeChapterId;
const currentChapterName = chapterMap[currentChapterId];
return <p>your selected chapter is {currentChapterName}.</p>;
};
const Page = () => {
return (
<>
<h1>video playback page.</h1>
{/* レンダリングの有無確認用。 */}
{/* ChapterComponent だけ再レンダリングさせたい。 */}
<p> current time : {new Date().toString()}</p>
<ChapterComponent />
</>
);
};
export default Page;
"use client";
import Link from "next/link";
import { FC } from "react";
const ChapterLink: FC<{ chapter: string }> = ({ chapter }) => {
const chapterLink = `/video?chapter=${chapter}`;
return (
<div>
<Link
href={`${chapterLink}`}
target={"_blank"}
>{`jump to Chapter ${chapter}.`}</Link>
</div>
);
};
const Page = () => {
return (
<>
<h1>video introduction page.</h1>
<ChapterLink chapter={"01"} />
<ChapterLink chapter={"02"} />
<ChapterLink chapter={"03"} />
</>
);
};
export default Page;
画面イメージ
実装
以下の段取りで実装していきます。
- 目的のページを既に別タブで開いている場合、このタブを表示する
- state を介して、別タブにデータを送信する
- 画面リロードを抑制する
- データ受信側にて、受け取ったデータを元に画面を再描画する
1. 目的のページを既に別タブで開いている場合、このタブを表示する
target
属性に、適切な値を設定します。
今回は、/video
ページへのリンクなので、そのまま /video
という値を挿入します。
...
href={`${chapterLink}`}
- target="_blank"
+ target={"/video"}
>{`jump to Chapter ${chapter}.`}</Link>
...
これにより、「編集した箇所のリンクから開いたウィンドウ」が再利用されるようになります(以下gif を参照)。
既存のタブが再利用されており、 "chapter is N" の部分が、リンクに合わせて更新されていることが確認できます。
しかし、表示している現在時刻情報も更新されております。
次は、 popstate
を使用して、画面をリロードせず(≒表示している現在時刻情報を更新せず)に "chapter is N" の部分を更新できるようにしていきます。
2. state を介して、別タブにデータを送信する
データ送信側とデータ受信側とで、分けて実装していきます。
◆送信側
-
pushState
にて、履歴(state
)情報をpush-
state
情報に、送信したいデータを詰め込む
-
-
dispatchEvent
にてpopstate
イベントを発火
◆受信側
-
addEventListener
にて、popstate
イベント発火時に実行したい処理を登録 - 発火した
popstate
イベントからデータを取得し、後続の処理で利用
送信側の実装
window.open
の戻り値として得られる Window
オブジェクトに対して、 pushState
を実行します。
その後、dispatchEvent
にて popstate
イベントを能動的に発火させます。
// ウィンドウを開く
const win = window.open(url, target);
if (win === null) throw new Error("window is null");
// 別タブへ送信したいデータを構築
const state = {
page: "video", // 受信側で、自分宛てのイベントか判断するための識別情報
customData: { chapterId }, // ここに送信したいデータを設定
};
// pushState APIにて、win(別タブ側)に履歴(state)を追加
win.history.pushState(state, "", url);
// popEventState イベントを能動的に発火させ、別タブ側にデータを送信(ついでにフォーカス)
win.dispatchEvent(new PopStateEvent("popstate", { state }));
win.focus();
pushState
および PopStateEvent
の詳細は、以下ドキュメントをご参照ください。
-
pushState
: https://developer.mozilla.org/ja/docs/Web/API/History/pushState -
PopStateEvent
https://developer.mozilla.org/ja/docs/Web/API/PopStateEvent
受信側の実装
popstate
イベントが発火した際に、実行したい処理を定義します。
-
page
プロパティにて、このコンポーネントに宛てたpopstate
イベントなのか識別する -
customData
プロパティから、送信側が埋め込んだデータを取り出す
// PopStateEvent 発火確認時に、実行したい処理
const handlePopState = (e: PopStateEvent) => {
const { state } = e;
// page プロパティにて、自コンポーネント宛てか識別
if (state && state.page === "video") {
const { chapterId } = state.customData;
// 確認用
alert(`received customData「${chapterId}」`);
}
};
実装した処理は、 addEventListener
を使用して登録します。
useEffect(() => {
// handlePopState 関数を、 「"popstate" イベント発火時に実行する処理」として登録
window.addEventListener("popstate", handlePopState);
// クリーンアップ関数で、イベントを削除
return () => {
window.removeEventListener("popstate", handlePopState);
};
}, []);
動作を確認をすると、アラートが上がります。
アラートの中身に、 state
に詰め込まれたデータが表示されることが分かります。
3. 画面リロードを抑制する
送信側 にて、 open.window
で得た Window
オブジェクトを状態管理し、再利用します。
これにより、「リンクをクリックする度に window.open
が実行され、画面がリロードされる」という事態を防ぎます。
ここでは、状態管理の一例として jotai(https://jotai.org/) を使用しています。
import { atom, useAtom } from "jotai";
// Window オブジェクト用 Jotai
const targetWindowAtom = atom<Window | null>(null);
export const useJotaiTargetWindow = () => {
const [targetWindow, setTargetWindow] = useAtom(targetWindowAtom);
return {
targetWindow,
setTargetWindow,
};
};
const ChapterLink: FC<{ chapterId: TypeChapterId }> = ({ chapterId }) => {
const { targetWindow, setTargetWindow } = useJotaiTargetWindow();
// PopStateEvent 発火処理を別関数化
const activatePopStateEvent = useCallback(
(targetWindow: Window, url: string) => {
// 別タブへ送信したいデータを構築
const state = {
page: "video", // 受信側で、自分宛てのイベントか判断するための識別情報
customData: { chapterId }, // ここに送信したいデータを設定
};
// popEventState イベントを発火
// このイベントを通じて、別タブ側にデータを送信
targetWindow.history.pushState(state, "", url);
targetWindow.dispatchEvent(new PopStateEvent("popstate", { state }));
targetWindow.focus();
},
[chapterId]
);
const onClick = useCallback(() => {
const url = `/video?chapter=${chapterId}`;
const target = "/video";
// まだタブを開いていない場合、新規タブで開きつつ windowインスタンスを状態管理
if (targetWindow === null || targetWindow.closed) {
const win = window.open(url, target);
if (win === null) throw new Error("window is null");
setTargetWindow(win);
activatePopStateEvent(win, url);
} else {
/** 既に別タブで開いている場合は再利用 **/
activatePopStateEvent(targetWindow, url);
}
}, [activatePopStateEvent, chapterId, setTargetWindow, targetWindow]);
return (...)
動作を確認します。
current time
部分の表示に注目すると、この値が変化していないことに気付きます。
これは、画面リロードが抑制できていることを示します。
4. データ受信側にて、受け取ったデータを元に画面を再描画する
最後に、 PopStateEvent
の customData
プロパティ経由でて受け取ったデータを、状態管理します。
状態管理ツールには、同じく "jotai" を使用します。
+const currentChapterIdAtom = atom<TypeChapterId>("01");
+export const useJotaiCurrentChapterId = () => {
+ const [currentChapterId, setCurrentChapterId] = useAtom(currentChapterIdAtom);
+ return {
+ currentChapterId,
+ setCurrentChapterId,
+ };
+};
+
// Chapter 情報を受け取って処理したいこと
const ChapterComponent: FC = () => {
- const chapterNum = useSearchParams().get("chapter") as TypeChapterId;
- const ChapterName = chapterMap[chapterNum];
+ const { currentChapterId, setCurrentChapterId } = useJotaiCurrentChapterId();
// PopStateEvent 発火確認時に、実行したい処理
const handlePopState = (e: PopStateEvent) => {
@@ -21,7 +32,7 @@ const ChapterComponent: FC = () => {
// page プロパティにて、自コンポーネント宛てか識別
if (state && state.page === "video") {
const { chapterId } = state.customData;
- alert(`received customData「${chapterId}」`);
+ setCurrentChapterId(chapterId);
}
};
@@ -36,7 +47,7 @@ const ChapterComponent: FC = () => {
}, []);
return (
<>
- <p>your selected chapter is {ChapterName}.</p>
+ <p>your selected chapter is {chapterMap[currentChapterId]}.</p>
</>
);
};
動作確認します。
current time
の値は変わらないまま、 chapter 情報部分だけ更新されていることが確認できました。
これにて、やりたかった 「別タブで開いているページを、リロードすることなく更新したい」を達成できました!
完装した感想
当初は実現可能か不安でしたが、何とかなりました。
実装する過程で、History API 周りの理解が深まりました。
popstate
辺りで検索すると、「編集中の画面から遷移する際にポップアップを出す」みたいな話が良く引っ掛かりますが、今ならこの辺りの実装例もちゃんと理解できそうな気がします。
参考文献
- https://developer.mozilla.org/ja/docs/Web/API/History/pushState
- https://developer.mozilla.org/ja/docs/Web/API/PopStateEvent
- https://jotai.org/
付録
最終的なソースコード
"use client";
import { FC, useCallback } from "react";
import Link from "next/link";
import { atom, useAtom } from "jotai";
import { chapterMap, TypeChapterId } from "../video/page";
const targetWindowAtom = atom<Window | null>(null);
export const useJotaiTargetWindow = () => {
const [targetWindow, setTargetWindow] = useAtom(targetWindowAtom);
return {
targetWindow,
setTargetWindow,
};
};
const ChapterLink: FC<{ chapterId: TypeChapterId }> = ({ chapterId }) => {
const { targetWindow, setTargetWindow } = useJotaiTargetWindow();
const activatePopStateEvent = useCallback(
(targetWindow: Window, url: string) => {
// 別タブへ送信したいデータを構築
const state = {
page: "video", // 受信側で、自分宛てのイベントか判断するための識別情報
customData: { chapterId }, // ここに送信したいデータを設定
};
// popEventState イベントを発火
// このイベントを通じて、別タブ側にデータを送信
targetWindow.history.pushState(state, "", url);
targetWindow.dispatchEvent(new PopStateEvent("popstate", { state }));
targetWindow.focus();
},
[chapterId]
);
const onClick = useCallback(() => {
const url = `/video?chapter=${chapterId}`;
const target = "/video";
// まだタブを開いていない場合、新規タブで開きつつ windowインスタンスを保持
if (targetWindow === null || targetWindow.closed) {
const win = window.open(url, target);
if (win === null) throw new Error("window is null");
setTargetWindow(win);
activatePopStateEvent(win, url);
} else {
/** 既に別タブで開いている場合は再利用 **/
activatePopStateEvent(targetWindow, url);
}
}, [activatePopStateEvent, chapterId, setTargetWindow, targetWindow]);
return (
<div>
<Link
onClick={onClick}
href={""}
>{`jump to Chapter ${chapterId}(${chapterMap[chapterId]}).`}</Link>
</div>
);
};
const Page = () => {
return (
<>
<h1>video introduction page.</h1>
<ChapterLink chapterId={"01"} />
<ChapterLink chapterId={"02"} />
<ChapterLink chapterId={"03"} />
</>
);
};
export default Page;
"use client";
import { FC, useEffect } from "react";
import { atom, useAtom } from "jotai";
export const chapterMap = {
"01": "開始",
"02": "メイン",
"03": "終了",
};
export type TypeChapterId = keyof typeof chapterMap;
const currentChapterIdAtom = atom<TypeChapterId>("01");
const useJotaiCurrentChapterId = () => {
const [currentChapterId, setCurrentChapterId] = useAtom(currentChapterIdAtom);
return {
currentChapterId,
setCurrentChapterId,
};
};
// Chapter 情報を受け取って処理したいこと
const ChapterComponent: FC = () => {
const { currentChapterId, setCurrentChapterId } = useJotaiCurrentChapterId();
// PopStateEvent 発火確認時に、実行したい処理
const handlePopState = (e: PopStateEvent) => {
const { state } = e;
// page プロパティにて、自コンポーネント宛てか識別
if (state && state.page === "video") {
const { chapterId } = state.customData;
setCurrentChapterId(chapterId);
}
};
useEffect(() => {
// handlePopState 関数を、 「"popstate" イベント発火時に実行する処理」として登録
window.addEventListener("popstate", handlePopState);
// クリーンアップ関数で、イベントを削除
return () => {
window.removeEventListener("popstate", handlePopState);
};
}, []);
return (
<>
<p>your selected chapter is {chapterMap[currentChapterId]}.</p>
</>
);
};
const Page = () => {
return (
<>
<h1>video playback page.</h1>
{/* レンダリングの有無確認用。 */}
{/* ChapterComponent だけ再レンダリングさせたい。 */}
<p> current time : {new Date().toString()}</p>
<ChapterComponent />
</>
);
};
export default Page;
Discussion