← 全部材料 JS
JAVASCRIPT · 15 个高频点

你以为是 A,实际是 B

JS 面试最难的地方在于:几乎所有概念你都"学过",但真让你预测一段代码的输出,总有几个地方会翻车。下面每一节都是一个具体的翻车点——先看代码,自己跑一遍,再看输出和原因。

01先预测,再看遮住"实际输出"框,自己心里跑一遍。预测错的那几节就是你的短板。
02抓住底层规则JS 没有那么多特例,ToPrimitive、原型链、事件循环这三套规则能解释八成现象。
03能说出为什么面试官追一句"为什么"的时候,能答上机制的人和背结论的人立刻分开了。
01

typeof 的三个谎言★

typeof → 一个从 1995 年留下来的坑
typeof 不是"取类型",是查一台机器里的类型标签。它的返回值里,有三处是不能按字面理解的。
实际输出 "object" ← null "function" ← 函数 "object" ← 数组,这里就开始骗人了 "function" ← class 也是

准确判断类型只有三条路:x === null 判 null,Array.isArray(x) 判数组(不能用 instanceof,跨 iframe 会失效),Object.prototype.toString.call(x) 拿精确类型。

面试怎么答typeof 返回的是历史包袱,不是类型系统。null 因为全零指针被误判成 object;function 是规范特意留的特例。要精确判断就用 Object.prototype.toString.call,判数组用 Array.isArray,因为 instanceof 跨执行上下文会失效。
02

隐式转换:ToPrimitive 只有两步

+ 号、== 、String() 背后都是同一套规则
对象转原始值只有两个候选方法:先 valueOf,再 toString。Date 是唯一的例外——+ 遇到 Date 时先调 toString。
逐步拆解 [] + {} [] → valueOf 还是数组 → toString 得 "" {} → valueOf 还是对象 → toString 得 "[object Object]" "" + "[object Object]" → "[object Object]"
面试怎么答对象转原始值走 ToPrimitive:先看有没有 Symbol.toPrimitive,没有就按 hint 选 valueOf 或 toString,普通对象两种情况都会落到 toString 上,所以 [] 变成空串、{} 变成 "[object Object]"。
03

数字:0.1 + 0.2 和它的亲戚★

IEEE 754 double, and what it quietly breaks
JS 里所有数字都是 IEEE 754 双精度浮点,一个符号位、11 位指数、52 位尾数。 0.1 和 0.2 在二进制里都是无限循环小数,存进去就已经不精确了, 相加自然得不到 0.3。同一套表示方式还带来另外三个坑:安全整数只有 253-1、 NaN !== NaN、以及 toFixed 的舍入不按你想的来。
实际输出(node 真跑) 0.1 + 0.2 = 0.30000000000000004
0.1 + 0.2 === 0.3 → false
Number.MAX_SAFE_INTEGER = 9007199254740991
2**53 === 2**53 + 1 → true
NaN === NaN → false  |  Object.is(NaN, NaN) → true
Object.is(0, -0) → false
(1.005).toFixed(2) = 1.00
(0.1 + 0.2).toFixed(2) = 0.30
BigInt: 2n**64n = 18446744073709551616

✕ 金额用浮点算

  • 0.1 + 0.2 得到 0.30000000000000004
  • 累加 100 次 0.01 得到 1.0000000000000007
  • 对账时差几分钱,查一整天

更隐蔽的问题:19.99 * 100 可能是 1998.9999999999998,取整后就少了一分。

✓ 金额用整数分

  • 数据库存 DECIMAL(10,2) 或整数分
  • 前端收到的"元"转成"分"再运算
  • 展示时再除 100,用 (fen / 100).toFixed(2)

Java 侧对应的答案是 BigDecimal,而且必须用字符串构造,new BigDecimal(0.1) 会带进误差。

