萌新别慌:搞懂JS Event对象,点击不再"失联"(附实战避坑指南)
前端萌新别慌:搞懂JS Event对象,点击不再"失联"(附实战避坑指南)
刚入门前端那会儿,我连event.preventDefault()都拼错成preverntDefault,结果按钮点一下页面就刷新,差点以为浏览器跟我有仇。那天我在工位上抓耳挠腮半小时,最后发现是字母顺序写反了的时候,真有想把键盘吃了的冲动。但今天回头看,这种弱智错误反而让我对Event对象的理解刻进了DNA里。所以咱们今天就唠唠这个又基础又关键的JS Event对象——它到底是谁?为啥你写的点击没反应?事件冒泡是啥玄学?别急,一篇给你整明白,保证比你看MDN有意思多了。
Event对象到底是个啥玩意儿
说白了,Event对象就是浏览器给你发的一份"现场目击报告"。当你在页面上点了一下、按了个键或者滚了个轮,浏览器内部其实闹翻天了,但它很贴心地把当时现场的所有细节打包成一个对象塞给你。这个对象里装着啥?比如你点击的确切坐标、按的是哪个键、触发的是哪个DOM元素,甚至还能告诉你这事件是咋传播过来的。
但你别一上来就把它想得太神秘。它就是个普通对象,只不过由浏览器在事件触发那一刻自动创建,然后作为第一个参数传到你的回调函数里。很多萌新一开始会困惑:卧槽,我没传参数啊,这event从哪冒出来的?这就是浏览器干的活,它自动帮你注入的。
// 最基础的写法,event自动出现
document.getElementById('btn').addEventListener('click', function(event) {
// 这个event就是浏览器打包好的现场报告
console.log(event); // 你会看到一堆属性,别慌,慢慢看
console.log(event.type); // "click"
console.log(event.timeStamp); // 距离页面加载完多少毫秒发生的
});
我刚开始学的时候,总以为这个event是全局变量。后来踩坑了才知道,在早期的IE里确实是window.event,但现代浏览器都是作为参数传进来的。所以如果你看到有人直接写event.target而不在函数参数里声明,要么他是老古董,要么就是在写bug。
从addEventListener说起:你注册的不只是函数,还有上下文
咱们天天写addEventListener,但你真的懂它在干啥吗?很多人以为这就是个"绑定事件"的简单API,其实里面门道多了去了。你看这个函数签名:target.addEventListener(type, listener, options),第三个参数就能玩出花来。
先说这个listener,它可以是个函数,也可以是个实现了handleEvent方法的对象。后者很多人没用过,但其实挺香的:
// 普通写法,大家都这么写
button.addEventListener('click', function(e) {
console.log('clicked');
});
// 对象写法,适合面向对象编程的老哥
const handler = {
handleEvent: function(event) {
// 这里的this指向handler对象本身,不是DOM元素
console.log('处理事件:', event.type);
this.cleanup(); // 可以调用对象的其他方法
},
cleanup: function() {
console.log('清理工作');
}
};
button.addEventListener('click', handler);
// 移除的时候直接传同一个对象引用
button.removeEventListener('click', handler);
再说第三个参数。你可以传个布尔值true表示在捕获阶段处理,也可以传个配置对象:
// 高级配置,看清楚了
element.addEventListener('click', handler, {
capture: false, // 默认冒泡阶段,true就是捕获阶段
once: true, // 只触发一次,自动移除监听,省得你忘记remove
passive: true // 告诉浏览器:我不会调preventDefault(),你别等我了,优化滚动性能用
});
这个passive: true在移动端特别重要。如果你监听touchstart或touchmove想做个下拉刷新,不加这个,浏览器会以为你要阻止默认滚动,于是卡在那里等你,结果页面就卡成PPT。加上passive: true,浏览器就知道:“哦,这家伙不拦我,我先滚为敬”,性能立马提升。
事件流三兄弟:捕获、目标、冒泡,谁先谁后真能乱来?
这事儿我当年被面试问烂了,但工作中真能用上的时候才发现,理解这个真能救命。事件流分三个阶段:捕获阶段(Capture)、目标阶段(Target)、冒泡阶段(Bubbling)。顺序是:先捕获,再目标,最后冒泡。
啥意思呢?想象你点击一个嵌套很深的按钮:
<div id="grandpa">
<div id="papa">
<button id="baby">点我</button>
</div>
</div>
当你点击button的时候,事件不是直接飞到button身上的。它从document根节点开始,像潜水员一样往下潜(捕获),经过grandpa、papa,直到找到button(目标)。然后再像冒泡一样往上浮(冒泡),经过papa、grandpa,最后回到document。
默认情况下,你的事件监听都是在冒泡阶段触发的。所以如果你给这三个元素都绑了点击事件,顺序是:baby先弹,然后papa,最后grandpa。
但你要是想在捕获阶段拦截呢?
// 捕获阶段监听,第三个参数true
document.getElementById('grandpa').addEventListener('click', function(e) {
console.log('爷爷在捕获阶段抓到你了');
}, true);
// 冒泡阶段监听,默认false或者不写
document.getElementById('baby').addEventListener('click', function(e) {
console.log('按钮自己冒泡阶段');
});
// 输出顺序:
// 爷爷在捕获阶段抓到你了(捕获阶段,从上到下)
// 按钮自己冒泡阶段(目标阶段算在冒泡里)
// 如果papa也在冒泡阶段监听,这里会继续输出papa的
玩明白这个能干啥?比如你想做个点击外部关闭弹窗的功能,就可以在document的捕获阶段监听,这样比冒泡阶段更早知道点击了哪里,而且可以stopPropagation()阻止后续传播,省得其他地方的监听器瞎响应。
event.target 和 event.currentTarget 到底有啥区别?别再混用了!
这俩玩意儿的区别,我写代码写了两年才敢说自己真的不会搞混。简单来说:
event.target:事件的源头,你实际点击的那个元素(可能是子元素)event.currentTarget:你绑定监听器那个元素,事件当前流到的这个位置
看个栗子就懂了:
<ul id="list">
<li>项目1 <span>×</span></li>
<li>项目2 <span>×</span></li>
</ul>
document.getElementById('list').addEventListener('click', function(e) {
console.log('target:', e.target.tagName); // 你实际点的是LI还是SPAN?
console.log('currentTarget:', e.currentTarget.tagName); // 永远是UL
// 情景1:如果你点的是那个×(SPAN标签)
// target: SPAN, currentTarget: UL
// 情景2:如果你点的是LI的文字部分
// target: LI, currentTarget: UL
// 所以事件委托的时候,我们这样判断:
if (e.target.tagName === 'LI' || e.target.closest('li')) {
console.log('点到列表项了');
}
});
很多萌新写事件委托的时候,直接用e.currentTarget去取数据,结果死活拿不到,就是因为没理解这俩的区别。currentTarget是固定的(就是你绑监听器的那个),target是变动的(实际触发事件的 deepest element)。
阻止默认行为和阻止冒泡:preventDefault vs stopPropagation,用错一个全盘皆输
这俩API名字长得像,功能却天差地别,用错地方的后果也截然不同。
event.preventDefault():阻止默认行为。比如链接跳转、表单提交、右键菜单。调用后,事件还是会继续冒泡,只是浏览器不做默认动作了。
event.stopPropagation():阻止事件传播。事件到此为止,不往上(或往下)传了,但默认行为还是会执行(除非你也调了preventDefault)。
// 经典错误示范:只想阻止表单提交,结果把其他逻辑也断了
form.addEventListener('submit', function(e) {
if (!isValid()) {
e.stopPropagation(); // 错误!这只会阻止事件冒泡,表单还是会提交!
// 正确的应该是:
// e.preventDefault();
}
});
// 正确示范:做个自定义右键菜单
document.addEventListener('contextmenu', function(e) {
e.preventDefault(); // 先阻止默认右键菜单弹出来
// 显示你自己的菜单
customMenu.style.display = 'block';
customMenu.style.left = e.pageX + 'px';
customMenu.style.top = e.pageY + 'px';
// 这里不需要stopPropagation,让事件继续冒泡也没事
});
// 什么时候用stopPropagation?
// 比如你的组件内部有监听,不想被外部感知
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeModal();
// 这里加不加stopPropagation取决于你的设计
// 如果加:外部就监听不到这个点击了
// e.stopPropagation();
}
});
还有个更狠的stopImmediatePropagation(),它不但阻止事件传播,还阻止同一个元素上其他监听器的执行。比如你给一个按钮绑了两个click监听,在第一个里面调了这个,第二个监听就不会执行了。
button.addEventListener('click', function(e) {
console.log('第一个监听');
e.stopImmediatePropagation(); // 第二个监听凉了
});
button.addEventListener('click', function(e) {
console.log('第二个监听,永远不会输出');
});
键盘事件里的keyCode已死?现在该用哪个属性才不被Chrome警告
老前端都习惯了if (e.keyCode === 13),但现在打开控制台,满屏的黄色警告告诉你keyCode被弃用了。为啥?因为keyCode不直观,13是回车,27是ESC,鬼记得住啊!
现代写法是用key和code:
input.addEventListener('keydown', function(e) {
// 老写法,已弃用但还能用
if (e.keyCode === 13) {
console.log('回车(deprecated)');
}
// 新写法1:key,返回字符串描述
if (e.key === 'Enter') {
console.log('按了回车键');
}
if (e.key === 'Escape') {
console.log('按了ESC');
}
// 注意:key对大小写敏感,按Shift+1,key是"!"不是"1"
// 新写法2:code,返回物理按键位置
if (e.code === 'Enter') {
console.log('主键盘区的回车(不是小键盘的)');
}
if (e.code === 'KeyA') {
console.log('A键,不管当前是shift+a还是 caps lock');
}
// 组合键判断
if (e.ctrlKey && e.key === 's') {
e.preventDefault(); // 阻止浏览器保存页面
console.log('Ctrl+S 自定义保存');
}
});
key和code的区别在于:key是逻辑值(考虑shift、caps lock等),code是物理位置(不看当前输入是什么字符)。做游戏按键映射用code更好,因为WASD的位置固定;做表单快捷键用key更直观。
鼠标位置怎么拿?clientX、pageX、screenX傻傻分不清
这三个X,我反正在不同项目里用错过无数次,每次都怀疑自己是不是失忆了。给你整张表记清楚:
clientX/Y:相对于浏览器可视区域viewport的坐标,不随滚动条变化。做fixed定位的悬浮窗用这个。pageX/Y:相对于整个document的坐标,包含滚动距离。做拖拽、画线用这个。screenX/Y:相对于你的物理屏幕的坐标。基本没啥用,除非你要做跨窗口的骚操作。
document.addEventListener('mousemove', function(e) {
console.log(`
视口内:client(${e.clientX}, ${e.clientY})
页面内:page(${e.pageX}, ${e.pageY})
屏幕里:screen(${e.screenX}, ${e.screenY})
元素内:offset(${e.offsetX}, ${e.offsetY}) // 相对于事件目标的坐标,做画板用这个
`);
});
// 实战:做个简单的拖拽效果
let isDragging = false;
let startX, startY, initialLeft, initialTop;
const box = document.getElementById('draggable');
box.addEventListener('mousedown', function(e) {
isDragging = true;
// 记录鼠标按下时相对于元素左上角的偏移
startX = e.clientX;
startY = e.clientY;
const rect = box.getBoundingClientRect();
initialLeft = rect.left;
initialTop = rect.top;
box.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', function(e) {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
// 注意这里用clientX计算,因为元素的left也是相对于视口的(如果是fixed定位)
// 如果是absolute定位,你得算pageX减去父元素的offset
box.style.left = `${initialLeft + dx}px`;
box.style.top = `${initialTop + dy}px`;
});
document.addEventListener('mouseup', function() {
isDragging = false;
box.style.cursor = 'grab';
});
还有个offsetX/Y,这个是相对于你绑定事件那个元素内部的坐标。比如你在canvas上画画,直接用offsetX/Y就能拿到相对于canvas左上角的坐标,不用自己算半天。
移动端touch事件和PC端mouse事件能共用一套逻辑吗?别天真了
很多萌新(包括当年的我)觉得移动端不就是点一下嘛,用click不就行了?大错特错!click在移动端有300ms延迟,因为浏览器要等看你是不是想双击缩放。而且touch事件和mouse事件的属性完全不一样。
// PC端写法
element.addEventListener('click', handleClick);
element.addEventListener('mousedown', handleStart);
element.addEventListener('mousemove', handleMove);
element.addEventListener('mouseup', handleEnd);
// 移动端写法
element.addEventListener('touchstart', handleTouchStart, {passive: false});
element.addEventListener('touchmove', handleTouchMove, {passive: false});
element.addEventListener('touchend', handleTouchEnd);
// 注意touch事件的事件对象里没有一个坐标属性,而是数组!
function handleTouchStart(e) {
// 阻止默认行为,比如滚动(如果不加passive: false,这里会报错)
e.preventDefault();
// 多指触摸,touches是个数组
const touch = e.touches[0]; // 取第一根手指
const x = touch.clientX;
const y = touch.clientY;
// changedTouches是发生变化的触摸点
console.log(e.changedTouches.length);
}
// 如果你想写个通用的拖拽,得同时处理mouse和touch
function getEventPos(e) {
if (e.touches && e.touches.length > 0) {
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
return { x: e.clientX, y: e.clientY };
}
记住:touch事件触发顺序是touchstart -> touchmove -> touchend -> touchcancel(突然中断比如电话来了) -> mousedown -> mousemove -> mouseup -> click。所以如果你在touchstart里调了preventDefault(),click就不会触发了,这叫"吞噬"。
自定义事件不是玩具:dispatchEvent真能派上大用场
别觉得自定义事件是花里胡哨的东西,它在解耦代码方面简直神器。比如你有个购物车模块,和商品详情页完全分离,但加购成功后详情页要刷新推荐列表,这时候直接调函数就耦合了,用自定义事件简直完美。
// 创建自定义事件,可以带数据
const addToCartEvent = new CustomEvent('productAdded', {
detail: {
productId: 123,
quantity: 2,
timestamp: Date.now()
},
bubbles: true, // 允许冒泡
cancelable: true // 允许preventDefault
});
// 监听
document.addEventListener('productAdded', function(e) {
console.log('收到加购事件:', e.detail);
// 更新UI
updateRecommendation(e.detail.productId);
});
// 触发(比如在商品详情页点击加购按钮时)
document.dispatchEvent(addToCartEvent);
// 甚至可以做个事件总线
const eventBus = {
on: function(event, callback) {
document.addEventListener(event, callback);
},
emit: function(event, data) {
document.dispatchEvent(new CustomEvent(event, { detail: data }));
},
off: function(event, callback) {
document.removeEventListener(event, callback);
}
};
// 使用
eventBus.on('loginSuccess', (e) => {
console.log('用户登录了:', e.detail.userName);
});
// 在登录组件里
eventBus.emit('loginSuccess', { userName: '张三' });
这比Vue的EventBus或者React的Context要轻量多了,在原生JS项目里简直就是救星。
高频事件(比如scroll、resize)里读event小心性能陷阱
scroll和resize这两个事件,触发频率高得离谱。如果你在回调里读event的属性还做重计算,页面立马卡成狗。因为每次触发都可能是16ms以内(60fps),你的JS执行时间过长就掉帧。
// 错误示范:直接操作,卡爆
window.addEventListener('scroll', function(e) {
// 每次滚动都读scrollY并做复杂计算
const scrollTop = e.target.scrollingElement.scrollTop;
heavyCalculation(scrollTop);
updateDOM(scrollTop); // 操作DOM更惨
});
// 正确示范:防抖(debounce)或节流(throttle)
// 防抖:停止操作后才执行(适合搜索框输入)
function debounce(fn, delay) {
let timer = null;
return function(...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// 节流:一定时间内只执行一次(适合滚动、resize)
function throttle(fn, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
fn.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
// 使用节流,至少100ms执行一次
window.addEventListener('scroll', throttle(function(e) {
console.log('滚动位置:', window.scrollY);
// 这里可以放心做 moderately heavy 的操作
}, 100));
// 现代写法:使用requestAnimationFrame做视觉更新,不卡主线程
let ticking = false;
window.addEventListener('scroll', function(e) {
if (!ticking) {
window.requestAnimationFrame(function() {
updateParallax(window.scrollY); // 视差滚动效果
ticking = false;
});
ticking = true;
}
});
记住原则:高频事件的回调里,只做"记录数据"的事,别做DOM操作。DOM操作扔到requestAnimationFrame里,或者直接上防抖节流。
事件委托为什么香?少写十行代码还能动态绑定
事件委托这词听起来高大上,其实就是利用冒泡机制,把本来要绑给一堆子元素的事件,绑给它们的父元素。好处太多了:省内存(一个监听器vs N个)、能管动态新增的子元素、代码简洁。
<ul id="todoList">
<li data-id="1">任务1 <button class="delete">删</button></li>
<li data-id="2">任务2 <button class="delete">删</button></li>
<!-- 后面还会动态添加li -->
</ul>
<input type="text" id="newTodo" placeholder="输入新任务">
<button id="addBtn">添加</button>
// 笨办法:给每个li都绑,新来的li还得重新绑
document.querySelectorAll('#todoList li').forEach(li => {
li.addEventListener('click', handleClick); // 效率低,维护难
});
// 聪明办法:委托给ul
document.getElementById('todoList').addEventListener('click', function(e) {
// 精髓:用target或closest判断实际点的是谁
const li = e.target.closest('li'); // 找到最近的li祖先
if (!li) return; // 点的是ul空白处,忽略
const id = li.dataset.id;
if (e.target.classList.contains('delete') || e.target.closest('.delete')) {
// 点的是删除按钮
deleteTodo(id);
li.remove();
} else {
// 点的是li本身,切换完成状态
li.classList.toggle('completed');
toggleTodoStatus(id);
}
});
// 动态添加新任务,完全不用重新绑定事件!
document.getElementById('addBtn').addEventListener('click', function() {
const input = document.getElementById('newTodo');
const text = input.value.trim();
if (!text) return;
const newId = Date.now();
const li = document.createElement('li');
li.dataset.id = newId;
li.innerHTML = `${text} <button class="delete">删</button>`;
document.getElementById('todoList').appendChild(li);
// 看,不用绑事件,委托自动管用!
input.value = '';
});
注意closest()方法,这简直是事件委托的黄金搭档,它会向上查找最近的匹配选择器的祖先元素,帮你轻松判断"点的是不是我想管的那个区域"。
console.log(event) 看到一堆灰色字段?那些只读属性背后有啥讲究
你在控制台console.log(event)的时候,是不是发现有些字段是灰色的、点不开?那是因为Event对象的很多属性是getter(访问器属性),只有在事件还"活着"的时候才能读到值。事件处理函数执行完,事件对象就被回收了(或者被复用了,不同浏览器实现不同)。
document.addEventListener('click', function(e) {
console.log(e); // 展开看,有些属性可能已经是null或者不可读了
// 如果你异步操作,比如setTimeout,可能就会出问题
setTimeout(() => {
console.log(e.target); // 可能已经是null了!
}, 1000);
// 正确做法:把需要的值缓存下来
const target = e.target;
const x = e.clientX;
setTimeout(() => {
console.log('之前点的元素:', target);
console.log('当时的位置:', x);
}, 1000);
});
另外,很多属性是只读的,你不能瞎赋值:
e.type = 'dblclick'; // 无效,只读
e.target = someElement; // 无效,只读
这些限制是为了保证事件对象的不可变性,防止你在回调里乱改,影响其他监听器的判断。
开发时遇到"event is not defined"?箭头函数背锅实录
这错误我见了八百回了。当你用箭头函数写事件回调时,函数内部没有自己的this,也没有自动的event局部变量(在非严格模式下,普通函数的event其实是window.event,是浏览器遗留的怪异模式特性)。
// 错误示范:箭头函数里直接写event,严格模式下报错
document.getElementById('btn').addEventListener('click', (e) => {
// 箭头函数不会自动创建event局部变量
// 如果你这里直接写event,严格模式报错,非严格模式读的是window.event(可能为undefined)
console.log(event.target); // ReferenceError: event is not defined
});
// 正确写法1:显式声明参数(推荐)
document.getElementById('btn').addEventListener('click', (event) => {
console.log(event.target);
});
// 正确写法2:如果你非要访问window.event(不推荐,怪异模式遗留)
document.getElementById('btn').addEventListener('click', () => {
console.log(window.event); // sloppy mode下能用,严格模式undefined
});
// 坑:即使普通函数,如果嵌套了箭头函数也要注意
element.addEventListener('click', function(e) {
// 外层是普通函数,e是正常的
console.log('外层:', e.target);
setTimeout(() => {
// 这里箭头函数没有自己的this和arguments,但e是闭包里的,正常
console.log('内层箭头:', e.target);
// 但如果你在这里又用普通函数
setTimeout(function() {
console.log(e); // 报错!这个普通函数里e未定义
}, 100);
}, 100);
});
所以最佳实践就是:永远显式声明event参数,别指望浏览器给你自动注入。这样代码清晰,也不会被严格模式坑。
在React/Vue里还能直接用原生event吗?框架封装下的真相
React和Vue都封装了事件系统,但你依然能拿到原生Event对象,只是藏得有点深。
// React里
function handleClick(e) {
// 这里的e是React的合成事件(SyntheticEvent),不是原生的
console.log(e.nativeEvent); // 这才是真正的原生事件对象
console.log(e.target); // 但SyntheticEvent也包装了target等常用属性
// 阻止默认和冒泡还是一样的用法
e.preventDefault();
e.stopPropagation();
// React 17之前的事件委托在document,17之后委托在root容器
// 所以有时候你发现e.stopPropagation()拦不住document上的监听,就是这个原因
}
// 如果你想在React里用addEventListener(比如监听scroll,合成事件不支持)
useEffect(() => {
const handler = (e) => { // 这里的e是原生event
console.log('原生滚动事件');
};
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler); // 记得清理
}, []);
<!-- Vue里 -->
<template>
<button @click="handleClick">点我</button>
</template>
<script>
export default {
methods: {
handleClick(e) {
// Vue没有做合成事件(不像React),这里的e就是原生event
console.log(e.target);
// 但Vue的自定义事件($emit)不是原生事件,别混了
this.$emit('customEvent', data);
}
}
}
</script>
记住:React有事件池(旧版,18已经改进了),不能异步访问event;Vue就是原生事件,随便用。
调试技巧:如何快速定位是事件没触发还是回调写崩了
event调试三板斧:
// 第一招:在监听器第一行打断点,看进没进来
element.addEventListener('click', function(e) {
debugger; // 直接断住,看调用栈
// ...
});
// 第二招:console.trace看事件传播路径
element.addEventListener('click', function(e) {
console.trace('事件触发'); // 能看到事件是从哪个元素冒泡/捕获上来的
});
// 第三招:Chrome DevTools的Monitor Events
// 在Console面板输入:
monitorEvents(document.getElementById('btn'), 'click');
// 然后每次点击都会输出事件对象,不用写监听
// 第四招:检查事件真的绑上了吗?
console.log($0._events); // 在React/Vue里可能看不到,原生JS可以
// 第五招:看看是不是被阻止传播了
document.addEventListener('click', function(e) {
console.log('全局捕获:', e.target);
}, true); // 捕获阶段监听,如果这里都没输出,说明事件根本没触发或者更低层就被拦截了
还有个常见坑:事件委托时判断条件写错了,回调执行了但逻辑没走进if分支。这时候在回调第一行先console.log(e.target),看看实际点的是不是你以为的那个元素。
别再手写if (e.keyCode === 13)了,现代写法长这样
最后放一张现代键盘事件判断的速查表:
input.addEventListener('keydown', (e) => {
// 回车提交
if (e.key === 'Enter') {
submitForm();
}
// ESC关闭
if (e.key === 'Escape') {
closeModal();
}
// 方向键控制
switch(e.key) {
case 'ArrowUp': moveUp(); break;
case 'ArrowDown': moveDown(); break;
case 'ArrowLeft': moveLeft(); break;
case 'ArrowRight': moveRight(); break;
}
// Tab键(注意:别随便阻止Tab的默认行为,影响无障碍访问)
if (e.key === 'Tab' && e.shiftKey) {
console.log('Shift+Tab,往前切');
}
// 判断是不是输入字符(不是功能键)
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
console.log('输入了字符:', e.key);
}
});
记住key返回的是字符串,直观好记;别再用magic number了。
event对象在不同浏览器里的兼容性暗坑(IE?算了当我没说)
现在基本上可以无视IE了,但移动端浏览器还有些小坑。比如:
- iOS Safari的
click有300ms延迟(虽然fastclick库能解决,但用好viewport meta标签更治本) - 某些安卓浏览器的touch事件在滚动时可能不触发(这是为了性能,正常现象)
e.path是Chrome私有的,标准写法是e.composedPath()
// 兼容写法:获取事件路径
function getEventPath(event) {
return event.composedPath ? event.composedPath() :
event.path ? event.path :
// 手动向上遍历兜底
(function() {
const path = [];
let node = event.target;
while (node) {
path.push(node);
node = node.parentNode;
}
return path;
})();
}
你以为事件监听加一次就行?内存泄漏警告:记得removeEventListener!
单页应用(SPA)里最恐怖的内存泄漏之一就是忘了移除事件监听。特别是你在addEventListener里用了匿名函数,后面想remove都remove不掉。
// 内存泄漏示范:匿名函数,无法移除,组件销毁了监听还在
class Component {
init() {
window.addEventListener('scroll', () => {
this.doSomething();
});
}
// 组件销毁时,这个监听还在,而且闭包里还引用着this(整个组件)!
}
// 正确示范:保存引用
class GoodComponent {
constructor() {
// 提前绑定this,保存引用
this.handleScroll = this.handleScroll.bind(this);
}
init() {
window.addEventListener('scroll', this.handleScroll);
}
destroy() {
// 能移除掉了
window.removeEventListener('scroll', this.handleScroll);
}
handleScroll() {
this.doSomething();
}
}
// 或者用AbortController新API(现代浏览器支持)
const controller = new AbortController();
const { signal } = controller;
window.addEventListener('scroll', handler, { signal });
// 一键移除所有用这个signal的监听
controller.abort();
高阶技巧:用WeakMap存监听函数引用,或者封装个自动管理生命周期的钩子。
实战小例子:拖拽、表单验证、快捷键,全靠event撑场面
来几个完整的、能直接跑的实战代码:
1. 完整的拖拽组件(支持touch和mouse)
class Draggable {
constructor(element) {
this.el = element;
this.isDragging = false;
this.startX = 0;
this.startY = 0;
this.initialLeft = 0;
this.initialTop = 0;
// 同时支持mouse和touch
this.el.addEventListener('mousedown', this.start.bind(this));
this.el.addEventListener('touchstart', this.start.bind(this), {passive: false});
document.addEventListener('mousemove', this.move.bind(this));
document.addEventListener('touchmove', this.move.bind(this));
document.addEventListener('mouseup', this.end.bind(this));
document.addEventListener('touchend', this.end.bind(this));
}
start(e) {
this.isDragging = true;
this.el.style.cursor = 'grabbing';
// 统一获取坐标
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
this.startX = clientX;
this.startY = clientY;
const rect = this.el.getBoundingClientRect();
this.initialLeft = rect.left;
this.initialTop = rect.top;
// 阻止touch的默认行为(滚动)
if (e.touches) e.preventDefault();
}
move(e) {
if (!this.isDragging) return;
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const dx = clientX - this.startX;
const dy = clientY - this.startY;
this.el.style.position = 'fixed';
this.el.style.left = `${this.initialLeft + dx}px`;
this.el.style.top = `${this.initialTop + dy}px`;
}
end() {
this.isDragging = false;
this.el.style.cursor = 'grab';
}
}
// 使用
new Draggable(document.getElementById('box'));
2. 带实时验证的表单(用Input事件)
const form = document.getElementById('myForm');
const inputs = form.querySelectorAll('input[data-validate]');
inputs.forEach(input => {
// 失去焦点时验证
input.addEventListener('blur', validateField);
// 输入时实时反馈(但延迟一点,别每敲一个字母都验证)
input.addEventListener('input', debounce(function(e) {
validateField(e);
updateSubmitButton();
}, 300));
});
function validateField(e) {
const input = e.target;
const type = input.dataset.validate; // 'email', 'phone', 'required'
const value = input.value.trim();
let isValid = true;
let message = '';
switch(type) {
case 'email':
isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
message = isValid ? '' : '邮箱格式不对啊老哥';
break;
case 'required':
isValid = value.length > 0;
message = isValid ? '' : '这得填啊,别空着';
break;
case 'phone':
isValid = /^1[3-9]\d{9}$/.test(value);
message = isValid ? '' : '手机号看着不像';
break;
}
// 显示错误提示
const errorEl = input.nextElementSibling;
if (!isValid) {
input.classList.add('error');
if (errorEl && errorEl.classList.contains('error-msg')) {
errorEl.textContent = message;
}
} else {
input.classList.remove('error');
if (errorEl) errorEl.textContent = '';
}
return isValid;
}
form.addEventListener('submit', function(e) {
e.preventDefault(); // 先拦住,验证通过再提交
let allValid = true;
inputs.forEach(input => {
// 构造假的event对象去验证(或者直接调验证逻辑)
const fakeEvent = { target: input };
if (!validateField(fakeEvent)) {
allValid = false;
}
});
if (allValid) {
console.log('提交数据:', new FormData(form));
// fetch发送...
} else {
// 找到第一个错误输入框focus
const firstError = form.querySelector('.error');
if (firstError) firstError.focus();
}
});
3. 全局快捷键管理器(参考VS Code那种)
const shortcuts = new Map();
// 注册快捷键
function registerShortcut(keys, callback, priority = 0) {
// keys格式: 'Ctrl+Shift+K'
shortcuts.set(keys, { callback, priority });
}
// 监听
document.addEventListener('keydown', function(e) {
const keys = [];
if (e.ctrlKey || e.metaKey) keys.push('Ctrl');
if (e.shiftKey) keys.push('Shift');
if (e.altKey) keys.push('Alt');
keys.push(e.key.toUpperCase());
const keyCombo = keys.join('+');
if (shortcuts.has(keyCombo)) {
e.preventDefault(); // 阻止浏览器默认快捷键
const { callback } = shortcuts.get(keyCombo);
callback(e);
}
});
// 使用
registerShortcut('Ctrl+S', () => {
console.log('保存文件');
saveDocument();
});
registerShortcut('Ctrl+Shift+P', () => {
console.log('打开命令面板');
showCommandPalette();
});
// 防止在输入框里触发
document.addEventListener('keydown', function(e) {
const tagName = e.target.tagName;
const isEditable = e.target.isContentEditable;
if (tagName === 'INPUT' || tagName === 'TEXTAREA' || isEditable) {
// 如果在输入状态,除非明确指定,否则不触发全局快捷键
if (shortcuts.has(keyCombo) && !e.target.dataset.allowHotkeys) {
return; // 不执行上面的回调
}
}
}, true); // 捕获阶段先检查
下次再有人说"前端就是切图",你就把event对象的复杂度甩他脸上
说真的,前端早就不只是切图了。一个Event对象涉及的浏览器底层机制、内存管理、性能优化、跨端兼容、框架封装,够研究半年的。今天跟你唠的这些,从preventDefault的正确拼写(对,就是prevent,我之前拼错的那个r和n的位置),到事件委托的精妙设计,再到React合成事件的坑,每一点都是血泪史。
代码这玩意儿,看多了文档不如多踩几个坑。建议你今晚就把文中那些代码片段抄下来跑一遍,改改参数,看看console输出啥。特别是那个拖拽的,你自己敲一遍,保证对MouseEvent和TouchEvent的理解上一个档次。
行了,今天就唠到这儿。下次碰见事件不触发,先别慌,检查一下是不是箭头函数没传event,或者是不是忘了passive: false。记住,浏览器不会无缘无故给你使绊子,大部分时候是你没摸清楚它的脾气。去写代码吧,有问题随时回来翻看这篇。

&spm=1001.2101.3001.5002&articleId=157582551&d=1&t=3&u=f4e922e3d4c7486cbd845412755b87b4)
1970

被折叠的 条评论
为什么被折叠?



