拡張機能フレームワークWXTでのPopupの作り方
ブラウザ拡張機能のフレームワークWXTを使ってPopup画面を作る方法と、実践的な例としてRouterの活用を紹介します。
popup構成
WXTでは、Popup、Background、Content Scriptのような機能ごとにエントリーポイントを分けます。
エントリーポイントは次のようにディレクトリを切っても切らなくてもよいです。
entrypoints/popup/index.ts
entrypoints/popup.ts
ディレクトリを用意していない場合は作りましょう。フロントエンドのフレームワークを使うと、App.tsx
やApp.svelte
なども一緒に置いたり、コンポーネントを別ファイルに切り分けることが多いためです。
エントリーポイントに書く内容
エントリーポイントとなるHTMLファイルには、通常どおりマークアップするだけで問題ありません。
一応、テンプレートで作成されるHTMLファイルも記載しておきます。script
の中でフレームワークのマウント処理をするmain.ts
を読み込みます。
<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Default Popup Title</title> <meta name="manifest.type" content="browser_action" /> </head> <body> <div id="app"></div> <script type="module" src="./main.ts"></script> </body></html>
titleタグの中の変更だけはやってきましょう。titleタグにしていした文字は、ツールバーでアイコンをホバーした時のツールチップになります。
次の画像では「URL Copy Helper」の部分がtitleタグで指定した文字列です。

