写在前面
DOM 让我们操作页面元素,而 BOM(Browser Object Model,浏览器对象模型) 让我们操作浏览器本身:地址栏、历史记录、浏览器信息、弹窗,以及最重要的------定时器。
BOM 的门面是顶级对象 window,它既是全局对象的宿主,也是浏览器各种能力的入口。但定时器真正的难点不在 API,而在它背后隐藏的 JS 运行机制:单线程、同步与异步、事件循环、宏任务与微任务 。不理解这套底层,"为什么 setTimeout(0) 也不是立刻执行""为什么 Promise 在 setTimeout 之前打印"这类问题就永远靠背。
本文分两层:
- BOM 对象层 :
window/location/navigator/history/screen及对话框; - 运行机制层:同步与异步、调用栈、事件循环、宏任务与微任务的执行顺序------这是理解定时器与 Promise 的钥匙。
一、认识 BOM 与 window
1.1 BOM 是什么
浏览器把"浏览器自身"暴露为一组对象,总称 BOM。与 DOM 的关系:
| 模型 | 操作对象 | 入口 |
|---|---|---|
| DOM | 页面文档(元素、节点) | document |
| BOM | 浏览器(窗口、地址、历史、信息) | window |
js
window.document === document; // true:DOM 也挂在 window 下
1.2 window 是全局对象
在浏览器里,window 是全局对象:用 var 声明的全局变量和全局函数都会成为 window 的属性。
js
var g = 10; // 会成为 window.g
function foo() {} // 会成为 window.foo
window.g; // 10
window.foo; // function foo
let/const声明的全局变量不会 挂在window上,但同样是全局作用域。
1.3 常用 window 方法与属性
js
window.innerWidth; // 视口宽度(不含滚动条)
window.innerHeight; // 视口高度
window.scrollTo(0, 0);// 滚动到顶部
window.open(url); // 新窗口打开
window.close(); // 关闭窗口
二、BOM 核心对象
2.1 location:地址栏对象
location 表示当前 URL,可读可改:
js
location.href; // 完整 URL,如 https://example.com/page?id=1#top
location.protocol; // "https:"
location.host; // "example.com"
location.pathname; // "/page"
location.search; // "?id=1"(查询参数)
location.hash; // "#top"(锚点)
// 跳转的三种方式
location.href = "https://gitee.com"; // 跳转(会留下历史记录)
location.assign("https://gitee.com"); // 同上
location.replace("https://gitee.com"); // 跳转(替换当前记录,不能后退)
location.reload(); // 刷新页面
2.2 navigator:浏览器信息
js
navigator.userAgent; // 浏览器 UA 字符串
navigator.platform; // 操作系统平台
navigator.language; // 语言,如 "zh-CN"
navigator.onLine; // 是否在线
判断移动端的常见(粗略)写法:
js
const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);
UA 判断并不可靠(可伪造),生产环境更推荐用 CSS 媒体查询或
navigator.userAgentData(新 API)。
2.3 history:历史记录
js
history.length; // 历史记录条数
history.back(); // 后退
history.forward(); // 前进
history.go(-2); // 后退 2 步
history.pushState(state, "", url); // 无刷新改地址(SPA 路由核心)
SPA 前端路由就是靠 pushState / replaceState + popstate 事件实现的------在不刷新页面的情况下改变 URL。
2.4 screen:屏幕信息(了解)
js
screen.width; // 屏幕物理宽度
screen.height; // 屏幕物理高度
screen.availWidth; // 可用宽度(排除任务栏)
2.5 对话框:alert / confirm / prompt
js
alert("提示"); // 警告框,只有确定
const ok = confirm("确定删除?"); // 确定/取消,返回布尔
const val = prompt("请输入", "默认值"); // 输入框,返回输入或 null
三个方法都会阻塞 JS 执行直到用户操作,正式产品里少用(尤其移动端体验差)。
三、定时器
3.1 setTimeout:延时执行一次
js
const timerId = setTimeout(() => {
console.log("1 秒后执行");
}, 1000);
// 取消
clearTimeout(timerId);
- 返回一个定时器 id ,用于
clearTimeout取消; - 第 3 个及以后参数会传给回调函数:
js
setTimeout((a, b) => console.log(a + b), 500, 10, 20); // 30
3.2 setInterval:间隔重复执行
js
const timerId = setInterval(() => {
console.log("每 1 秒执行一次");
}, 1000);
// 停止
clearInterval(timerId);
3.3 定时器回调里的 this
定时器回调中的 this 是 window(非严格模式),不指向调用它的对象 。用箭头函数捕获外层 this:
js
const obj = {
count: 0,
start() {
// ❌ 普通函数:this 是 window
// setInterval(function () { this.count++; }, 1000);
// ✅ 箭头函数:this 来自外层 start() 的 obj
setInterval(() => {
this.count++;
console.log(this.count);
}, 1000);
},
};
3.4 递归 setTimeout 与 setInterval 的区别
定时器回调的执行时机不保证精确:事件循环繁忙时会被延后。累积误差是经典问题:
js
// setInterval:即使回调执行很久,仍然按固定间隔排队(可能堆积)
setInterval(() => { /* 耗时的任务 */ }, 1000);
// 递归 setTimeout:等上一次回调执行完,再等 1000ms
function tick() {
// 执行任务...
setTimeout(tick, 1000); // 保证"上次完成后再等 1000ms"
}
tick();
递归 setTimeout 更可控 :不会像 setInterval 那样在回调未完成时排队堆积。需要精确节奏时优先考虑。
定时器回调是异步宏任务,只在当前同步代码执行完后才轮到它------这是理解下一节的关键。
四、同步与异步底层
4.1 JS 是单线程的
JS 引擎一次只能执行一段代码(一个调用栈),没有真正的多线程并发。为什么?
- 因为 JS 需要操作 DOM,多线程同时改 DOM 会导致状态错乱;
- 浏览器用"单线程 + 事件循环"解决:耗时的任务(网络、定时器、文件)交给浏览器其他线程,完成后把回调放进队列,等主线程空闲再执行。
4.2 同步与异步的执行顺序
- 同步代码:按书写顺序立即执行;
- 异步代码 :注册后不立即执行,等条件满足后进入任务队列,同步代码全部执行完才轮到它们。
js
console.log("1"); // 同步
setTimeout(() => console.log("2"), 0); // 异步(即使 0ms 也最后)
console.log("3"); // 同步
// 输出顺序:1 → 3 → 2
setTimeout(..., 0) 也不是立刻执行:它只是把回调放到队列尾部,至少等当前同步代码跑完。
4.3 事件循环(Event Loop)
浏览器运行时持续执行一个循环:
erlang
① 执行调用栈里的所有同步代码(宏任务)
② 执行所有微任务队列(清空)
③ 从宏任务队列取出一个任务执行
④ 回到 ②(执行新产生的微任务)
...
核心:调用栈空 → 先清空微任务 → 再取一个宏任务。微任务永远在下一个宏任务之前清空。
4.4 宏任务与微任务
| 类别 | 常见来源 | 特点 |
|---|---|---|
| 宏任务(Macro-task) | setTimeout、setInterval、I/O、事件回调 |
每次事件循环只取一个 |
| 微任务(Micro-task) | Promise.then、queueMicrotask、MutationObserver |
每次执行完同步/宏任务后全部清空 |
执行顺序口诀:同步 → 微任务 → 宏任务(→ 新微任务 → 新宏任务...)。
经典输出顺序题:
js
console.log("A"); // 同步
setTimeout(() => console.log("B"), 0); // 宏任务
Promise.resolve().then(() => console.log("C")); // 微任务
console.log("D"); // 同步
// 输出:A → D → C → B
分析:
- 同步
A、D先打印; - 同步执行完,清空微任务 → 打印
C; - 再从宏任务队列取
setTimeout→ 打印B。
微任务(Promise)一定先于宏任务(setTimeout)执行------即使 setTimeout 的延迟是 0。
4.5 为什么 setTimeout 不阻塞 UI
因为定时器回调被丢进宏任务队列,主线程继续渲染;用户能立刻看到界面响应。反之,如果用一个 while 死循环占住主线程,页面会卡死------UI 渲染也是任务,永远轮不到它:
js
// ⚠️ 会卡死页面:主线程被占死
while (true) { /* 死循环 */ }
五、实战案例
实战 1:倒计时(setInterval + clearInterval)
html
<button id="btn">开始倒计时 10 秒</button>
<span id="display">10</span>
js
let timer = null;
document.getElementById("btn").addEventListener("click", () => {
let count = 10;
const display = document.getElementById("display");
if (timer) clearInterval(timer); // 防止重复启动
display.textContent = count;
timer = setInterval(() => {
count--;
display.textContent = count;
if (count <= 0) {
clearInterval(timer); // 到 0 停止
timer = null;
}
}, 1000);
});
实战 2:轮播图自动播放(setInterval)
js
let current = 0;
const slides = document.querySelectorAll(".slide");
setInterval(() => {
slides[current].classList.remove("active");
current = (current + 1) % slides.length; // 循环索引
slides[current].classList.add("active");
}, 3000);
实战 3:防抖与节流(定时器两大应用)
防抖(Debounce):停止触发 N 毫秒后才执行------适合搜索框。
js
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const search = debounce((val) => console.log("搜索:" + val), 300);
节流(Throttle):N 毫秒内最多执行一次------适合滚动、拖拽。
js
function throttle(fn, interval) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= interval) {
last = now;
fn.apply(this, args);
}
};
}
const onScroll = throttle(() => console.log("滚动处理"), 200);
实战 4:用定时器封装 Promise 延时
把"延时"变成可 await 的 Promise,配合 async/await 让代码线性化:
js
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function run() {
console.log("开始");
await sleep(1000);
console.log("1 秒后");
await sleep(500);
console.log("再 0.5 秒后");
}
run();
实战 5:事件循环输出顺序综合题
js
console.log("1"); // 同步
setTimeout(() => console.log("2"), 0); // 宏任务
new Promise((resolve) => {
console.log("3"); // Promise 执行器是同步的
resolve();
}).then(() => console.log("4")); // 微任务
async function fn() {
console.log("5"); // 同步
await Promise.resolve();
console.log("6"); // await 之后是微任务
}
fn();
console.log("7"); // 同步
// 输出:1 → 3 → 5 → 7 → 4 → 6 → 2
分析要点:
new Promise的执行器函数立即同步执行 (所以3先于5?注意顺序:1→ 执行器3→ 注册 then 微任务 → 调用fn()→ 打印5→await挂起 → 返回 → 同步7→ 微任务4→ 微任务6→ 宏任务2);await之后的代码属于微任务;- 宏任务
setTimeout永远在微任务之后。
六、高频误区清单
| 误区 | 正解 |
|---|---|
setTimeout(0) 会立刻执行 |
至少等当前同步代码完成,是异步宏任务 |
定时器回调里 this 指向原对象 |
普通函数下是 window,用箭头函数捕获外层 this |
setInterval 时间精确 |
不精确;回调耗时可能堆积,用递归 setTimeout |
| Promise 回调立即执行 | 执行器同步执行,但 .then 回调是微任务 |
| 微任务在宏任务之后 | 每次同步/宏任务后先清空全部微任务,再取宏任务 |
| 死循环只是"慢" | 会永久卡死页面(UI 渲染排不上队) |
location.href = url 与 replace 等价 |
replace 不产生历史记录,不能后退 |
prompt 友好无副作用 |
阻塞 JS 执行,正式产品慎用 |
七、总结
用一张表收束本篇核心:
| 主题 | 一句话记住 |
|---|---|
| BOM | window 是全局宿主,location/navigator/history 都是它的属性 |
| 跳转 | href/assign 留历史,replace 不留历史 |
| 定时器 | setTimeout 一次、setInterval 重复,都能 clear |
| this | 定时器回调用箭头函数保 this |
| 单线程 | JS 一次执行一段代码,耗时任务交给浏览器线程 |
| 事件循环 | 调用栈空 → 清空微任务 → 取一个宏任务 |
| 宏/微 | setTimeout 是宏任务,Promise.then 是微任务,微任务先执行 |
三条实践心法收尾:
- 写定时器必配清理 :
setInterval/setTimeout存 id,页面卸载或任务结束clear,防内存泄漏与重复触发; - 理解顺序再写异步:背下"同步 → 微任务 → 宏任务",面试题和线上 bug 排查都好用;
- 交互密集用防抖节流:搜索防抖、滚动节流,定时器是它们的底层。