Old-School Front-End / 2026 ・ No.14

古法前端入门

第14讲 jQuery ── 用“$”重写同一个页面,并排比较

发布

左右并排的两个申请表单。左边是原生 JavaScript 的 form.html,右边是 jQuery 4.0.0 的 form-jq.html。两边都是事由纽约出差(10月13日〜16日)、明细 3 行(成田特快 3070 日元、酒店 1 晚 189.50 美元、会议参加费 100 美元)、合计 46,851 日元,显示着同样的需要附收据的提示和 1 美元 = 151.23 日元的换算说明
本讲的目标。左边是第8〜10讲的原生 JavaScript,右边是用 jQuery 重写的版本。外观和行为都一样(笔者的演示页面,经由练习用服务器,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月19日拍摄,页面时钟固定在 2026年10月17日 15:00。账号是仅限本演示的虚构账号。浏览器边框是为了说明而补画的)

不靠 AI,一行一行亲手写的“古法”前端入门。第14讲是 jQuery。
把第8〜10讲亲手写的申请表单 JavaScript,用 jQuery 4.0.0 原样重写一遍。
然后用同样的步骤运行两个版本,确认行为是不是真的一样。
重写之后就能看清 jQuery 的方便、原生 JavaScript 追上了多少,以及容易踩的坑。

一

本讲的目标

古井今天讲 jQuery。我年轻的时候,网页的界面全靠它写。

新井就是满屏美元符号的那个吧。维护老网站时见过,看不懂。

古井看不懂可不行。世上六成以上的网站还在用它。今天把第8〜10讲的申请表单,用 jQuery 原样重写一遍。

新井好好能跑的东西,还特意重写?

古井同一个东西用两种写法写,两边的好坏都看得清。就像翻译练习。

当前位置:第14讲

1 准备 2 HTML 骨架 3 表单 4 CSS 基础 5 布局 6 Bootstrap 7 JS 基础 8 DOM 与事件 9 输入校验 10 通信 11 登录 12 审批流程 13 统计与打印 14 jQuery 15 收尾 当前位置 准备 HTML CSS JavaScript 实战 收尾
  • 弄清 $ 返回什么 ── 不是元素本身,而是“容器”。读取只看第一个,写入作用于全部
  • 重写 ── 行的添加・删除、输入检查的汇总、和服务器的通信。计算(app.js)和检查(validate.js)原样使用
  • 确认行为相同 ── 用同样的步骤操作两个版本,记录页面状态并比较
  • 试试坑 ── 把重写时绊倒的地方集中到一个测试页面,实际运行

使用的是 jQuery 4.0.0。2026年1月17日发布,是时隔约 10 年的大版本。为了比较,也会试试上一个版本 3.7.1。

二

$ 返回的是什么

jQuery 的核心是一个名叫 $ 的函数。传入 CSS 选择器,它会把找到的元素放进容器里返回。这个容器带着许多批量处理元素的方法。

$() 返回的是装着元素的容器

$('[name="amount"]') jQuery 的容器 [0] <input value="3070"> [1] <input value="189.50"> [2] <input value="100"> length: 3 .val() → "3070" 只读第一个 .addClass('x') → 3 个都 写的时候对全部生效 [0] → <input> 里面的普通元素 .textContent → undefined 容器不是元素 $('#nothing') 空容器(length: 0) .text('x') 不报错,也不做事 读取的方法只看第一个,写入的方法作用于全部(隐式循环) 找不到也不吭声。打错了很难发现
# 在 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 按“是否成功”分

fetch $.ajax 200 / 201 成功 then then 400 输入有问题 then catch 401 / 403 登录・权限 then catch 500 服务器故障 then catch 通信失败 断线・超时 catch catch fetch 按“有没有回复”分,$.ajax 按“是否成功”分 重写时,要把 400、401 的处理从 then 那边移到 catch 那边
# 在 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 用同样的步骤操作两个版本,在每个场景记录页面状态(合计、提示、错误汇总、红框、结果、按钮、行数、读屏用名字、焦点位置等),再做比较。

jQuery 版的 3 个场景。1 什么都不写就按“提交申请”,上方汇总显示“请检查输入内容(3 处)”和 3 个链接,事由栏是红框。2 发送申请后,绿色横条显示“申请已受理。申请编号是 EXP-2026-0013。”。3 登录过期时按保存草稿,红色横条显示“登录已过期。请重新登录(输入的内容还留在这个页面上)。”
jQuery 版的①输入检查,②发送,③登录过期。提示文字和焦点都和原生 JavaScript 版相同(笔者的演示页面,经由练习用服务器,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月19日拍摄,页面时钟固定在 2026年10月17日 15:00。账号是仅限本演示的虚构账号。浏览器边框是为了说明而补画的)
# 用同样的步骤操作,记录页面状态并比较(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 版来确认。

