浏览器 Window 底层操作全解
涵盖范围 :页面生命周期控制、窗口间通信、历史栈管理、存储机制、底层事件拦截等核心方向
难度梯度:⭐ 基础 API → ⭐⭐ 进阶用法 → ⭐⭐⭐ 深入原理 → ⭐⭐⭐⭐ 工程实践
📑 目录
一、页面关闭拦截与生命周期
⭐ Q1:如何阻止用户关闭页面或刷新时丢失未保存的数据?
答:
浏览器提供了 beforeunload 事件来拦截页面关闭/刷新/跳转行为。
javascript
// 基础用法:阻止页面关闭
window.addEventListener('beforeunload', (event) => {
if (hasUnsavedChanges) {
// 现代浏览器已不支持自定义消息,只能触发默认提示
event.preventDefault();
event.returnValue = ''; // Chrome 需要设置 returnValue
return ''; // 某些旧浏览器需要 return
}
});
⚠️ 重要限制(现代浏览器安全策略):
| 浏览器 | 行为 |
|---|---|
| Chrome 51+ | 忽略自定义消息,显示固定提示 |
| Firefox 44+ | 同上 |
| Safari | 同上 |
| 触发条件 | 必须用户有交互(点击、输入等),否则不弹窗 |
javascript
// 推荐:配合表单自动保存
let hasUnsavedChanges = false;
// 监听输入变化
form.addEventListener('input', () => {
hasUnsavedChanges = true;
});
// 自动保存到 sessionStorage(兜底)
form.addEventListener('input', debounce(() => {
sessionStorage.setItem('draft', JSON.stringify(getFormData()));
hasUnsavedChanges = false;
}, 1000));
window.addEventListener('beforeunload', (e) => {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
});
⭐⭐ Q2:beforeunload、unload、pagehide 三个事件有什么区别?
答:
| 事件 | 触发时机 | 能否阻止关闭 | 能否执行异步操作 | 典型用途 |
|---|---|---|---|---|
beforeunload |
页面即将卸载前 | ✅ 可以弹窗阻止 | ❌ 不建议 | 未保存数据提示 |
unload |
页面正在卸载 | ❌ 不能 | ❌ 不可靠(同步代码可能不执行) | 发送埋点(不推荐) |
pagehide |
页面隐藏(包括缓存) | ❌ 不能 | ⚠️ 有限 | 保存状态到 sessionStorage |
visibilitychange |
标签页可见性变化 | ❌ 不能 | ✅ 可以 | 暂停视频/轮询、保存草稿 |
javascript
// 最佳实践:组合使用
// 1. 阻止意外关闭
window.addEventListener('beforeunload', (e) => {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
});
// 2. 页面隐藏时保存状态(比 unload 更可靠)
window.addEventListener('pagehide', () => {
sessionStorage.setItem('lastState', JSON.stringify(appState));
});
// 3. 页面重新可见时恢复
window.addEventListener('pageshow', (e) => {
if (e.persisted) { // 从 bfcache 恢复
console.log('页面从缓存恢复');
}
});
⭐⭐⭐ Q3:什么是 bfcache(往返缓存)?如何控制它?
答:
bfcache(Back-Forward Cache) 是浏览器的一种优化机制:当用户点击"后退"时,直接从内存中恢复整个页面状态(包括 DOM、JS 堆内存),而不是重新加载。
css
正常流程:后退 → 重新请求 HTML → 解析 → 执行 JS → 渲染
bfcache:后退 → 从内存恢复完整页面状态(瞬间完成)
触发 bfcache 的条件:
- 页面没有未完成的网络请求(XHR/fetch)
- 没有打开的
WebSocket连接 - 页面没有使用
unload事件监听器(Chrome 限制) - 没有正在播放的音频/视频
javascript
// 检测是否从 bfcache 恢复
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
console.log('从 bfcache 恢复,无需重新初始化');
// 注意:此时页面状态是离开时的快照
// 如果需要刷新数据,在这里处理
}
});
// 主动阻止 bfcache(不推荐,除非必要)
window.addEventListener('unload', () => {}); // Chrome 会因此禁用 bfcache
最佳实践:
- 使用
pagehide代替unload(不阻止 bfcache) - 从 bfcache 恢复时,检查数据时效性,必要时重新获取
⭐⭐⭐⭐ Q4:如何实现一个可靠的"页面意外关闭前自动保存"机制?
答:
javascript
class PageGuard {
constructor() {
this.draftKey = 'page_draft';
this.lastSave = 0;
this.init();
}
init() {
// 1. 定时自动保存(每 5 秒)
this.autoSaveTimer = setInterval(() => this.save(), 5000);
// 2. 输入时防抖保存
document.addEventListener('input', debounce(() => this.save(), 500));
// 3. 页面隐藏时立即保存(最可靠)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.save();
// 发送 Beacon(页面关闭时仍可发送)
navigator.sendBeacon('/api/log', JSON.stringify({ action: 'page_hide' }));
}
});
// 4. 阻止关闭提示
window.addEventListener('beforeunload', (e) => {
if (this.hasUnsavedChanges()) {
this.save(); // 最后尝试保存
e.preventDefault();
e.returnValue = '';
}
});
}
save() {
const data = this.collectData();
const payload = JSON.stringify({ data, timestamp: Date.now() });
// 优先使用 IndexedDB(容量大)
this.saveToIndexedDB(payload).catch(() => {
// 降级到 sessionStorage
sessionStorage.setItem(this.draftKey, payload);
});
this.lastSave = Date.now();
}
async saveToIndexedDB(payload) {
// 使用 IndexedDB 存储大体积草稿
const db = await openDB('DraftDB', 1);
await db.put('drafts', payload, 'current');
}
collectData() {
// 收集表单、编辑器状态等
return { /* ... */ };
}
hasUnsavedChanges() {
return Date.now() - this.lastSave > 1000; // 1秒内未保存视为有变更
}
restore() {
const saved = sessionStorage.getItem(this.draftKey);
if (saved) {
const { data, timestamp } = JSON.parse(saved);
// 恢复数据逻辑
console.log('恢复草稿,保存时间:', new Date(timestamp));
}
}
}
二、窗口间数据共享与通信
⭐ Q1:如何在新打开的窗口和原窗口之间传递数据?
答:
方法一:window.open + opener(同域)
javascript
// 父窗口
const child = window.open('https://same-domain.com/child.html', 'childWindow');
// 向子窗口发送数据(需同域)
child.onload = () => {
child.postMessage('hello from parent', '*');
};
// 子窗口获取父窗口引用
console.log(window.opener); // 父窗口的 window 对象
window.opener.postMessage('hello from child', '*');
方法二:localStorage(同源跨标签页)
javascript
// 窗口 A:写入数据
localStorage.setItem('crossTabData', JSON.stringify({ userId: 123, time: Date.now() }));
// 窗口 B:监听变化
window.addEventListener('storage', (e) => {
if (e.key === 'crossTabData') {
const data = JSON.parse(e.newValue);
console.log('收到跨标签页数据:', data);
}
});
⚠️ storage 事件特点:
- 只在其他标签页触发,当前页面修改不会触发自身事件
- 仅在同源页面间生效
- 只能监听
localStorage,sessionStorage不触发
⭐⭐ Q2:BroadcastChannel 是什么?比 localStorage 好在哪里?
答:
javascript
// 创建广播频道(同源跨标签页/iframe)
const channel = new BroadcastChannel('app_channel');
// 发送消息
channel.postMessage({ type: 'LOGIN', userId: 123 });
// 接收消息
channel.onmessage = (event) => {
console.log('收到广播:', event.data);
};
// 关闭频道
channel.close();
| 特性 | localStorage + storage 事件 | BroadcastChannel |
|---|---|---|
| API 设计 | 间接(通过存储变更) | 直接的消息 API |
| 数据类型 | 只能存字符串 | 支持结构化克隆(对象、数组、Blob 等) |
| 性能 | 写入磁盘,较慢 | 内存通信,更快 |
| 隐私模式 | 可能不可用 | 可用 |
| 兼容性 | 全浏览器 | Chrome 54+, Firefox 38+, Safari 15.4+ |
⭐⭐⭐ Q3:如何实现"获取前一个窗口的数据"?有哪些方案?
答:
场景:用户从页面 A 跳转到页面 B,页面 B 需要获取页面 A 的状态。
方案对比:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| URL 参数 | ?data=xxx |
简单、可分享 | 数据量受限、暴露敏感信息 |
| sessionStorage | 跳转前写入,跳转后读取 | 不暴露 URL、容量大(5MB) | 只能同源、页面关闭即清除 |
| localStorage | 同上,但持久化 | 跨会话可用 | 需手动清理、多标签冲突 |
| BroadcastChannel | 跳转前广播,新页监听 | 实时、结构化数据 | 需要短暂时间窗口 |
| postMessage + opener | 新页读取 window.opener |
可跨域(配合 postMessage) | 仅限 window.open 打开 |
| SharedWorker | 共享 Worker 存储状态 | 多标签共享内存 | 兼容性差、复杂 |
| Service Worker + Cache | SW 作为中间层 | 离线可用 | 实现复杂 |
javascript
// ===== 推荐方案:sessionStorage(最可靠)=====
// 页面 A:跳转前保存
function navigateToB() {
sessionStorage.setItem('prevPageData', JSON.stringify({
from: 'pageA',
selectedItems: [1, 2, 3],
timestamp: Date.now()
}));
location.href = '/page-b';
}
// 页面 B:加载时读取
window.addEventListener('DOMContentLoaded', () => {
const prevData = sessionStorage.getItem('prevPageData');
if (prevData) {
const data = JSON.parse(prevData);
console.log('来自上一页的数据:', data);
// 使用后清理
sessionStorage.removeItem('prevPageData');
}
});
// ===== 方案二:BroadcastChannel(实时同步)=====
// 页面 A
const channel = new BroadcastChannel('page_transition');
channel.postMessage({ source: 'pageA', payload: { userId: 123 } });
// 页面 B(在新标签页打开时立即能收到)
const channel = new BroadcastChannel('page_transition');
channel.onmessage = (e) => {
if (e.data.source === 'pageA') {
console.log('收到上一页数据:', e.data.payload);
}
};
// ===== 方案三:URL + History State(配合路由)=====
// 页面 A
history.pushState({ from: 'pageA', data: { id: 1 } }, '', '/page-b');
// 页面 B
console.log(history.state); // { from: 'pageA', data: { id: 1 } }
⭐⭐⭐⭐ Q4:如何实现多标签页间的"单点登录"状态同步?
答:
javascript
class CrossTabAuth {
constructor() {
this.channel = new BroadcastChannel('auth_channel');
this.init();
}
init() {
// 监听其他标签页的登录状态变化
this.channel.onmessage = (event) => {
switch (event.data.type) {
case 'LOGIN':
this.syncLogin(event.data.token);
break;
case 'LOGOUT':
this.syncLogout();
break;
case 'TOKEN_REFRESH':
this.updateToken(event.data.token);
break;
}
};
// 监听 storage(兼容旧浏览器)
window.addEventListener('storage', (e) => {
if (e.key === 'auth_event') {
const event = JSON.parse(e.newValue);
this.handleAuthEvent(event);
}
});
}
login(token) {
localStorage.setItem('accessToken', token);
// 广播给其他标签页
this.channel.postMessage({ type: 'LOGIN', token });
this.broadcastStorage({ type: 'LOGIN', token });
}
logout() {
localStorage.removeItem('accessToken');
this.channel.postMessage({ type: 'LOGOUT' });
this.broadcastStorage({ type: 'LOGOUT' });
window.location.href = '/login';
}
syncLogin(token) {
// 其他标签页登录,本页自动同步
localStorage.setItem('accessToken', token);
console.log('检测到其他标签页登录,已同步状态');
}
syncLogout() {
// 其他标签页登出,本页同步登出
localStorage.removeItem('accessToken');
alert('您的账号已在其他窗口登出');
window.location.href = '/login';
}
broadcastStorage(data) {
// 降级方案:通过 localStorage 触发 storage 事件
localStorage.setItem('auth_event', JSON.stringify({
...data,
timestamp: Date.now()
}));
}
}
// 使用
const auth = new CrossTabAuth();
三、窗口管理与导航控制
⭐ Q1:window.open 有哪些常用参数和注意事项?
答:
javascript
// 完整参数
const win = window.open(
'https://example.com', // URL
'windowName', // 窗口名称(同名将复用窗口)
'width=800,height=600,left=100,top=100,resizable=yes,scrollbars=yes'
);
// 常用窗口特性
const features = [
'width=800', // 窗口宽度
'height=600', // 窗口高度
'left=100', // 距离屏幕左边缘
'top=100', // 距离屏幕上边缘
'resizable=yes', // 可调整大小
'scrollbars=yes', // 显示滚动条
'status=yes', // 显示状态栏
'toolbar=no', // 隐藏工具栏
'menubar=no', // 隐藏菜单栏
'location=no', // 隐藏地址栏
'noopener=yes', // 新窗口不持有 opener 引用(安全)
'noreferrer=yes' // 不发送 Referer 头(安全)
].join(',');
安全最佳实践:
javascript
// 1. 始终使用noopener防止tabnabbing攻击
const win = window.open(url, '_blank', 'noopener,noreferrer');
// 2. 检查弹窗是否被拦截
if (!win || win.closed || typeof win.closed === 'undefined') {
alert('弹窗被浏览器拦截,请允许弹窗');
}
// 3. 关闭窗口前检查
if (win && !win.closed) {
win.close();
}
⭐⭐ Q2:如何检测当前窗口是否被其他窗口通过 window.open 打开?
答:
javascript
// 检测当前窗口是否为弹出窗口
if (window.opener) {
console.log('本窗口由其他窗口打开');
console.log('opener URL:', window.opener.location.href); // 同域可访问
// 安全:检查 opener 来源
if (window.opener.location.origin !== location.origin) {
// 非同域,opener 可能被恶意篡改
window.opener = null; // 断开引用,防止 tabnabbing
}
}
// 检测是否在 iframe 中
if (window.self !== window.top) {
console.log('本页面在 iframe 中');
console.log('父窗口:', window.parent);
console.log('顶层窗口:', window.top);
}
// 检测是否被嵌套多层
console.log('嵌套深度:', window.parent === window.top ? 1 : '多层');
⭐⭐⭐ Q3:如何实现"只允许单标签页登录"?
答:
javascript
class SingleTabLock {
constructor() {
this.channel = new BroadcastChannel('tab_lock');
this.tabId = Math.random().toString(36).slice(2);
this.isMaster = false;
this.init();
}
init() {
// 询问当前是否有主标签页
this.channel.postMessage({ type: 'WHO_IS_MASTER' });
this.channel.onmessage = (e) => {
if (e.data.tabId === this.tabId) return;
switch (e.data.type) {
case 'WHO_IS_MASTER':
if (this.isMaster) {
this.channel.postMessage({ type: 'I_AM_MASTER', tabId: this.tabId });
}
break;
case 'I_AM_MASTER':
this.handleMasterExists(e.data.tabId);
break;
case 'CLAIM_MASTER':
if (this.isMaster && e.data.tabId !== this.tabId) {
// 有冲突,比较时间戳
this.resolveConflict(e.data);
}
break;
}
};
// 如果没有收到响应,自己成为主标签页
setTimeout(() => {
if (!this.isMaster) {
this.claimMaster();
}
}, 500);
// 页面关闭时释放锁
window.addEventListener('beforeunload', () => {
if (this.isMaster) {
this.channel.postMessage({ type: 'MASTER_LEAVING' });
}
});
}
claimMaster() {
this.isMaster = true;
localStorage.setItem('master_tab_id', this.tabId);
console.log('本页成为主标签页');
}
handleMasterExists(masterId) {
console.log('已有主标签页:', masterId);
// 显示提示或禁用某些功能
document.body.classList.add('slave-tab');
}
resolveConflict(other) {
// 简单策略:保持现状,或根据时间戳决定
}
}
四、浏览器历史栈操作
⭐ Q1:history.pushState 和 history.replaceState 的区别?
答:
| 特性 | pushState |
replaceState |
|---|---|---|
| 历史记录 | 新增一条记录 | 替换当前记录 |
| 后退行为 | 可后退到上一页 | 不能后退(当前页被替换) |
| 典型场景 | 路由跳转、分页 | 登录后替换、修改 URL 参数 |
javascript
// pushState:新增历史记录
history.pushState({ page: 2 }, '', '/page/2');
// 用户点击后退 → 回到 /page/1
// replaceState:替换当前记录
history.replaceState({ page: 1 }, '', '/page/1');
// 用户点击后退 → 回到更前一页(跳过当前页)
⚠️ 重要 :pushState/replaceState 不会触发页面加载,需要手动处理内容更新。
⭐⭐ Q2:如何监听浏览器的前进/后退按钮?
答:
javascript
// popstate 事件:监听前进/后退
window.addEventListener('popstate', (event) => {
console.log('用户点击了前进或后退');
console.log('当前状态:', event.state); // pushState 传入的 state 对象
// 根据 state 恢复页面内容
if (event.state?.page) {
loadPage(event.state.page);
}
});
// 注意:pushState/replaceState 不会触发 popstate!
history.pushState({ page: 2 }, '', '/page/2');
// 不会触发 popstate
完整的路由管理封装:
javascript
class HistoryRouter {
constructor() {
this.routes = {};
window.addEventListener('popstate', (e) => this.handleChange(e.state));
}
register(path, handler) {
this.routes[path] = handler;
}
push(path, state = {}) {
history.pushState(state, '', path);
this.handleChange(state);
}
replace(path, state = {}) {
history.replaceState(state, '', path);
this.handleChange(state);
}
back() {
history.back();
}
handleChange(state) {
const path = location.pathname;
const handler = this.routes[path] || this.routes['/404'];
handler?.(state);
}
}
⭐⭐⭐ Q3:如何实现"浏览器前进/后退时恢复页面滚动位置"?
答:
javascript
// 保存滚动位置
const scrollPositions = new Map();
// 页面滚动时记录位置(防抖)
let scrollTimer;
window.addEventListener('scroll', () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
const key = location.pathname + location.search;
scrollPositions.set(key, { x: window.scrollX, y: window.scrollY });
// 同时存入 history.state
history.replaceState(
{ ...history.state, scrollY: window.scrollY },
''
);
}, 100);
});
// 前进/后退时恢复
window.addEventListener('popstate', () => {
const scrollY = history.state?.scrollY || 0;
window.scrollTo(0, scrollY);
});
// 或者使用原生 scrollRestoration(推荐)
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual'; // 关闭浏览器自动恢复
// 或 history.scrollRestoration = 'auto'; // 让浏览器自动处理
}
五、页面可见性与后台运行
⭐ Q1:document.visibilityState 有哪些状态?如何使用?
答:
javascript
// visibilityState 有三个值:
// 'visible' → 页面至少部分可见
// 'hidden' → 页面完全不可见(最小化、切换标签)
// 'prerender' → 页面正在预渲染(部分浏览器支持)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
console.log('页面进入后台');
// 暂停轮询、视频、动画
stopPolling();
video.pause();
} else if (document.visibilityState === 'visible') {
console.log('页面回到前台');
// 恢复轮询、视频
startPolling();
video.play();
}
});
// 实用场景:统计页面真实停留时间
let visibleTime = 0;
let lastVisibleTime = Date.now();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
lastVisibleTime = Date.now();
} else {
visibleTime += Date.now() - lastVisibleTime;
}
});
⭐⭐ Q2:页面进入后台后,setInterval 和 setTimeout 会有什么变化?
答:
| 定时器类型 | 后台行为 | 解决方案 |
|---|---|---|
setTimeout |
延迟时间可能变长(浏览器节流) | 使用 Web Worker 保持计时 |
setInterval |
间隔可能变长,甚至被暂停 | 同上 |
requestAnimationFrame |
完全暂停 | 回到前台时补帧 |
javascript
// 后台计时方案:Web Worker
// timer.worker.js
let timer;
self.onmessage = (e) => {
if (e.data === 'start') {
timer = setInterval(() => {
self.postMessage('tick');
}, 1000);
} else if (e.data === 'stop') {
clearInterval(timer);
}
};
// 主页面
const worker = new Worker('timer.worker.js');
worker.postMessage('start');
worker.onmessage = () => {
console.log('1秒过去了(即使在后台也准确)');
};
⭐⭐⭐ Q3:如何实现"用户离开页面一段时间后自动登出"?
答:
javascript
class AutoLogout {
constructor(timeout = 30 * 60 * 1000) { // 默认30分钟
this.timeout = timeout;
this.timer = null;
this.lastActivity = Date.now();
this.init();
}
init() {
// 监听用户活动
['click', 'mousemove', 'keydown', 'scroll', 'touchstart'].forEach(event => {
document.addEventListener(event, () => this.resetTimer(), true);
});
// 页面可见性变化
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
// 检查离开期间是否超时
if (Date.now() - this.lastActivity > this.timeout) {
this.logout();
} else {
this.resetTimer();
}
}
});
this.resetTimer();
}
resetTimer() {
this.lastActivity = Date.now();
clearTimeout(this.timer);
this.timer = setTimeout(() => this.logout(), this.timeout);
}
logout() {
alert('长时间未操作,已自动登出');
// 清除 token 并跳转
localStorage.removeItem('token');
location.href = '/login';
}
}
new AutoLogout(30 * 60 * 1000); // 30分钟无操作自动登出
六、存储机制底层原理
⭐ Q1:localStorage、sessionStorage、cookie 的区别?
答:
| 特性 | localStorage | sessionStorage | cookie |
|---|---|---|---|
| 容量 | ~5-10 MB | ~5-10 MB | ~4 KB |
| 生命周期 | 永久(需手动清除) | 页面会话(关闭标签即清除) | 可设置 Expires/Max-Age |
| 作用域 | 同源窗口共享 | 同源同标签页 | 可设置 Domain/Path |
| 服务端读取 | ❌ 不能 | ❌ 不能 | ✅ 自动随请求发送 |
| 跨标签通信 | ✅ storage 事件 | ❌ 不触发事件 | ❌ |
| 性能 | 同步读写,阻塞主线程 | 同步读写 | 每次请求携带,增加开销 |
⭐⭐ Q2:IndexedDB 适合什么场景?和 localStorage 相比有什么优势?
答:
javascript
// IndexedDB 基础操作
const request = indexedDB.open('MyDatabase', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
const store = db.createObjectStore('users', { keyPath: 'id' });
store.createIndex('name', 'name', { unique: false });
};
request.onsuccess = (event) => {
const db = event.target.result;
// 写入
const tx = db.transaction('users', 'readwrite');
const store = tx.objectStore('users');
store.add({ id: 1, name: 'Alice', data: largeBlob });
// 读取
const getReq = store.get(1);
getReq.onsuccess = () => console.log(getReq.result);
};
| 特性 | localStorage | IndexedDB |
|---|---|---|
| 容量 | ~5MB | 理论上无上限(通常 50MB+,可请求更多) |
| 数据类型 | 仅字符串 | 任意结构化数据(对象、Blob、ArrayBuffer) |
| 查询能力 | 无(只能 key-value) | 索引、范围查询、游标遍历 |
| 事务支持 | ❌ | ✅ |
| 异步 | ❌ 同步(阻塞) | ✅ 异步(不阻塞主线程) |
| 适用场景 | 简单配置、token | 离线应用、大量数据缓存、文件存储 |
⭐⭐⭐ Q3:Storage 事件为什么在当前页面修改时不触发?如何 workaround?
答:
javascript
// Storage 事件的设计意图:只通知其他同源页面
localStorage.setItem('key', 'value');
// 当前页面不会触发 storage 事件!
// ===== Workaround 1:自定义事件 + Storage 事件组合 =====
class CrossTabStorage {
constructor() {
this.listeners = new Map();
window.addEventListener('storage', (e) => this.notify(e.key, e.newValue));
}
setItem(key, value) {
const oldValue = localStorage.getItem(key);
localStorage.setItem(key, value);
// 手动触发当前页面的监听器
if (oldValue !== value) {
this.notify(key, value);
}
}
onChange(key, callback) {
if (!this.listeners.has(key)) this.listeners.set(key, []);
this.listeners.get(key).push(callback);
}
notify(key, value) {
this.listeners.get(key)?.forEach(cb => cb(value));
}
}
// ===== Workaround 2:使用 BroadcastChannel(推荐)=====
const channel = new BroadcastChannel('storage_sync');
function setItem(key, value) {
localStorage.setItem(key, value);
channel.postMessage({ key, value, source: 'self' });
}
channel.onmessage = (e) => {
console.log('存储变化(包括当前页面):', e.data);
};
七、窗口尺寸与视口控制
⭐ Q1:如何获取窗口的各种尺寸信息?
答:
javascript
// 屏幕尺寸(物理显示器)
screen.width; // 屏幕总宽度
screen.height; // 屏幕总高度
screen.availWidth; // 可用宽度(排除任务栏)
screen.availHeight; // 可用高度
screen.pixelRatio; // DPR(设备像素比)
// 窗口尺寸(浏览器窗口)
window.innerWidth; // 视口宽度(含滚动条)
window.innerHeight; // 视口高度(含滚动条)
window.outerWidth; // 窗口总宽度(含边框、工具栏)
window.outerHeight; // 窗口总高度
// 文档尺寸
document.documentElement.clientWidth; // 视口宽度(不含滚动条)
document.documentElement.clientHeight; // 视口高度(不含滚动条)
document.documentElement.scrollWidth; // 文档总宽度
document.documentElement.scrollHeight; // 文档总高度
// 滚动位置
window.scrollX || window.pageXOffset; // 水平滚动
window.scrollY || window.pageYOffset; // 垂直滚动
⭐⭐ Q2:如何实现"进入全屏"和"退出全屏"?
答:
javascript
// 进入全屏
function enterFullscreen(element = document.documentElement) {
if (element.requestFullscreen) {
element.requestFullscreen();
} else if (element.webkitRequestFullscreen) {
element.webkitRequestFullscreen();
} else if (element.msRequestFullscreen) {
element.msRequestFullscreen();
}
}
// 退出全屏
function exitFullscreen() {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
}
}
// 监听全屏变化
document.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement) {
console.log('进入全屏');
} else {
console.log('退出全屏');
}
});
// 检测当前是否全屏
const isFullscreen = !!document.fullscreenElement;
⭐⭐⭐ Q3:如何实现"监听元素进入视口"(懒加载)?
答:
javascript
// 现代方案:IntersectionObserver(性能优于 scroll 监听)
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 元素进入视口
const img = entry.target;
img.src = img.dataset.src; // 加载真实图片
observer.unobserve(img); // 停止观察(一次性)
}
});
}, {
root: null, // 视口作为根
rootMargin: '100px', // 提前 100px 触发
threshold: 0.1 // 元素可见 10% 时触发
});
// 观察所有懒加载图片
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img);
});
// 兼容性降级(IE)
function fallbackLazyLoad() {
const images = document.querySelectorAll('img[data-src]');
images.forEach(img => {
const rect = img.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
img.src = img.dataset.src;
}
});
}
window.addEventListener('scroll', throttle(fallbackLazyLoad, 200));
八、打印与媒体控制
⭐ Q1:如何实现打印特定区域?
答:
javascript
// 方法 1:使用 window.print() + CSS 媒体查询
@media print {
body * {
display: none !important; // 隐藏所有内容
}
#print-area, #print-area * {
display: block !important; // 只显示打印区域
}
}
// 触发打印
function printArea() {
window.print();
}
// 方法 2:动态创建 iframe(不影响当前页面)
function printHTML(html) {
const iframe = document.createElement('iframe');
iframe.style.position = 'absolute';
iframe.style.left = '-9999px';
document.body.appendChild(iframe);
iframe.contentDocument.write(html);
iframe.contentDocument.close();
iframe.contentWindow.focus();
iframe.contentWindow.print();
// 打印完成后移除
iframe.contentWindow.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
⭐⭐ Q2:如何检测用户是否正在使用"打印预览"?
答:
javascript
// 监听打印事件
window.addEventListener('beforeprint', () => {
console.log('用户打开了打印预览');
// 可以在这里加载高分辨率图片、展开折叠内容等
});
window.addEventListener('afterprint', () => {
console.log('用户关闭了打印预览或完成打印');
});
// 检测打印样式是否生效(通过 matchMedia)
const mediaQueryList = window.matchMedia('print');
mediaQueryList.addListener((mql) => {
if (mql.matches) {
console.log('打印媒体查询匹配');
}
});
九、底层事件与系统交互
⭐ Q1:navigator.sendBeacon 是什么?有什么用?
答:
javascript
// sendBeacon:在页面卸载时可靠地发送数据
window.addEventListener('unload', () => {
// ❌ 不可靠:unload 中同步请求可能被浏览器取消
// fetch('/api/log', { method: 'POST', body: data });
});
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// ✅ 可靠:即使页面关闭也会发送
navigator.sendBeacon('/api/log', JSON.stringify({
page: location.href,
duration: Date.now() - enterTime,
timestamp: Date.now()
}));
}
});
| 特性 | fetch/XHR | sendBeacon |
|---|---|---|
| 异步 | ✅ | ✅(但不可见) |
| 页面关闭后 | ❌ 可能失败 | ✅ 浏览器保证发送 |
| 返回值 | Promise/Response | boolean(是否入队成功) |
| 数据格式 | 任意 | BodyInit(Blob、FormData、字符串等) |
| 大小限制 | 无 | ~64KB(各浏览器不同) |
⭐⭐ Q2:如何复制内容到剪贴板?
答:
javascript
// 现代 API:Clipboard API(需要 HTTPS + 用户交互触发)
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
console.log('复制成功');
} catch (err) {
console.error('复制失败:', err);
// 降级方案
fallbackCopy(text);
}
}
// 降级方案(兼容旧浏览器)
function fallbackCopy(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy'); // 已废弃但仍有兼容性
document.body.removeChild(textarea);
}
// 读取剪贴板
async function readClipboard() {
try {
const text = await navigator.clipboard.readText();
console.log('剪贴板内容:', text);
} catch (err) {
console.error('读取失败(需要用户授权):', err);
}
}
⭐⭐⭐ Q3:如何实现"页面离开前发送埋点数据"?
答:
javascript
class PageTracker {
constructor() {
this.events = [];
this.init();
}
init() {
// 1. 定期批量发送
setInterval(() => this.flush(), 5000);
// 2. 页面隐藏时立即发送(最可靠)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.flush(true); // true = 使用 sendBeacon
}
});
// 3. beforeunload 兜底
window.addEventListener('beforeunload', () => {
this.flush(true);
});
}
track(event) {
this.events.push({
...event,
timestamp: Date.now(),
url: location.href
});
// 事件过多时立即发送
if (this.events.length >= 10) {
this.flush();
}
}
flush(useBeacon = false) {
if (this.events.length === 0) return;
const payload = JSON.stringify(this.events);
this.events = [];
if (useBeacon && navigator.sendBeacon) {
navigator.sendBeacon('/api/track', payload);
} else {
fetch('/api/track', {
method: 'POST',
body: payload,
keepalive: true // 页面关闭后仍尝试发送(部分浏览器支持)
}).catch(() => {
// 发送失败,存回队列(使用 IndexedDB 持久化)
this.saveFailed(payload);
});
}
}
async saveFailed(payload) {
// 存入 IndexedDB,下次页面打开时重试
const db = await openDB('TrackerDB', 1);
await db.add('failed', { payload, time: Date.now() });
}
}
十、安全策略与跨域限制
⭐ Q1:window.opener 有什么安全风险?如何防护?
答:
Tabnabbing 攻击 :恶意页面通过 window.opener 篡改原页面的 location。
javascript
// 攻击场景:
// 1. 用户在正常页面 A 点击链接打开恶意页面 B
// 2. 页面 B 通过 window.opener.location = '钓鱼网站' 篡改页面 A
// 3. 用户回到页面 A 时,看到的是钓鱼网站
// 防护方案 1:使用 noopener
<a href="https://untrusted.com" target="_blank" rel="noopener noreferrer">
外部链接
</a>
// 防护方案 2:JS 打开窗口时
const win = window.open('https://untrusted.com', '_blank', 'noopener,noreferrer');
// 防护方案 3:如果必须保留 opener,限制其能力
if (window.opener) {
window.opener = null; // 主动断开引用
}
⭐⭐ Q2:postMessage 跨窗口通信时如何保证安全?
答:
javascript
// ❌ 不安全的写法
window.postMessage(data, '*'); // 允许任何来源接收
// ✅ 安全的写法
// 发送方:明确指定目标 origin
otherWindow.postMessage(data, 'https://trusted-domain.com');
// 接收方:严格验证来源
window.addEventListener('message', (event) => {
// 1. 验证来源
if (event.origin !== 'https://trusted-domain.com') {
return;
}
// 2. 验证发送窗口(如果是从特定窗口发送)
if (event.source !== expectedWindow) {
return;
}
// 3. 验证数据格式(防止注入)
if (!isValidMessage(event.data)) {
return;
}
console.log('安全接收:', event.data);
});
function isValidMessage(data) {
return typeof data === 'object' &&
data.type &&
typeof data.type === 'string';
}
⭐⭐⭐ Q3:同源策略对 window 操作有哪些限制?
答:
| 操作 | 同源 | 跨域 |
|---|---|---|
window.location 读取 |
✅ | ✅(只能读取写入后的值) |
window.location 写入 |
✅ | ✅ |
window.document 读取 |
✅ | ❌ |
window.document 写入 |
✅ | ❌ |
window.opener 访问 |
✅ | 有限(可设置 location) |
postMessage |
✅ | ✅(需指定 origin) |
localStorage |
✅ | ❌ |
sessionStorage |
✅ | ❌ |
IndexedDB |
✅ | ❌ |
cookies |
✅ | 受 Domain/Path 限制 |
window.parent |
✅ | 有限 |
window.frames |
✅ | 有限 |
🎯 面试速查表
| 知识点 | 核心要点 |
|---|---|
| 阻止关闭 | beforeunload + event.preventDefault() + event.returnValue = '' |
| 页面生命周期 | beforeunload → visibilitychange → pagehide → unload |
| bfcache | 用 pagehide 替代 unload,pageshow 检测 event.persisted |
| 跨标签通信 | BroadcastChannel(首选)→ localStorage + storage → postMessage |
| 获取前窗口数据 | sessionStorage(最可靠)→ BroadcastChannel → history.state |
| 单标签登录 | BroadcastChannel 广播 + localStorage 降级 |
| 历史栈 | pushState(新增)/ replaceState(替换)+ popstate 监听 |
| 后台计时 | Web Worker 保持 setInterval 准确性 |
| 自动登出 | 监听用户活动 + visibilitychange + 定时器 |
| 存储选择 | localStorage(简单配置)→ IndexedDB(大数据/结构化)→ cookie(服务端读取) |
| 打印控制 | window.print() + @media print CSS |
| 埋点发送 | navigator.sendBeacon(页面关闭时可靠) |
| 剪贴板 | navigator.clipboard.writeText() + 降级 execCommand |
| 安全 | noopener 防 tabnabbing,postMessage 严格验证 origin |
📌 面试建议 :回答浏览器底层问题时,建议从 API 用法 → 底层原理 → 安全限制 → 工程实践 四个层次展开,体现对浏览器机制的系统性理解。