この記事は、自ブログ Rapls Works で公開した記事を、Qiita 向けに 5 つの Tips 集 として大幅に再構成・加筆したものです。実装の細かい背景やエッセイは元記事を、続編 (UI 実装版) は Rapls Works の Part 2 をご覧ください。
はじめに
Google の検索窓に「あ」と入力すると、「雨」「赤」「青」のような漢字の候補がすっと出てきます。あの仕組みを自分のサイトの検索機能に組み込もうとしたとき、私が実際に詰まった点と、その回避策を Tips としてまとめました。
フレームワークなしの素の JavaScript で、IME に頼らずに「あ → 雨」のような変換候補を実現する話です。React や Vue を使った実装にも応用できます。
検証環境
- Google Chrome 147 (主)、Firefox 130、Safari 17 (iOS 17 実機含む)
- macOS Tahoe 26.4
- JavaScript ES6+
- 検証日: 2026年4月
対象読者
- 自サイトの検索機能に日本語サジェストを付けたい人
- IME と JavaScript の合成イベント (compositionstart / compositionend) で苦戦した人
- 素の JS で実装したい人 (React / Vue / Svelte などでも応用可)
- 「Lodash の debounce を使えば終わりだろう」と思っていたが、日本語入力で挙動が崩れて困っている人
結論先出し
| Tip | 内容 |
|---|---|
| Tip 1 | IME の変換候補は JavaScript から取れない。自前の辞書で前方一致検索する |
| Tip 2 |
isComposing だけでは不十分。自前フラグとの二重チェックが確実 |
| Tip 3 | debounce の待ち時間は用途で変える (ローカル 150ms / API 300ms) |
| Tip 4 | 完全一致を上位に出すソートで、検索体験が劇的に変わる |
| Tip 5 | キーボード操作と blur 競合は別の話。先に設計を決めておく |
| 番外編 | IME composition の仕様、ブラウザ実装の歴史と現在地 |
順に解説します。手っ取り早く動かしたい方は、各 Tip のコード例だけ拾い読みしても OK です。
Tip 1: IME の変換候補は取得できない。自前辞書で前方一致検索する
最初に試したのは、「IME の変換候補を JavaScript で読めばいいのでは」というアプローチでした。これは 技術的に不可能 でした。
なぜ不可能か
IME (Input Method Editor) は OS レベルで動作し、ブラウザの JavaScript からは内部状態にアクセスできません。これはセキュリティとプライバシーを守るための意図的な設計です。
もし IME の候補が JavaScript から取得できたら:
- ユーザーが確定していない入力(草案レベルのテキスト)が漏れる
- IME の学習で残った個人情報(人名、住所、メールアドレスの一部、社外秘のキーワード)まで Web 側に取られる
- 「打ち間違えた検索ワード」を含む全変換候補がトラッキングされる
これは、Web プラットフォームのセキュリティモデル上、看過できない情報リークです。
歴史的には、navigator.imeManager のような提案 API も検討されたことがありますが、プライバシー懸念で実装には至っていません。ブラウザが渡してくれるのは「合成が始まった/終わった」という最低限の通知だけです。
では、どうするか
発想を「IME の変換を取得する」から「自前で検索する」に切り替えます。「変換」ではなく「検索」、これがこの記事全体の鍵です。
const dictionary = [
{ text: '雨', reading: 'あめ' },
{ text: '赤', reading: 'あか' },
{ text: '青', reading: 'あお' },
{ text: 'アメリカ', reading: 'あめりか' },
];
// 入力「あ」 → 読みが「あ」で始まるもの → 雨, 赤, 青, アメリカ
// 入力「あめ」→ 読みが「あめ」で始まるもの → 雨, アメリカ
実装に必要なのは、辞書データ、検索ロジック、表示 UI の 3 つだけです。
辞書データの作り方
実運用での辞書データの作り方には、主に 3 つの選択肢があります。
選択肢 A: JS 内にハードコード
const dictionary = [
{ text: '記事タイトル A', reading: 'きじたいとるえー' },
// ...
];
学習や PoC 向き。データが数十〜数百件なら、これで十分な体感速度が出ます。
選択肢 B: JSON ファイルを fetch
async function loadDictionary() {
const response = await fetch('/data/dictionary.json');
return await response.json();
}
ビルド時に静的に生成して配信できるので、CDN との相性も良いです。
選択肢 C: API 経由で動的取得
async function fetchSuggestions(query) {
const response = await fetch(
`/api/suggest?q=${encodeURIComponent(query)}`
);
return await response.json();
}
WordPress REST API、Algolia、Elasticsearch などのバックエンドと連携する場合。
ブログ内検索向けの辞書ソース
実際に WordPress サイトのブログ内検索を作る場合、以下のデータを辞書化すると使い勝手が良いです。
- 記事タイトル
- カテゴリ名・タグ名
- よく検索されるキーワード(Search Console のクエリレポートから)
- 自作プラグイン名や商品名
- 表記ゆれ(「JavaScript」と「javascript」「JS」など)
私の場合、最初は記事タイトル + カテゴリ + タグの 3 種類だけで動かして、運用しながら検索ログを見て辞書を育てていく方針にしています。
読みデータをどう作るか
辞書データには「読み」のフィールドが必要ですが、これをどう生成するかも実務上の問題です。
| 規模 | 生成方法 | 工数 |
|---|---|---|
| 〜100 件 | 手動入力 | 数時間 |
| 〜1,000 件 | kuromoji.js でブラウザ側で生成 | 1日 |
| 1,000 件以上 | サーバー側 MeCab で事前生成 | 環境構築含めて数日 |
私は記事タイトル数百件規模なので、手動入力 + たまに kuromoji.js で補完する運用です。
Tip 2: isComposing だけでは不十分。自前フラグとの二重チェックが確実
日本語入力では、IME による「合成」というステップがあります。input イベントだけで検索を走らせると、合成中の文字列でも検索が暴発します。
問題: input イベントの暴発
「あめ」と入力する流れを追ってみます。
1. 「a」を押す → input 発火 (入力欄: 「あ」) ← 未確定
2. 「m」を押す → input 発火 (入力欄: 「あm」) ← 未確定
3. 「e」を押す → input 発火 (入力欄: 「あめ」) ← 未確定
4. Enter で確定 → input 発火 (入力欄: 「あめ」) ← 確定
input ごとに検索を実行すると、「あ」で候補が出て、「あm」で消えて、「あめ」でまた出て…と画面がガタガタします。
ユーザー視点で見ると、これは単純に「使いにくいサジェスト」です。英語入力では気にならない処理が、日本語入力では UX を一気に悪化させます。
解決: composition イベント + 二重チェック
合成中かどうかを判定するには、compositionstart / compositionend イベントを使います。input イベント側にも isComposing プロパティがあるのですが、これだけだとブラウザによっては取りこぼします。
const input = document.getElementById('searchInput');
let isComposing = false;
input.addEventListener('compositionstart', () => {
isComposing = true;
});
input.addEventListener('compositionend', () => {
isComposing = false;
performSearch(input.value); // 確定時のみ検索
});
input.addEventListener('input', (e) => {
// 二重チェックがポイント
if (e.isComposing || isComposing) {
return;
}
performSearch(e.target.value); // 英字入力など IME 不使用時
});
なぜ二重チェックが必要なのか
e.isComposing || isComposing で二重チェックしている理由は、ブラウザごとの実装差です。
iOS Safari の挙動
iOS Safari (15 系まで) では、event.isComposing が期待通りに true を返さないケースが報告されています。特にフリック入力中の挙動が不安定で、compositionend が発火しないまま input イベントが連続することがあります。
Chrome と Firefox の発火順序の違い
compositionend イベントと input イベントの発火順序が、ブラウザによって異なります。
- Chrome / Edge:
compositionend→input - Firefox:
input→compositionend(場合により逆) - Safari: 状況によって変わる
input イベントの中で e.isComposing を見るとき、ブラウザによっては「もう合成は終わっているのに e.isComposing がまだ true」だったり、「合成中なのに false」だったりします。
自前のフラグを併用すれば、イベント順序に依存せず確実に判定できます。
検証ログ: 私が iOS Safari で確認した症状
実機 (iPhone 14 / iOS 17) で確認した範囲:
- フリック入力で「あめ」を入力 → 確定 → 検索が 2 回 走る
- ハードウェアキーボード (Bluetooth) で同じ操作 → 検索 1 回 (正常)
-
e.isComposing単独だと検索が二重に走るケースがフリック入力時のみ発生
フラグ併用に変更後、同じ症状は再現しなくなりました。
落とし穴: compositionstart が発火しないケース
意外と知られていないのが、compositionstart が発火しないケースです。
- IME を OFF にした状態で英字を入力 → compositionstart は発火せず、直接 input イベントだけが発火
- パスワードマネージャーや自動入力ツールによる入力 → composition 系イベントが発火しない
-
document.execCommand('insertText', ...)で JavaScript から値を挿入 → composition 系イベントは発火しない
これらのケースでも検索が走るように、input イベント側で「isComposing が true でなければ検索する」という判定にしておくと取りこぼしがありません。
React での実装
React の SyntheticEvent でも基本的な書き方は同じです。ただし、onCompositionStart / onCompositionEnd を使い、フラグは useRef か useState で管理します。
function SearchInput() {
const [value, setValue] = useState('');
const isComposingRef = useRef(false);
const handleCompositionStart = () => {
isComposingRef.current = true;
};
const handleCompositionEnd = (e) => {
isComposingRef.current = false;
performSearch(e.target.value);
};
const handleChange = (e) => {
setValue(e.target.value);
if (e.nativeEvent.isComposing || isComposingRef.current) return;
performSearch(e.target.value);
};
return (
<input
value={value}
onChange={handleChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
);
}
ポイントは、React の SyntheticEvent ではなく e.nativeEvent.isComposing を参照することです。React 16〜18 の e.isComposing は、ブラウザによって不正確な値を返すことが知られています。
Tip 3: debounce の待ち時間は用途で変える
合成中の検索を止められるようになったら、次は検索回数の調整です。
debounce は「最後の入力から一定時間待って、それ以上入力が続かなければ処理を実行する」仕組みです。連続確定 (「てんき」確定 →「よほう」確定) でチラつくのを防げます。
debounce の実装
function debounce(func, delay) {
let timeoutId = null;
return function(...args) {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
// 使い方
const debouncedSearch = debounce(performSearch, 150);
仕組みは単純です。setTimeout で処理を予約し、次の入力が来たら clearTimeout で前の予約を取り消します。
待ち時間の使い分け
| 用途 | 推奨遅延 | 理由 |
|---|---|---|
| ローカル辞書 (ブラウザ内) | 100〜200ms | 体感ほぼ即時、チラつき抑制 |
| 外部 API 連携 | 300〜500ms | リクエスト数を減らす、サーバー負荷軽減 |
| 大規模辞書 (1 万件超) | 200ms 程度 | 検索負荷の分散 |
| ストリーミング検索 | 50〜100ms | 入力中も結果を更新したい場合 |
実測: 私が試した debounce 値の体感
具体的な体感としては:
- 50ms: ほぼリアルタイム。タイピング速度が速いとフラッシュが目立つ
- 150ms: 体感ほぼ即時。「タイプを止めたら出てくる」感覚で違和感なし
- 300ms: わずかにラグを感じる。API 連携ならこのくらいが妥当
- 500ms: 「待たされている」感が出る。ローカル辞書には不向き
ローカル辞書なら 150ms、API 連携なら 300ms が、私の体感ではバランスが良いです。
落とし穴: debounce と composition イベントの干渉
debounce を使うときに気をつけたいのが、「compositionend の直後に debounce を経由しない検索が走る」 設計です。
// ❌ 全部debounceにすると、確定後の検索もラグが出る
input.addEventListener('compositionend', debouncedSearch);
// ✅ compositionendは即座に検索、inputイベントだけdebounce
input.addEventListener('compositionend', (e) => {
isComposing = false;
performSearch(e.target.value); // ← 即時
});
const debouncedSearch = debounce(performSearch, 150);
input.addEventListener('input', (e) => {
if (e.isComposing || isComposing) return;
debouncedSearch(e.target.value); // ← debounce 経由
});
確定 (compositionend) のタイミングは「ユーザーが選択を終えた瞬間」なので、ここで待たせる理由がありません。即時に検索を走らせるほうが体感が良くなります。
一方、英字入力やフリック後の連続 input は、debounce で間引いたほうが UX が安定します。
Tips: this 束縛に注意
func.apply(this, args) を使っているのは、this を正しく束縛するためです。アロー関数を使う場合は不要ですが、汎用性を考えると function + apply のほうが無難です。
// アロー関数版(this束縛が不要なら)
function debounce(func, delay) {
let timeoutId = null;
return (...args) => {
if (timeoutId !== null) clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
}
React のコンポーネント内で使うなら、こちらの方がシンプルです。
Lodash の debounce と何が違うか
「Lodash の _.debounce を使えばいい」と思う方も多いと思います。確かに Lodash の debounce は機能が豊富 (leading, trailing, maxWait などのオプションがある) で、本番運用では十分使えます。
ただし、
- 依存追加によるバンドルサイズの増加(15KB ほど)
- ES6+ なら自前 debounce が 10 行程度で書ける
- 動作の理解が深まる
という観点から、私は自前実装を推しています。Lodash を既に使っているプロジェクトなら、もちろん _.debounce で OK です。
Tip 4: 完全一致を上位に出すソートで検索体験が変わる
検索ロジックの骨格は filter + startsWith ですが、ソート順 で使いやすさが大きく変わります。
基本の検索ロジック
const dictionary = [
{ text: '雨', reading: 'あめ' },
{ text: '赤', reading: 'あか' },
{ text: '青', reading: 'あお' },
{ text: 'アメリカ', reading: 'あめりか' },
{ text: '天気', reading: 'てんき' },
{ text: '天気予報', reading: 'てんきよほう' }
];
function searchByReading(query) {
const q = query.toLowerCase().trim();
if (!q) return [];
return dictionary.filter(item =>
item.reading.startsWith(q) ||
item.text.toLowerCase().startsWith(q)
);
}
これで「あめ」で検索すれば、「雨」「アメリカ」が両方ヒットします。
落とし穴: 読みだけで完全一致判定すると…
最初は読み (reading) だけで完全一致を判定していました。すると「アメリカ」と入力したときに、「アメリカ」自体が最上位に来ない現象が起きます。
// ❌ 読みだけで完全一致判定
const aExact = a.reading === query;
「アメリカ」を入力 → reading === 'アメリカ' は false (reading は「あめりか」) → 完全一致と判定されない。
これは典型的な「テキスト側と読み側の検索を混在させたときの罠」です。実装初期にハマりやすいポイント。
解決: テキスト側の完全一致判定も追加
function searchByReading(query) {
const q = query.toLowerCase().trim();
if (!q) return [];
// 読み OR テキスト自体で前方一致
let results = dictionary.filter(item =>
item.reading.startsWith(q) ||
item.text.toLowerCase().startsWith(q)
);
// 完全一致を最優先、次に読みが短い順
results.sort((a, b) => {
// ✅ 読みとテキスト両方で完全一致判定
const aExact = a.reading === q || a.text.toLowerCase() === q;
const bExact = b.reading === q || b.text.toLowerCase() === q;
if (aExact && !bExact) return -1;
if (!aExact && bExact) return 1;
return a.reading.length - b.reading.length;
});
return results.slice(0, 10);
}
これで「アメリカ」と入力すると、「アメリカ」自体が最上位に来ます。
より高度なソート: スコアリング方式
完全一致 + 読み長さ順だけでも十分実用ですが、より精度を上げたい場合はスコアリング方式が便利です。
function calculateScore(item, query) {
let score = 0;
// 完全一致(text または reading)
if (item.text.toLowerCase() === query || item.reading === query) {
score += 100;
}
// 前方一致(text)
if (item.text.toLowerCase().startsWith(query)) {
score += 50;
}
// 前方一致(reading)
if (item.reading.startsWith(query)) {
score += 30;
}
// 短いほど優先(具体性)
score -= item.reading.length * 0.5;
// 人気度(アクセス数など)があれば加点
if (item.popularity) {
score += item.popularity * 0.1;
}
return score;
}
function searchByReading(query) {
const q = query.toLowerCase().trim();
if (!q) return [];
return dictionary
.filter(item =>
item.reading.startsWith(q) ||
item.text.toLowerCase().startsWith(q)
)
.map(item => ({ ...item, score: calculateScore(item, q) }))
.sort((a, b) => b.score - a.score)
.slice(0, 10);
}
スコアリング方式の利点は、複数の要素を組み合わせやすいこと。記事のアクセス数、公開日、カテゴリ別の重みなど、サイトのコンテキストに応じてカスタマイズできます。
候補数は 10 件程度に絞る
slice(0, 10) で最大 10 件に制限しています。多すぎると選びづらくなるので、検索窓のサジェストは「選びやすい数」に絞るほうが使ってもらえます。
UX 観点からは:
- 5 件: 一画面で全部見える、選びやすい
- 7〜10 件: 標準的、スクロールしなくて済む
- 15 件以上: 多すぎ、ユーザーが迷う
ブログ内検索なら 7〜10 件、商品検索なら 5 件程度が無難です。
サイトの用途別 カスタマイズ例
実際の運用では、サイトの用途に合わせたカスタマイズが効きます。
記事検索の場合
- アクセス数の多い記事を上に
- 新着記事を加点
- カテゴリ重みを設定 (「お知らせ」より「技術記事」を優先など)
商品検索の場合
- 在庫のあるものを優先
- セール商品を加点
- レビュー数の多い順
社内ツールの場合
- 自分が過去に開いたものを優先 (ローカルストレージで保存)
- 更新日の新しい順
- 自分のチームに紐づくものを優先
Tip 5: キーボード操作と blur 競合は別の話。先に設計を決めておく
ここまでは検索ロジックの実装でしたが、実際の UI 実装で 必ず遭遇する 落とし穴が 2 つあります。キーボード操作と blur イベントです。
これらは詳細を Rapls Works の Part 2 で書いていますが、Qiita 読者にも先に共有しておきたいポイントだけ整理します。
落とし穴 1: blur イベントでクリックが効かない
候補リストを表示しても、ユーザーがクリックする前に input が blur(フォーカスアウト) すると、候補リストを閉じる処理が走って、クリックが届きません。
// ❌ これだとクリックが効かない
input.addEventListener('blur', () => {
closeSuggestions();
});
suggestionItem.addEventListener('click', () => {
// ↑ ここに来る前に候補が消えている
selectSuggestion(item);
});
解決策 A: mousedown でフォーカスを奪わない
// ✅ mousedown でデフォルト動作(blur)を防ぐ
suggestionItem.addEventListener('mousedown', (e) => {
e.preventDefault(); // input の blur を防ぐ
});
suggestionItem.addEventListener('click', () => {
selectSuggestion(item);
});
解決策 B: setTimeout で遅延
// ✅ blur処理を少し遅らせてクリックの猶予を作る
input.addEventListener('blur', () => {
setTimeout(() => {
closeSuggestions();
}, 150);
});
解決策 C: Pointer Events (最新)
suggestionItem.addEventListener('pointerdown', (e) => {
e.preventDefault();
selectSuggestion(item);
});
私のおすすめは、mousedown + setTimeout の併用 です。mousedown だけだと iOS Safari の一部バージョンで挙動が安定しないので、保険として setTimeout も入れます。
落とし穴 2: キーボード操作で aria-activedescendant を忘れる
↑↓キーで候補を移動、Enter で確定、Escape で閉じる。これ自体は実装できても、アクセシビリティ対応 を忘れると、スクリーンリーダーで使えない UI になります。
<input
type="text"
role="combobox"
aria-expanded="true"
aria-controls="suggestions-list"
aria-activedescendant="suggestion-2"
id="searchInput"
/>
<ul id="suggestions-list" role="listbox">
<li id="suggestion-0" role="option">候補 1</li>
<li id="suggestion-1" role="option">候補 2</li>
<li id="suggestion-2" role="option" aria-selected="true">候補 3</li>
</ul>
aria-activedescendant を input の属性で動的に更新することで、フォーカスは input に残したまま、スクリーンリーダーが「いまどの候補がハイライトされているか」を読み上げてくれます。
これは見た目には現れない実装ですが、アクセシビリティ的にも、Google などのプロダクトと並ぶ品質を目指す上でも重要なポイントです。
キーボード操作の最小実装
let selectedIndex = -1;
input.addEventListener('keydown', (e) => {
const items = document.querySelectorAll('.suggestion-item');
if (items.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
selectedIndex = (selectedIndex + 1) % items.length;
updateActiveDescendant(items);
break;
case 'ArrowUp':
e.preventDefault();
selectedIndex = selectedIndex <= 0 ? items.length - 1 : selectedIndex - 1;
updateActiveDescendant(items);
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0) {
selectSuggestion(items[selectedIndex]);
}
break;
case 'Escape':
e.preventDefault();
closeSuggestions();
break;
}
});
function updateActiveDescendant(items) {
items.forEach((item, i) => {
item.classList.toggle('active', i === selectedIndex);
item.setAttribute('aria-selected', i === selectedIndex);
});
const activeItem = items[selectedIndex];
if (activeItem) {
activeItem.scrollIntoView({ block: 'nearest' });
input.setAttribute('aria-activedescendant', activeItem.id);
}
}
e.preventDefault() を忘れると、↑↓キーでページ全体がスクロールしてしまいます。Enter でフォームが送信されてしまわないようにも注意。
詳細な実装と落とし穴は、Rapls Works の Part 2 で書いていますので、UI まで作り込みたい方はそちらをご覧ください。
番外編: IME composition の仕様、ブラウザ実装の歴史と現在地
最後に、IME 周りの周辺知識をまとめておきます。実装に直接必要というよりは、「なぜそうなっているのか」を理解する助けになる話です。
W3C 仕様での位置づけ
compositionstart / compositionupdate / compositionend は、W3C の UI Events 仕様 で定義されています。これらは「Composition Events」というカテゴリで、IME や音声入力など「複数ステップを経て確定する入力」全般を扱うイベントです。
isComposing プロパティは DOM Level 3 Events で追加されたもので、比較的新しい仕様(2013 年〜)です。
ブラウザ実装の歴史
-
2003年頃: IE が独自の
keydownプロパティで合成判定 (keyCode === 229) - 2008年頃: Chrome / Firefox で composition イベントが標準実装
-
2013年頃:
isComposingプロパティが追加され始める -
2018年頃: 主要ブラウザで
isComposingが安定動作 - 2024年現在: iOS Safari の一部 (フリック入力時) でまだ不安定なケースあり
keyCode === 229 のチェックは古いコードで見かけることがありますが、これは Windows IME 時代の名残です。現代のコードでは isComposing を使うのが標準です。
IME ON/OFF の検出はできない
意外と知られていない制約として、「ユーザーが今 IME を ON にしているか OFF にしているか」を JavaScript から知る方法は ありません。
compositionstart が発火するかどうかで間接的に判定できますが、これは「合成が始まったかどうか」であって「IME が ON か」とはイコールではありません。
例えば、半角英数モードの IME 状態で「Hello」と打っても composition イベントは発火しません。これは「IME が OFF だから」ではなく、「合成が始まっていないから」です。
モバイルでのフリック入力の扱い
iOS や Android のフリック入力 (ソフトウェアキーボード) では、composition イベントの発火が独特です。
- iOS: 1 文字打つごとに
compositionstart→compositionupdate→compositionendのセットが発火することがある - Android: 機種により挙動が大きく異なる (Gboard / Simeji / ATOK で差がある)
実装するときは、PC ブラウザだけでなく実機での確認が欠かせません。BrowserStack などのサービスを使うか、自分の手元のデバイスで確認するのが安全です。
音声入力やパスワードマネージャー
input イベントは発火するが composition イベントが発火しないパターンとして:
- ブラウザの音声入力 (Voice Typing)
- パスワードマネージャー (1Password、Bitwarden など) による自動入力
- JavaScript からの
input.value = '...'設定
これらでも検索が動くようにするには、Tip 2 で書いた通り、input イベント側で「isComposing が false なら検索」という判定を入れておきます。
おまけ: HTML エスケープを忘れない
候補を画面に表示するときは、HTML エスケープを必ず入れます。辞書データが自前であっても、将来的にユーザー入力や API 経由のデータを含める可能性があるなら、最初から入れておくのが安全です。
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
li.innerHTML = `
${escapeHtml(item.text)}
${escapeHtml(item.reading)}
`;
textContent に代入してから innerHTML で取り出すと、< や & が安全な文字列に変換されます。
より安全な実装: createElement を使う
エスケープすら忘れないようにするなら、最初から createElement で要素を組み立てる方法もあります。
function createSuggestionItem(item) {
const li = document.createElement('li');
li.className = 'suggestion-item';
li.role = 'option';
const textSpan = document.createElement('span');
textSpan.textContent = item.text; // 自動エスケープ
const readingSpan = document.createElement('span');
readingSpan.textContent = item.reading;
readingSpan.className = 'reading';
li.appendChild(textSpan);
li.appendChild(readingSpan);
return li;
}
少し冗長ですが、XSS リスクをコード上で完全に排除できます。
まとめ
ここまで読んでいただいた方は、日本語サジェスト実装の主要な落とし穴をほぼ把握できたかと思います。改めて整理します。
- IME の変換候補は取れない → 自前辞書で前方一致検索する
-
isComposingだけでは不十分 → 自前フラグとの二重チェックで確実に判定 - debounce の待ち時間は用途で変える → ローカル 150ms / API 300ms
- 完全一致を上位に出すソート → 読みとテキスト両方で完全一致判定
- キーボード操作と blur 競合は別の話 → 設計を先に決めておく
- おまけ: HTML エスケープを忘れない
このパターンを押さえておけば、フォーム検索、ブログ内検索、管理画面の入力補助など、日本語サイトの様々な場面で応用できます。
実装の優先順位
時間が限られている場合、私のおすすめの実装順序は:
- Tip 1 (辞書を持つ): 30 分
- Tip 2 (composition イベント): 1 時間
- Tip 4 (検索ロジック): 30 分
- Tip 3 (debounce): 15 分
- Tip 5 (UI 実装): 2〜3 時間 (Rapls Works Part 2 参照)
合計で半日〜1 日あれば、基本機能は完成します。本番投入する場合は、各 Tip の落とし穴セクションも含めて検証してください。
検証チェックリスト
実装後の動作確認チェックリストです。
- Chrome / Firefox / Safari の各最新版で動作確認
- iOS Safari (フリック入力) で 2 重発火がないか
- Android Chrome (Gboard) で composition イベントが正常動作
- 候補リストのキーボード操作 (↑↓ Enter Escape) が動く
- スクリーンリーダー (VoiceOver / NVDA) で候補が読み上げられる
- 候補リストのクリックが効く (blur 競合がない)
-
HTML エスケープが効いている (
<script>を辞書に入れても XSS にならない) - 検索結果が 10 件以下に絞られている
- パスワードマネージャー経由の自動入力でも検索が動く
さらに先へ: 候補表示 UI、blur 競合、API 連携
ここまでは検索ロジックと、UI 実装の核心部分(キーボード操作・blur)の概要でした。実際にユーザーが触れる完全な形にするには、もう一段踏み込んだ実装が必要です。
そのあたりは自ブログ Rapls Works の Part 2 にまとめています。コピペで動くサンプルコードと、aria-activedescendant を使ったアクセシビリティ完全対応、外部 API 連携、パフォーマンス最適化 (LRU キャッシュ、仮想スクロール) まで含めた実装版です。
- 元記事 (基礎編): 日本語サジェストを JavaScript で実装する: IME に頼らない読み検索方式
- 続編 (実装版): 日本語サジェストの実装版|キーボード操作と blur 競合まで直して、ようやく使える検索 UI にした話
関連記事
同じ composition イベントを使った日本語フォーム実装シリーズも書いています。Qiita 読者の方にもきっと役立つ内容です。
- 姓名フォームのフリガナ自動入力を composition イベントで自前実装した話 — 名前入力欄でフリガナを自動入力する実装
- 日本語フォームの半角カナ・全角英数を input と blur で使い分けて自動変換する話 — NFKC の落とし穴を含む実装
- 日本語入力で Enter を押した瞬間に送信されてしまう問題と二段ガード — IME 確定の Enter 誤送信を防ぐ実装
自ブログ Rapls Works では、WordPress プラグイン開発やフロントエンドの技術記事を書いています。WordPress プラグインを 3 本公開していて (Rapls AI Chatbot、Thanks Mail for Stripe、Rapls PDF Image Creator)、開発で踏んだハマりどころを記録に残しています。
もし記事の内容で質問や指摘があれば、コメントいただけると嬉しいです。実装で詰まった点をお持ちでしたら、コメント欄でお気軽にどうぞ。