Reactで、ひらがなとカタカナを変換する方法

​​React​JavaScript,Playwright,TypeScript,Unicode

ひらがなとカタカナは、Unicode では同じ並びで、文字コードがちょうど 0x60 離れています。そのため、「ぁ」から「ゖ」までの文字コードに 0x60 を足せばカタカナに、カタカナから 0x60 を引けばひらがなに変わります。この範囲から外れる「ヷ」や半角カナにだけ、別の処理を足します。

ここでは、変換の関数を kana.ts にまとめ、ボタンで変換する KanaConverter と、欄を離れたときにカタカナにそろえる FuriganaField という2つのコンポーネントを作りました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、wanakana 5.3.1、Playwright 1.63.0 です。関数の結果は Node.js で確かめ、tsc で型エラーがないことを確かめました。コンポーネントは StrictMode で包んだ開発用のビルドを、幅 375px の Chromium で Playwright から操作しました。日本語入力(IME)で打ったときの動き、Firefox と Safari、スマートフォンの実機は試していません。

幅375pxの画面で撮ったコンポーネント。「変換する文字」の欄に「ネコノ テチョウ」「ヴァイオリント ラーメン」の2行が入り、その下に「ひらがなにする」「カタカナにする」のボタン、さらにその下に水色の文字で「カタカナにしました」と出ている
「カタカナにする」を押したところ。助詞の「の」「と」もカタカナになる

文字コードを 0x60 ずらす関数

変換の関数は次のとおりです。正規表現で変換する文字だけを選び、文字コードをずらします。

ts
// ひらがなとカタカナを、文字コードを 0x60 ずらして変換する

const OFFSET = 0x60;

// ぁ〜ゖ(U+3041〜U+3096)と、繰り返し記号の ゝ ゞ をカタカナにする
export function toKatakana(input: string): string {
  return (
    input
      .replace(/[\u3041-\u3096\u309D\u309E]/g, (c) =>
        String.fromCharCode(c.charCodeAt(0) + OFFSET),
      )
      // 「わ」「ゐ」「ゑ」「を」に濁点(U+3099)が付いていたら、1文字の ヷ ヸ ヹ ヺ にまとめる
      .replace(/([\u30EF-\u30F2])\u3099/g, (_, c: string) =>
        String.fromCharCode(c.charCodeAt(0) + 8),
      )
  );
}

// ァ〜ヶ(U+30A1〜U+30F6)と、繰り返し記号の ヽ ヾ をひらがなにする
export function toHiragana(input: string): string {
  return (
    input
      // ヷ ヸ ヹ ヺ は1文字のひらがながないので、「わ」などと濁点(U+3099)の2文字にする
      .replace(/[\u30F7-\u30FA]/g, (c) =>
        String.fromCharCode(c.charCodeAt(0) - 8 - OFFSET) + '\u3099',
      )
      .replace(/[\u30A1-\u30F6\u30FD\u30FE]/g, (c) =>
        String.fromCharCode(c.charCodeAt(0) - OFFSET),
      )
  );
}

// 半角カナ(U+FF61〜U+FF9F)の部分だけを全角にする。「ガ」は1文字の「ガ」になる
export function halfwidthKanaToFullwidth(input: string): string {
  return input.replace(/[\uFF61-\uFF9F]+/g, (s) => s.normalize('NFKC'));
}

// ひらがなとカタカナの違いを無視して、文字列が含まれるかを調べる
export function includesIgnoringKana(text: string, query: string): boolean {
  const fold = (s: string) => toHiragana(halfwidthKanaToFullwidth(s));
  return fold(text).includes(fold(query));
}

検証環境の Node.js で toKatakana('ねこの てちょう') を実行すると ネコノ テチョウ になりました。小さい「ぁ」「っ」「ゃ」「ゎ」も、小さいカタカナの「ァ」「ッ」「ャ」「ヮ」になります。「ゔ」「ゕ」「ゖ」も、それぞれ「ヴ」「ヵ」「ヶ」に変わりました。

逆に toHiragana('ヴァイオリンと ラーメン') は ゔぁいおりんと らーめん です。長音の「ー」(U+30FC)は変えずに残しています。Unicode での名前が KATAKANA-HIRAGANA PROLONGED SOUND MARK で、ひらがなとカタカナの両方で使う文字だからです。中黒の「・」(U+30FB)も変換の範囲の外なので、そのまま残ります。Tokyo 東京 2026 は、toHiragana() と toKatakana() のどちらに渡しても変わりませんでした。

ぁ〜ゖ、ァ〜ヶの外にある文字

ぁ〜ゖ(U+3041〜U+3096)とァ〜ヶ(U+30A1〜U+30F6)の外にある、かなに関係する文字は、次のように扱いました。

