Reactで、タブのタイトルとアイコンに未読の件数を出す方法

​​React​Canvas API,Playwright,TypeScript,useEffect,カスタムフック

未読が3件あるとき、ブラウザのタブに「(3) 猫のメモ」と出し、ファビコンの右上にピンクの丸と「3」を描きます。ほかのタブを開いていても、タブの並びを見れば新着があることが分かる、という狙いです。

タイトルは React 19 の <title> に件数を混ぜた文字列を渡すだけで変わります。ファビコンは、元の画像を canvas に描き、その上に件数を重ねた PNG を作って、<link rel="icon"> の href に入れます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Node.js で作った検証用のサーバーから配信しました。タブの表示を撮るため、Chromium は Xvfb の仮想画面の上で、ヘッドレスでないウィンドウとして動かしています。Firefox と Safari、スマートフォンのブラウザ、サーバーレンダリングは試していません。タブを裏に回したときの動きも確かめられていません(Playwright で開いたページは、別のタブを前に出しても document.visibilityState が visible のままでした)。

上にタブの部分を2倍にした画像、下にブラウザのウィンドウ全体がある。「新着を1件受け取る」を3回押すと、タブのタイトルが「猫のメモ」から「(1) 猫のメモ」「(2) 猫のメモ」「(3) 猫のメモ」に変わり、ファビコンの右上にピンクの丸と数字が出る。「すべて既読にする」を押すと、タイトルとファビコンが元に戻る
ボタンを押すたびに、タブのタイトルとファビコンの件数が変わる様子

GIF は、表示倍率を1.5倍にしたウィンドウを撮ったものです。上の段は、同じ画面のタブの部分を切り出して2倍にしたものです。右上に出したクリックの回数、カーソルと押した位置の輪は、撮影のために重ねました。ページの中の件数は、変わった瞬間だけピンクにしています。

タイトルの先頭に件数を付ける

件数は、あるときだけ (3) 猫のメモ のようにタイトルの先頭に付けます。100件以上は 99+ にまとめます。

tsx
export function formatCount(count: number) {
  return count > 99 ? '99+' : String(count);
}

export function UnreadTitle({ count, title }: { count: number; title: string }) {
  // <title> の中身は文字列1つにする
  const text = count > 0 ? `(${formatCount(count)}) ${title}` : title;
  return <title>{text}</title>;
}

React 19 では、コンポーネントの中に書いた <title> を、React が <head> へ移します。<title> の公式リファレンスにあるとおり、<title> の中身は文字列1つでなければなりません。<title>({count}) 猫のメモ</title> のように JSX の {} で混ぜると、中身が配列になってしまいます。そのため、テンプレートリテラルで1つの文字列にしてから渡しています。この落とし穴は、以前の記事「React 19で、titleやmetaをコンポーネントの中に書く方法」でも試しました。

検証環境で件数を変えたときの document.title は、次のとおりです。

未読の件数 document.title
0 猫のメモ
1 (1) 猫のメモ
10 (10) 猫のメモ
99 (99) 猫のメモ
100 (99+) 猫のメモ

検証用の index.html には、最初から <title>猫のメモ</title> を書いてあります。React の <title> を足すと、<head> の <title> は2つになりましたが、document.title は件数の付いたほうになりました。ただし、公式リファレンスには、<title> が複数あるときのブラウザの動きは決まっていない、という注意があります。2つ並んだときの動きは、以前の記事で詳しく確かめています。コンポーネントをアンマウントすると、document.title は index.html の「猫のメモ」に戻りました。

ファビコンに件数を描く

ファビコンは、元の画像(32×32px の PNG)を canvas に描き、右上にピンクの角丸の四角と白い数字を重ねます。数字が2文字以上のときは、文字を小さくし、四角を横に伸ばします。

ts
import { formatCount } from './UnreadTitle';

const SIZE = 32;

