JavaScript 运行机制与资深面试
从类型转换、执行上下文、原型链和事件循环深入到并发控制、内存管理与工程边界。
学习 JavaScript 应关注代码在运行时如何求值、查找变量和调度任务,而不只是记住语法形式。
值、类型与比较
JavaScript 有七种原始类型:string、number、bigint、boolean、undefined、symbol 和 null。对象、数组、函数都属于对象类型。
const count = 1
const enabled = true
const empty = null
const missing = undefined
typeof count // "number"
typeof empty // "object",这是历史遗留行为
原始值按值复制,对象变量保存的是引用:
const original = { count: 1 }
const shared = original
shared.count += 1
console.log(original.count) // 2
展开语法只执行浅复制。嵌套对象仍然共享引用;需要深复制时,应根据数据类型选择结构化克隆或显式转换。
优先使用严格相等 ===。宽松相等 == 会执行隐式类型转换,使边界行为更难预测。
变量、作用域与提升
现代代码默认使用 const,确实需要重新赋值时才使用 let。避免使用函数作用域的 var。
function createCounter() {
let count = 0
return function increment() {
count += 1
return count
}
}
const next = createCounter()
next() // 1
next() // 2
内部函数保留了对外层词法环境的引用,这就是闭包。闭包适合封装状态、生成回调,但也会延长被引用对象的生命周期。
函数声明整体提升;let、const 在声明前处于暂时性死区,访问会抛出错误。
函数与 this
普通函数的 this 由调用方式决定,箭头函数不创建自己的 this,而是捕获外层值。
const timer = {
seconds: 0,
start() {
setInterval(() => {
this.seconds += 1
}, 1000)
},
}
将对象方法单独传递可能丢失接收者:
const user = {
name: 'Ada',
greet() {
return `Hello ${this.name}`
},
}
const greet = user.greet.bind(user)
greet() // "Hello Ada"
原型与类
对象通过原型链共享属性和方法。class 是对原型机制更清晰的语法封装。
class Article {
constructor(title) {
this.title = title
}
summarize() {
return this.title.slice(0, 20)
}
}
组合通常比深层继承更易维护。只有对象确实存在稳定的“属于某种类型”关系时,才使用继承。
数组转换
区分产生新数组的方法和修改原数组的方法:
const articles = [
{ title: 'HTML', published: true },
{ title: 'CSS', published: false },
]
const titles = articles
.filter((article) => article.published)
.map((article) => article.title)
map、filter、slice 返回新数组;sort、reverse、splice 会修改原数组。对共享数据排序前先复制:
const sorted = [...articles].sort((a, b) => a.title.localeCompare(b.title))
Promise 与 async/await
Promise 表示一个未来完成或失败的异步结果。async 函数总是返回 Promise。
async function loadArticle(id, signal) {
const response = await fetch(`/api/articles/${id}`, { signal })
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
return response.json()
}
并行且互不依赖的任务不要串行等待:
const [profile, articles] = await Promise.all([
fetchProfile(),
fetchArticles(),
])
需要允许部分失败时使用 Promise.allSettled。请求可能过期或组件可能卸载时,使用 AbortController 取消工作。
事件循环
同步代码先执行;当前调用栈清空后,运行微任务,再进入下一轮宏任务。
console.log('A')
setTimeout(() => console.log('B'), 0)
Promise.resolve().then(() => console.log('C'))
console.log('D')
// A、D、C、B
Promise 回调属于微任务,setTimeout 回调属于宏任务。连续创建大量微任务也可能阻塞渲染。
模块与边界
// math.js
export function sum(a, b) {
return a + b
}
// app.js
import { sum } from './math.js'
ES Module 使用静态导入导出,构建工具可以分析依赖并执行 tree shaking。动态导入适合按需加载较重功能:
const editor = await import('./editor.js')
editor.open()
错误处理
只捕获能够处理或补充上下文的错误,不要静默吞掉异常。
try {
await saveArticle(article)
} catch (error) {
console.error('Failed to save article', { articleId: article.id, error })
throw error
}
执行上下文、词法环境与闭包
每次进入全局代码、函数或模块时,引擎都会建立相应的执行上下文。可以用三个问题理解一次变量访问:
- 当前词法环境里是否存在这个绑定?
- 如果不存在,沿定义位置的外层环境继续查找。
- 找到后读取当前值;直到最外层仍未找到才抛出
ReferenceError。
词法作用域由函数定义在哪里决定,不由函数在哪里调用决定:
const scope = 'global'
function createReader() {
const scope = 'local'
return () => scope
}
const read = createReader()
read() // "local"
闭包不是一种特殊语法,而是“函数及其可访问的词法环境”的组合。面试中不要只回答“函数套函数”,还应说明:
- 外层函数返回后,只要内部函数仍可达,它引用的绑定就仍然可达。
- 闭包捕获的是绑定,不是创建函数那一刻的值快照。
- 闭包适合封装私有状态、函数工厂和回调上下文。
- 长生命周期监听器若捕获大对象,可能造成意外的内存保留。
经典循环问题本质上是 var 只创建一个函数级绑定:
for (var index = 0; index < 3; index += 1) {
setTimeout(() => console.log(index), 0)
}
// 3、3、3
for (let index = 0; index < 3; index += 1) {
setTimeout(() => console.log(index), 0)
}
// 0、1、2;每轮迭代都有独立绑定
类型转换与相等算法
资深面试更关注是否能解释转换过程,而不是背诵零散结果。
Boolean(value)中,false、0、-0、0n、NaN、空字符串、null、undefined为假值;空数组和空对象都是真值。- 数值运算通常触发
ToNumeric;+同时承担字符串拼接,因此会先判断是否需要转为原始值和字符串。 - 对象转原始值会尝试
Symbol.toPrimitive,再根据提示调用valueOf或toString。 Object.is与===大体相同,但Object.is(NaN, NaN)为true,且能区分0与-0。
Number('') // 0
Boolean('false') // true
[] == false // true,不应在业务代码中依赖这种转换
Number.isNaN(NaN) // true,不会先做类型转换
Object.is(0, -0) // false
回答 == 与 === 时,重点不是说“前者只比较值”,因为这不准确。== 使用抽象相等比较并可能转换类型,=== 使用严格相等比较但仍有 NaN !== NaN 等规则。工程代码默认使用 ===,只在明确利用 value == null 同时匹配 null 和 undefined 时谨慎例外。
原型链、属性描述符与 new
读取 object.key 时,引擎先检查对象自身属性,再沿 [[Prototype]] 链查找。写入时通常创建或更新自身属性,不会直接修改原型上的同名数据属性。
const prototype = { role: 'reader' }
const account = Object.create(prototype)
account.name = 'Ada'
Object.hasOwn(account, 'name') // true
Object.hasOwn(account, 'role') // false
属性不仅有值,还可能包含 writable、enumerable、configurable,或 getter/setter。Object.defineProperty 新建属性时,这三个布尔描述符默认都是 false。
new Constructor(...args) 可概括为:
- 创建一个以
Constructor.prototype为原型的新对象。 - 以该对象作为
this调用构造函数。 - 构造函数若显式返回对象,则使用该对象;否则返回步骤 1 创建的对象。
class 方法定义在原型上且默认不可枚举;类必须通过 new 调用,并在类体内使用严格模式。它改善了语义,但没有替换原型模型。
异步并发、竞态与背压
Promise.all 提供的是并发聚合,不会自动创建线程。传入的异步任务通常在构造 Promise 时就已经启动;任一 Promise 拒绝后,聚合结果立即拒绝,但其他任务不会自动取消。
大量请求应限制并发,避免压垮浏览器连接、服务端或用户设备:
async function mapWithConcurrency(items, limit, worker) {
const results = new Array(items.length)
let nextIndex = 0
async function run() {
while (nextIndex < items.length) {
const currentIndex = nextIndex
nextIndex += 1
results[currentIndex] = await worker(items[currentIndex], currentIndex)
}
}
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, () => run())
)
return results
}
搜索建议等“后发请求应覆盖先发请求”的场景要显式处理竞态:取消旧请求、记录递增版本号,或在提交结果前核对当前查询。只判断组件是否卸载不够,因为组件仍挂载时也可能发生响应乱序。
还要区分三种失败:
- 业务失败:请求成功,但结果不满足业务规则。
- 协议失败:例如
fetch收到404或500;fetch本身不会因此拒绝。 - 传输或取消失败:断网、DNS、CORS 网络错误或
AbortController取消。
事件循环与渲染的完整回答
面试中可以按以下顺序回答浏览器事件循环:
- 执行一个 task,例如初始脚本、定时器或用户事件回调。
- 调用栈清空后执行 microtask checkpoint,直到微任务队列清空。
- 浏览器可能进行样式计算、布局和绘制。
- 进入下一轮,选择下一个可运行任务。
requestAnimationFrame 在下一次绘制前执行,适合视觉更新;requestIdleCallback 只能做可延后的低优先级工作,且必须准备超时和不被调用的情况。微任务中不断追加微任务会延迟渲染和用户输入,这称为微任务饥饿。
button.addEventListener('click', () => {
queueMicrotask(() => console.log('microtask'))
requestAnimationFrame(() => console.log('before paint'))
setTimeout(() => console.log('later task'), 0)
})
定时器的延迟参数表示“至少等待多久后才有资格进入任务队列”,不保证精确执行时间。后台标签页、嵌套定时器和主线程长任务都会继续推迟它。
内存管理与泄漏定位
现代引擎主要基于可达性回收内存。只要对象还能从全局变量、当前调用栈、闭包或宿主对象等根节点访问,就不能被回收。
前端常见的意外保留来源包括:
- 已从 DOM 移除的节点仍被 JavaScript 集合或闭包引用。
- 组件销毁后,事件监听、定时器、Observer 或订阅没有释放。
- 无上限的 Map、缓存、日志队列和历史快照。
- 闭包只需要一个字段,却捕获了整个大型上下文对象。
WeakMap 的键不会阻止对象被回收,适合给对象附加元数据,但它不可枚举,不能替代所有缓存。排查时用 Memory 面板对比多次堆快照:重复执行同一交互后,相关对象数量应回落到稳定水平。单次内存上涨不等于泄漏。
资深面试追问
为什么 await 不会阻塞整个页面?
await 只暂停当前 async 函数。函数先返回一个 pending Promise;等待对象完成后,后续代码作为微任务恢复。主线程可以先结束当前任务并处理其他事件。但如果 await 前后仍执行大量同步计算,主线程照样会被阻塞。(深入阅读:Promise 与 async/await)
如何设计一个可靠的请求层?
先明确边界,再谈封装:统一 URL、认证和可观测字段;区分超时、取消、网络、HTTP 与业务错误;只对幂等且可恢复的请求重试,并使用指数退避和随机抖动;透传 AbortSignal;限制并发;避免把服务端错误原文直接展示给用户。请求缓存与去重应由明确的键、有效期和失效策略驱动。(深入阅读:异步并发、竞态与背压)
防抖和节流分别解决什么问题?
防抖在连续触发停止一段时间后执行,适合搜索输入和草稿保存;节流限制固定时间内最多执行一次,适合滚动采样。完整实现还要明确 leading、trailing、取消、立即刷新、返回值和异常语义。动画场景优先考虑 requestAnimationFrame,它与浏览器帧同步。(深入阅读:防抖与节流的区别)
如何回答“深拷贝怎么实现”?
先反问数据契约。JSON 往返会丢失 undefined、Symbol、函数、原型,并改变 Date、Map、Set 等类型,也不能处理循环引用。structuredClone 支持循环引用和多种内建类型,还能转移 transferable 对象,但仍不能克隆函数、DOM 节点等。业务模型常常更适合显式复制所需字段,而不是无差别深拷贝。(深入阅读:完整的深拷贝实现)
实践检查
- 默认使用
const,避免隐式全局变量。 - 外部输入先校验,再进入业务逻辑。
- 不直接修改共享数组和对象。
- 互不依赖的异步任务并行执行,并处理取消与失败。
- 明确普通函数和箭头函数的
this差异。 - 错误包含足够上下文,但日志中不泄露敏感数据。
- 能从执行上下文和词法环境解释闭包,而不是只背定义。
- 能解释一次异步操作的取消、超时、竞态和并发上限。
- 能用堆快照验证内存是否持续保留,而不是凭感觉判断泄漏。