文字 文字コード 扱い
ゝ ゞ / ヽ ヾ U+309D〜U+309E / U+30FD〜U+30FE 繰り返し記号。0x60 離れているので、範囲に入れて変換する
ヷ ヸ ヹ ヺ U+30F7〜U+30FA 1文字のひらがながない。「わ」などと濁点(U+3099)の2文字にする
ゟ U+309F 「より」を1文字にした字(HIRAGANA DIGRAPH YORI)。そのまま残す
ヿ U+30FF 「コト」を1文字にした字(KATAKANA DIGRAPH KOTO)。そのまま残す
ㇰ ㇱ ㇲ など U+31F0〜 小さいカタカナの追加分(KATAKANA LETTER SMALL KU など)。0x60 ずらす範囲の外なので残す
カ ゙ など U+FF61〜U+FF9F 半角カナ。先に halfwidthKanaToFullwidth() で全角にする

「ヷ」(U+30F7)から 0x60 を引くと U+3097 になりますが、ここには文字が割り当てられていません。検証環境の Node.js では /\p{Cn}/u(未割り当ての文字)に一致し、Python の unicodedata でも名前がありませんでした。そこで、「ヷ」は「ワ」に濁点を付けた文字として、「わ」(U+308F)と結合用の濁点(U+3099)の2文字にしています。toKatakana() では、この2文字が並んでいたら1文字の「ヷ」に戻します。検証環境で toKatakana(toHiragana('ヷ')) は ヷ になり、元に戻りました。

toKatakana() で「ワ」と濁点をまとめる処理には、正規表現を使いました。normalize('NFC') でも、検証環境では「ワ」(U+30EF)と濁点(U+3099)の2文字が1文字の「ヷ」(U+30F7)になりました。MDN の normalize() の説明にあるとおり、NFC は分解してから合成し直す形式なので、「ヷ」のように合成済みの文字がある組み合わせは1文字になります。ただし、文字列全体に NFC をかけると、ほかの文字まで変わります。検証環境では、互換用の漢字 U+FA19(CJK COMPATIBILITY IDEOGRAPH-FA19。見た目は「神」)が、ふつうの「神」(U+795E)になりました。そこで、「ワ」「ヰ」「ヱ」「ヲ」と濁点が並んだところだけを置き換えています。

「か」と結合用の濁点(U+3099)の2文字で書かれた「が」は、toKatakana() に渡すと「カ」と濁点の2文字になりました。濁点は範囲の外なので、そのまま残ります。

半角カナは、先に全角にする

toHiragana('ネコ') は ネコ のままです。半角カナは文字コードの並びが全角と違うので、0x60 ずらす方法では変換できません。halfwidthKanaToFullwidth() で半角カナの部分だけに normalize('NFKC') をかけ、全角にしてから変換します。検証環境では、ネコ ガイド ①㈱ が ネコ ガイド ①㈱ になりました。「ガ」の2文字は1文字の「ガ」にまとまり、半角カナの外にある「①」と「㈱」はそのまま残っています。

ボタンで変換する入力欄

KanaConverter は、欄の文字を、ボタンでひらがなかカタカナに変えます。

tsx
import { useId, useState } from 'react';
import { toHiragana, toKatakana } from './kana';