Routerを使おう
ブラウザ拡張のpopupには、画面サイズに限りがあります。
たとえば、Chrome拡張機能であれば25x25から800x600ピクセルの間と決まっています。
そうなると必然的に要素の表示・非表示の切り替えが必要になります。この切り替えはルーティングを使うと実装しやすいです。
popupでのルーティングの注意点
通常のルーティングでは末尾を/login
、/about
のように変化させます。しかし、popupのURLはchrome://xxxxx/yyy.html
であり、ルートが/
ではなくhtml
になり、ルーティングの設計・実装がしづらくなってしまいます。
解決策は次のいずれかです(括弧内はReact Routerの機能)。
- URLではなくメモリを使ったルーティング(MemoryRouter)
- ハッシュパラメータを使ったルーティング(HashRouter)
リダイレクトなどを用いて回避する方法もありますが、コードが読みにくくなってしまうため賢明ではないでしょう。
React Routerを使ってみる
React RouterのMemoryRouter
を使い、ログインありの例を載せておきます。
ルーティングを書くコンポーネント
まずは実際にMemoryRouter
を使ってルーティングするコンポーネントです。
import { MemoryRouter, Route, Routes } from "react-router-dom";import Layout from "./Layout";import AuthUser from "./auth/AuthUser";import PrivateRoute from "./auth/PrivateRoute";import Login from "./login/Login";import Dashboard from "./dashboard/Dashboard";import Setting from "./setting/Setting";
function App() { return ( <AuthUser> <MemoryRouter> <Routes> <Route path="/" element={<Layout />}> <Route path="/" element={<PrivateRoute />}> <Route path="/" element={<Dashboard />} /> <Route path="/setting" element={<Setting />} /> </Route> <Route path="/login" element={<Login />} /> </Route> </Routes> </MemoryRouter> </AuthUser> );}
export default App;
ざっくりとした全体の構成は次のとおりです。
<AuthUser>
はログイン中かどうか管理するContextを提供<Layout />
は共通のレイアウトを定義<PrivateRoute />
を使い、未ログインのとき/login
へリダイレクト
繰り返しになりますが、popupの画面サイズには限りがあるため、コンポーネントの実装には注意が必要です。
Contextを管理するコンポーネント
ContextとProviderによりログイン状態を全体で共有するためのコンポーネントです。
import { postAuth } from "@/utils/auth";import React, { useContext, useEffect, useState } from "react";import type { ReactNode } from "react";// chromeのstorage APIのラッパーimport { storageIsLogin } from "@/utils/storage";
// 型やContextの定義type LoginState = "fetching" | "loggedIn" | "loggedOut";export class AuthFailedError extends Error { public constructor() { super(); this.name = "AuthFailedError"; }}export type AuthUserContextType = { loginState: LoginState; login: ( username: string, password: string, callback: () => void, ) => Promise<void>; logout: (callback: () => void) => Promise<void>;};
const AuthUserContext = React.createContext<AuthUserContextType>( {} as AuthUserContextType,);
type Props = { children: ReactNode;};
// コンポーネント本体export default function AuthUser({ children }: Props) { // ログイン状態を取得するまでは`fetching`とする const [loginState, setLoginState] = useState<LoginState>("fetching");
// ログイン状態を取得して反映 useEffect(() => { async function setFromStorage() { const isLogin = await storageIsLogin.getValue(); if (isLogin) { setLoginState("loggedIn"); } else { setLoginState("loggedOut"); } } setFromStorage(); }, []);
// ログインとその状態管理 const login: AuthUserContextType["login"] = async ( username: string, password: string, callback: () => void, ) => { // 実際にログインする const isSuccess = await postAuth(username, password); if (!isSuccess) { throw new AuthFailedError(); } setLoginState("loggedIn"); await storageIsLogin.setValue(true); // callbackによりログイン後に必要に応じた処理を実行 callback(); };
// ログアウト時の状態管理 const logout: AuthUserContextType["logout"] = async ( callback: () => void, ) => { setLoginState("loggedOut"); await storageIsLogin.setValue(false); callback(); };
const value: AuthUserContextType = { loginState, login, logout }; return ( <AuthUserContext.Provider value={value}> {children} </AuthUserContext.Provider> );}
// contextを取り出すときに使用するexport function useAuthUserContext(): AuthUserContextType { return useContext<AuthUserContextType>(AuthUserContext);}
ちょっと長い!
しかしやっていることは単純です。それぞれコメントに記載しました。
login
は、「ログイン」「Reactの状態変化」「storageなどへの記録」をまとめて実行する関数です。logout
も同様です。
このコンポーネントで定義しておいて、実際にログイン・ログアウトするコンポーネントで呼び出します。
レイアウトを定義するコンポーネント
ヘッダーやフッターのような共通のレイアウトがある場合はこのコンポーネントに書いておきます。
import { Outlet } from "react-router-dom";
export default function Layout() { return ( <> <div>header</div> <Outlet /> <div>footer</div> </> );}
<Outlet />
にはネストされた<Route>
にて指定したコンポーネントが置き換わります。
たとえば次のようなイメージです。
<Route path="/" element={<Layout />}> <Route path="/login" element={<Login />} /></Route>
<> <div>header</div> <Login /> <div>footer</div></>
未ログイン時にリダイレクト
続いて、ログインしていない時にリダイレクトさせるコンポーネントです。
import { Navigate, Outlet } from "react-router-dom";import { useAuthUserContext } from "./AuthUser";
export default function PrivateRoute() { const loginState = useAuthUserContext().loginState; return loginState === "loggedIn" ? <Outlet /> : <Navigate to="/login" />;}
Contextからログイン状態を取得します。
ログイン済みであればネストされた<Route>
にて指定したコンポーネントを返します。
一方、ログインしていなければ/login
にリダイレクトさせます。
さきほどのルーティングで使われている部分を見てみましょう。
<Route path="/" element={<PrivateRoute />}> <Route path="/" element={<Dashboard />} /> <Route path="/setting" element={<Setting />} /></Route>
/
や/setting
に遷移した際、未ログインなら/
にリダイレクトされますが、ログイン済みならそのまま/
や/setting
にアクセスできます。
ログインのコンポーネント
よく使われていそうなReact Hook Formを想定して簡略化したログインコンポーネントです。
<AuthUser>
で定義したログイン関数を使った例です。
import { useEffect } from "react";import { type SubmitHandler, useForm } from "react-hook-form";import { useNavigate } from "react-router-dom";import { AuthFailedError, useAuthUserContext } from "../auth/AuthUser";
type Inputs = { username: string; password: string;};
export default function Login() { const navigate = useNavigate(); const authUser = useAuthUserContext(); const { handleSubmit, setError, formState: { errors }, } = useForm<Inputs>();
useEffect(() => { if (authUser.loginState === "loggedIn") { navigate("/"); } }, [authUser.loginState]);
const onSubmit: SubmitHandler<Inputs> = async (data) => { const username = data.username; const password = data.password; try { await authUser.login(username, password, () => { navigate("/"); }); } catch (e) { if (e instanceof AuthFailedError) { setError("root.auth", { type: "authError" }); } } };
return ( <form onSubmit={handleSubmit(onSubmit)} > 入力欄など <button type="submit" > ログイン </button> </form> );}
ポイントは次の2つです。
- 取得したログイン情報に応じて
/
へリダイレクト - ボタンを押したらログインして
/
へリダイレクト
参考:Authentication with React Router v6: A complete guide - LogRocket Blog
以上、WXTを使ったpopup画面の作り方と、実践的な例でした。
popupの画面で何でもかんでもやるのではなく、必要に応じてオプションページやcontent scriptを使いましょう。