- JavaScript
JavaScript keydownイベントの使い方|Enter判定とIME対策
keydownイベントとは、キーが押し下げられた瞬間に発生するイベントで、押されたキーは event.key(入力される文字やキー名)と event.code(キーの物理的な位置)で判定します。 Enterで送信、Escapeで閉じる、Ctrl+Sで保存といったキーボード操作は、すべてこの keydown を入り口にして作ります。
日本語サイトで特に大切なのが、変換を確定するEnterで送信まで走ってしまう現象への対策です。この記事では、その現象を Chromium 141 上で再現し、isComposing と keyCode === 229 を併用した判定で Chrome・Firefox・Safari のどのイベント順でも送信されないことを確かめました。あわせて、event.key と event.code の値一覧、ショートカット判定の完成形コードを、実行結果つきで載せています。
検証環境は Node.js 22 + Playwright 1.56.0 + Chromium 141.0.7390.37(ヘッドレス、Linux)です。日本語変換は Chrome DevTools Protocol(CDP)の IME 用コマンドで再現しており、Firefox と Safari の実機での確認は未実行です(両ブラウザの挙動は MDN と W3C UI Events、後述の検証記事で確認した内容として区別して書きます)。
keydownイベントの早見表
| やりたいこと | 書き方 | 補足 |
|---|---|---|
| キーが押されたら処理する | el.addEventListener('keydown', (e) => { ... }) |
ページ全体なら document に付ける |
| Enterかどうか判定する | e.key === 'Enter' |
テンキーのEnterも key は Enter |
| 日本語変換中のキーを除外する | if (e.isComposing || e.keyCode === 229) return; |
3ブラウザ共通で使える判定 |
| Escapeで閉じる | e.key === 'Escape' |
Esc ではなく Escape |
| 矢印キーを判定する | e.key === 'ArrowUp' など |
Down / Left / Right も同じ形 |
| スペースキーを判定する | e.key === ' ' または e.code === 'Space' |
key は半角スペース1文字 |
| Ctrl+S(MacはCmd+S) | (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's' |
e.preventDefault() で保存ダイアログを止める |
| Shift+Enterは改行、Enterは送信 | if (e.key === 'Enter' && !e.shiftKey) |
テキストエリアの定番 |
| 押しっぱなしの連続実行を防ぐ | if (e.repeat) return; |
2回目以降の keydown は repeat が true |
| 入力欄にいる間はショートカットを無視 | e.target の tagName と isContentEditable を見る |
完成形は後半に掲載 |
| ブラウザ既定の動作を止める | e.preventDefault() |
スクロール、改行、フォーム送信など |
| キーの位置で判定する(WASDなど) | e.code === 'KeyW' |
キーボード配列が違っても同じ位置 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「JavaScript keydownイベントの使い方|Enter判定とIME対策」
keydown・keyup・keypress の違いと発火順
キーボードのイベントは3種類あります。新しく書くコードでは keydown と keyup の2つを使い、keypress は使わないのが基本です(keypress は非推奨で、Enter や文字キー以外では発生しません)。
| イベント | 発生するタイミング | 押しっぱなし | 主な用途 |
|---|---|---|---|
| keydown | キーを押し下げた瞬間 | 繰り返し発生(repeat が true) |
ショートカット、Enter送信、既定動作の停止 |
| keyup | キーを離した瞬間 | 1回だけ | 離したタイミングの検知、ゲームのキー解放 |
| keypress(非推奨) | 文字が入力されるキーを押したとき | 繰り返し発生 | 既存コードの読解のみ |
| input | 入力欄の値が変わった後 | 値が変わるたび | 文字数カウント、入力内容の検証 |
入力欄で a を1回押したときの実際の発火順です。
keydown key=a code=KeyA keyCode=65 isComposing=false repeat=false
keypress key=a code=KeyA keyCode=97 isComposing=false repeat=false
beforeinput data=a
input data=a
keyup key=a code=KeyA keyCode=65 isComposing=false repeat=false
keydown の時点では、入力欄の値はまだ変わっていません。 値が反映された後に処理したい場合(文字数カウントなど)は input イベントを使います。逆に「この文字を入力させたくない」「Enterで改行させたくない」といった既定動作の停止は、keydown で preventDefault() を呼びます。
keydownを受け取れる要素
keydown はフォーカスを持っている要素で発生し、そこから親へ伝わります(バブリング)。フォーカスできない要素に付けても、その要素では発生しません。
<div id="plain">div</div>
<div id="tab" tabindex="0">tab</div>
<input id="i">
<script>
for (const id of ['plain', 'tab', 'i']) {
document.getElementById(id).addEventListener('keydown', (e) => console.log(id + ':' + e.key));
}
document.addEventListener('keydown', (e) =>
console.log('document:' + e.key + ' target=' + (e.target.id || e.target.tagName)));
</script>
それぞれをクリックしてから a、b、c を押した実行結果です。
document:a target=BODY
tab:b
document:b target=tab
i:c
document:c target=i
| 付ける場所 | 受け取れる条件 | 向いている用途 |
|---|---|---|
document |
ページ内のどこにフォーカスがあっても届く | ページ全体のショートカット |
input / textarea |
その入力欄にフォーカスがあるとき | Enter送信、入力の制御 |
tabindex="0" を付けた要素 |
その要素にフォーカスがあるとき | 自作のリスト、タブ、メニュー |
tabindex のない div |
届かない(上の結果の1行目) | 親か document に付ける |
event.key と event.code の値一覧
event.key は「入力される文字・キーの意味」、event.code は「キーの物理的な位置」を表します。次の表は Chromium 141 で実際にキーを押して記録した値です(keyCode は既存コードを読むときの参考用)。
| 押したキー | event.key | event.code | keyCode(非推奨) |
|---|---|---|---|
| Enter | Enter |
Enter |
13 |
| テンキーのEnter | Enter |
NumpadEnter |
13 |
| Escape | Escape |
Escape |
27 |
| Tab | Tab |
Tab |
9 |
| スペース | 半角スペース1文字 | Space |
32 |
| Backspace | Backspace |
Backspace |
8 |
| Delete | Delete |
Delete |
46 |
| ↑ | ArrowUp |
ArrowUp |
38 |
| ↓ | ArrowDown |
ArrowDown |
40 |
| ← | ArrowLeft |
ArrowLeft |
37 |
| → | ArrowRight |
ArrowRight |
39 |
| Home / End | Home / End |
Home / End |
36 / 35 |
| PageUp / PageDown | PageUp / PageDown |
PageUp / PageDown |
33 / 34 |
| A | a |
KeyA |
65 |
| Shift+A | A |
KeyA |
65 |
| 1 | 1 |
Digit1 |
49 |
| -(ハイフン) | - |
Minus |
189 |
| /(スラッシュ) | / |
Slash |
191 |
| F1 / F5 | F1 / F5 |
F1 / F5 |
112 / 116 |
| Shift | Shift |
ShiftLeft |
16 |
| Ctrl | Control |
ControlLeft |
17 |
| Alt(MacのOption) | Alt |
AltLeft |
18 |
| Windowsキー / MacのCmd | Meta |
MetaLeft |
91 |
| CapsLock | CapsLock |
CapsLock |
20 |
| Insert | Insert |
Insert |
45 |
| 日本語変換中のキー(参考) | Process |
押したキーの位置 | 229 |
表から読み取れるポイントは3つです。
- スペースの key は
'Space'ではなく半角スペース1文字です。e.key === ' 'と書くか、e.code === 'Space'を使います。 - key は Shift や配列の影響を受けます。同じキーでも
aとAに変わるため、ショートカット判定ではe.key.toLowerCase()で小文字にそろえます。 - 修飾キー単体でも keydown は発生します。Ctrl+S を押すと、
Controlの keydown、続いてsの keydown(ctrlKeyが true)の順に届きます。
最後の「日本語変換中のキー」の行だけは実際のキー入力ではなく、Chrome が変換中に返す値として知られる Process と 229 を CDP で送って再現したものです。
テンキーの数字は NumLock の状態で key が変わります。今回の環境(NumLock オフ相当)では、テンキーの1は key が End、code が Numpad1 でした。
key と code の使い分け
| 判定したいもの | 使うプロパティ | 理由 |
|---|---|---|
| Enter、Escape、矢印、Tab などの機能キー | key |
名前がそのまま読める |
| Ctrl+S などの文字ショートカット | key(小文字にそろえる) |
キーボード配列が違っても「S の文字」で判定できる |
| WASD 移動などキーの位置が大事な操作 | code |
配列が違っても同じ位置のキーになる |
| 左右の Shift や Ctrl の区別 | code |
ShiftLeft と ShiftRight で分かれる |
| テンキーの Enter だけ区別 | code |
NumpadEnter で判定できる |
| 既存コードの数値判定 | keyCode |
非推奨。新規では IME 判定の 229 だけに使う |
修飾キーが押されているかは、次の4つの真偽値で調べます。
| プロパティ | 対応するキー |
|---|---|
e.ctrlKey |
Ctrl(Mac では control) |
e.metaKey |
Mac の Cmd、Windows の Windows キー |
e.shiftKey |
Shift |
e.altKey |
Alt(Mac では Option) |
Ctrl+Shift+S を押したときの s の keydown は、実行結果では次の値になりました。
key="s" code=KeyS ctrl=true shift=true alt=false meta=false
Enterキーを判定する基本コード
もっとも基本の形です。入力欄で Enter が押されたら処理します。
<input id="keyword" type="text" placeholder="キーワードを入力して Enter">
<script>
const keyword = document.querySelector('#keyword');
keyword.addEventListener('keydown', (e) => {
if (e.isComposing || e.keyCode === 229) return; // 日本語変換中は何もしない
if (e.key !== 'Enter') return;
e.preventDefault();
console.log('検索: ' + keyword.value);
});
</script>
2行目の isComposing と keyCode === 229 の判定が、次の章で説明する日本語変換への対策です。Enter 判定を書くときは、この1行を必ずセットにすると覚えておくと安心です。
フォーム内のEnterは、何も書かなくても送信される
form の中の1行入力欄で Enter を押すと、ブラウザが自動でフォームを送信します。これは keydown のコードとは別の、ブラウザ標準の動作です。入力欄の Enter では送信させたくない場合は、keydown で既定動作を止めます。
<form id="g">
<input id="n2"><input id="m2"><button>登録</button>
</form>
<script>
const g = document.getElementById('g');
g.addEventListener('submit', (e) => { e.preventDefault(); console.log('g submit'); });
g.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target instanceof HTMLInputElement) e.preventDefault();
});
</script>
対策なしのフォームと並べて確かめた結果です。
| 操作 | 対策なしのフォーム | 上のコードを入れたフォーム |
|---|---|---|
| 入力欄で Enter | submit が発生 | 発生しない |
| ボタンにフォーカスして Enter | submit が発生 | submit が発生(意図どおり) |
日本語変換の確定Enterで送信される現象を再現する
日本語を入力するとき、読みを打ってから Enter で変換を確定します。この確定の Enter も keydown として届くため、Enter で送信するコードがそのまま反応し、文章の途中で送信されてしまうことがあります。チャットや検索欄でよく見かける現象です。
確かめるために、判定の書き方を3種類用意しました。
// A: Enter だけを見る
(e) => { if (e.key === 'Enter' || e.keyCode === 13) send(); }
// B: isComposing だけで変換中を除外する
(e) => { if (e.isComposing) return; if (e.key === 'Enter') send(); }
// C: isComposing と keyCode 229 を併用する
(e) => { if (e.isComposing || e.keyCode === 229) return; if (e.key === 'Enter') send(); }
それぞれに、変換確定の Enter を2通りのイベント順で送りました。1つ目は「keydown が先、compositionend が後」(Chrome・Firefox の順)、2つ目は「compositionend が先、keydown が後」(Safari の順)です。Chromium 141 上で CDP の Input.imeSetComposition、Input.insertText、Input.dispatchKeyEvent を使って再現しています。
### A: key===Enter のみ
chrome: 送信0回 [keydown key=Process keyCode=229 isComposing=true / compositionend]
safari: 送信1回 [compositionend / keydown key=Enter keyCode=229 isComposing=false]
plain: 送信1回 [keydown key=Enter keyCode=13 isComposing=false]
### B: isComposing のみ
chrome: 送信0回 [keydown key=Process keyCode=229 isComposing=true / compositionend]
safari: 送信1回 [compositionend / keydown key=Enter keyCode=229 isComposing=false]
plain: 送信1回 [keydown key=Enter keyCode=13 isComposing=false]
### C: isComposing + keyCode 229
chrome: 送信0回 [keydown key=Process keyCode=229 isComposing=true / compositionend]
safari: 送信0回 [compositionend / keydown key=Enter keyCode=229 isComposing=false]
plain: 送信1回 [keydown key=Enter keyCode=13 isComposing=false]
(chrome・safari は再現したイベント順の名前、plain は変換なしの通常の Enter です)
| 判定の書き方 | Chrome・Firefox の順 | Safari の順 | 通常の Enter |
|---|---|---|---|
| A: Enter だけを見る | 送信されない | 確定で送信されてしまう | 送信される |
| B: isComposing だけ | 送信されない | 確定で送信されてしまう | 送信される |
| C: isComposing と 229 を併用 | 送信されない | 送信されない | 送信される |
3つのイベント順すべてで意図どおりに動いたのは C だけでした。B の isComposing だけでは、compositionend が先に届く順のときに isComposing が false になり、確定の Enter が通常の Enter と同じに見えてしまいます。
なお A について、Chrome・Firefox の順では key を Process(Chrome が変換中に返す値として知られるもの)として送ったため送信されませんでした。変換中の key はブラウザや OS によって Enter になる場合もあります。A の形に頼らず、C の形で明示的に除外するのが確実です。
ブラウザ別のイベント発火順
変換確定の Enter を押したときの順序を整理します。
| ブラウザ | イベントの順序 | 確定Enterの keydown の isComposing | keyCode | 確認方法 |
|---|---|---|---|---|
| Chrome / Edge | keydown → compositionend → keyup | true | 229 | Chromium 141 で CDP により再現 |
| Firefox(65以降) | keydown → compositionend → keyup | true | 229 | MDN の記載で確認(実機は未実行) |
| Safari | compositionend → keydown → keyup | false | 229 | MDN の注記と検証記事で確認(実機は未実行) |
W3C の UI Events 仕様では、変換中の順序は「keydown、compositionstart、compositionupdate、compositionend、keyup」とされ、isComposing は「compositionstart の後、対応する compositionend の前」に発生したキーイベントで true になると定義されています。Safari の順では、確定の keydown が compositionend の後に届くため、定義どおり false になります。
MDN の keydown のページにも、次の趣旨の注記があります。
- 変換を閉じる最後のキーでは、compositionend が keydown より先に発生することがあり、その場合
isComposingは false になる - その場合でも
keyCodeは 229 のままなので、非推奨ではあるもののkeyCodeも併せて確認するのがよい - Firefox は 65 から、変換中にも keydown と keyup を発生させるようになった
変換を始める最初の1文字でも同じことが起きます。今回の再現では、最初のキーの keydown は compositionstart より前に届き、isComposing は false、keyCode は 229 でした。
keydown key=Process code=KeyA keyCode=229 isComposing=false
compositionstart
compositionupdate data=あ
input data=あ isComposing=true
keyup key=a code=KeyA keyCode=65 isComposing=true
変換の最初と最後は isComposing だけでは拾えない場合があり、229 の併用がその両方を補います。
keyCode 229 とは
keyCode は非推奨のプロパティですが、229 は「このキー操作は IME(日本語入力などの変換ソフト)が処理した」ことを表す特別な値です。通常の Enter は 13、変換確定の Enter は 229 になるため、数値だけで区別できます。
| 状況 | key | keyCode | isComposing |
|---|---|---|---|
| 通常の Enter | Enter |
13 | false |
| 変換中の文字キー・確定 Enter(Chrome の順) | Process |
229 | true |
| 確定 Enter(Safari の順) | Enter |
229 | false |
「非推奨のプロパティを使ってよいのか」と迷うところですが、MDN 自身が併用を勧めており、現時点では 229 の判定に代わる標準の手段がありません。IME 判定の1か所だけに閉じ込めて使うのがおすすめです。
// 変換中のキー操作かどうか(この関数にだけ keyCode を閉じ込める)
const isImeEvent = (e) => e.isComposing || e.keyCode === 229;
compositionstart と compositionend でフラグを持つ方法
変換の開始と終了を自分で記録する方法もあります。
let composing = false;
input.addEventListener('compositionstart', () => { composing = true; });
input.addEventListener('compositionend', () => { composing = false; });
input.addEventListener('keydown', (e) => {
if (composing) return;
if (e.key === 'Enter') send();
});
この方法は Chrome・Firefox の順では働きますが、compositionend が先に届く順では、keydown の時点でフラグがすでに false に戻っています。仕組みとしては B(isComposing のみ)と同じ結果になるため、フラグ方式を使う場合も e.keyCode === 229 の確認を足しておきます。変換中の表示切り替え(確定前の文字に下線を出すなど)には、composition 系のイベントが向いています。
コピペ用の完成形:Enter送信とショートカット
ここまでの内容をまとめた、そのまま保存して開ける HTML です。Enter で送信、Shift+Enter で改行、変換確定の Enter は無視、Ctrl+S(Mac は Cmd+S)などのショートカットに対応しています。
<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<title>keydown デモ</title>
<form id="chat">
<textarea id="msg" rows="2" placeholder="Enterで送信、Shift+Enterで改行"></textarea>
<button type="submit">送信</button>
</form>
<input id="search" type="search" placeholder="検索(/ でフォーカス)">
<ul id="log"></ul>
<script>
const log = (text) => {
const li = document.createElement('li');
li.textContent = text;
document.querySelector('#log').append(li);
};
// 1) 変換中のキー操作かどうか(Chrome・Firefox・Safari 共通)
const isImeEvent = (e) => e.isComposing || e.keyCode === 229;
// 2) Enterで送信、Shift+Enterで改行
const form = document.querySelector('#chat');
const msg = document.querySelector('#msg');
msg.addEventListener('keydown', (e) => {
if (isImeEvent(e)) return; // 変換確定のEnterは通さない
if (e.key !== 'Enter') return;
if (e.shiftKey) return; // Shift+Enter は改行のまま
e.preventDefault(); // 改行が入らないようにする
if (e.repeat) return; // 押しっぱなしの連続送信を防ぐ
form.requestSubmit();
});
form.addEventListener('submit', (e) => {
e.preventDefault();
const text = msg.value.trim();
if (text === '') return;
log('送信: ' + text);
msg.value = '';
});
// 3) ショートカット
const isMac = /Mac|iPhone|iPad/.test(navigator.platform);
const isEditable = (el) =>
el instanceof HTMLElement &&
(el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
const shortcuts = [
{ key: 's', mod: true, allowInInput: true, run: () => log('保存') },
{ key: 'z', mod: true, shift: true, run: () => log('やり直し') },
{ key: 'k', mod: true, allowInInput: true, run: () => document.querySelector('#search').focus() },
{ key: '/', run: () => document.querySelector('#search').focus() },
{ key: '?', run: () => log('ヘルプ') },
{ key: 'Escape', allowInInput: true, run: () => document.activeElement.blur() },
];
document.addEventListener('keydown', (e) => {
if (isImeEvent(e)) return; // 変換中は何もしない
if (e.altKey) return;
const mod = isMac ? e.metaKey : e.ctrlKey; // Cmd か Ctrl
const other = isMac ? e.ctrlKey : e.metaKey;
if (other) return;
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
const hit = shortcuts.find((s) =>
s.key === key &&
Boolean(s.mod) === mod &&
(!s.mod || Boolean(s.shift) === e.shiftKey)); // 修飾キーつきは Shift も厳密に比べる
if (!hit) return;
if (isEditable(e.target) && !hit.allowInInput) return; // 入力中は文字入力を優先
e.preventDefault(); // ブラウザ既定の動作(保存ダイアログなど)を止める
if (e.repeat) return; // 押しっぱなしでも実行は1回だけ
hit.run();
});
</script>
</html>
実行結果
このファイルを Chromium 141 で開き、Playwright からキーを押して確かめた結果です(変換確定の2行は CDP による再現)。
| 操作 | ログに出た内容 | フォーカス | defaultPrevented |
|---|---|---|---|
| hello、Shift+Enter、world、Enter | 送信: hello(改行)world | msg | true |
| 空のまま Enter | なし | msg | true |
| abc を入力して Enter を押しっぱなし(keydown 3回) | 送信: abc(1回だけ) | msg | true |
| 変換確定の Enter(Chrome・Firefox の順) | なし | msg | false |
| 変換確定の Enter(Safari の順) | なし | msg | false |
| 確定後にもう一度 Enter | 送信: こんにちは世界 | msg | true |
| Ctrl+S | 保存 | body | true |
| Ctrl+Shift+S | なし | body | false |
| Ctrl+Shift+Z | やり直し | body | true |
| Ctrl+Z | なし | body | false |
| Ctrl+Alt+S | なし | body | false |
| S だけ | なし | body | false |
| Ctrl+S を押しっぱなし(keydown 4回) | 保存(1回だけ) | body | true |
| ?(Shift+/) | ヘルプ | body | true |
| / | なし(検索欄へ移動) | search | true |
| 検索欄で / | なし(文字として入力される) | search | false |
| 検索欄で Ctrl+S | 保存 | search | true |
| 検索欄で Escape | なし(フォーカスが外れる) | body | true |
| テキストエリアで Ctrl+K | なし(検索欄へ移動) | search | true |
navigator.platform を MacIntel に差し替えた状態でも確かめました。
| 操作(Mac 相当) | ログに出た内容 | defaultPrevented |
|---|---|---|
| Cmd+S | 保存 | true |
| Ctrl+S | なし | false |
| Cmd+Shift+Z | やり直し | true |
変換確定の Enter では defaultPrevented が false のままです。変換中のキーには手を出さず、IME にそのまま処理してもらうのがポイントで、ここで preventDefault() を呼ぶと確定そのものに影響することがあります。
ショートカット判定の4つのポイント
完成形コードの中身を、判断の理由とあわせて説明します。
1. 修飾キーは「押されていること」と「押されていないこと」の両方を見る
e.ctrlKey && e.key === 's' だけだと、Ctrl+Shift+S や Ctrl+Alt+S でも反応します。別の機能を割り当てたくなったときに衝突するため、使わない修飾キーが押されていないことも確認します。
// 直す前:Ctrl+Shift+S でも Ctrl+Alt+S でも反応する
if (e.ctrlKey && e.key === 's') save();
// 直した後:Ctrl+S(Mac は Cmd+S)ちょうどのときだけ反応する
const mod = isMac ? e.metaKey : e.ctrlKey;
if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 's') {
e.preventDefault();
save();
}
Mac と Windows を分けずに (e.ctrlKey || e.metaKey) と書く方法も手軽です。厳密さより短さを優先したい小さなツールなら、この形で十分に使えます。
? や / のような記号キーは、配列によって Shift が必要だったり不要だったりします。完成形コードでは、修飾キーなしのショートカットは Shift の状態を比べず、key の文字だけで判定しています。
2. preventDefault はブラウザの既定動作と重なるキーで呼ぶ
| キー | ブラウザの既定動作 | preventDefault すると |
|---|---|---|
| Ctrl+S / Cmd+S | ページの保存ダイアログ | ダイアログが出ず、自前の保存だけ動く |
| テキストエリアの Enter | 改行の入力 | 改行が入らない |
| フォーム内入力欄の Enter | フォームの送信 | 送信されない |
| スペース、矢印、PageDown | ページのスクロール | スクロールしない |
| Tab | 次の要素へフォーカス移動 | 移動しない(キーボード操作の妨げになりやすいので慎重に) |
| / | 文字の入力 | 入力されない |
preventDefault() は自分のショートカットに一致したときだけ呼びます。keydown の先頭で無条件に呼ぶと、文字入力も Tab 移動もできなくなります。
なお、Ctrl+W(タブを閉じる)、Ctrl+T(新しいタブ)、Ctrl+N(新しいウィンドウ)など、ブラウザ自身の操作に使われる組み合わせは、ページ側から止められないものとして一般に知られています(今回は未実行)。ショートカットは、こうしたキーと重ならない組み合わせを選びます。
3. repeat は preventDefault の後で確認する
キーを押し続けると、keydown が繰り返し発生し、2回目以降は e.repeat が true になります。
keydown key=a keyCode=65 repeat=false
keydown key=a keyCode=65 repeat=true
keydown key=a keyCode=65 repeat=true
keyup key=a keyCode=65 repeat=false
保存や送信は1回で十分なので repeat を除外します。ここで順番が大切です。最初に書いたコードでは、関数の先頭に if (e.repeat) return; を置いていました。
// 直す前:repeat のときは preventDefault まで届かない
document.addEventListener('keydown', (e) => {
if (e.repeat) return;
// ...一致するショートカットを探す...
e.preventDefault();
hit.run();
});
この形で Ctrl+S を押しっぱなしにすると、保存の実行は1回でしたが、2回目以降の keydown の defaultPrevented は false でした。つまり、押し続けている間にブラウザの保存ダイアログが開く余地が残ります。
// 直した後:既定動作は毎回止め、実行だけ1回にする
e.preventDefault();
if (e.repeat) return;
hit.run();
この順にすると、押しっぱなしの4回すべてで defaultPrevented が true、実行は1回になりました(実行結果の表のとおり)。矢印キーでの移動のように、押し続けて連続で動かしたい操作では repeat を除外しません。
4. 入力欄にフォーカスがある間は、文字入力を優先する
/ や ? のような1文字のショートカットは、入力欄で文字を打っているときにも反応してしまうと困ります。keydown の e.target が入力できる要素かどうかを見て切り分けます。
const isEditable = (el) =>
el instanceof HTMLElement &&
(el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
| ショートカットの種類 | 入力欄にいるとき | 例 |
|---|---|---|
| 1文字キー(修飾キーなし) | 無視する | /、?、j、k |
| Ctrl / Cmd つき | 多くは有効のままでよい | Ctrl+S、Ctrl+K |
| Ctrl / Cmd つきで編集と重なるもの | 無視する | Ctrl+Z、Ctrl+A、Ctrl+C |
| Escape | 有効のまま | 入力欄からフォーカスを外す、モーダルを閉じる |
完成形コードでは、ショートカットごとに allowInInput を持たせています。実行結果でも、検索欄で / を押したときは文字として入力され、Ctrl+S は入力中でも保存が動きました。
よくあるつまずきと直し方
| 症状 | 原因 | 直し方 |
|---|---|---|
| 変換確定の Enter で送信される | 変換中の keydown を除外していない | if (e.isComposing || e.keyCode === 229) return; を先頭に置く |
| Chrome では動くが Safari では確定で送信される | isComposing だけで判定している |
keyCode === 229 を併用する |
| スペースキーが判定できない | e.key === 'Space' と書いている |
e.key === ' ' か e.code === 'Space' |
| Escape が判定できない | 'Esc' と書いている |
'Escape' |
| Shift を押すとショートカットが効かない | key が大文字の S になっている |
e.key.toLowerCase() でそろえる |
| div に付けた keydown が反応しない | フォーカスできない要素に付けている | tabindex="0" を付けるか document に付ける |
| 保存ダイアログが出てしまう | preventDefault() を呼んでいない、または repeat で先に抜けている |
一致したら毎回 preventDefault() を呼ぶ |
| 押しっぱなしで何度も送信される | repeat を見ていない |
if (e.repeat) return; |
入力中に / が入力できない |
入力欄でもショートカットが反応している | e.target が入力要素なら抜ける |
| Mac で Ctrl+S になってしまう | ctrlKey だけを見ている |
Mac では metaKey を見る |
| keydown で取った入力欄の値が1文字足りない | keydown は値が変わる前に発生する | 値を使う処理は input イベントで行う |
書く前のチェックリスト
- Enter を判定する場所に、IME の除外(
isComposingと 229)が入っているか - 判定は
key(意味)とcode(位置)のどちらが合っているか - 使わない修飾キーが押されていないことまで確認しているか
preventDefault()は一致したときだけ呼んでいるかrepeatの扱い(1回だけか、連続で動かすか)を決めたか- 入力欄にフォーカスがあるときの扱いを決めたか
- Mac の Cmd と Windows の Ctrl の両方で試したか
- スマートフォンのソフトウェアキーボードでは keydown の key が
Unidentifiedや 229 になる場合があるため、文字の検知は input イベントで行っているか(一般に知られた挙動。今回は未実行)
よくある質問
Q. keyCode は非推奨なのに、229 の判定に使ってよいのですか?
A. IME 判定に限って使うのがおすすめです。MDN の keydown のページでも、event.isComposing || event.keyCode === 229 の形が紹介されています。compositionend が keydown より先に届くイベント順では isComposing が false になるため、今回の再現でも 229 を併用した書き方だけがすべての順序で送信を防げました。isImeEvent のような小さな関数にまとめておくと、将来の書き換えも1か所で済みます。
Q. keydown と keyup のどちらで Enter を判定すればよいですか? A. keydown がおすすめです。改行やフォーム送信といった既定動作を止められるのは keydown の時点です。keyup はキーを離した後に届くため、その時点では改行や送信がすでに済んでいます。
Q. event.key と event.code はどちらを使えばよいですか?
A. 「Enter」「Escape」「S の文字」のように意味で判定したいときは key、WASD のようにキーの位置で判定したいときは code を使います。ショートカットは多くの場合 key を小文字にそろえて判定すると、キーボード配列が違う環境でも同じ文字のキーで動きます。
Q. React や Vue でも同じ対策が必要ですか?
A. 必要です。フレームワークのイベントでも元になっているのはブラウザの keydown なので、同じ判定を入れます。React では合成イベントの e.nativeEvent.isComposing と e.keyCode を参照する書き方が知られています(フレームワーク上での動作は今回未実行)。
まとめ
- キー操作は keydown で受け、
e.key(意味)とe.code(位置)で判定する。keypress は非推奨 - Enter 判定には
if (e.isComposing || e.keyCode === 229) return;を必ず添える - isComposing だけでは、compositionend が先に届く順(Safari の順)で確定 Enter が通ってしまう。229 の併用で3つの順序すべてを防げた
- スペースの key は半角スペース、Escape は
'Escape'。文字キーはtoLowerCase()でそろえる - ショートカットは、修飾キーの有無を厳密に比べ、一致したときだけ
preventDefault()を呼ぶ repeatの確認はpreventDefault()の後に置くと、押しっぱなしでも既定動作を止めたまま実行を1回にできる- 入力欄にフォーカスがある間は、1文字ショートカットを無視して文字入力を優先する
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「JavaScript keydownイベントの使い方|Enter判定とIME対策」
株式会社RJCでは、keydownイベントやIME対策のようなブラウザの細かな挙動を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。