😊

別タブで開いているページを、リロードすることなく更新したい(実装編)

2024/09/16に公開

概要

拙著 別タブで開いているページを、リロードすることなく更新したい(理論編) にて、以下を実装する際の技術課題を確認しました。

  1. 目的のページを既に別タブで開いている場合、このタブを表示する
  2. タブを表示する際、リロードを行わずに画面のデータを更新する
    • 画面のデータ : 上記例で言うと、選択されたチャプター動画の再生位置)

本記事では、サンプルコードを作成し、期待した動作が実現できることを確認します。

前提

Next.js にてサンプルコードを実装します。

初期状態

ファイル構成

.
└── app
    ├── fonts
    ├── intro
        └── page.tsx
    └── video
        └── 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;

intro/page.tsx
"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;

画面イメージ

初期状態

実装

以下の段取りで実装していきます。

  1. 目的のページを既に別タブで開いている場合、このタブを表示する
  2. state を介して、別タブにデータを送信する
  3. 画面リロードを抑制する
  4. データ受信側にて、受け取ったデータを元に画面を再描画する

1. 目的のページを既に別タブで開いている場合、このタブを表示する

target 属性に、適切な値を設定します。

今回は、/video ページへのリンクなので、そのまま /video という値を挿入します。

intro/page.tsx
...
         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 イベントを能動的に発火させます。

intro/page.tsx
// ウィンドウを開く
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 の詳細は、以下ドキュメントをご参照ください。

受信側の実装

popstate イベントが発火した際に、実行したい処理を定義します。

  • page プロパティにて、このコンポーネントに宛てた popstate イベントなのか識別する
  • customData プロパティから、送信側が埋め込んだデータを取り出す
video/page.tsx
// PopStateEvent 発火確認時に、実行したい処理
const handlePopState = (e: PopStateEvent) => {
  const { state } = e;
  // page プロパティにて、自コンポーネント宛てか識別
  if (state && state.page === "video") {
    const { chapterId } = state.customData;

    // 確認用
    alert(`received customData「${chapterId}`);
  }
};

実装した処理は、 addEventListener を使用して登録します。

video/page.tsx
useEffect(() => {
  // handlePopState 関数を、 「"popstate" イベント発火時に実行する処理」として登録
  window.addEventListener("popstate", handlePopState);

  // クリーンアップ関数で、イベントを削除
  return () => {
    window.removeEventListener("popstate", handlePopState);
  };
}, []);

動作を確認をすると、アラートが上がります。
アラートの中身に、 state に詰め込まれたデータが表示されることが分かります。

実装完了

3. 画面リロードを抑制する

送信側 にて、 open.window で得た Window オブジェクトを状態管理し、再利用します。
これにより、「リンクをクリックする度に window.open が実行され、画面がリロードされる」という事態を防ぎます。

ここでは、状態管理の一例として jotai(https://jotai.org/) を使用しています。

intro/page.tsx
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. データ受信側にて、受け取ったデータを元に画面を再描画する

最後に、 PopStateEventcustomData プロパティ経由でて受け取ったデータを、状態管理します。

状態管理ツールには、同じく "jotai" を使用します。

video/page.tsx
+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 辺りで検索すると、「編集中の画面から遷移する際にポップアップを出す」みたいな話が良く引っ掛かりますが、今ならこの辺りの実装例もちゃんと理解できそうな気がします。

参考文献

付録

最終的なソースコード

intro/page.tsx
"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;
video/page.tsx
"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