export function loadImage(src: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = 'anonymous'; // 別オリジンの画像は CORS で読み込む
    img.onload = () => resolve(img);
    img.onerror = () => reject(new Error(`画像を読み込めません: ${src}`));
    img.src = src;
  });
}

export function drawBadgeIcon(base: HTMLImageElement, count: number): string {
  const canvas = document.createElement('canvas');
  canvas.width = SIZE;
  canvas.height = SIZE;
  const ctx = canvas.getContext('2d');
  if (!ctx) throw new Error('canvas に描けません');

  ctx.drawImage(base, 0, 0, SIZE, SIZE);

  const label = formatCount(count);
  ctx.font = `bold ${label.length === 1 ? 20 : 15}px sans-serif`;
  const width = Math.max(20, ctx.measureText(label).width + 6);
  const height = 20;
  const x = SIZE - width;
  const y = 0;

  // 右上に、数字の幅に合わせた角丸の四角を描く
  ctx.fillStyle = '#d85878';
  ctx.beginPath();
  ctx.roundRect(x, y, width, height, height / 2);
  ctx.fill();

  ctx.fillStyle = '#ffffff';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(label, x + width / 2, y + height / 2 + 1);

  return canvas.toDataURL('image/png');
}

角丸の四角は roundRect() で描いています。四角の高さは 20px で、角の半径をその半分にしているので、1文字のときは丸、2文字以上のときは横長の角丸になります。最後に toDataURL() で、描いた内容を data:image/png;base64,… の形の文字列にします。この文字列は、そのまま href に入れられます。

検証環境で作られたファビコンを、4倍に拡大して並べると次のようになりました。

水色の角丸の四角に白い猫の顔を描いたアイコンが6つ並ぶ。0件は元のまま。1件と9件は右上にピンクの丸と白い数字、10件と99件は横に少し伸びた角丸、100件は「99+」の横長の角丸になっている
件数ごとに作られたファビコン(32×32px を4倍に拡大)

上の画像は4倍に拡大したものです。タブの中のファビコンは、これよりずっと小さく表示されます。冒頭の GIF の下の段は、表示倍率1.5倍の画面をそのままの大きさで撮ったもので、上の段はそれを2倍にしたものです。この画面では、「3」のような1文字の数字は読み取れました。表示倍率1倍の画面や、2文字以上・「99+」のタブの中での見え方は確かめていません。

link の href を書き換えるフック

作った画像は、index.html に最初からある <link rel="icon"> の href を書き換えて表示します。

ts
import { useEffect } from 'react';
import { drawBadgeIcon, loadImage } from './drawBadgeIcon';

function getIconLink() {
  return document.querySelector<HTMLLinkElement>('link[rel="icon"]');
}

export function useFaviconBadge(count: number, baseHref: string) {
  useEffect(() => {
    const link = getIconLink();
    if (!link) return;
    if (count <= 0) {
      link.href = baseHref;
      return;
    }
    let ignore = false;
    loadImage(baseHref)
      .then((img) => {
        if (!ignore) link.href = drawBadgeIcon(img, count);
      })
      .catch((error) => {
        console.warn('ファビコンにバッジを描けません', error);
      });
    return () => {
      ignore = true; // 古い件数の描画が、あとから上書きしないようにする
    };
  }, [count, baseHref]);

  // 画面から外れたら、元のアイコンに戻す
  useEffect(() => {
    return () => {
      const link = getIconLink();
      if (link) link.href = baseHref;
    };
  }, [baseHref]);
}

新しい <link rel="icon"> を足さずに、いまある1つを書き換えているのには理由があります。MDN の rel 属性の説明によると、<link rel="icon"> が複数あるときは、ブラウザが media、type、sizes の属性を見て、いちばん合うものを選びます。足したほうが使われるとは限りません。検証では、件数を変えても <link rel="icon"> は1つのままでした。なお、このフックが書き換えるのは最初に見つかった1つだけです。MDN の説明から考えると、大きさの違うアイコンを sizes で何枚も並べているページでは、ブラウザが書き換えていないほうを選んで、件数が出ないことがありえます(この場合は試していません)。