面试这样答
JS 的 number 统一是 IEEE 754 双精度浮点,尾数只有 52 位。0.1 和 0.2 在二进制里是无限循环小数,存进去就已经截断了,所以相加得到 0.30000000000000004 而不是 0.3,判断相等要用差值小于 Number.EPSILON,或者干脆转成整数算。同一套表示还带来三个问题:安全整数上限是 2 的 53 次方减 1,超过之后加一可能不变,所以大 ID 要用字符串传;NaN 按规范不等于自己,判断要用 Number.isNaN;toFixed 是按实际二进制值舍入,1.005 会得到 1.00。金额场景必须用整数分或者 BigDecimal,不能用浮点。需要大整数运算可以用 BigInt,但它不能和 Number 混算。
04

var / let / const,TDZ 到底是什么

提升不是"把代码搬上去"
三者的区别不在"有没有提升"——都提升了。区别在于 let/const 提升后是未初始化状态,读它就会抛错,这段"已声明但不可用"的区间就是 TDZ。
typeof 也救不了 TDZ typeof c // ReferenceError let c = 1; // 对比:完全没声明过的变量 typeof d // "undefined" 不报错
面试怎么答三者都会提升,差别在初始化:var 提升时一并初始化为 undefined,let/const 只提升声明、不初始化,从作用域开始到声明语句之间就是 TDZ,访问会抛 ReferenceError。const 多一条约束是不可重新赋值,但对象的属性照样能改。
05

闭包:为什么循环里打印的全是同一个值★

捕获的是变量,不是值
闭包捕获的是变量本身,不是它在某一刻的值。所以 var 声明的 i 在所有回调里指向同一块内存,循环结束时它就是 5。
三种写法,三种结果 var i → 5 5 5 5 5 同一个 i let i → 0 1 2 3 4 每轮新绑定 var + IIFE → 0 1 2 3 4 把当轮的值封进去
面试怎么答闭包是函数加上它定义时的词法环境。var 在 for 里只声明一次,五个回调共享同一个 i,异步执行时循环早已结束;let 在每次迭代创建新绑定,所以每个回调捕获的是各自那一轮的值。
06

this 的五种绑定与箭头函数★

不看在哪里定义,只看怎么调用
this 在调用时才确定,优先级从高到低:new > 显式 bind/call/apply > 隐式 obj.fn() > 默认(严格模式下是 undefined)。箭头函数是唯一的例外——它没有自己的 this。
最容易答错的一题 const o = { v: 1, get() { return () => this.v; } }; o.get()() // 1 箭头函数的 this 是 get 执行时的 this,也就是 o
面试怎么答this 由调用方式决定,跟定义位置无关。优先级是 new 高于显式绑定高于隐式绑定高于默认。箭头函数没有自己的 this,取外层词法作用域的,所以它也不能被 new。
07

new 做了四步,instanceof 走原型链

两个都是可以手写出来的东西
new 不是"调用构造函数"这么简单,它做了四步;instanceof 是沿着原型链一路往上找,而不是查类型。
面试怎么答new 做四件事:创建对象并链接原型、以它为 this 执行构造函数、构造函数返回对象则用它、否则返回新对象。instanceof 是沿原型链查找右侧函数的 prototype,跟类型系统无关,所以跨 iframe 会失效。
08

原型链与 class 的真相

class 是糖,但有三个地方不透
class 在语义上等价于构造函数加原型方法,但有三处不是糖:不能当普通函数调用、方法不可枚举、内部隐式严格模式。
super 是怎么找到父类的 class 上定义方法时,会把它记录到方法的 [[HomeObject]] 上; super.foo() 就是拿 [[HomeObject]].__proto__ 去找 foo。 所以把父类方法摘出来单独调用,super 就失效了。
面试怎么答class 本质是构造函数加原型方法的语法糖,实例通过 __proto__ 链继承方法。但有三点不同:class 必须用 new 调用、原型方法默认不可枚举、内部是严格模式。super 依赖方法的 [[HomeObject]] 定位父类,不是靠词法作用域。
09

Proxy 与 Reflect:Vue3 为什么换掉它★

intercept anything, including what defineProperty cannot see
Object.defineProperty 是改一个已存在的属性,改之前得先知道属性名; Proxy 是在对象外面包一层,任何操作都先经过你。 所以新增属性、删除属性、数组下标、in 判断这些 defineProperty 一辈子看不见的操作, Proxy 全能拦住。Reflect 则是把这些操作的"默认行为"变成函数,方便在 trap 里原样调用。
实际输出(node 真跑) --- Proxy 能拦住的四种操作 ---
  get     a
  set     b = 2
  delete  a
  has     b
