接手一个维护了三年的后台管理系统,第一眼扫过去满屏都是$()调用,我当时脑子里冒出来的想法和很多人一样:这都什么年代了,还在用 jQuery?但等到真正沉下心去读代码、改需求、调 bug 的时候,我发现自己之前对 jQuery 的轻视有点草率。JS vs jQuery 这个老生常谈的对比,放到今天依然值得认真聊清楚——不是为了给"过气"技术唱挽歌,而是想弄明白一件事:原生 JavaScript 这些年到底补齐了哪些能力,jQuery 当年那些让一代开发者上瘾的设计,跟现代原生 API 之间还有多大差距。无论你是在维护老项目,还是打算把存量代码迁移到原生 JS,这篇整理应该都能帮上忙。
1. 一个老项目让我重新审视 jQuery 的生存逻辑
1.1 满屏$()背后的真实感受
那个后台系统大概是三年前搭的,技术栈是 jQuery + 一套自研的模板渲染。打开任何个业务页面,几乎都能看到这样的代码:
$(document).ready(function () { $('#data-list').on('click', '.edit-btn', function () { var id = $(this).data('id'); $('#edit-modal').modal('show').find('form').attr('action', '/update/' + id); }); });说实话,接手第一周我的内心是拒绝的。但你会发现一个反直觉的事实:这个系统跑得很稳。页面交互没问题,数据请求没问题,CDN 上那个压缩过的 jQuery 文件也无怨无悔地服务了三年。稳定背后是有原因的——jQuery 在 DOM 操作和事件处理上做了一层非常厚实的封装,只要开发者按它的套路来,代码基本不会因为浏览器差异而翻车。
这让我意识到,在嘲笑"老技术"之前,得先理解它当初为什么能活下来。
1.2 当年的 jQuery 到底在解决什么问题
用现在的眼光看,jQuery 的 API 设计似乎平平无奇,但如果把一个开发者扔回 2006 年,他会感激涕零。那时候浏览器生态可以说是各自为政,DOM API 数量少、标准混乱。想绑定事件,在老版本浏览器里可能得写attachEvent,其他浏览器里又得写addEventListener;想取一批元素,getElementsByClassName也不是到处都有;想发一个异步请求,XMLHttpRequest在各个浏览器身上的行为还不完全一致。
jQuery 做的最关键的一件事,是用特性检测代替浏览器检测。它不关心你用的是哪个浏览器,只关心"这个浏览器支不支持某个能力",然后把统一的接口暴露给开发者。于是你写一句$('#id').on('click', fn),它在老浏览器里也能正常绑上事件。这种"一次编写,处处运行"的价值,在跨端、跨版本的时代是致命的吸引力。
另一个被忽视的点是:jQuery 直接借用了 CSS 选择器语法来操作 DOM。前端开发者本来就懂 CSS,$('.nav .item')这种写法几乎零学习成本,比原生那套getElementById+ 遍历 childNodes 的写法直观太多了。一种语言的成功往往是因为它让某个高频操作变得极其舒服,jQuery 做到了。
1.3 今天聊这个对比还有什么价值
可能有人会说:"现在都写现代框架或者原生 JS 了,jQuery 已经退出历史舞台,讨论它没有意义。"但我认为恰恰相反,这个对比在今天有三个实际用途:
第一,大量存量项目还在用 jQuery。你随便去找一家传统企业的后台系统,八成能翻到 jQuery 的痕迹。这些系统没那么容易推倒重写,维护它们、给它们接新需求,就必须懂 jQuery 的写法,也得知道怎么用原生 JS 和它协作。
第二,jQuery 的设计思想在现代前端里并没有消失。你仔细想想,隐式迭代、批量操作、事件委托优化、链式调用——这些概念在今天的现代框架和原生 API 里依然存在,只是换了个马甲。理解了 jQuery 为什么这样设计,再去看现代这些工具,会顺畅很多。
第三,面试和团队协作里这个问题依然高频。当候选人能把"jQuery 和原生 JS 的核心差异"讲清楚,说明他不只是会用 API,而是真正理解了 DOM 操作模型的演进。
2. 选择器与 DOM 集合操作:从 API 设计看代差
2.1$()与querySelectorAll()返回对象的本质差异
表面上看,$('.item')和document.querySelectorAll('.item')都是在找元素,但两者返回的对象有本质区别。querySelectorAll返回的是一个NodeList,它本质上是静态元素的集合,虽然有forEach可以遍历,但它不是数组,也没有一堆现成的 DOM 操作方法挂在身上。jQuery 返回的是一个jQuery对象——一个类数组结构,里面封装了几十个操作 DOM 的方法。
这个差异直接决定了后续代码的写法:
// 原生 JS const items = document.querySelectorAll('.item'); items.forEach(function (item) { item.classList.add('active'); }); // jQuery $('.item').addClass('active');你会发现原生 JS 的写法是"先把集合拿出来,然后手动遍历,再逐个操作元素";jQuery 的写法是"声明我要给哪些元素加类,剩下的交给库"。后者少了一整层的循环思维负担。
2.2 隐式迭代才是真正的生产力优势
jQuery 最核心的体验,就是隐式迭代。什么叫隐式迭代?就是你调用$('.btn').css('color', 'red')时,jQuery 会在内部自动帮你把这个操作应用到集合里的每一个元素上。你不需要写循环,库替你循环。
这带来的一个显著好处是:代码的意图表达更贴近自然语言。你关注的是"做什么"而不是"怎么遍历"。"给所有.btn设置红色"这句话,翻译成代码就是$('.btn').css('color', 'red'),几乎是逐字对照。原生的写法则要求你先把"遍历"动作写出来,意图被过程打断了一截。
但隐式迭代也有它的反面。如果你对集合做了多次连续操作,jQuery 每次都会重新遍历整批元素,不会因为你的链式调用而合并成"只遍历一次":
// 看起来优雅,但内部遍历了三次 $('.item').addClass('active').css('color', 'red').attr('data-state', 'on');这段代码在语义上没问题,性能上也未必是瓶颈,但理解这一点很重要:链式调用不等于批量优化,它只是语法上的连续调用。在这个维度上,原生写法有时反而更"显式"——你知道自己在循环里做三件事,只需要一次遍历。
2.3 选择器引擎的扩展能力:Sizzle 走了很前面
jQuery 自带一个选择器引擎叫 Sizzle,它的作用不只是"解析 CSS 选择器",还扩展了一批 CSS 标准里没有的语法。比如:first、:last、:eq(2)、:visible、:hidden这种基于索引或状态的筛选。
相比之下,querySelectorAll只能识别标准的 CSS 选择器。你想在原生 JS 里实现"取第二个.item",要么写document.querySelectorAll('.item')[2],要么用:nth-child()这类标准伪类。想实现"找出页面上所有可见元素"?原生 API 就没有这个能力,你必须自己写一个getComputedStyle加过滤的筛选函数。
下面这张表能直观地看出边界:
| 能力 | querySelectorAll | jQuery 选择器 |
|---|---|---|
| 标准 CSS 选择器 | 支持 | 支持 |
:first/:last/:eq()索引伪类 | 不支持 | 支持 |
:visible/:hidden状态筛选 | 不支持 | 支持 |
| 内部缓存与二次过滤 | 不支持 | 支持(.filter()) |
| 自定义选择器 | 不支持,需手动filter | 原生支持 |
这也是我在迁移老项目时经常踩到的坑:很多 jQuery 代码里用了$('li:visible')这种筛选逻辑,原生 JS 里没有直接等价物,必须改成"先取所有 li,再用getComputedStyle判断display是否可见"。功能能实现,但代码量一下子多了不少。
2.4 链式调用:语法糖还是设计哲学?
jQuery 的链式调用是它的招牌特性,实现原理其实特别简单:大多数方法在操作完成后返回this。所以你可以一个接一个地调用方法,写出一段读起来像流水账一样的代码:
$('#card') .addClass('highlight') .fadeIn() .find('.title') .text('更新后的标题');这种写法的好处是代码简短、上下文一致,也容易读。坏处是调试困难:如果中间某一步操作出了错,你很难在链式调用里定位到底是哪一步出了问题,控制台报错往往指着整条链的最后一句话。我在实际项目中见过好几条十几层的链式调用,读起来像天书,改起来像拆炸弹。
我的建议是:超过三个方法的链就考虑拆成变量缓存。比如把$('.list')缓存成const $list = $('.list');,每一步操作单独写成一行。这对维护期的项目尤其重要——你永远不知道三个月后接手代码的那个人能不能承受一串 200 字符的方法链。
3. 事件系统:on()与addEventListener()的深层差异
3.1 表面相同,内在不同
表面上看,jQuery 的.on('click', fn)和原生addEventListener('click', fn)都是绑定事件,用法差异不大。但 jQuery 的背后做了很多隐藏工作。
第一,jQuery 在内部对事件对象做了标准化。老版本浏览器里event对象有各种差异——target还是srcElement、stopPropagation存在与否、preventDefault是否可靠。用 jQuery 绑事件,你统一用event.target、event.stopPropagation()就行,库里已经替你处理好了。原生addEventListener在现代浏览器里同样标准,但如果你要兼容非常老的浏览器,这套统一封装的价值就体现出来了。
第二,jQuery 的on()支持直接在参数里传入选择器做事件委托,原生addEventListener需要自己判断事件目标。这个差异在动态 DOM 场景下会非常明显。
3.2 事件命名空间:一个被低估的能力
jQuery 的on()支持事件命名空间,这是原生 API 始终没有提供的功能:
// 给同一个事件绑两套逻辑 $('#btn').on('click.menu', openMenu); $('#btn').on('click.tab', switchTab); // 只解绑"菜单"相关的逻辑,保留"选项卡"逻辑 $('#btn').off('click.menu');原生removeEventListener只能通过传入绑定时保存的函数引用去精确移除,而且匿名函数一旦绑上去,基本就"黏死"了——你想移除它,手里却没有引用。更麻烦的是,如果你想"把某个元素上的所有 click 监听都清掉"再重新绑一批,原生只能寄存一个 handler 数组挨个移除,非常笨重。
实际开发里,这个差异最常在"弹窗关闭时清理事件"的场景触发。jQuery 里一句$('#modal').off('click.modal')就能安全清理,原生 JS 里你可能得给每个函数单独命名、登记,真的一点都不省心。
3.3 事件委托:一行 vs 一打
事件委托是 jQuery 用得最爽的场景之一。假设有一个动态生成列表,列表项点击时需要触发逻辑。jQuery 的写法十分干净:
// jQuery:事件绑在父容器上,通过选择器过滤子元素 $('#list').on('click', '.item', function () { console.log('clicked', this); });原生的等价实现要麻烦不少:
document.getElementById('list').addEventListener('click', function (e) { const el = e.target.closest('.item'); if (el && this.contains(el)) { console.log('clicked', el); } });用closest()找最近的.item,再用contains()确认它确实在容器内——这套逻辑在 jQuery 内部被封装好了,你还不需要处理e.target是文本节点或者子元素之类的边界情况。这也是为什么很多从 jQuery 转原生的人会觉得"代码变长了一倍"。
3.4 this 指向的坑位迁移
jQuery 绑定的回调里,this指向触发事件的 DOM 元素:
$('.btn').on('click', function () { $(this).text('已点击'); // this 是按钮 DOM 元素 });原生addEventListener的回调里this也指向当前元素,这一点是一致的。但在你实际写代码的时候,很可能因为用了箭头函数而踩坑——箭头函数没有自己的this,它会向上穿透到外层作用域:
// 踩坑写法 element.addEventListener('click', () => { // 此处的 this 不指向 element this.classList.add('active'); // 一旦这里出错,很难想起来是 this 的问题 });我在同事的代码里见到过几次"箭头函数 in event listener"引发的诡异 bug。排查方法其实很简单:把箭头函数换回普通function,先验证this指向,再继续排查其他业务逻辑。
4. Ajax 与异步流程:从回调到 Promise 的演进
4.1$.ajax()一行代码的背后
jQuery 的 Ajax 封装,在一个老开发者眼里简直是"良心之作"。它帮你处理了太多原生XMLHttpRequest的琐碎细节:
// jQuery 时代 $.ajax({ url: '/api/users', method: 'GET', dataType: 'json', success: function (data) { console.log(data); }, error: function (xhr, status, error) { console.error(error); }, });这里面的dataType: 'json'非常关键——jQuery 会自动把响应体解析成 JavaScript 对象,你不用自己JSON.parse还要处理异常。原生XMLHttpRequest拿到的是一个字符串,你还得手动解析、手动处理 HTTP 错误状态,代码量是翻倍的。
另外,$.getJSON、$.post这些语法糖进一步压缩了常见场景的代码量。对于当年那些不熟悉异步细节的开发者,jQuery 把"发请求拿数据"这件事变成了三个参数的事,这个简化的价值怎么强调都不过分。
4.2fetch来了之后,差异并没有消失
用fetch写原生异步请求,很多新手会天真地以为只是"换了个函数名"。但fetch和$.ajax之间有几个关键差异:
| 能力 | $.ajax | fetch |
|---|---|---|
| JSON 自动解析 | 有 | 没有,需要手动response.json() |
| HTTP 错误状态处理 | 自动进入error回调 | 不 reject,需要手动判断response.ok |
| 超时控制 | 有timeout参数 | 没有,要配合 AbortController |
| 携带 Cookie | 同域请求默认带上 | 默认不带,需要credentials: 'include' |
| 中断请求 | 有xhr.abort() | 需要 AbortController 手动实现 |
最坑的一点是fetch对 HTTP 404/500 的处理方式。fetch在这些状态码下依然会 resolve,而不是 reject。如果你不加response.ok检查,404 响应会被当成成功处理。
// 原生 fetch 的正确姿势 fetch('/api/data') .then((response) => { if (!response.ok) { throw new Error('HTTP ' + response.status); } return response.json(); }) .catch((err) => console.error(err));这些差异意味着:把 jQuery 的 Ajax 改成 fetch,不是"换个函数名",而是要对整个状态处理逻辑重写。老项目里几十处$.ajax调用,每一处的成功/失败分支可能都依赖 jQuery 的处理语义,批量迁移的风险是不低的。
4.3 改造老请求的真实经验
我自己的做法是"中间层替换法":在源码级别保留一个全局的request函数,签名与$.ajax保持兼容,内部用fetch实现,再把业务代码里的$.ajax替换成request。这样做的收益是业务代码几乎不动,所有差异被收敛到这一个函数里。
// 替代函数一例 function request(options) { const { url, method = 'GET', data, dataType = 'json' } = options; return fetch(url, { method, body: data ? JSON.stringify(data) : undefined, }).then((res) => { if (!res.ok) throw new Error('HTTP ' + res.status); return dataType === 'json' ? res.json() : res.text(); }); }当然这只是骨架,真实项目还得加超时、错误码归一、loading 状态等。但这种"接口兼容"的改造方式,比逐行重写几十处$.ajax稳妥得多。我先挑一个低频接口做验证,跑通一轮再批量铺开,整个迁移周期控制在两个迭代内,回归压力小很多。
5. 性能实测:哪些差距真实存在,哪些只是谈资
5.1 选择器性能的直觉误区
很多人一谈起"jQuery 为什么慢",张口就是选择器性能差。这个说法得分场景。现代浏览器的querySelectorAll本身是 C++ 实现的选择引擎,jQuery 3.x 在支持querySelectorAll的环境下也会转调原生实现——换句话说,标准选择器的性能差距小到可以忽略。真正慢的场景是:visible、:eq()这类自定义扩展伪类,它们需要 jQuery 自己解析并逐一过滤,没法用原生的选择引擎加速。
我曾经在一个包含近万个 DOM 节点的测试页面上跑了简单测试:$('#id')和document.getElementById()对比,原生确实快,但对用户来说那几毫秒的差异毫无感知。真实的性能瓶颈永远不在"选择器本身快 5 毫秒",而在"DOM 操作触发了多少次重排重绘"。
5.2 真正的性能陷阱:重复查找与重排重绘
真正值得警惕的是这样一类代码——在循环里反复调用选择器,而不做缓存:
// 反面教材 for (let i = 0; i < 100; i++) { $('.item-' + i).css('left', i * 10 + 'px'); }每次$()都是一次新的查找,加上每次.css()都可能触发一次样式重计算。这里的问题不在于 jQuery,而在于"没有缓存 + 反复操作 DOM"这个习惯。原生写法同样会踩这个坑,你得手动把querySelectorAll的结果缓存到一个变量里。
jQuery 的链式操作还有一层隐藏成本:如果每个环节都往 DOM 上写一次,重排次数会累积。真正的优化办法不是改用原生 API,而是合并操作——使用 class 一次切换,或者离线处理完再一次性把 HTML 片段塞入容器。你从 jQuery 换到原生时,这个优化逻辑一点都不会变。
5.3 体积、加载与初始化开销
jQuery 3.7 的压缩版 gzip 后在 30KB 左右。放在今天的项目里,这个体积谈不上重,但问题在于:你只用到它十分之一的功能。如果项目里只用选择器和事件绑定,为了这些用掉 30KB 的网络传输和解析成本,移动端场景里就有点不划算。
但反过来说,在老项目里因为这 30KB 就大动干戈搞迁移,性价比更低。很多后台管理系统的真实瓶颈在数据查询、图片加载、渲染结构上,jQuery 那 30KB 根本不排不上号。我在优化某页面时用Performance面板看了加载瀑布图,jQuery 的加载时间只占整条瀑布的很小一块,节省它的收益远不如压缩一张大图来得明显。性能优化不 chat engineer 至上,得先看数据说话。
6. 从 jQuery 迁移到原生 JS 的渐进路线
6.1 高频方法替代清单
如果你决定动手迁移,下面这份高频方法对照表可以先收藏起来,平时写代码时对照着看:
| 功能 | jQuery 写法 | 原生 JS 写法 |
|---|---|---|
| 选择单个元素 | $('#id') | document.getElementById('id') |
| 选择多个元素 | $('.item') | document.querySelectorAll('.item') |
| 遍历集合 | .each(function(i, el){}) | forEach((el, i) => {}) |
| 添加 class | .addClass('x') | element.classList.add('x') |
| 删除 class | .removeClass('x') | element.classList.remove('x') |
| 绑定事件 | .on('click', fn) | element.addEventListener('click', fn) |
| 事件委托 | .on('click', '.item', fn) | 手动closest()判断 |
| 设置属性 | .attr('disabled', true) | element.setAttribute('disabled', '')或直接.disabled = true |
| 修改文本 | .text('新内容') | element.textContent = '新内容' |
| 隐藏元素 | .hide() | element.style.display = 'none' |
| 发起请求 | $.get(url) | fetch(url).then(r => r.json()) |
这份清单看起来很简单,但真正迁移时你会碰到很多细枝末节的语义差异。比如 jQuery 的.hide()设置的是display: none,但原来的display值如果是flex,show 的时候要恢复成flex而不是简单的block;.text()和.textContent基本一致,但 jQuery 的.text()在读取时会把所有空格的细节处理掉。这些都是文档里不会提醒你的隐形坑。
6.2 分模块迁移的节奏把握
我不建议一次性重写全部页面,风险太大。我的建议是按"成本从低到高"的顺序推进:
- 先替换
$(document).ready。这个最简单,就是DOMContentLoaded事件,纯生命周期替换,改完不会破坏交互。 - 再替换静态页面的 DOM 读写。只涉及简单的选择器、class 切换、文本修改,用
querySelector+classList就能搞定,回归测试也容易。 - 接着替换事件委托逻辑。这一步要小心
this指向和closest()边界,但逻辑本身比较独立。 - 最后才动 Ajax。因为涉及请求状态处理、JSON 解析、错误分支,改动面最大,要留足够的时间专门测试。
每完成一类,我都在浏览器里手动回归一轮关键路径,而不是全部改完再统一测试。这看起来慢,实际反而省时间——定位问题时你不用同时怀疑"是选择器坏了还是事件坏了还是请求坏了"。
6.3 迁移前先想明白三件事
在开工之前,有四个问题必须想清楚:
第一,存量用户是否还包含老版本浏览器?如果项目要兼容老旧浏览器环境,迁移到原生 JS 的成本会急剧上升,因为很多现代 API 需要 polyfill 才能工作,而 jQuery 自己就已经把兼容层处理好了。这种情况下,保留 jQuery 反而是更理性的选择。
第二,团队的熟悉度。如果团队里基本都是写 jQuery 的老手,突然全部改原生 JS,开发效率会短期下降。我一般建议"新代码用原生,老代码逐步改",给团队一个缓冲期。
第三,项目的预期寿命。如果这个项目半年后就要被新系统替换,那迁移就是纯浪费成本;如果它还要活五年以上,那早点动手,债务越早还越轻松。
第四,兼容方案是否可控。对于个别旧浏览器,用少量 polyfill 是否能覆盖?如果只有一小撮特例,完全可以接受 polyfill 的成本,让迁移继续推进。
这些问题想清楚再动手,比急着炫技用原生 API 去替换一切要重要得多。
最后再分享一点个人的体会。那次维护老项目的经历,让我最终没有把 jQuery 从代码库里彻底删除。它还在那里,作为全局工具函数被老页面继续使用,而新页面我已经在用原生 JS 配合现代模块写法落地。不是我不愿意改,而是意识到"迁移"本身应该有目的——如果它没有带来可量化的收益,只是满足了一种"要用新技术"的冲动,那这个动作就是纯成本。
真正有价值的,是借由JS vs jQuery这组对比,把浏览器原生能力完整地摸了一遍。当你理解了 jQuery 当初帮你封装了什么、原生 API 现在又补齐了什么,再去看任何新框架、新工具,都会有一种"这层封装我认识"的踏实感。写代码这件事,知道某个 API 怎么用只是底线,知道"为什么会有这个 API"才算真的入了门。