画像の読み込みは非同期なので、読み込んでいるあいだに件数が変わることがあります。そこで、公式リファレンスの、エフェクトでデータを取ってくる例と同じように、クリーンアップ関数で ignore を true にします。古い件数の画像の読み込みがあとから終わっても、描かずに終わり、href は書き換えません。

検証では、ボタンを3回続けて押しました。href が書き換わったのは1回だけで、そのときの画像は、1件ずつ押して3件にしたときと同じでした。1件目と2件目の読み込みの結果は使われていません。

2つ目の useEffect は、コンポーネントが画面から外れたときに、元のアイコンに戻すためのものです。件数が2件のときにアンマウントすると、href は /favicon.png に戻りました。開発用のビルドで StrictMode を付けて動かしても、3件のときの画像と、すべて既読にしたあとの href は、本番用のビルドと同じでした。

未読の件数を持つコンポーネントで使う

使う側では、未読の件数を UnreadTitle と useFaviconBadge の両方に渡します。検証では、ボタンで件数を増やしたり0に戻したりしました。

tsx
import { useState } from 'react';
import { UnreadTitle } from './UnreadTitle';
import { useFaviconBadge } from './useFaviconBadge';

export function Inbox({ iconHref = '/favicon.png' }: { iconHref?: string }) {
  const [unread, setUnread] = useState(0);
  useFaviconBadge(unread, iconHref);

  return (
    <main>
      <UnreadTitle count={unread} title="猫のメモ" />
      <h1>猫のメモ</h1>
      <p className="unread">
        未読 <output data-testid="unread">{unread}</output> 件
      </p>
      <button onClick={() => setUnread((n) => n + 1)}>新着を1件受け取る</button>
      <button onClick={() => setUnread(0)}>すべて既読にする</button>
    </main>
  );
}

index.html の <head> には、元になる <title> と <link rel="icon"> を書いておきます。

html
<title>猫のメモ</title>
<link rel="icon" type="image/png" href="/favicon.png">

元のアイコンを別のオリジンに置くとき

元のアイコンを CDN など別のオリジンに置いていると、件数を描けないことがあります。MDN の、別オリジンの画像を canvas で使う説明によると、CORS の許可なしに読み込んだ別オリジンの画像を canvas に描くと、その canvas は「汚染された」状態になり、toDataURL() を呼ぶとエラーになります。

検証では、アプリを 127.0.0.1:8100 から配信し、アイコンだけを 127.0.0.1:8101 から返しました。ポート番号が違うので、別のオリジンです。

読み込み方 アイコンのサーバーの応答 結果
crossOrigin なし Access-Control-Allow-Origin: * あり toDataURL() が SecurityError
crossOrigin = 'anonymous' CORS のヘッダーなし 画像の読み込みが失敗し、アイコンは元のまま
crossOrigin = 'anonymous' Access-Control-Allow-Origin: * あり 件数を描けた

表の1行目のとおり、サーバーが CORS のヘッダーを返していても、<img> の側で crossOrigin を付けなければ、canvas は汚染されます。記事の loadImage が crossOrigin = 'anonymous' を付けているのは、このためです。

表の2行目では、ブラウザのコンソールに CORS でブロックされたというエラーが出て、loadImage の Promise が失敗しました。useFaviconBadge は警告を出すだけで、href は元のアイコンのままです。タイトルの件数は、この場合も「(1) 猫のメモ」と出ました。

ポイント

元のアイコンは、アプリと同じオリジンに置くのがいちばん簡単です。別のオリジンに置くなら、Access-Control-Allow-Origin を返すようにサーバーを設定し、画像を crossOrigin = 'anonymous' で読み込みます。

​Posted by 86