export function KanaConverter({ initial = '' }: { initial?: string }) {
  const id = useId();
  const [text, setText] = useState(initial);
  const [message, setMessage] = useState('');

  function convert(to: 'hiragana' | 'katakana') {
    const next = to === 'hiragana' ? toHiragana(text) : toKatakana(text);
    const label = to === 'hiragana' ? 'ひらがな' : 'カタカナ';
    setMessage(next === text ? `${label}にする文字はありませんでした` : `${label}にしました`);
    setText(next);
  }

  return (
    <div className="kana-converter">
      <label htmlFor={`${id}-text`}>変換する文字</label>
      <textarea
        id={`${id}-text`}
        rows={3}
        value={text}
        onChange={(e) => {
          setText(e.target.value);
          setMessage('');
        }}
      />
      <div className="buttons">
        <button type="button" onClick={() => convert('hiragana')}>
          ひらがなにする
        </button>
        <button type="button" onClick={() => convert('katakana')}>
          カタカナにする
        </button>
      </div>
      <p role="status">{message}</p>
    </div>
  );
}
css
.kana-converter, .furigana-field { display: grid; gap: 8px; max-width: 480px; }
.kana-converter textarea, .furigana-field input { font-size: 16px; }
.kana-converter .buttons { display: flex; gap: 8px; }
.kana-converter [role='status'] { margin: 0; min-height: 1.5em; color: #067a98; }

変換の前と後の文字列が同じなら、変える文字がなかったと分かります。その場合は「ひらがなにする文字はありませんでした」のように出します。結果のメッセージは role="status" の <p> に出し、欄に何か打ったら消します。

初期値を「ねこの てちょう」「ヴァイオリンと ラーメン」の2行にして、Playwright でボタンを押しました。「ひらがなにする」を押すと2行目が「ゔぁいおりんと らーめん」になり、「ひらがなにしました」と出ました。もう一度押すと、欄は変わらず「ひらがなにする文字はありませんでした」と出ました。続けて「カタカナにする」を押すと、2行は「ネコノ テチョウ」「ヴァイオリント ラーメン」になりました。

関数は文の意味を見ないので、助詞の「の」や「と」もカタカナになります。文章の中の一部の言葉だけを変えたいときは、その部分を選んでから変換する作りにするか、変換したあとに手で直す必要があります。

フリガナの欄を、離れたときにカタカナにそろえる

フリガナの欄は、ひらがなで打っても、送るときには全角カタカナにそろえたいことがあります。FuriganaField では、打っている間は欄の文字を変えず、欄を離れたとき(onBlur)に変換しました。

tsx
import { useId, useState } from 'react';
import { halfwidthKanaToFullwidth, toKatakana } from './kana';

// フリガナの欄。打っている間は変えず、欄を離れたときに全角カタカナにそろえる
export function FuriganaField({ name }: { name: string }) {
  const id = useId();
  const [value, setValue] = useState('');

  return (
    <div className="furigana-field">
      <label htmlFor={id}>フリガナ</label>
      <input
        id={id}
        name={name}
        value={value}
        onChange={(e) => setValue(e.target.value)}
        onBlur={() => setValue(toKatakana(halfwidthKanaToFullwidth(value)))}
      />
    </div>
  );
}

この欄を <form> の中に置き、Playwright で「やまだ タロウ」と1文字ずつ打ちました。打った直後の欄は「やまだ タロウ」のままで、Tab キーで欄を離れると「ヤマダ タロウ」になりました。フォームを FormData にして get('kana') で読んだ値も「ヤマダ タロウ」です。半角カナの「タロウ」も、全角のカタカナにそろいました。

onChange では打った文字をそのまま state に入れるだけにして、変換は欄を離れたときに1回だけにしています。

ライブラリの wanakana と比べる

ひらがなとカタカナの変換は、wanakana というライブラリの toHiragana() と toKatakana() でもできます。同じ文字を渡すと、結果が違うものがありました。

入力 wanakana この記事の関数
toHiragana('ラーメン') らあめん らーめん
toHiragana('ヷ') U+3097(未割り当て) わ と濁点の2文字
toHiragana('ヵヶ') ヵヶ(変わらない) ゕゖ
toHiragana('ヽヾ') ヽヾ(変わらない) ゝゞ
toKatakana('ねこ neko') ネコ ネコ ネコ neko

wanakana の toHiragana() は、「ラーメン」の長音を「あ」にして「らあめん」にしました。また、wanakana はローマ字をかなにするライブラリでもあるので、toKatakana() は英字の「neko」も「ネコ」にします。wanakana の README にあるとおり、英字を残したいときは { passRomaji: true } を付けます。

ひらがなとカタカナを区別せずに探す

一覧から「ねこ」で探したときに「ネコ」も見つけたい場合は、比べる両方をひらがなにそろえてから includes() で調べます。kana.ts の includesIgnoringKana() がこれで、半角カナも全角にしてから比べます。検証環境では、次のようになりました。

対象の文字列 探す言葉 結果
ネコの手帳 ねこ true
ねこのてちょう ネコ true
ネコ ショップ ねこ true
ラーメン らあめん false

「ラーメン」は「らーめん」になるので、「らあめん」では見つかりません。長音まで同じとみなしたいときは、「ー」を前の文字の母音に置き換える処理を別に足す必要があります。

文字列どうしが等しいかだけを調べるなら、Intl.Collator でも比べられます。検証環境の Node.js では、new Intl.Collator('ja').compare('ねこ', 'ネコ') が 0 を返し、同じとみなしました。sensitivity を MDN の Intl.Collator の説明にある4つの値(base、accent、case、variant)のどれにしても、結果は 0 でした。半角の ネコ とも 0 です。濁点の違い(「ねこ」と「ねご」)は、accent と variant では区別し、base と case では同じとみなしました。

ただし compare() は文字列の全体を比べるので、「ネコの手帳」の中に「ねこ」があるかどうかは調べられません。また、['ネコ', 'ねこ', 'ねご', 'ネコ'] をこの compare で並べ替えると、['ネコ', 'ねこ', 'ネコ', 'ねご'] になり、ひらがなとカタカナは元の並びのまま混ざって残りました。

​Posted by 86