target 现在是: {"b":2}

--- defineProperty 拦不住新增 ---
  log = ["set a"] | o.b = 3
o.b = 3 完全没进 log —— 这就是 Vue2 要用 $set 补救的原因

✕ defineProperty 的四个天花板

  • 新增属性拦不住 → 要 $set
  • 删除属性拦不住 → 要 $delete
  • 数组下标改不了 → 只能重写 7 个数组方法
  • 必须递归遍历整棵树挂钩子 → 初始化慢

前三个是"必须让开发者记住额外 API"的设计缺陷,第四个是性能问题。

✓ Proxy 顺手解决的

  • 新增、删除、数组下标、in 全部能拦
  • 懒代理:访问到某一层才包一层,初始化快
  • 能拦 Map / Set(defineProperty 完全没法)
  • 不用改 Array.prototype,不污染全局

代价是不支持 IE,且每次访问多一层开销。

面试这样答
defineProperty 的本质是修改一个已存在属性的描述符,所以它只能拦截事先定义过的 key,新增属性、删除属性、数组下标都拦不住,Vue2 只能靠 $set 和重写七个数组方法打补丁。Proxy 是在对象外面包一层拦截器,13 个 trap 覆盖了读、写、删、查、遍历、原型和函数调用,新增和删除属性天然就能拦,而且可以只代理第一层、访问到哪层再代理哪层,初始化开销小。Reflect 的作用是在 trap 里执行默认行为,它的参数顺序和 trap 一致,返回值是 boolean 不抛异常,正好满足 trap 的要求,第三个参数 receiver 保证 getter 里的 this 指向代理对象而不是原始对象,这是依赖收集正确的关键。Proxy 不能被 polyfill,所以 Vue3 不支持 IE11。
10

宏任务与微任务,顺序到底怎么排★

一轮事件循环只做一件事:清空微任务
一轮循环的顺序是:执行一个宏任务 → 清空所有微任务 → 需要的话渲染 → 下一个宏任务。微任务队列必须清空,中间不会被宏任务插队。
◐ 可交互:把这段代码一步步执行完
1console.log(1)
2setTimeout(() => console.log(2), 0)
3Promise.resolve().then(() => console.log(3))
4console.log(4)
微任务队列
空
宏任务队列
空
输出
还没开始
点「下一步」,看这四个数字是按什么顺序出来的。
第 0 / 7 步
实际输出 1 同步 5 同步 3 微任务(先入队) 4 微任务 2 宏任务(下一轮)
面试怎么答浏览器一轮循环执行一个宏任务,然后清空整个微任务队列,再决定是否渲染。Promise.then、queueMicrotask、MutationObserver、await 之后都属于微任务,setTimeout 和事件回调是宏任务。
11

Promise:状态机与四个静态方法

一旦敲定就改不了
Promise 只有三个状态,pending → fulfilled / rejected,且不可逆。then 返回的是新的 promise,所以能链式调用;值会被"穿透"到最近的 catch。
四个静态方法的分工 all 全部成功才成功,一个失败立刻失败 allSettled 全部有结果才结束,结果是 {status, value|reason} 数组 race 第一个落定(不管成功失败)就返回 any 第一个成功的返回;全失败则抛 AggregateError
面试怎么答Promise 是不可逆的状态机。then 返回新 promise,所以能链式;返回值会被包成 fulfilled,抛错会变成 rejected,中间任何一环没捕获就一直往后传。all 是一败全败,allSettled 等全部,race 取第一个落定,any 取第一个成功。
12

async/await 的四个坑★

写起来像同步,跑起来还是微任务
await 只是 then 的语法糖,它不会让代码真的"同步"。最常见的三个错:把 await 写进循环导致串行、在 forEach 里 await 以为会等、以为 try/catch 能兜住回调里的错。

串行,慢

  • for 循环里逐个 await,5 个请求的耗时是总和
  • forEach 里 await 完全无效,外层不等它
  • 在 catch 里只包了 await,回调里的异步错误漏出去

