Old-School Front-End / 2026 ・ No.14
いにしえのフロントエンド入門
第14回 jQuery ── 同じ画面を「$」で書き直して、並べて比べる
公開
AI に頼まず、一行ずつ手で書く「いにしえの」フロントエンド入門。第14回は jQuery です。
第8〜10回で手で書いた申請フォームの JavaScript を、jQuery 4.0.0 でそっくり書き直します。
そして、2つの版を同じ手順で動かし、本当に同じ動きになるかを確かめます。
書き直すと分かるのは、jQuery の便利さと、素の JavaScript がどれだけ追いついたか、そして踏みやすい罠です。
今回のゴール
古井今日は jQuery だ。わしが若いころ、ウェブの画面は全部これで書いた。
新井ドルマークだらけのやつですね。古いサイトの保守で見たことあります。読めませんでした。
古井読めないと困る。世の中のサイトの6割以上が、まだ使っているからな。今日は第8〜10回の申請フォームを、jQuery でそっくり書き直す。
新井動いているものを、わざわざ書き直すんですか。
古井同じものを2通りで書くと、どっちの良さも悪さもよく見える。翻訳の練習みたいなものだ。
いまここ:第14回
- $ が返すものを知る ── 要素そのものではなく「入れ物」。読むのは最初の1つ、書くのは全部
- 書き直す ── 行の追加・削除、入力チェックのまとめ、サーバーとのやりとり。計算(app.js)とチェック(validate.js)はそのまま使う
- 同じ動きか確かめる ── 2つの版を同じ手順で操作して、画面の様子を記録して比べる
- 罠を試す ── 書き直しでつまずいたところを、試すページにまとめて実際に動かす
使うのは jQuery 4.0.0 です。2026年1月17日に出た、約10年ぶりの大きな版です。比べるために、ひとつ前の 3.7.1 も試します。
$ が返すもの
jQuery の中心は、$ という名前の関数です。CSS のセレクタを渡すと、見つかった要素を入れ物に入れて返します。この入れ物には、要素をまとめて扱うメソッドがたくさん付いています。
$() が返すのは、要素を入れた入れ物
# lab.html で実際に動かした結果(Chromium、jQuery 4.0.0) # 試したこと 結果 1 $('#total') に textContent を聞く undefined 2 $('#total')[0] にすると 46,851 3 $('[name="amount"]').val()(3つある) "3070" 4 map(…).get() で全部 ["3070","189.50","100"] 5 無い要素に $('#nothing').text('x') エラーなし(length = 0) 6 無い要素に querySelector('#nothing').textContent = 'x' TypeError: Cannot set properties of null (setting 'textContent')
- 入れ物は要素ではないので、textContent を聞いても undefined。中の要素は [0] で取り出す
- .val() のように「読む」メソッドは、最初の1つしか読まない。全部ほしいときは map(…).get()
- 反対に .addClass() のように「書く」メソッドは、入っている要素の全部に効く(暗黙の繰り返し)。for を書かなくていいのが、jQuery の気持ちよさ
- 見つからなくても、空の入れ物が返るだけでエラーにならない。素の JavaScript なら null にぶつかって止まるところを、jQuery は黙って何もしない。セレクタの打ち間違いに気づきにくい
そこで、入れ物を入れる変数には頭に $ を付けて($tbody など)、ふつうの要素と見分けられるようにします。昔からの習慣です。
読み込む
Bootstrap(第6回)と同じく、ファイルを vendor/jquery/ に置いて読み込みます。jQuery を使う form-jq.js より先に読み込むのが決まりです。どれも defer なので、書いた順に動きます。
<script src="vendor/jquery/jquery.min.js" defer></script> <script src="js/app.js" defer></script> <script src="js/auth.js" defer></script> <script src="js/validate.js" defer></script> <script src="js/form-jq.js" defer></script>
大きさを比べました。
# 大きさ(バイト。gzip は圧縮したときの目安) ファイル バイト gzip 行(コメントと空行を除く) form.js(素の JS) 10,398 4,096 185 form-jq.js(jQuery) 8,830 3,732 168 jquery.min.js 4.0.0 78,748 27,519 jquery.slim.min.js 4.0.0 56,032 19,424 jquery.min.js 3.7.1 87,533 30,336 (参考)bootstrap.bundle.min.js 80,496 23,743
書き直した form-jq.js は、元の form.js より1割ほど短くなりました。ただし、そのために約 79KB(圧縮して約 27KB)の jquery.min.js を毎回読み込みます。Bootstrap の JavaScript と同じくらいの重さです。slim 版は小さいですが、通信($.ajax)が入っていません(八で確かめます)。今回は通信を書き直すので、ふつうの版を使います。
行の追加・削除を書き直す
まず第8回の、明細の行の番号を振り直す関数です。上が素の JavaScript、下が jQuery 版です。
/** 行番号に合わせて、読み上げ用の名前を付け直す */
function renumber() {
[...tbody.rows].forEach((row, i) => {
const n = i + 1;
for (const field of row.querySelectorAll('[data-label]')) {
const label = field.dataset.label;
field.id = `${field.name}-${n}`; // まとめのリンク用の id(第9回)
field.setAttribute('aria-label', `${n}行目の${label}`);
}
row.querySelector('[data-action="remove"]').setAttribute('aria-label', `${n}行目を削除`);
});
addButton.disabled = tbody.rows.length >= MAX_ROWS; // 10行になったら追加できなくする
}
/** 行番号に合わせて、読み上げ用の名前を付け直す */
function renumber() {
const $rows = $tbody.children('tr');
$rows.each(function (i) { // each の中の this は、その行(ふつうの DOM の要素)
const n = i + 1;
$(this).find('[data-label]').each(function () {
$(this).attr({ id: `${this.name}-${n}`, 'aria-label': `${n}行目の${$(this).data('label')}` });
});
$(this).find('[data-action="remove"]').attr('aria-label', `${n}行目を削除`);
});
$addButton.prop('disabled', $rows.length >= MAX_ROWS); // disabled のような「状態」は prop、HTML に書く「属性」は attr
}
- for…of の代わりに .each(function () {…})。中の this は、その回の要素(入れ物ではない、ふつうの要素)
- 属性は .attr()、disabled や checked のような「いまの状態」は .prop()。.attr() にはオブジェクトでまとめて渡せる
次はイベントの委任(第8回)です。ここは jQuery がいちばん得意なところです。
// 入力のたびに合計を計算し直す(後から足した行にも効くよう、tbody で受ける)
tbody.addEventListener('input', (event) => {
const name = event.target.name;
if (name === 'amount' || name === 'currency') updateTotal();
});
// 「削除」ボタン(これも tbody で受ける)
tbody.addEventListener('click', (event) => {
const button = event.target.closest('[data-action="remove"]');
if (button) removeRow(button.closest('tr'));
});
addButton.addEventListener('click', () => addRow());
// イベントの委任(第8回)は、on の2番目に「どの子で起きたら」を書くだけ
$tbody.on('input', '[name="amount"], [name="currency"]', updateTotal);
$tbody.on('click', '[data-action="remove"]', function () {
removeRow($(this).closest('tr'));
});
$addButton.on('click', () => addRow());
.on() の2番目に「どの子で起きたら」をセレクタで書くと、closest で探す手間を jQuery が引き受けてくれます。そのとき this は、押されたボタンそのものになります。ただし、これは function で書いたときだけです。
# lab.html で実際に動かした結果(Chromium、jQuery 4.0.0) # 試したこと 結果 13 on の中のアロー関数の this window 14 on の中の function の this <button id="lab-btn">
アロー関数は自分の this を持たないので、外側(ここでは window)になります。jQuery の this を使うところは function、使わないところはアロー関数、と使い分けます。
まとめの表示を書き直す
第9回の、エラーのまとめを出す関数です。要素を作って中身を入れる、という仕事が多いところです。
/** チェックの結果を画面に出す(赤い枠と、上のまとめ) */
function showErrors(errors) {
for (const field of form.querySelectorAll('.is-invalid')) {
field.classList.remove('is-invalid');
field.removeAttribute('aria-invalid');
}
const items = errors.map(({ field, message }) => {
const el = document.getElementById(field);
el.classList.add('is-invalid');
el.setAttribute('aria-invalid', 'true');
const a = document.createElement('a');
a.href = '#' + field;
a.className = 'alert-link';
a.textContent = message; // 文言は textContent で(第8回)
const li = document.createElement('li');
li.append(a);
return li;
});
summary.querySelector('ul').replaceChildren(...items);
summary.querySelector('h2').textContent = `入力内容を確認してください(${errors.length}件)`;
summary.hidden = errors.length === 0;
}
/** チェックの結果を画面に出す(赤い枠と、上のまとめ) */
function showErrors(errors) {
$form.find('.is-invalid').removeClass('is-invalid').removeAttr('aria-invalid');
const items = errors.map(({ field, message }) => {
$(document.getElementById(field)).addClass('is-invalid').attr('aria-invalid', 'true'); // id に記号が入っていても困らないよう、要素は getElementById で探して包む
return $('<li>').append($('<a>', { href: '#' + field, class: 'alert-link', text: message })); // text: で渡すと textContent と同じ(第8回。html: にすると XSS の入り口)
});
$summary.find('ul').empty().append(items);
$summary.find('h2').text(`入力内容を確認してください(${errors.length}件)`);
$summary.prop('hidden', errors.length === 0);
}
$('<a>', { … }) のように、タグと属性をまとめて渡すと、要素が1行で作れます。.removeClass().removeAttr() のように、メソッドをつなげて書けるのも jQuery らしさです(メソッドチェーン)。
文字を入れるところには注意がいります。第8回で、innerHTML ではなく textContent を使う理由を見ました。jQuery では .html() が innerHTML、.text() が textContent にあたります。
# lab.html で実際に動かした結果(Chromium、jQuery 4.0.0) # 試したこと 結果 19 .html(文字) にスクリプト入りの img 実行された 20 .text(文字) に同じもの 実行されない(文字として表示)
.html() に入れた img の onerror は、本当に動きました。人が書いた文字は .text()(または text:)で入れます。古い jQuery のコードには .html() で文字をつなげて入れている所がよくあるので、読むときの要注意ポイントです。
通信を書き直す
第10回の、申請をサーバーへ送る関数です。fetch を $.ajax に置き換えます。
/** 申請をサーバーへ送る */
async function send(data, button) {
setSending(true, button);
try {
const url = form.getAttribute('action'); // form.action だと name="action" のボタンが返ってくる(実測)
const res = await fetch(url, { method: 'POST', body: data, signal: AbortSignal.timeout(15000) }); // 15秒で諦める
if (res.status === 401) { // ログインが切れていた(第11回)
showResult('danger', 'ログインの有効期限が切れました。もう一度ログインしてください(入力した内容は、この画面に残っています)。');
return;
}
if (res.status === 400) { // サーバーのチェックに引っかかった(第9回と同じ形で返ってくる)
const body = await res.json();
showErrors(body.errors);
summary.focus();
return; // return しても finally は動く
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
showResult('success', body.draft ? `下書きを保存しました(${body.id})。` : `申請を受け付けました。申請番号は ${body.id} です。`);
} catch (error) {
showResult('danger', '送信できませんでした。通信の状態を確かめて、もう一度押してください。入力した内容は消えていません。');
} finally {
setSending(false, button); // うまくいっても、失敗しても、ボタンを元に戻す
}
}
/** 申請をサーバーへ送る */
async function send(data, button) {
setSending(true, button);
try {
const body = await $.ajax({
url: $form.attr('action'), // attr は属性を読むので、name="action" のボタンに邪魔されない(第10回の罠)
method: 'POST',
data, // FormData は 4.0 ならそのまま渡せる(3.x では processData: false と contentType: false が要る。六で実測)
dataType: 'json',
timeout: 15000,
});
showResult('success', body.draft ? `下書きを保存しました(${body.id})。` : `申請を受け付けました。申請番号は ${body.id} です。`);
} catch (xhr) { // 400 や 401 も、ここに来る
if (xhr.status === 401) {
showResult('danger', 'ログインの有効期限が切れました。もう一度ログインしてください(入力した内容は、この画面に残っています)。');
return;
}
if (xhr.status === 400) {
showErrors(xhr.responseJSON.errors);
$summary.trigger('focus');
return;
}
showResult('danger', '送信できませんでした。通信の状態を確かめて、もう一度押してください。入力した内容は消えていません。');
} finally {
setSending(false, button);
}
}
いちばん大きな違いは、400 や 401 がどこへ行くかです。
fetch は「返事が来たか」、$.ajax は「うまくいったか」で分ける
# lab.html で実際に動かした結果(Chromium、jQuery 4.0.0) # 試したこと 結果 7 $.ajax の data に FormData だけ(4.0.0) status 400 / Content-Type: multipart/form-data 8 同じことを 3.7.1 で TypeError: Illegal invocation 9 $.ajax で 400 が返ると 失敗(catch へ) status 400 / responseJSON.errors 2 10 fetch で 400 が返ると 成功(then へ) ok false / status 400 11 jQuery のイベントの event.submitter undefined 12 event.originalEvent.submitter <button value="submit">
- fetch は、返事が来れば 400 でも成功扱い。だから第10回では res.status を見て分けた。$.ajax は 400 で失敗扱いになり、catch に来る。返事の JSON は xhr.responseJSON に入っている
- FormData は、jQuery 4.0.0 ならそのまま data に渡せる。Content-Type もブラウザが付けた(multipart/form-data)。3.7.1 で同じことをすると TypeError: Illegal invocation で止まる。3.x のコードでは processData: false と contentType: false の2行が要る
- jQuery のイベントには submitter(押された送信ボタン)が無い。元のイベント(originalEvent)から取る
$form.on('submit', async function (event) {
event.preventDefault();
$result.prop('hidden', true);
const button = event.originalEvent.submitter; // jQuery のイベントには submitter が無い。元のイベント(originalEvent)から取る
if (!button.formNoValidate) {
const errors = validateClaim(readClaim(), localDate(), rate);
showErrors(errors);
if (errors.length > 0) {
$summary.trigger('focus');
return;
}
}
await send(new FormData(this, button), button);
});
なお、$.ajax の返り値は await で待てます(jQuery の Deferred は、Promise と同じように待てる形をしています)。昔のコードでは .done() と .fail() をつなげて書くのが定番でした。
同じ動きか確かめる
書き直しで一番こわいのは、「だいたい同じ」で終わることです。そこで、2つの版を同じ手順で Playwright に操作させ、各場面で画面の様子(合計、お知らせ、エラーのまとめ、赤い枠、結果、ボタン、行の数、読み上げ用の名前、フォーカスの位置など)を記録して、比べました。
# 同じ手順で操作して、画面の様子を記録して比べた(Chromium。3言語とも) 場面 素の JS(form.js) jQuery(form-jq.js) 同じ? 何も書かずに「申請する」 3 / focus error-summary 3 / focus error-summary 同じ 「行を追加」 focus date-2 focus date-2 同じ 3行入れて合計 46,851 円 46,851 円 同じ 4行目を足して消す 3 / focus add-row 3 / focus add-row 同じ 送信中 [x]下書き保存 [x]送信中… [x]下書き保存 [x]送信中… 同じ 送れた success 申請を受け付けました… success 申請を受け付けました… 同じ ログイン切れで下書き保存 danger ログインの有効期限が… danger ログインの有効期限が… 同じ # 記録した14項目(合計・3つのお知らせ・まとめの見出しと中身・赤い枠・結果・ボタン・aria-busy・行数・読み上げ用の名前・「行を追加」の可否・フォーカス)が、8 場面とも一致
3つの言語のどれでも、8つの場面すべてで、記録した値が一致しました。特にフォーカスの行き先(エラーのまとめ、足した行の日付、消したあとの「行を追加」)まで同じなのは、第8〜9回で決めた「キーボードの人が迷わない」動きを、書き直しでも落とさなかった、ということです。
新井全部「同じ」ですね。じゃあ jQuery 版のほうが短いし、こっちでいいのでは?
古井短くなったのは1割、増えたのは 79KB だ。それに、短くなったのは for と closest のあたりだけ。第8回のときに素の DOM の書き方を覚えたから、比べられる。
3.x から 4.0 へ
世の中の jQuery の多くは 3.x です。4.0.0 で何が変わったかを、同じページに 3.7.1 と slim 版も読み込んで確かめました。
# lab.html で実際に動かした結果(Chromium、jQuery 4.0.0) # 試したこと 結果 15 typeof $.trim(4.0.0 / 3.7.1) undefined / function 16 typeof $.ajax(slim 版 / ふつうの版) undefined / function 17 フォーカスを A から B へ(3.7.1) A:focusout → A:blur → B:focusin → B:focus 18 フォーカスを A から B へ(4.0.0) A:blur → A:focusout → B:focus → B:focusin
- $.trim、$.isArray、$.parseJSON など、ブラウザに同じものがある関数は消えた。' a '.trim()、Array.isArray()、JSON.parse() を使う
- slim 版には $.ajax が無い(Deferred も無い)。通信を使うなら、ふつうの版か fetch
- フォーカスのイベントの順番が、ブラウザ本来の順番(W3C の順番)に変わった。順番に頼っていたコードは動きが変わる
入力欄 A から B へ移ったときのイベントの順番
ほかにも、IE10 以前と古い Edge への対応が終わり、ソースが ES モジュールになり、FormData を自動で扱うようになりました。上げるときは、公式の移行ガイドと、古い書き方を警告してくれる jQuery Migrate を使うのが近道です。
いま jQuery とどう付き合うか
書き直してみると、jQuery のメソッドのほとんどに、素の JavaScript の相手がいることが分かります。
jQuery と素の JavaScript の対応
jQuery が生まれた2006年、ブラウザごとに DOM の書き方がばらばらで、querySelector も classList も fetch もありませんでした。jQuery はその差を埋める、なくてはならない道具でした。いまは右側が標準になり、どのブラウザでも同じように動きます。jQuery の考え方(セレクタで探す、まとめて扱う)が、ブラウザの標準に取り込まれた、とも言えます。
それでも、まだ多くのサイトで動いている
- 新しく作るなら ── 素の JavaScript で十分。この連載の申請フォームも、jQuery なしで全部書けた
- すでに jQuery で動いているなら ── 無理に書き換えなくていい。直すときに読める力が要る。上げるなら 3.x の最新から 4.0 へ、移行ガイドを見ながら
- どちらにしても ── 中で何が起きているかは、素の DOM の言葉で理解しておく。jQuery の罠は、だいたい「入れ物と要素の違い」と「黙って何もしない」から来る
jQuery は「素の JavaScript を短く書くための道具」です。道具が何を代わりにやってくれているのかが分かっていれば、jQuery のコードも、AI が書いたコードも、落ち着いて読めます。書き直して、記録して、比べる。今回やったのは、古いコードに手を入れるときの基本の手順そのものです。
結 — 一枚まとめと次回予告
- $() が返すのは入れ物。読むのは最初の1つ、書くのは全部。中の要素は [0]。見つからなくても黙っている。
- jQuery の得意技は、on の委任・メソッドチェーン・要素を1行で作ること。each の中は function で this。
- 文字を入れるのは .text()。.html() は innerHTML と同じで、スクリプトが動く。
- $.ajax は 400 や 401 で catch へ。fetch とは逆。4.0 は FormData をそのまま渡せる(3.x は 2行足す)。
- 同じ手順で動かして記録し、書き直しが同じ動きか確かめる。今回は 8 場面 × 14 項目 × 3 言語で一致。
- 新しく作るなら素の JavaScript、動いている jQuery は読めるように。
新井なんだか、古いコードがこわくなくなりました。
古井次はいよいよ最終回、仕上げだ。品質・安全・公開。それから、React や Vue のような「フレームワーク」の入口を少しだけのぞく。いにしえの道具で作ったものを、いまの世界に送り出すぞ。
新井最終回……。打ち上げは経費で落ちますか?
参考
- jQuery blog — jQuery 4.0.0
- jQuery Core 4.0 Upgrade Guide
- jQuery API — .on()
- jQuery API — jQuery.ajax()
- jQuery API — .text()
- MDN — Using the Fetch API
- MDN — SubmitEvent: submitter property
- W3Techs — Usage statistics of jQuery
ページの写真は、筆者が作ったデモページを、練習用のサーバー(Node.js)経由で2026年10月19日にクラウド上の Chromium(Playwright)で撮影したものです(ページの時計は 2026年10月17日 15:00 に固定)。撮影環境には Hiragino Sans がないため、日本語は Noto Sans CJK JP で表示されています。アカウント・申請は、練習用サーバーの架空のデータです。2つの版を同じ手順で操作した記録、ファイルの大きさ、試すページ(lab.html)の結果は、同じ Chromium を Playwright で操作・実行して記録した実際の値です。jQuery は npm の jquery 4.0.0 と 3.7.1 の配布ファイルを使いました。jQuery の利用率は W3Techs の 2026年10月19日の値です。各ページの HTML は Nu Html Checker(vnu)でエラーがないことを確かめています。登場人物は架空の人物です。
いにしえのフロントエンド入門 第14回 — jQuery / 2026.10