异步与事件
约 3471 字大约 12 分钟
2025-11-24
不会冒泡的事件
思考
不会冒泡的事件有哪些?
在 JavaScript 中,大多数事件都会按照 DMO 事件流模型冒泡,即事件会从目标元素开始向上冒泡到父元素。但也有一些事件直接在目标元素上触发,不会向上传播。
| 事件 | 简单描述 |
|---|---|
focus | 元素获得焦点时触发(如点击输入框),不冒泡 |
focusin | 与 focus 类似,但会在父元素上触发,冒泡 |
focusout | 与 blur 类似,但会在父元素上触发,冒泡 |
blur | 元素失去焦点时触发(如点击别处),不冒泡 |
mouseenter | 鼠标首次进入元素边界时触发,不冒泡,且不因进入子元素重复触发 |
mouseleave | 鼠标完全离开元素边界时触发,不冒泡,与 mouseenter 成对 |
load | 资源(如 <img>、<script> 或 window)加载完成时触发,不冒泡 |
unload | 页面或资源即将卸载(如关闭页面)时触发,不冒泡 |
mouseenter 与 mouseover
思考
mouseenter与mouseover有什么区别?
| 特性 | mouseenter | mouseover |
|---|---|---|
| 是否冒泡 | ❌ 不冒泡 | ✅ 会冒泡 |
| 进入子元素时是否再次触发 | ❌ 不会 | ✅ 会(因为事件冒泡) |
| 触发时机 | 仅当鼠标首次进入目标元素整体区域 | 鼠标进入目标元素 或其任何子元素 时都会触发 |
| 常用于 | 需要“整体悬停”逻辑(如 Tooltip、下拉菜单) | 一般悬停效果(但需注意子元素干扰) |
示例说明
<div id="outer" style="padding: 20px; background: lightblue;">
外层
<div id="inner" style="padding: 20px; background: pink;">内层</div>
</div>const outer = document.getElementById("outer");
outer.addEventListener("mouseenter", () => {
console.log("mouseenter");
});
outer.addEventListener("mouseover", () => {
console.log("mouseover");
});操作 & 输出:
鼠标从外层空白处进入
#outer:mouseenter→ 1 次mouseover→ 1 次
鼠标从
#outer移动到#inner(子元素):mouseenter→ 无输出(因为仍在#outer区域内)mouseover→ 再次输出!(因为#inner触发了mouseover,并冒泡到#outer)
鼠标继续从
#inner移动到#outer:mouseenter→ 无输出(因为仍在#outer区域内)mouseover→ 再次输出!(因为#outer触发了mouseover)
这就是为什么 mouseover 容易“误触发”,而 mouseenter 更符合“整体悬停”的直觉。
对应的离开事件
| 进入事件 | 离开事件 |
|---|---|
mouseenter | mouseleave(不冒泡) |
mouseover | mouseout(会冒泡) |
同样,mouseleave 只在鼠标完全离开整个元素区域时触发,而 mouseout 在离开子元素时也会触发。
async 与 await 的实现原理
思考
async 与 await 的实现原理是什么?
async/await 是基于 Promise 的语法糖。async 函数会自动返回一个 Promise,而 await 会暂停函数执行,直到等待的 Promise 完成。 在引擎层面,现代 JavaScript 引擎(如 V8)将 async 函数编译为状态机,每个 await 对应一个状态,Promise 完成后通过微任务恢复执行。 这使得异步代码可以像同步一样编写,同时保持非阻塞特性。
TODO: async 与 await 的实现原理
节流与防抖
思考
什么是节流与防抖?
节流(Throttle)和防抖(Debounce)是前端开发中用于优化高频率触发事件处理的两种技术,常用于处理如窗口缩放、滚动、输入框输入等高频事件。
节流(Throttle)
节流是指在一定时间内,只执行一次函数。也就是说,在规定的时间间隔内,无论触发多少次事件,函数只会执行一次。就像水龙头的节流阀,控制水的流速,在一定时间内只放出一定量的水。
应用场景
- 窗口缩放(
resize事件):当用户调整浏览器窗口大小时,resize事件会频繁触发。如果在这个事件处理函数中进行一些复杂的布局计算或重新渲染操作,会导致性能问题。使用节流可以限制在一定时间内只执行一次布局计算,减轻浏览器负担。 - 滚动加载(
scroll事件):在网页滚动加载更多内容的场景中,scroll事件会随着用户滚动页面不断触发。通过节流可以控制在一定时间内只进行一次数据加载请求,避免频繁请求导致服务器压力过大。
实现方式
function throttle(func, delay) {
let timer = null;
return function () {
if (!timer) {
func.apply(this, arguments);
timer = setTimeout(() => {
timer = null;
}, delay);
}
};
}
// 使用示例
function handleScroll() {
console.log("Scroll event triggered");
}
window.addEventListener("scroll", throttle(handleScroll, 500));防抖(Debounce)
概念
防抖是指在一定时间内,只有最后一次触发事件才会执行函数。如果在这个时间间隔内再次触发事件,则重新计时。就像电梯门,当有人不断进出时,电梯门不会关闭,直到一段时间内没有人再触发开门或关门操作,电梯门才会关闭。
应用场景
- 输入框搜索提示:在搜索框中输入关键词时,每次输入都会触发
input事件。如果实时根据输入内容进行搜索请求,会产生大量不必要的请求。使用防抖可以在用户停止输入一段时间后,才进行搜索请求,减少请求次数。 - 按钮点击防止重复提交:当用户快速多次点击提交按钮时,使用防抖可以确保只有最后一次点击才会触发提交操作,避免重复提交数据。
实现方式
function debounce(func, delay) {
let timer = null;
return function () {
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, arguments);
}, delay);
};
}
// 使用示例
function handleInput() {
console.log("Input event triggered");
}
const input = document.querySelector("input");
input.addEventListener("input", debounce(handleInput, 300));总结
- 节流:适用于需要限制函数执行频率的场景,保证在一定时间内函数只执行一次,常用于处理高频触发的事件,如窗口缩放、滚动等。
- 防抖:适用于需要等待用户操作结束后再执行函数的场景,避免因用户频繁操作而导致函数多次执行,常用于输入框搜索提示、按钮点击等场景。
竞态问题
思考
什么是竞态问题?
如何解决竞态问题?
竞态问题 是指多个异步操作的执行顺序不确定,导致最终结果依赖于它们完成的相对时间,从而产生非预期行为。
常见场景
- 快速输入内容,后发起的请求先返回,覆盖了先发起但后返回的正确结果。
- 多次点击按钮触发多个相同请求。
示例
<input autocomplete="off" type="text" id="query" placeholder="输入搜索词..." />
<div id="result"></div>// 模拟不确定请求:延迟 0.5 ~ 2 秒
function mockRequest(query) {
const delay = 500 + Math.random() * 1500;
return new Promise((resolve) => {
setTimeout(() => {
resolve(`显示结果: "${query}" (实际耗时 ${delay.toFixed(0)}ms)`);
}, delay);
});
}
const input = document.getElementById("query");
const resultEl = document.getElementById("result");
input.addEventListener("input", async (e) => {
const q = e.target.value.trim();
if (!q) {
resultEl.textContent = "";
return;
}
resultEl.textContent = `请求已发出: "${q}"...`;
// 没有取消机制:每个输入都发请求,且都会更新 UI
const result = await mockRequest(q);
resultEl.textContent = result; // 可能被“旧但晚到”的请求覆盖
});/* css 代码 */在上面的例子当中,我们使用 mockRequest 模拟一个耗时的异步请求,请求时间 0.5 秒到 2 秒之间,每输入一个搜索词,会触发一个请求。
可以发现,当用户输入多个搜索词时,多个请求会并发发出,实际结果会显示耗时最短的。
解决方案
在 JS 当中,可以使用 AbortController 来取消过期的请求。
<input autocomplete="off" type="text" id="query" placeholder="输入搜索词..." />
<div id="result"></div>// 模拟不确定请求:延迟 0.5 ~ 2 秒
function mockRequest(query, signal) {
const delay = 500 + Math.random() * 1500;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (signal?.aborted) {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
} else {
resolve(`结果: "${query}" (耗时 ${delay.toFixed(0)}ms)`);
}
}, delay);
});
}
const input = document.getElementById("query");
const resultEl = document.getElementById("result");
let abortController = null;
input.addEventListener("input", async (e) => {
const q = e.target.value.trim();
resultEl.textContent = "加载中...";
// 取消上一个请求
if (abortController) abortController.abort();
abortController = new AbortController();
try {
const res = await mockRequest(q, abortController.signal);
resultEl.textContent = res;
} catch (err) {
if (err.name !== "AbortError") {
resultEl.textContent = "请求出错";
console.error(err);
}
// 若是 AbortError,说明是过期请求,静默丢弃
}
});/* css 代码 */示例中,我们使用了 AbortController 来取消过期的请求。请求发起之前,先取消上一次的请求,然后创建一个新的 AbortController,并绑定到当前请求。这样,当用户输入新的搜索词时,上一个请求将被取消,新的请求将开始执行,最终显示结果将总是最后一次请求结果。
在 Vue3 中,使用 watch 或者 watchEffect 来触发请求也需要注意竞态问题,可以使用 onCleanup() 忽略过时请求。在 React 中,可以在 Effect 中返回一个清理函数,来处理竞态问题。
e.target 与 e.currentTarget
思考
e.target与e.currentTarget的区别是什么?
e.target 与 e.currentTarget 都是 DOM 事件对象中的属性,用于获取触发事件的相关元素,当它们实际指向的元素不同。
e.target:表示触发事件的实际元素。e.currentTarget:表示事件监听函数绑定的元素。
<div id="parent" style="background-color: red;padding: 0 20px;">
parent
<div id="child" style="background-color: blue; padding: 0 20px;">child</div>
</div>document.getElementById("parent").addEventListener(
"click",
function (e) {
console.log("e.target:", e.target); // 如果点按钮,打印 button;如果点 div 区域,打印 div
console.log("e.currentTarget:", e.currentTarget); // 始终是 #parent 元素
},
true,
);:::
如何在不同阶段触发监听?
addEventListener() 我们通常只传入了两个参数,但实际上还可以传入第三个参数,类型为 boolean,表示事件触发阶段。
false:默认值,表示事件冒泡阶段触发。true:表示事件捕获阶段触发。
click 事件被触发后会经历两个阶段:捕获阶段,目标阶段,冒泡阶段。
- 捕获阶段:从最外层元素开始,向目标元素传递。此时事件尚未到达目标元素。
- 目标阶段:事件到达目标元素。
- 冒泡阶段:从目标元素开始,依次经过其父元素,向最外层元素传递。
下面是一个示例:
<div id="grandparent">
<div id="parent">
<button id="child">Click me</button>
</div>
</div>const log = (phase, id, e) =>
console.log(`${phase}: ${id},target: ${e.target.id},currentTarget: ${e.currentTarget.id}`);
//
document
.getElementById("grandparent")
.addEventListener("click", (e) => log("capture", "grandparent", e), true);
document.getElementById("parent").addEventListener("click", (e) => log("capture", "parent",e), true);
document.getElementById("child").addEventListener("click", (e) => log("target", "child",e), true); // 捕获或冒泡都能触发 target
document.getElementById("child").addEventListener("click", (e) => log("target (bubble)", "child",e));
document.getElementById("parent").addEventListener("click", (e) => log("bubble", "parent",e), false);
document
.getElementById("grandparent")
.addEventListener("click", (e) => log("bubble", "grandparent",e), false);:::
上面的代码执行结果为:
capture: grandparent,target: child,currentTarget: grandparent
capture: parent,target: child,currentTarget: parent
target: child,target: child,currentTarget: child
target (bubble): child,target: child,currentTarget: child
bubble: parent,target: child,currentTarget: parent
bubble: grandparent,target: child,currentTarget: grandparent可以看到事件的执行是先从外层开始,依次向内执行(捕获阶段),然后内向外执行(冒泡阶段)。事件触发元素都为 child(因为点击的是 child 元素)。
取消请求
思考
在JavaScript中如何取消请求?
在前端开发中,取消 HTTP 请求主要用于避免内存泄漏、竞态条件(如快速切换搜索关键词)或组件卸载后响应仍被处理等问题。不同请求方式的取消机制如下:
1. fetch + AbortController
取消 fetch 请求,需要用到 AbortController API,AbortController 实例中有一个只读属性 signal, 可以传入 fetch 中将控制器与请求关联起来。
const controller = new AbortController();
const { signal } = controller;
fetch('/api/data', { signal })
.then(res => res.json())
.then(data => console.log(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('请求已取消');
} else {
console.error('请求失败', err);
}
});
// 取消请求
controller.abort(); // 触发 AbortErrorsignal 可传递给多个 fetch 共享同一取消信号。
2. Axios
方式一:AbortController
下面是如今的推荐取消方式:
const controller = new AbortController();
axios.get('/api/data', {
signal: controller.signal
}).catch(err => {
if (axios.isCancel(err)) {
console.log('请求已取消');
}
});
controller.abort();方式二:CancelToken(已废弃,不推荐)
const source = axios.CancelToken.source();
axios.get('/api/data', { cancelToken: source.token })
.catch(err => {
if (axios.isCancel(err)) console.log('取消:', err.message);
});
source.cancel('手动取消');官方已弃用 CancelToken,推荐使用 AbortController。
3. XMLHttpRequest(传统方式)
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/data');
xhr.send();
// 取消
xhr.abort(); // 立即终止请求,触发 onreadystatechange(readyState=4, status=0)4. React 中的典型应用场景(防内存泄漏)
useEffect(() => {
const controller = new AbortController();
fetch('/api/user', { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(e => {
if (e.name !== 'AbortError') setError(e);
});
return () => {
controller.abort(); // 组件卸载时取消请求
};
}, []);捕获 Promise 的拒绝
思考
.catch与.then(undefined, err => {})的区别是什么?
.catch(onRejected) 与 .then(onFulfilled, onRejected) 的第二参数(即 onRejected)都能捕获 Promise 的拒绝(rejection),但它们在错误捕获范围上有关键区别:
关键区别
| 方式 | 能捕获的错误范围 |
|---|---|
.then(null, onRejected) | 仅能捕获前一个 Promise 的拒绝 |
.catch(onRejected) | 能捕获前面整个链中任何未处理的拒绝(包括 .then 回调内部抛出的错误) |
示例说明
情况 1:捕获前一个 Promise 的 reject
Promise.reject('err')
.then(null, err => console.log('A:', err)); // 捕获 → "A: err"
Promise.reject('err')
.catch(err => console.log('B:', err)); // 捕获 → "B: err"此时两者行为相同。
情况 2:捕获 .then 回调内部抛出的错误
Promise.resolve()
.then(() => {
throw new Error('boom!');
})
.then(null, err => console.log('C:', err)); // 不会执行!- 第二个
.then的onRejected只监听前一个 Promise 的状态; - 前一个 Promise 是 fulfilled(虽然回调抛错),所以走
onFulfilled分支; - 抛出的错误会跳过当前
.then的onRejected,继续向后传递。
而用 .catch:
Promise.resolve()
.then(() => {
throw new Error('boom!');
})
.catch(err => console.log('D:', err.message)); // ✅ 捕获 → "D: boom!".catch 能捕获链中任何未处理的 rejection 或抛错。
最佳实践
- 始终使用
.catch()统一处理错误,避免遗漏.then内部异常; - 避免在
.then中使用第二个参数处理错误(除非有特殊分层处理需求); - 一个 Promise 链通常以
.catch()结尾,实现“全局错误兜底”。
拓展阅读:编码与实现-捕获 Promise 的错误