并发,快

  • Promise.all 一次性发出去,耗时取最慢的那个
  • 要顺序执行就用 for...of,它真的会等
  • 错误要么在 Promise 内部捕获,要么用 allSettled
面试怎么答async/await 是 Promise 的语法糖,await 之后的代码等价于 then 的回调,属于微任务。要并发就用 Promise.all,要顺序就用 for...of。forEach 不会等内部 async 回调,这是最常见的误用。
13

深拷贝:为什么没人用 JSON 了★

五个坑加一个循环引用
JSON.parse(JSON.stringify(x)) 会丢掉所有 JSON 表达不了的东西,而且遇到循环引用直接抛错。现在首选 structuredClone。
正确姿势 structuredClone(x) ✅ 支持循环引用、Date、Map、Set、RegExp、ArrayBuffer ❌ 不支持函数、Symbol、DOM 节点、原型链上的方法 JSON 方案 ❌ 上面五坑 + 循环引用直接抛错
面试怎么答JSON 序列化只适合纯数据,会丢函数、Date 变字符串、RegExp 和 Map 变空对象、NaN 变 null、BigInt 直接抛错,而且不能处理循环引用。现在用 structuredClone,但它不支持函数和 DOM 节点。手写就用 WeakMap 记录已拷贝的对象来解决循环引用。
14

数组方法的几个反直觉行为

sort 默认按字符串比,这事年年有人踩
sort() 不带比较函数时,会把元素转成字符串再按字典序比较。[1, 10, 2].sort() 得到的顺序是 1、10、2。
面试怎么答sort 默认把元素转成字符串按字典序比较,所以数字要传比较函数。splice 改原数组并返回被删的元素,slice 不改。indexOf 用严格相等所以找不到 NaN,includes 能。
15

手写 debounce 与 throttle★

为什么节流要"时间戳 + 定时器"两个
debounce 是"等你停下来再做",throttle 是"固定间隔最多做一次"。后者的难点在最后一次:只用时间戳会丢掉尾部调用,只用定时器会让第一次延迟。
为什么不能只用一种 只用时间戳 → 最后一次移动后的位置不会触发(拖拽松手时停在旧值) 只用定时器 → 第一次要等 wait 毫秒才响应,手感很钝 两个一起 → 立即响应 + 尾部保底
面试怎么答debounce 是延迟执行,连续触发就重置计时器,适合搜索输入和窗口 resize。throttle 保证固定间隔内最多一次,用时间戳保证首次立即执行,用定时器保证最后一次不丢,适合滚动和拖拽。实现时都要用 apply 转发 this 和参数。
16

内存与垃圾回收★

四种泄漏,全都长一个样
现代浏览器用可达性判断垃圾:从根对象出发能走到的就留着。所以"泄漏"的本质不是没释放,而是还有个不该有的引用指着它。
面试怎么答浏览器用标记清除,从根出发不可达才回收。所以泄漏都是因为多余引用还在:意外的全局变量、没清的定时器和监听、闭包留住的大对象、已移除但被数组持有的 DOM。排查用堆快照做前后对比。
17

Generator 与迭代器★

async/await 的底层就是它
迭代器是一个有 next() 方法的对象,每次返回 { value, done }。Generator 是一个能"暂停和恢复"的函数,用 yield 打暂停点。async/await 就是 generator + 自动推进器 + Promise 的语法糖。
async/await 是怎么用 generator 实现的// 1) async 函数本质是 generator + 自动推 next function run(genFn) { const gen = genFn(); function step(v) { const { value, done } = gen.next(v); if (done) return Promise.resolve(value); // value 是 yield 出来的 Promise,等它完成再喂回去 return Promise.resolve(value).then( (v) => step(v), (e) => { const r = gen.throw(e); return step(r.value); } ); } return step(); } // 2) 用起来 run(function* () { const user = yield fetch('/user').then((r) => r.json()); const posts = yield fetch('/posts/' + user.id).then((r) => r.json()); return posts; }); // 3) 把 run 换成语法,就是 async/await // async function () { const user = await ... } // 差别:await 是语言级的,不需要外部 runner
面试怎么答迭代器是一个有 next 方法、每次返回 value 和 done 的对象。Generator 是一个能暂停的函数,用 yield 打暂停点,所以 while(true) 加 yield 也不会死循环。async/await 的底层就是 generator 加自动推进器:runner 不断调 next 把 Promise 的结果喂回去,遇 reject 就调 gen.throw。所以可以说 async 函数是自带 runner 的 generator。yield 是双向的,既向外产出值也接收 next 传进来的值,第一次 next 的参数会被丢弃。
18

