这一篇已经是系列的第六部分了。前面几篇我们把选择器、DOM操作、事件绑定、动画这些基础过了一遍,有读者留言说光看基础不过瘾,想看点能直接用到业务里的东西。这篇文章我就换个思路,专门挑了一批在真实项目中高频出现的场景来展开——数组操作、动态创建标签、事件冒泡的坑、表单校验,还有二维码合成这种稍微综合一点的需求。每个场景我都会给出可直接复用的写法,顺手把背后的原理和坑也讲清楚。
1. 数组操作:判断、添加、去重,两行代码解决的事别绕远路
先看这个出现频率极高的场景:var arr = [],然后要往里添加数据,添加之前还要判断数组里有没有这条数据。很多新手写出来的代码是这样的:
var arr = []; if (arr.length == 0) { arr.push('a'); } // 添加之前判断是否存在 var flag = false; for (var i = 0; i < arr.length; i++) { if (arr[i] == 'a') { flag = true; } } if (!flag) { arr.push('a'); }功能没问题,但在jQuery的环境里,这代码显得太啰嗦了。$.inArray()这个方法就是干这个的,它返回的是元素在数组中的索引,找不到就返回 -1。
var arr = ['a', 'b', 'c']; if ($.inArray('b', arr) === -1) { arr.push('b'); } else { console.log('数组里已经有了'); }这里有个关键点,判断一定要用=== -1,因为它返回的是索引位置0也是合法的,你要是用if (!$.inArray(...))这种写法,索引为 0 的时候会被当成 false,直接就是你写了个寂寞。我见过不止一个人在这里踩坑。
再进一步,如果这个数组不是简单的字符串,而是一组对象,$.inArray就不太好使了,因为它是严格比较,对象之间没法直接判断相等。这种场景我一般配合$.grep或者$.each来查:
var users = [ { id: 1, name: '张三' }, { id: 2, name: '李四' } ]; var exists = $.grep(users, function(item) { return item.name === '张三'; }).length > 0; if (!exists) { users.push({ id: 3, name: '张三' }); }$.grep的作用是过滤数组,传入一个回调函数,返回 true 的元素会被保留。过滤完看长度,就知道有没有匹配项了。这种方法在写购物车、标签选择器这类功能时特别常用。
数组去重是另一个高频需求。jQuery 本身没有专门的去重方法,但配合原生的$.unique()可以做一件事——注意,$.unique()只对 DOM 元素数组有效,对普通字符串数组不起作用。普通数组去重我喜欢直接用 ES6 的Set,这跟 jQuery 不冲突,混着用很舒服:
var arr = ['a', 'b', 'a', 'c', 'b']; arr = $.unique(arr); // 这里对字符串数组没用! // 正确做法 var uniqueArr = $.makeArray(new Set(arr));$.makeArray能把类数组对象转成真正的数组,配合Set去重,两行代码搞定。说到底,jQuery 不是万能的,它提供的是操作 DOM 和简化事件处理的便利,类似去重这种任务,原生 ES6 方法往往更直接。
2. each遍历:你以为会用,其实总在this上栽跟头
$.each可能是 jQuery 里被用得最频繁的方法之一了。网上关于它的热词里还有jquery foreach,说明很多人其实是从其他语言转过来的,上来就找类似 foreach 的东西。
var data = ['a', 'b', 'c']; $.each(data, function(index, item) { console.log(index + ' = ' + item); });这是基本用法。但我发现实际项目中,最容易出问题的不是$.each本身,而是回调函数里的this。在$.each中,this指向当前遍历的元素,但注意,对于数组来说this就是当前值,这个值是原始值类型,无法赋值。你可以试试看:
$.each(data, function(index, item) { this = 'x'; // 报错,this 不能被赋值 });所以在$.each里要修改数组元素的值,直接操作item是没用的,你需要通过索引来修改原数组:
$.each(data, function(index, item) { if (item === 'a') { data[index] = 'aa'; } });另一个容易踩的坑是遍历对象。当你$.each一个对象时,回调函数里的this指向的是对象的属性值。如果你在回调里调用了一个 jQuery 方法,很容易因为this上下文不对报错。我的习惯是在回调函数开头就把需要的值缓存下来,避免this带来的迷之问题:
var config = { url: '/api', type: 'POST', timeout: 3000 }; $.each(config, function(key, value) { // 这里的 this === value,但不要依赖它 console.log(key + ': ' + value); });再说说跳出循环。JavaScript 原生 forEach 里,break是没法用的,很多人习惯了之后就以为$.each也一样。其实$.each支持 return false 来终止遍历,这算是 jQuery 的一个贴心设计:
$.each([1, 2, 3, 4, 5], function(index, item) { if (item > 3) { return false; // 跳出循环 } console.log(item); // 只会打印 1 2 3 });注意是return false,不是return。你只写一个return的话,效果等同于 continue,只是跳过本次循环。return true也是跳过本次循环。这三个的区别值得记在小本本上,写循环逻辑的时候能省不少事。
遍历 DOM 元素集合时,还有一个常见误区。$('li').each(function() {...})这个用法大家都会,但回调里$(this)和$(this).eq(index)是等效的,没必要多此一举再构建一次 jQuery 对象,直接用$(this)就行。性能上虽然差距不大,但代码看起来简洁很多。
3. 动态创建标签与内容判断:拼接字符串前先想想 XSS
热词里有两个问题放在一起看很有意思:jquery 创建一个标签和jquery 判断内容中是否有换行符。这俩其实可以发生在同一个场景里——比如你从后端拿了一段文本,要动态创建标签展示到页面上,先判断文本里有没有换行,有换行的就把\n替换成<br>。
先看创建标签的基本姿势:
var $newDiv = $('<div>', { id: 'myDiv', class: 'box', text: '这是一个新创建的标签' }); $('body').append($newDiv);很多新手用$('<div>')创建标签时会把属性拼进字符串里:
$('<div id="myDiv" class="box"></div>').appendTo('body');两种写法都能用,但我更推荐第一种。原因很简单:第二种写法要是拼接的属性值里有引号,很容易把整个字符串搞崩,或者更危险的,直接给了 XSS 可乘之机。热词里专门有jquery xss,这不是空穴来风。jQuery 早期版本的html()方法对含script标签的字符串处理存在隐患,虽然在 3.5.0 之后的版本修复了大部分问题,但你自己写代码时也该养成好习惯——能用text()绝不用html()。
举个例子,如果后端返回的数据里有这样的内容:
var userContent = '<img src=x onerror=alert(1)>'; $('#content').append(userContent); // 危险!后端返回的数据你就把人家当纯文本处理,用text()方法写入,浏览器就老老实实把它当文本展示,不会执行里面的脚本:
var userContent = '<img src=x onerror=alert(1)>'; $('<div>').text(userContent).appendTo('#content'); // 页面上显示的是纯文本,不会被解析成标签:contains选择器可以用来判断某个元素是否包含指定的文本,但注意它匹配的是子字符串,不是完整匹配,用的时候心里有数就行。
再来看换行符的判断。判断一个字符串里有没有换行,用正则最方便:
var content = '第一行\n第二行'; if (/\r|\n/.test(content)) { console.log('包含换行符'); }注意\r也不能漏,有些老系统的换行符是\r\n或者单独的\r,只匹配\n可能漏掉。
把换行转成<br>展示,写法如下:
var content = $('#content').val(); var htmlContent = content.replace(/\r\n|\r|\n/g, '<br>'); $('#preview').html(htmlContent);这里有个细节,如果你用$('#content').text()而不是.val()拿内容,取到的是元素的纯文本内容,换行符本身是保留的,可以正常处理。但如果你通过别的方式拿到了 HTML 内容,里面可能已经包含了<br>之类的转义字符,正则匹配的时候就要注意了。项目里如果展示的是用户输入的文本,建议先text()获取再替换,这样最安全。
4. 事件冒泡与“点击某元素消失,点击元素内按钮不消失”
这是很典型的场景:一个弹窗或者下拉菜单,你点击它之外的区域时它要消失,但点击它内部的按钮时,它不应该消失。实现这个功能的核心就在于理解事件冒泡和阻止冒泡。
先看一段常见的错误写法:
$(document).click(function() { $('#popup').hide(); }); $('#popup').click(function() { // 想阻止冒泡,但没写对 });如果这个#popup的点击事件里没有阻止冒泡,那么点击弹窗时,事件会一路冒泡到 document 上,document 的 click 处理器就把弹窗隐藏了。表现出来的现象就是:你刚点开弹窗,想点里面的按钮,弹窗就消失了,按钮根本点不到。
正确的做法是在弹窗的 click 事件里调用event.stopPropagation():
$(document).click(function() { $('#popup').hide(); }); $('#popup').click(function(event) { event.stopPropagation(); });这样点击弹窗内部时,事件在弹窗这一层就被拦住了,不会冒泡到 document 上,弹窗自然就不会被隐藏。
但这里要注意另一个更隐蔽的坑。如果弹窗内部的某一个按钮,它的业务逻辑就是点击后要关闭弹窗,这时候直接绑定click事件处理逻辑就行,不需要额外调用stopPropagation(),因为本来就要消失嘛。真正需要结合用的是这样的场景——弹窗里有个输入框,点输入框时弹窗不消失,点输入框外的弹窗区域时可以消失:
$('#popup').click(function(event) { if ($(event.target).is('#inputArea')) { event.stopPropagation(); } });用event.target判断事件来源是个很实用的技巧。event.target指向实际触发事件的元素,而this指向绑定事件的元素。一个#popup的点击事件里,this永远是#popup,但event.target可能是它内部的任意子元素。搞清这两个的区别,很多事件的疑难杂症都能迎刃而解。
还有一种主动出击的写法,不用stopPropagation(),而是在 document 的 click 处理里判断点击的目标是否属于某个区域:
$(document).click(function(event) { var $target = $(event.target); if ($target.closest('#popup').length === 0) { $('#popup').hide(); } });closest()方法会从当前元素向上查找,看它自己或其父级元素里有没有匹配选择器的。如果点击的目标在#popup内部(包括它自己),closest('#popup')就能找到,length 大于 0;如果点在外部,length 为 0,就执行隐藏操作。这种方法有时候比stopPropagation()更好用,因为它不干预事件本身的传播,适合处理多个弹窗共存的情况。
5. 表单校验与二维码合成:两个“真·业务场景”解析
热词里还有两个跟业务结合比较紧的需求,一个是form jqvalidate,一个是把二维码移动到底图上然后调整大小保存成图片。这两块放到一起讲,因为它们都是典型的“拿 jQuery 接业务”的活。
5.1 jquery.validate 快速接入
表单校验是前端必备技能,jQuery Validate 插件是标配。最基础的用法是这样的:
<form id="myForm"> <input type="text" name="username" id="username"> <input type="password" name="password" id="password"> <button type="submit">提交</button> </form>$('#myForm').validate({ rules: { username: { required: true, minlength: 2 }, password: { required: true, minlength: 6 } }, messages: { username: { required: '请输入用户名', minlength: '用户名至少2个字符' }, password: { required: '请输入密码', minlength: '密码至少6位' } }, submitHandler: function(form) { // 校验通过后执行,在这里用 AJAX 提交 $.ajax({ url: '/api/login', method: 'POST', data: $(form).serialize(), success: function(res) { console.log(res); } }); return false; // 阻止表单默认提交 } });这个插件有几个细节值得注意。rules里的name属性必须和表单元素的name一致,用id是不行的,这是一个新手必踩的坑。校验不通过时,错误消息默认插入在校验元素后面,如果有自定义样式需求,可以通过errorElement和errorClass调整。
submitHandler我强烈建议写进每个校验里。默认情况下,表单校验通过后会走原生的表单提交,页面就刷新了,这在很多单页应用里是灾难。在submitHandler里拦截并用 AJAX 提交是更合理的方案,提交完再决定是跳转还是留在当前页。
还有一点,如果表单里的有些字段不想校验,但在表单提交时又需要它们的值,可以给这些字段加上ignore配置:
$('#myForm').validate({ ignore: '.skip-validate', // ... });远程校验(比如用户名是否已被注册)也是常用的功能,用remote规则指向一个后端接口就行,接口返回 true 表示校验通过,返回 false 或字符串就表示不通过,字符串会被当作错误消息展示。注意 remote 校验默认是异步的,而且只有当前字段的值发生变化时才会重新请求,值没变就提交表单的话,remote 校验不会第二次触发。
5.2 二维码合成图片的完整思路
这个需求背景很常见:生成一张带二维码的图片,比如海报、工牌或者宣传图。二维码可以是从后端接口返回的图片地址,也可以是自己生成的。用户需要把二维码移动到背景图上、调整大小、最终保存成一张新图片。
实现方案需要分两半来看:第一步是合成,第二步是保存。合成的基础是 HTML5 Canvas,jQuery 在这里的角色是简化 DOM 操作和事件绑定,真正画图的部分得靠 Canvas API。
先画背景图,再画二维码:
var canvas = document.getElementById('canvas'); var ctx = canvas.getContext('2d'); var bgImage = new Image(); var qrImage = new Image(); bgImage.onload = function() { canvas.width = bgImage.width; canvas.height = bgImage.height; ctx.drawImage(bgImage, 0, 0); qrImage.onload = function() { ctx.drawImage(qrImage, 100, 100, 160, 160); // 绘制二维码 }; qrImage.src = 'https://example.com/qrcode.png'; }; bgImage.src = 'https://example.com/bg.png';这里有一个注意点:跨域图片直接绘制到 canvas 上会导致 canvas 被“污染”,之后再调用canvas.toDataURL()就会报错。解决方式是在后端设置允许跨域的响应头,并且在 image 对象上设置crossOrigin属性:
qrImage.crossOrigin = 'anonymous';移动二维码在 canvas 上的位置,本质上是改变绘制时的坐标。你可以监听鼠标的mousedown、mousemove、mouseup事件,记录鼠标偏移量,然后重新绘制整个 canvas。每动一下都重绘一次,性能上是够用的,因为 canvas 的绘制效率很高。
调整大小,可以拖拽右下角的一个手柄,根据拖拽距离改变二维码的绘制宽高。计算方式很简单:记录初始宽高和拖拽起始坐标,然后用鼠标当前位置和起始坐标的差值加上初始宽高,就是新的大小。
拖拽手柄的核心逻辑:
var isDragging = false; var startX, startY; var qrWidth = 160, qrHeight = 160; var qrX = 100, qrY = 100; $('#resizeHandle').on('mousedown', function(e) { isDragging = true; startX = e.clientX; startY = e.clientY; }); $(document).on('mousemove', function(e) { if (!isDragging) return; var dx = e.clientX - startX; var dy = e.clientY - startY; qrWidth = Math.max(50, qrWidth + dx); qrHeight = qrHeight * (qrWidth / (qrWidth - dx + (qrWidth - dx === 0 ? 1 : 0))); // 这里简化处理,实际保持等比可以直接用 qrWidth * (qrHeight / qrWidth) redrawCanvas(); startX = e.clientX; startY = e.clientY; }); $(document).on('mouseup', function() { isDragging = false; });等比缩放的一个简化写法,是固定一个基准比例:height = width * (originHeight / originWidth),这样只拖一个轴,另一个轴自动跟着变化,四周不会变形。
最终保存时,canvas.toDataURL('image/png')拿到 base64 的数据,构造一个<a>标签模拟下载:
var link = document.createElement('a'); link.download = 'poster.png'; link.href = canvas.toDataURL('image/png'); link.click();注意如果是跨域图片,前面说的crossOrigin必须设置对,不然这一步会直接抛SecurityError。这个报错很经典,排查方向就是图片能不能跨域加载。
6. 别让 jQuery 变成你的安全隐患:XSS 与内容安全
jquery xss登上热搜词,说明越来越多的人开始关注这个问题了。jQuery 的核心优势是操作 DOM 方便,但方便的代价是——如果操作不当,很容易不小心把用户输入的恶意内容当成了 HTML 插入页面。
XSS(跨站脚本攻击)的本质,是程序把用户可控的内容当代码执行了。在 jQuery 语境下,最常见的入口就是html()、append()、prepend()、after()、before()这些方法。它们都会把传入的字符串当作 HTML 来解析。
之前说过能用text()就用text()。有些场景确实需要插入富文本,那入库之前就该做好服务端过滤。前端能做的,是至少保证文本内容不用html()传入:
// 不安全 $('#box').html(userInput); // 相对安全 $('#box').text(userInput); // 需要展示富文本时,先自己转义特殊字符 function escapeHtml(str) { var div = document.createElement('div'); div.appendChild(document.createTextNode(str)); return div.innerHTML; } $('#box').html(escapeHtml(userInput));这个escapeHtml小函数很有用,它利用textContent和innerHTML的组合,把<、>、&、"等特殊字符转成 HTML 实体,这样即使用户输入了<script>也只会被当作纯文本展示。
还有一个容易被忽略的入口是 URL 属性。如果你允许用户填写链接,然后放进href属性里,要警惕javascript:协议的链接:
// 用户可能输入:javascript:alert(1) $('#link').attr('href', userInput); // 合理做法:限制协议 if (/^https?:\/\//i.test(userInput)) { $('#link').attr('href', userInput); }事件处理属性的绑定也一样,onclick="..."这种字符串形式尽量不要用,用 jQuery 的.on('click', fn)来绑定,数据就是数据,代码就是代码,互不混淆。
$.ajax 获取 JSON 数据后,如果直接拼进 HTML 片段里再用html()渲染,同样有风险。稳妥的做法是把渲染逻辑拆出来,每条数据都用text()处理后再插入:
$.ajax({ url: '/api/comments', dataType: 'json', success: function(res) { var $list = $('#commentList').empty(); $.each(res.data, function(i, item) { var $li = $('<li>'); $('<span>').text(item.username).appendTo($li); $('<p>').text(item.content).appendTo($li); $list.append($li); }); } });再强调一次,jQuery 3.5.0 之后的版本已经修复了html()方法里script标签的自动执行问题,但这不是你随意拼接 HTML 的理由。习惯决定安全,代码习惯才是第一道防线。
7. 这一篇的实操心得,几句话打包带走
做前端这些年,用 jQuery 的时间算起来最长。它被很多人嘲笑过时,但它在业务中的实际地位,尤其是在老项目维护和快速原型开发中,依然是不可替代的。把这一篇的几个要点总结一下,都是我在实际开发中验证过的经验。
数组操作这一块,$.inArray判断存在性、$.grep处理对象数组过滤,配合 ES6 的Set做去重,基本覆盖了业务里九成场景。别再手写 for 循环来查数组了,代码是给人读的,语义化方法名比注释管用。
$.each最重要的认知是return false跳出、return跳过,以及回调里的this不能修改原数组的值。这三个点踩过一次坑,你就长记性了。每次在$.each里改数据失败的时候,回来查一眼这一节,能省半小时。
创建标签别拼大字符串,属性多的时候用$('<div>', {...})的写法,可读性和安全性都高一个档次。凡是往页面里插入用户数据,问自己一个问题:这里用text()行不行?能用就用,不能说明你真需要富文本,那就得把转义做完整。
事件冒泡的“点击外部消失、点击内部不消失”,本质是stopPropagation()和event.target的灵活运用。用closest()判断目标归属,比层层阻止冒泡来说,更符合直觉。
jquery.validate 是成熟插件,但两件事必须自己拿主意:一是submitHandler里写 AJAX 提交,二是name属性必须和规则对应。这两点做不到,插件用得越顺,上线后的坑越深。
二维码合成是 Canvas 的活,jQuery 负责事件和 DOM 控制。核心记住两点:跨域图片要设置crossOrigin,保存用toDataURL。图片被画到 canvas 上之后,后续所有操作都是重绘。
最后是安全问题。XSS 的坑大多是写代码时图省事埋下的,别把用户输入当 HTML,别用字符串拼事件属性,该转义的时候老老实实转义。这些习惯养成了,不管以后你用 jQuery、Vue 还是 React,都能少交很多安全学费。
这系列文档写到这里,基础篇差不多完整了。下一篇我会往里填一些动画与异步控制的进阶内容,比如用 Deferred 对象管理多个 AJAX 请求的依赖关系,这是个容易被忽略但很实战的主题。