拡張機能フレームワークWXTでのPopupの作り方

ブラウザ拡張機能のフレームワークWXTを使ってPopup画面を作る方法と、実践的な例としてRouterの活用を紹介します。

popup構成

WXTでは、Popup、Background、Content Scriptのような機能ごとにエントリーポイントを分けます。

エントリーポイントは次のようにディレクトリを切っても切らなくてもよいです。

  • entrypoints/popup/index.ts
  • entrypoints/popup.ts

ディレクトリを用意していない場合は作りましょう。フロントエンドのフレームワークを使うと、App.tsxApp.svelteなども一緒に置いたり、コンポーネントを別ファイルに切り分けることが多いためです。

Popup – WXT

エントリーポイントに書く内容

エントリーポイントとなる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タグで指定した文字列です。

titleタグ

Routerを使おう

ブラウザ拡張のpopupには、画面サイズに限りがあります。
たとえば、Chrome拡張機能であれば25x25から800x600ピクセルの間と決まっています。

そうなると必然的に要素の表示・非表示の切り替えが必要になります。この切り替えはルーティングを使うと実装しやすいです。

popupでのルーティングの注意点

通常のルーティングでは末尾を/login/aboutのように変化させます。しかし、popupのURLはchrome://xxxxx/yyy.htmlであり、ルートが/ではなくhtmlになり、ルーティングの設計・実装がしづらくなってしまいます。

解決策は次のいずれかです(括弧内はReact Routerの機能)。

  • URLではなくメモリを使ったルーティング(MemoryRouter
  • ハッシュパラメータを使ったルーティング(HashRouter

リダイレクトなどを用いて回避する方法もありますが、コードが読みにくくなってしまうため賢明ではないでしょう。

React Routerを使ってみる

React RouterMemoryRouterを使い、ログインありの例を載せておきます。

ルーティングを書くコンポーネント

まずは実際に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によりログイン状態を全体で共有するためのコンポーネントです。

./auth/AuthUser.tsx
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も同様です。
このコンポーネントで定義しておいて、実際にログイン・ログアウトするコンポーネントで呼び出します。

レイアウトを定義するコンポーネント

ヘッダーやフッターのような共通のレイアウトがある場合はこのコンポーネントに書いておきます。

./Layout.tsx
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>で定義したログイン関数を使った例です。

./login/Login.tsx
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を使いましょう。