速答清单

考前十分钟只看这页
01typeof null 为什么是 object?
早期实现用低三位标记类型,对象是 000,null 是全零指针,于是被误判。为了兼容一直没修。
02let 和 var 的区别?
都提升,但 let 不初始化,声明前访问在 TDZ 里会抛错;var 初始化为 undefined。let 还有块级作用域和每轮循环新绑定。
03闭包是什么,有什么用?
函数加上它定义时的词法环境。用于封装私有变量、缓存、柯里化。代价是延长了外层变量的生命周期。
04this 的优先级?
new > bind/call/apply > obj.fn() > 默认。箭头函数没有自己的 this,取外层的。
05new 做了哪四步?
创建对象并链原型 → 以它为 this 执行构造函数 → 返回对象就用它 → 否则返回新对象。
06微任务和宏任务谁先?
同步代码 → 清空微任务 → 渲染 → 下一个宏任务。Promise.then、await 之后都是微任务。
07Promise.all 和 allSettled?
all 一败全败;allSettled 等全部结束,返回 status 数组。有副作用的并发用后者更安全。
08为什么 forEach 里 await 没用?
forEach 只负责把回调调一遍,不收集返回值,所以不会等里面的 promise。用 for...of 或 Promise.all。
09深拷贝怎么做?
structuredClone(不支持函数);JSON 方案会丢函数、Date 变字符串、循环引用抛错;手写用 WeakMap 处理循环。
10常见内存泄漏?
意外全局变量、没清的定时器和监听、闭包留住大对象、脱离文档但被引用的 DOM。
11Proxy 和 Object.defineProperty 的根本区别是什么?
defineProperty 是修改一个已存在属性的描述符,改之前必须知道属性名,所以新增属性、删除属性、数组下标都拦不住。Proxy 是在对象外面包一层拦截器,读、写、删、查、遍历、原型、函数调用全都经过它,新增和删除天然能拦。前者是改属性,后者是换对象,这是本质区别。
12Reflect 到底解决什么问题?
两个问题。一是把 [[Get]]、[[Set]] 这些内部方法变成可调用的函数,参数顺序和 Proxy 的 trap 完全对应,所以在 trap 里写 Reflect.get(t, k, r) 就是执行默认行为,不用手写 t[k](后者遇到 getter 可能递归炸掉)。二是返回值更合理:defineProperty 失败抛异常,Reflect.defineProperty 返回 false,而 trap 必须返回 boolean。
13Vue3 为什么换 Proxy,代价是什么?
换来四件事:新增和删除属性不用 $set、数组下标能直接监听(不用重写七个数组方法)、可以懒代理(访问到哪层才包哪层,初始化快)、能拦 Map 和 Set。代价是 Proxy 无法 polyfill,所以 Vue3 不支持 IE11;另外每次访问多一层函数调用,有额外开销。
140.1 + 0.2 为什么不等于 0.3?
因为 number 是 IEEE 754 双精度浮点,尾数只有 52 位,0.1 和 0.2 的二进制都是无限循环小数,存进去就已经截断了,相加得到 0.30000000000000004。判断相等要用差值小于 Number.EPSILON,或者干脆转成整数分来算。这不是 JS 的问题,Java 和 Python 的浮点都一样。
15为什么大整数 ID 后端要用字符串返回?
因为 Number.MAX_SAFE_INTEGER 是 9007199254740991,超过之后精度就丢了。2**53 === 2**53 + 1 的结果是 true,加一已经加不动。雪花 ID、订单号动辄 18 位,直接当数字传过来会末几位变形,所以后端序列化时转成字符串。