stin's Blog

React でキーボードショートカットを設定するためのカスタムフックを実装する


以前、『ポケットモンスターダイヤモンド・パール』の BGM をループ再生できる Web サイトを作成しました。

この Web サイトの実装については過去に Zenn に投稿しているのでそちらも御覧ください。

自分が一番のヘビーユーザーなのですが、YouTube や Spotify のようにスペースキーで再生・停止ができないのが不便だと感じていました。不便と感じつつ放置していたのですが、重い腰を上げてやっとキーボードショートカット機能を実装しました。

キーと操作の対応は次の通りです。

キー操作
space再生・停止
alt(option) + 次の曲を再生
alt(option) + 前の曲を再生
alt(option) + 音量を上げる
alt(option) + 音量を下げる

この機能を実装するに当たり、React で keybind を簡単に実装する汎用カスタムフックがあると便利だと思って考えてみました。本記事はそのカスタムフックの実装方法を紹介します。

インターフェイス

まずはカスタムフックのインターフェイスから検討します。

どのキーが入力されたらどんな処理を行うかを受け取ればいいですね。また、修飾キーが押されている必要があるかどうかも受け取りたいです。ということでこんな感じにしてみます。

type KeybindProps = {
  altKey?: boolean;
  ctrlKey?: boolean;
  metaKey?: boolean;
  shiftKey?: boolean;
  key: KeyboardEvent["key"];
  onKeyDown?: (event: KeyboardEvent) => void;
  targetRef?: RefObject<HTMLElement>;
};

function useKeybind(props: KeybindProps): void;

修飾キーの要不要を指定は、 altKey, ctrlKey, metaKey, shiftKey に対して boolean 値を渡すことで制御します。

key でメインのキーを指定してもらいます。型を KeyboardEvent["key"] と書いていますがその実ただの string です。が、 KeyboardEvent.key と比較される値なんだなということが伝わると思い、あえてこのように書いています。

targetRef については実装の節に説明します。

戻り値は void です。何かを計算して返却する関数ではなく、 keybinding を設定する副作用を発生させるタイプの関数なので void にしています。

このインターフェイスにしておけば、コンポーネントにするのも簡単です。

export const Keybind: FC<KeybindProps> = (props) => {
  useKeybind(props);
  return null;
};

コンポーネントを用意するメリットは、条件分岐で付け外しが簡単にできることです。カスタムフックは条件分岐で実行するしないを制御できないので、コンポーネントにする意味はあります。

export const App:FC = () => {
  // これはやっちゃダメ
  if(condition) {
    useKeybind({...});
  }

  return (
    <>
      {/* これはやっていい */}
      {condition && <Keybind {...} />}
      <SomeComponent />
    </>
  );
}

実装

まず、 useLatest を用意しておきます。

function useLatest<T>(value: T) {
  const ref = useRef(value);
  ref.current = value;
  return ref;
}

これは常に最新の値を保持しておくのに使います。今回は onKeyDown の最新版を参照するために使用します。コールバック関数を受け取るカスタムフックやコンポーネント内部の useEffect で受け取ったコールバック関数を使用する場合、参照の変化に注意する必要があります。

カスタムフックを使う側で useKeybind({ onKeyDown: () => {} }) のように毎回異なる関数が渡される場合、そのまま useEffect の依存配列に入れるとレンダリングの度に effect が発火されてしまいます。かと言って、使う側で useCallback してもらうのをルールにするのは内部実装に依存してもらうことになりナンセンスです。代わりに useKeybindonKeyDown の依存配列を受け取るようにすることも考えられますが、インターフェイスが煩雑になるし依存配列の渡し漏れを eslint-plugin-react-hooks で検出できなくなります(プラグインのオプションで依存配列渡し漏れを検出するカスタムフックを追加できますが、インターフェイスを useEffect 等に合わせる必要があるのと、あまりそのオプションを使うべきではないということが言及されています)。

こんなときに、コールバック関数の最新を参照しつつ、 useEffect の依存配列には渡さなくても問題ない方法として useRef を使った useLatest が便利です。 useRef の戻り値は常に同じオブジェクトなので、依存配列に含めても useEffect の発火要因にはなりません。同じオブジェクトでも、その .current プロパティはレンダリング毎に最新の値に書き換えられるので、 useEffect 内で最新のものを取得できます。

ちなみに、新しい React Hooks として検討されている useEvent がこれを解消してくれそうです。多分。


本題の useKeybind では実質 DOM に対して keyDown イベントをアタッチする useEffect を用意するだけですね。

export function useKeybind({
  altKey,
  ctrlKey,
  metaKey,
  shiftKey,
  key,
  onKeyDown,
  targetRef,
}: KeybindProps) {
  const onKeyDownLatest = useLatest(onKeyDown);

  useEffect(() => {
    const eventListener = (event: KeyboardEvent) => {
      if (altKey && !event.altKey) return;
      if (ctrlKey && !event.ctrlKey) return;
      if (metaKey && !event.metaKey) return;
      if (shiftKey && !event.shiftKey) return;
      if (event.key !== key) return;

      event.preventDefault();
      onKeyDownLatest.current?.(event);
    };

    if (targetRef?.current) {
      const target = targetRef.current;

      target.addEventListener("keydown", eventListener);
      return () => target.removeEventListener("keydown", eventListener);
    } else {
      window.addEventListener("keydown", eventListener);
      return () => window.removeEventListener("keydown", eventListener);
    }
  }, [altKey, ctrlKey, key, metaKey, onKeyDownLatest, shiftKey, targetRef]);
}

alt キー(Mac では option キー)や ctrl キーが一緒に押下されているかどうかは event.altKey などで判定できます。メインキーは event.key で取得できるので、それを引数の key と突き合わせて、満たしていなければそこで eventListener の 処理は終了です。

修飾キーも含めて指定したキーが押下されていれば、 event.preventDefault() でブラウザ標準動作を停止します。そして対応する処理が格納された onKeyDownLatest.current を関数実行します(onKeyDown をオプショナルにしているので、オプショナルチェーン記法で undefined の場合を避けています)。

HTMLElement が格納されている targetRef が引数で渡されている場合、その DOM にイベントをアタッチします。 targetRef が渡されていない、または渡されていても .current に要素が格納されていない場合は window オブジェクトに直接アタッチします。どちらのケースでも、 removeEventListener によるクリーンアップを忘れないようにしましょう。 useEffect は何回実行されても問題ない冪等性が求められます。

使い方

コンポーネント内でキーと関数を渡すだけです。

const App: FC = () => {
  useKeybind({
    key: "f",
    shiftKey: true,
    onKeyDown: () => console.log("Foo!!"),
  });

  useKeybind({
    key: "b",
    altKey: true,
    onKeyDown: () => console.log("Bar!!"),
  });

  return <>...</>;
};

Shift + f を押せばコンソールに "Foo!!" と表示され、 Alt(Option) + b を押せばコンソールに "Bar!!" と表示されるようになるでしょう。

https://dp-soundlibrary.stin.ink のソースコードでは次の箇所で使用しています。

まとめ

React でキーボードショートカットを実装するためのカスタムフックを作成する手順を紹介しました。

キーボードメインで Web を操作するユーザーにとってキーボードショートカットの存在は非常にありがたいので積極的に実装していきたいですね。僕はトラックパッドを使いますが。

この記事が誰かの参考になれば幸いです。

それではよい React ライフを!