Old-School Front-End / 2026 ・ No.14
古法前端入门
第14讲 jQuery ── 用“$”重写同一个页面,并排比较
发布
不靠 AI,一行一行亲手写的“古法”前端入门。第14讲是 jQuery。
把第8〜10讲亲手写的申请表单 JavaScript,用 jQuery 4.0.0 原样重写一遍。
然后用同样的步骤运行两个版本,确认行为是不是真的一样。
重写之后就能看清 jQuery 的方便、原生 JavaScript 追上了多少,以及容易踩的坑。
本讲的目标
古井今天讲 jQuery。我年轻的时候,网页的界面全靠它写。
新井就是满屏美元符号的那个吧。维护老网站时见过,看不懂。
古井看不懂可不行。世上六成以上的网站还在用它。今天把第8〜10讲的申请表单,用 jQuery 原样重写一遍。
新井好好能跑的东西,还特意重写?
古井同一个东西用两种写法写,两边的好坏都看得清。就像翻译练习。
当前位置:第14讲
- 弄清 $ 返回什么 ── 不是元素本身,而是“容器”。读取只看第一个,写入作用于全部
- 重写 ── 行的添加・删除、输入检查的汇总、和服务器的通信。计算(app.js)和检查(validate.js)原样使用
- 确认行为相同 ── 用同样的步骤操作两个版本,记录页面状态并比较
- 试试坑 ── 把重写时绊倒的地方集中到一个测试页面,实际运行
使用的是 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() 这样“读取”的方法,只读第一个。想要全部就用 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) 9,423 3,927 185 form-jq.js(jQuery) 8,077 3,599 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 短了一成左右。但为此每次都要加载约 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
}
- 用 .each(function () {…}) 代替 for…of。里面的 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>', { … }) 这样把标签和属性一起传入,一行就能创建元素。像 .removeClass().removeAttr() 这样把方法连起来写,也是 jQuery 的特色(方法链)。
放文字的地方要小心。第8讲看过为什么用 textContent 而不用 innerHTML。在 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, // 4.0 可以直接传 FormData(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 里
- jQuery 4.0.0 可以把 FormData 直接传给 data。Content-Type 也由浏览器加上(multipart/form-data)。用 3.7.1 做同样的事,会以 TypeError: Illegal invocation 停下。3.x 的代码需要 processData: false 和 contentType: false 两行
- 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() 是经典写法。
确认行为是否相同
重写最怕的是以“差不多一样”收场。所以让 Playwright 用同样的步骤操作两个版本,在每个场景记录页面状态(合计、提示、错误汇总、红框、结果、按钮、行数、读屏用名字、焦点位置等),再做比较。
# 用同样的步骤操作,记录页面状态并比较(Chromium。三种语言都是) 场景 原生 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 个场景全部一致
三种语言都是,8 个场景记录的值全部一致。特别是焦点的去向(错误汇总、新加行的日期、删除后的“添加一行”)也一样,说明第8〜9讲定下的“用键盘的人不迷路”的行为,重写时也没有丢掉。
新井全都“相同”啊。那 jQuery 版更短,用这个不就好了?
古井短了一成,多了 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 写的代码也好,都能沉着地读下去。重写、记录、比较。这次做的,正是修改老代码时的基本步骤。
结 — 一页总结与下讲预告
- $() 返回的是容器。读取只看第一个,写入作用于全部。里面的元素用 [0]。找不到也不吭声。
- jQuery 的拿手好戏是 on 的委托・方法链・一行创建元素。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)。截图环境里没有 PingFang SC,中文以 Noto Sans CJK SC 显示。账号・申请都是练习用服务器的虚构数据。用同样步骤操作两个版本的记录、文件大小、测试页面(lab.html)的结果,都是用 Playwright 操作和运行同一个 Chromium 记录的实际值。jQuery 使用的是 npm 上 jquery 4.0.0 和 3.7.1 的发布文件。jQuery 的使用率是 W3Techs 2026年10月19日的数据。各页面的 HTML 已用 Nu Html Checker(vnu)确认没有错误。登场人物均为虚构。
古法前端入门 第14讲 — jQuery / 2026.10