“试试 jQuery 的坑”页面。20 行的表格列出试了什么和结果。向 $('#total') 问 textContent 得到 undefined,val() 只有第一个 “3070”,对不存在的元素 $('#nothing').text('x') 不报错,$.ajax 收到 400 是失败(进入 catch),fetch 收到 400 是成功(进入 then),jQuery 事件的 submitter 是 undefined,$.trim 在 4.0.0 是 undefined,焦点事件的顺序在 3.7.1 和 4.0.0 不同,放进 .html() 的脚本被执行了,等等
打开页面时把 jQuery 的坑实际运行一遍、做成表格的练习页面(笔者的演示页面,经由练习用服务器,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月19日拍摄,页面时钟固定在 2026年10月17日 15:00。账号是仅限本演示的虚构账号。浏览器边框是为了说明而补画的)
# 在 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 时事件的顺序

jQuery 3.7.1 A focusout A blur B focusin B focus jQuery 4.0.0 A blur A focusout B focus B focusin 在 Chromium 中实测。4.0.0 改成了浏览器本来的顺序 依赖顺序的代码,从 3.x 升级后行为可能会变

此外,还结束了对 IE10 及以前和旧版 Edge 的支持,源码改成了 ES 模块,并能自动处理 FormData。升级时,参照官方的迁移指南,再用会对旧写法发出警告的 jQuery Migrate,是最快的路。

九

现在该怎样对待 jQuery

重写一遍就会发现,jQuery 的方法几乎都有原生 JavaScript 的对应。

jQuery 和原生 JavaScript 的对应

jQuery 原生 JavaScript $('#total') document.querySelector('#total') $tbody.on('click', 'sel', fn) addEventListener + closest('sel') $el.addClass('x') el.classList.add('x') $el.text(s) el.textContent = s $el.attr(k, v) / .prop(k, v) el.setAttribute(k, v) / el[k] = v $('<a>', { text: s }) document.createElement('a') $rows.each(function () {…}) for (const row of rows) {…} await $.ajax({…}) await fetch(url, {…}) 现在每一项,原生 JavaScript 都有同样作用的东西(第8〜10讲用过的) jQuery 诞生的 2006 年,右边的很多东西还不存在

jQuery 诞生的 2006 年,各浏览器操作 DOM 的写法五花八门,也没有 querySelector、classList、fetch。jQuery 是填平这些差异、不可或缺的工具。如今右边成了标准,在哪个浏览器里都一样运行。也可以说,jQuery 的思路(用选择器查找、批量处理)被吸收进了浏览器的标准。

尽管如此,它还在很多网站上运行

使用 jQuery 的网站 64.5% 其中的版本 3.x 78.8% 1.x 17.0% ・ 2.x 4.2% 占所有网站的比例 4.x 还没有出现在表里 与其说在新网站上,不如说留在一直运行的老网站上 还停留在 1.x 的网站,也还有将近两成 W3Techs,2026年10月19日的数据
  • 新做的话 ── 原生 JavaScript 就够了。这个连载的申请表单,不用 jQuery 也全写出来了
  • 已经用 jQuery 在运行的话 ── 不必勉强改写。修改时需要能读懂。要升级,就从最新的 3.x 升到 4.0,边看迁移指南边做
  • 无论哪种 ── 里面发生了什么,要用原生 DOM 的语言来理解。jQuery 的坑,大多来自“容器和元素的区别”和“不吭声什么也不做”
POINT

jQuery 是“把原生 JavaScript 写短的工具”。只要明白工具替你做了什么,jQuery 的代码也好,AI 写的代码也好,都能沉着地读下去。重写、记录、比较。这次做的,正是修改老代码时的基本步骤。

结 — 一页总结与下讲预告

  1. $() 返回的是容器。读取只看第一个,写入作用于全部。里面的元素用 [0]。找不到也不吭声。
  2. jQuery 的拿手好戏是 on 的委托・方法链・一行创建元素。each 里用 function 才有 this。
  3. 放文字用 .text()。.html() 和 innerHTML 一样,脚本会执行。
  4. $.ajax 遇到 400、401 进入 catch。和 fetch 相反。4.0 可以直接传 FormData(3.x 要加 2 行)。
  5. 用同样的步骤运行并记录,确认重写后行为相同。这次 8 个场景 × 14 项 × 3 种语言全部一致。
  6. 新做就用原生 JavaScript,正在运行的 jQuery 要能读懂。

新井不知怎么的,老代码不再可怕了。

古井下一讲终于是最后一讲,收尾。质量・安全・发布。再稍微看一眼 React、Vue 这类“框架”的入口。把用古法工具做出来的东西,送进当今的世界。

新井最后一讲……。庆功宴能报销吗?

参考

页面截图是笔者制作的演示页面,经由练习用服务器(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

← 返回文章列表