前端核心原理深度话题
事件循环 · 闭包 · this · 原型链 · 浏览器渲染 · 重排重绘 — 八股但有实战故事
🎬 怎么引出(避免干背八股)
面试官问"了解 JS 吗",别干背"事件循环是…"。用踩坑引出:「我对事件循环的理解来自一次踩坑 — 有次写 async 循环里 await,期望串行结果却并发了,排查发现是 Promise.all 的微任务穿插。还有闭包,大屏 7×24 跑内存一直涨,定位就是闭包持有大对象 GC 回收不掉。」八股 + 真实故事,立刻不一样。
1
事件循环:宏任务 vs 微任务
🧠 一句话理解
JS 单线程,事件循环就是「调用栈空了 → 清微任务 → 取一个宏任务 → 再清微任务 → 循环」。微任务(Promise.then)在每轮宏任务后清空,优先级高于宏任务(setTimeout)。
📋 经典输出题(必考)
console.log('1') // 同步
setTimeout(() => console.log('2'), 0) // 宏任务
Promise.resolve().then(() => console.log('3')) // 微任务
console.log('4') // 同步
// 输出: 1 4 3 2 (同步 → 微任务 → 宏任务)
关键: 微任务优先级 > 宏任务。每轮宏任务执行完,把所有积压的微任务清空,才取下一个宏任务。所以 Promise.then 永远在 setTimeout 之前(即使 setTimeout 是 0ms)。
⚠️ 实战坑:async 循环里的并发问题
现象: for...of 里 await 期望串行,但写成 Promise.all(arr.map(async...)) 就并发了(map 立即执行所有 async)。
区别: for...of + await 是串行(等上一个);Promise.all + map 是并发(同时跑,一起等)。串行用 for...of,并发用 Promise.all,别混。
2
闭包与内存泄漏
🔑 闭包本质
函数 + 它引用的外层变量,组成闭包。函数return了,但引用的外层变量还活着(被函数持有,GC 不回收)。这是模块化、私有变量的基础,也是内存泄漏的源头。
function createCounter() {
let count = 0 // 被 return 的函数引用,不会被回收
return () => ++count
}
const counter = createCounter()
counter() // 1, count 还活着
⚠️ 真实泄漏:大屏长跑
场景: 大屏 7×24 跑,某个定时器回调里引用了一个大 option 对象(闭包持有),即使 option 逻辑上不用了,GC 也回收不掉,内存阶梯式上涨。
常见泄漏源: ① 定时器未 clear(回调持有外部变量);② 事件监听未 removeEventListener;③ 闭包持有不需要的大对象;④ 脱离 DOM 的引用;⑤ 全局变量。排查: Chrome Memory → Heap Snapshot 对比,看 Retained Size 大的对象。
3
this 指向(5 条规则)
| 场景 | this 指向 |
|---|---|
| 普通函数调用 | window(严格模式 undefined) |
| 对象方法调用 | 调用它的对象 |
| new 构造 | 新创建的对象 |
| call/apply/bind | 指定的对象 |
| 箭头函数 | 继承外层(定义时确定,不被改变) |
高频坑: 回调里的 this 丢了。
obj.fn() 直接调,this 是 obj;但 const f = obj.fn; f() 或传给 setTimeout,this 变 window。解法: 箭头函数(继承外层)、bind、或 const self = this。
4
原型链
🧠 一句话
访问对象属性,自己没有就沿 __proto__ 往上找,直到 null。原型链 = 对象之间的继承关系链。实例.__proto__ === 构造函数.prototype,构造函数.prototype.constructor === 构造函数。
实战意义: ① 理解 class 本质(语法糖,背后是原型链);② 判断类型
Object.prototype.toString.call(x) 最准;③ 别在原型上加属性(实例共享,Vue2 数组方法改原型有坑)。
5
浏览器渲染全链路(从 URL 到画面)
📋 完整链路(每个环节对应优化)
DNS 解析 → TCP 三次握手 → TLS 握手(HTTPS) → 发请求 → 收响应
→ HTML 解析(构建 DOM) → CSS 解析(构建 CSSOM)
→ 合成 Render Tree → Layout(布局) → Paint(绘制) → Composite(合成) → 显示
关键阻塞点: ① CSS 阻塞渲染(CSSOM 没好不渲染);② JS 阻塞 HTML 解析(script 会停下解析等执行完);③ JS 依赖 CSSOM(JS 可能读样式,所以 CSS 没解析完 JS 不执行)。这是 defer/async 优化的根本原因。
6
重排重绘合成层
重排(Reflow)
- 改了几何属性(width/height/位置)
- 要重新 Layout,代价大
- top/left/margin 触发
重绘(Repaint)
- 只改颜色/背景/阴影
- 不重新 Layout,代价小
- color/background 触发
最优:合成层(Composite) 改
transform 和 opacity 既不重排也不重绘,只在合成阶段处理(GPU)。动画一定用 transform 不用 top/left。
⚠️ 强制同步布局(读写交替)
坑: el.style.width = '100px'; const h = el.offsetHeight; el.style.width = '200px' — 读 offsetHeight 会强制立即 Layout(因为前面改了 width),然后又改,又 Layout。读写交替 = 反复重排。
解法: 批量读、批量写。先读完所有 offset,再统一写 style。或用 requestAnimationFrame 延迟写。
7
高频追问
1宏任务微任务区别?输出顺序? 必问
每轮宏任务执行完,清空所有微任务,才取下一个宏任务。微任务优先级更高。Promise.then 是微任务,setTimeout 是宏任务。输出顺序:同步代码 → 微任务 → 宏任务。
2闭包应用和泄漏? 高频
应用: 模块化(私有变量)、柯里化、防抖节流。泄漏: 闭包持有的大对象不会被 GC 回收。常见:定时器/事件监听未清理。排查:Chrome Heap Snapshot 对比 Retained Size。
3箭头函数和普通函数 this 区别? 必问
箭头函数没有自己的 this,继承定义时外层的 this,且不能被 call/apply/bind 改变。普通函数 this 在调用时确定,取决于调用方式。React 用箭头函数避免 this 丢失;Vue 不建议用箭头函数(methods 里,this 会变成 undefined 而非组件)。
4动画为什么用 transform 不用 top? 高频
top/left 触发重排(重新 Layout,主线程,慢);transform 只触发合成(GPU,不重排不重绘,快)。will-change: transform 提前提升为合成层。60fps 动画必须用 transform + opacity。
5JS 为什么设计成单线程? 中等
浏览器场景决定:JS 要操作 DOM,如果多线程,两个线程同时改一个 DOM 会冲突。单线程避免竞争。但为不阻塞,用事件循环 + 异步(Promise/setTimeout/Web Worker)。Web Worker 是 JS 多线程方案,但不能操作 DOM。
6事件委托原理? 基础
利用事件冒泡,把子元素的事件监听绑在父元素上。e.target 是实际触发元素。优点:① 动态添加的子元素也能响应(不用重新绑);② 节省内存(不用每个子元素都绑)。适用:列表项点击、表格行操作。
8
30 秒陈述
🎤 30 秒版
「前端核心原理我不是背的,是从实战里理解的。比如闭包 — 大屏 7×24 跑内存涨,定位就是定时器回调闭包持有了大 option 对象 GC 不掉;事件循环 — 写 async 循环踩过 Promise.all 并发 vs for await 串行的坑;渲染 — 动画卡顿,从 top 改成 transform 后流畅了,因为不触发重排。原理我都能结合具体场景讲。」