你以为是 A,实际是 B
JS 面试最难的地方在于:几乎所有概念你都"学过",但真让你预测一段代码的输出,总有几个地方会翻车。下面每一节都是一个具体的翻车点——先看代码,自己跑一遍,再看输出和原因。
01先预测,再看遮住"实际输出"框,自己心里跑一遍。预测错的那几节就是你的短板。
02抓住底层规则JS 没有那么多特例,ToPrimitive、原型链、事件循环这三套规则能解释八成现象。
03能说出为什么面试官追一句"为什么"的时候,能答上机制的人和背结论的人立刻分开了。
01
typeof 的三个谎言★
typeof → 一个从 1995 年留下来的坑
typeof 不是"取类型",是查一台机器里的类型标签。它的返回值里,有三处是不能按字面理解的。
实际输出
"object" ← null
"function" ← 函数
"object" ← 数组,这里就开始骗人了
"function" ← class 也是
- null 为什么是 object:早期实现里,值的类型由低三位标记决定,对象的标记是
000,而null是一个全零的指针。判断逻辑先看标记,于是把 null 归成了对象。这个 bug 因为兼容性一直没修。 - function 为什么单独一类:规范里特意给可调用对象留了这个返回值。原因是函数有
[[Call]]内部方法,行为跟普通对象差别太大,需要能一眼判断。 - typeof 未声明的变量不报错:这是 typeof 唯一"安全"的用法。但 TDZ 里的 let 会抛错——声明被提升了,只是没初始化,这时读它会进暂时性死区。
准确判断类型只有三条路: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]"
- 一个对象能重写这套逻辑:定义
Symbol.toPrimitive优先级最高,比 valueOf/toString 都高。 - == 的规则不是"转成同一类型"那么简单:null 和 undefined 互等且不等于任何其他值;对象与原始值比较时对象先 ToPrimitive;布尔值先转数字。
- 面试里最稳的答法:不背转换表,答"对象走 ToPrimitive,先 valueOf 后 toString",剩下的当场推。
面试怎么答对象转原始值走 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.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.30000000000000004:
0.1的二进制是0.0001100110011...无限循环,52 位尾数存不下,只能截断。两个都截断过的数相加,误差就露出来了。这不是 JS 的 bug,Java 的double、Python 的float全都一样。 - 怎么判断相等:比较差值是否小于一个容差,
Math.abs(a - b) < Number.EPSILON。注意Number.EPSILON是 2-52,只适合比较接近 1 的数;比较很大的数时要按量级放大容差。 - 安全整数 253-1:尾数 52 位加一个隐含的 1,能精确表示的整数上限是 9007199254740991。超过之后
x + 1 === x就可能成立——上面2**53 === 2**53 + 1就是 true。所以雪花 ID、订单号这类超过 16 位的整数,后端要用字符串返回。 - NaN 不等于自己:这是 IEEE 754 规定的,任何和 NaN 的比较都返回 false。判断用
Number.isNaN()(不是全局isNaN,后者会先做类型转换,isNaN('abc')也是 true)。 - toFixed 不是四舍五入:它做的是"按二进制实际值舍入",
1.005实际存的是1.00499999999999989...,所以舍成 1.00。要精确就先把数字转成整数算。 - BigInt 是另一套类型:
10n,不能和 Number 混算(1n + 1抛 TypeError),不能用于Math方法。适合大整数运算,不适合需要小数的场景。
✕ 金额用浮点算
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" 不报错
- 为什么要有 TDZ:
const必须保证"声明后值不再变",如果声明前就能读到,读到的会是 undefined,那就等于允许了一个短暂的可变窗口。TDZ 把这个洞直接堵成报错。 - 循环里的 let 是每轮一个新绑定:规范里对 for 语句做了特殊处理,每次迭代都复制一份绑定。这是第 04 节那个经典题能成立的原因。
- const 只锁绑定,不锁内容:
const arr = []之后arr.push(1)完全合法,因为没改变 arr 指向谁。
面试怎么答三者都会提升,差别在初始化: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 把当轮的值封进去
- 闭包不是"函数里套函数",而是函数被带走时,它所在的词法环境跟着一起活下来了。判断标准是"函数在哪里定义",不是"在哪里调用"。
- 内存上要当心:闭包会把外层变量一直留住。如果闭包捕获了一个大数组却只用其中一项,那个数组就一直回收不掉——这是第 14 节泄漏的常见来源。
面试怎么答闭包是函数加上它定义时的词法环境。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、没有 arguments、不能被 new。因为 [[Construct]] 内部方法它根本没有,所以 new 会直接抛 TypeError。
- 丢失绑定的真凶是"值拷贝":
const f = obj.regular只拿到了函数本身,obj 这个调用上下文没跟过来。回调踩坑几乎都是这个原因。 - bind 的返回值是永久绑定,再 call 也改不回来;而 call/apply 只影响这一次。
面试怎么答this 由调用方式决定,跟定义位置无关。优先级是 new 高于显式绑定高于隐式绑定高于默认。箭头函数没有自己的 this,取外层词法作用域的,所以它也不能被 new。
07
new 做了四步,instanceof 走原型链
两个都是可以手写出来的东西
new 不是"调用构造函数"这么简单,它做了四步;instanceof 是沿着原型链一路往上找,而不是查类型。
- return 原始值被忽略:构造函数里
return 123不起作用,new 照样返回新对象;return {}才有效。原因就是上面第 3 步。 - instanceof 的本质:拿右边函数的
prototype,沿着左边对象的[[Prototype]]链逐层比较。所以它判断的是"原型链上有没有这个原型",不是"谁创建了它"。 - 它可以被改写:类上定义
static [Symbol.hasInstance]()就能接管 instanceof 的结果。
面试怎么答new 做四件事:创建对象并链接原型、以它为 this 执行构造函数、构造函数返回对象则用它、否则返回新对象。instanceof 是沿原型链查找右侧函数的 prototype,跟类型系统无关,所以跨 iframe 会失效。
08
原型链与 class 的真相
class 是糖,但有三个地方不透
class 在语义上等价于构造函数加原型方法,但有三处不是糖:不能当普通函数调用、方法不可枚举、内部隐式严格模式。
super 是怎么找到父类的
class 上定义方法时,会把它记录到方法的 [[HomeObject]] 上;
super.foo() 就是拿 [[HomeObject]].__proto__ 去找 foo。
所以把父类方法摘出来单独调用,super 就失效了。
- 原型链的形状:实例 →
A.prototype→B.prototype→Object.prototype→null。注意构造函数本身的[[Prototype]]指向父类构造函数,所以静态方法能继承。 - 为什么方法挂在 prototype 上:挂实例上意味着每个对象一份拷贝。一万个实例就是一万个函数对象。
- class 也不能被改变:
A.prototype = {}虽然不报错,但prototype属性本身是 writable false,改不动。
面试怎么答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 补救的原因
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 补救的原因
- 13 个 trap:
get、set、has、deleteProperty、ownKeys、getOwnPropertyDescriptor、defineProperty、getPrototypeOf、setPrototypeOf、isExtensible、preventExtensions、apply(函数调用)、construct(new)。记住"读、写、删、查、遍历、原型、函数调用"这七类就够了。 - Reflect 为什么存在:它把
[[Get]]、[[Set]]这些内部方法变成了可调用的函数,而且参数顺序和 trap 一一对应(target、key、receiver)。所以在 trap 里写Reflect.get(t, k, r)就是"执行默认行为",不用手动写t[k]——后者在 getter 里可能递归炸掉。 - Reflect 的返回值更合理:
Object.defineProperty失败时抛异常,Reflect.defineProperty返回 false;delete obj.x在严格模式下抛异常,Reflect.deleteProperty返回 boolean。trap 里必须返回 boolean,所以只能用 Reflect。 - receiver 是第三个参数:它指向"这次访问是谁发起的",通常是 Proxy 自己。不传的话,getter 里的
this会指向原始对象而不是 Proxy,Vue3 的依赖收集就会漏。这也是为什么Reflect.get(t, k, r)要带三个参数。 - Proxy 的代价:它是一个真实的对象,所有操作都要走一层函数调用,比直接访问慢(虽然现代引擎优化得不错)。而且Proxy 无法被 polyfill——这是 Vue3 不支持 IE11 的根本原因。
✕ 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 宏任务(下一轮)
- 微任务会插队到渲染前:所以在微任务里改 DOM,本轮渲染就能反映出来;在 setTimeout 里改,要多等一轮。
- 微任务自己也会产生微任务:如果一直往里加,队列永远清不空,页面就卡死了。这比死循环更难查。
- async 函数里的 await:
await后面的代码等同于.then里的回调,都是微任务。 - setTimeout 有最小延迟:嵌套超过 4 层后,浏览器强制最少 4ms。
面试怎么答浏览器一轮循环执行一个宏任务,然后清空整个微任务队列,再决定是否渲染。Promise.then、queueMicrotask、MutationObserver、await 之后都属于微任务,setTimeout 和事件回调是宏任务。
11
Promise:状态机与四个静态方法
一旦敲定就改不了
Promise 只有三个状态,pending → fulfilled / rejected,且不可逆。then 返回的是新的 promise,所以能链式调用;值会被"穿透"到最近的 catch。
四个静态方法的分工
all 全部成功才成功,一个失败立刻失败
allSettled 全部有结果才结束,结果是 {status, value|reason} 数组
race 第一个落定(不管成功失败)就返回
any 第一个成功的返回;全失败则抛 AggregateError
- all 的"立刻失败"意味着别的请求还在跑:如果它们有副作用或需要清理,用 allSettled 更安全。
- 错误穿透是真的穿透:中间任何一级没写 catch,都会跳过所有 then,直到遇到 catch 或 unhandledrejection。
- then 里 return 一个 promise 会被"展开":不会得到嵌套的 promise,而是等它落定。这也是 async 函数返回值统一的行为。
- Promise.resolve(thenable) 会去读它的 then 方法,这点常被用来"接管"第三方库的回调。
面试怎么答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
- try/catch 只兜 await 那一句:如果 await 之后又起了个没 await 的异步任务,它的错跟这个 try 无关。
- await 让出的是线程控制权,不是执行顺序:后面的代码一定在微任务里跑,所以别指望它和同步代码的时序一致。
- 顶层 await:ESM 模块里可以直接写,会让依赖这个模块的代码等它;CJS 里不行。
面试怎么答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。- reduce 不给初值的两种情况:空数组直接抛 TypeError;单元素数组直接返回那个元素、不执行回调。
- sort 会原地改数组:想保留原数组就先
[...arr]。用返回布尔值的比较函数会得到不可预测的顺序——有些引擎会当成 0/1 处理。 - 修改变原数组的方法:push/pop/shift/unshift/splice/sort/reverse。其余像 map/filter/concat/slice 都返回新数组。
面试怎么答sort 默认把元素转成字符串按字典序比较,所以数字要传比较函数。splice 改原数组并返回被删的元素,slice 不改。indexOf 用严格相等所以找不到 NaN,includes 能。
15
手写 debounce 与 throttle★
为什么节流要"时间戳 + 定时器"两个
debounce 是"等你停下来再做",throttle 是"固定间隔最多做一次"。后者的难点在最后一次:只用时间戳会丢掉尾部调用,只用定时器会让第一次延迟。
为什么不能只用一种
只用时间戳 → 最后一次移动后的位置不会触发(拖拽松手时停在旧值)
只用定时器 → 第一次要等 wait 毫秒才响应,手感很钝
两个一起 → 立即响应 + 尾部保底
面试怎么答debounce 是延迟执行,连续触发就重置计时器,适合搜索输入和窗口 resize。throttle 保证固定间隔内最多一次,用时间戳保证首次立即执行,用定时器保证最后一次不丢,适合滚动和拖拽。实现时都要用 apply 转发 this 和参数。
16
内存与垃圾回收★
四种泄漏,全都长一个样
现代浏览器用可达性判断垃圾:从根对象出发能走到的就留着。所以"泄漏"的本质不是没释放,而是还有个不该有的引用指着它。
- 标记清除 vs 引用计数:引用计数遇到"循环引用"永远不会归零,这就是老 IE 泄漏的根源。现代引擎用标记清除,循环引用不算泄漏。
- WeakMap / WeakSet 是解药:它们的 key 是弱引用,只在别处没人用时,条目自动消失。所以适合存"对象的附属数据"。
- 怎么查:DevTools 的 Memory → Heap Snapshot,拍两次快照做 Comparison,看哪个类型在涨。Detached DOM 一眼就能看出来。
面试怎么答浏览器用标记清除,从根出发不可达才回收。所以泄漏都是因为多余引用还在:意外的全局变量、没清的定时器和监听、闭包留住的大对象、已移除但被数组持有的 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
- 为什么
while(true)加 yield 不会卡死:yield会把执行上下文挂起并交还控制权,不是循环执行。这一点是理解 generator 的关键——它是"能暂停的函数",不是"能返回多个值的函数"。 - yield 是双向的:向外产出值,也接收
next(v)传进来的值。第一次next()的参数会被丢弃,因为此时还没执行到第一个 yield。 - generator 也是迭代器:它自带
[Symbol.iterator],所以能被for...of遍历、能展开成数组([...gen]),但注意无限 generator 不能展开。 - 实际用途:状态机(比如分步表单)、无限序列、以及库内部的异步流程控制(redux-saga 就完全基于 generator)。业务代码里现在很少直接写,但理解它是理解 async/await 的前提。
- 和 async/await 的一个实际差别:generator 产出的不一定是 Promise,可以同步 yield 值;而 await 后面如果不是 Promise 会立即 resolve。所以 generator 更通用,async/await 更专一。
面试怎么答迭代器是一个有 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 位,直接当数字传过来会末几位变形,所以后端序列化时转成字符串。