前端真题 130 题 · JavaScript 核心
apply/call、事件循环、Promise、V8 GC、原型链、闭包、作用域、this、new、Generator、柯里化、大数相加、模块化等 24-48 题。
三、JavaScript 核心
本章分组导航(题目严格按原题表顺序排列,未重排;下表帮你按主题定位,避免按"JS 核心"一章复习时漏掉网络/工程化考点):
分组 题号 主题 A. JS 基本原理 24–38 this / 手写 call·apply·bind / new / instanceof / 原型链 / 闭包 / 作用域 / 变量提升 / Generator / Promise / 事件循环 B. 手写与算法入门 39–43 柯里化 / 大数相加 / 数组 flatten 等 C. 浏览器与渲染性能 44–57 URL 到页面 / 动画 / 缓存 / 白屏 / RAIL / 重排重绘 / 合成层 / 浏览器架构 / 进程线程 D. 网络协议 58–63 HTTP/2 / HTTP/3 / TCP / WebSocket / UDP / OSI 七层 E. 工程化与构建 64–69 UglifyJS / Babel / Webpack(原理·优化·热更新)/ 微前端 F. 算法进阶 70–83 二叉树 / Trie / LRU 等 面试建议:A、D 是必考区;C、E 是"区分度高"的加分区;B、F 是算法轮的主战场。
24. apply 和 call
两者作用相同:以指定 this 调用函数;区别只在传参方式:
fn.call(ctx, a, b, c)——参数逐个传入;fn.apply(ctx, [a, b, c])——参数数组传入;bind则是返回绑定 this 的新函数(不立即执行)。
手写实现(利用对象方法调用时 this 指向该对象):
Function.prototype.myCall = function (ctx, ...args) {
ctx = ctx ?? globalThis; // null/undefined -> 全局(非严格)
const key = Symbol('fn'); // 避免覆盖原属性
Object.defineProperty(ctx, key, { value: this, writable: true, configurable: true });
const r = ctx[key](...args);
delete ctx[key];
return r;
};
Function.prototype.myApply = function (ctx, args) {
ctx = ctx ?? globalThis;
const key = Symbol('fn');
Object.defineProperty(ctx, key, { value: this, writable: true, configurable: true });
const r = ctx[key](...(args || []));
delete ctx[key];
return r;
};
const obj = { name: 'x' };
function greet(age) { return `${this.name}-${age}`; }
greet.myCall(obj, 18); // 'x-18'
greet.myApply(obj, [18]); // 'x-18'
考察点:
- 说出 call/apply/bind 三者差异;
- 手写要点:用 Symbol 属性挂到 ctx 上避免冲突、处理后删除;
- 注意原始值当 ctx(如 1/'s')会自动装箱。
25. 事件循环机制
JavaScript 单线程,靠事件循环(Event Loop)调度异步任务。
浏览器事件循环:
- 执行栈:同步任务依次执行,遇到异步(定时器、Promise、DOM 事件、网络)交给 Web API 登记;
- 任务分两类(注意:
requestAnimationFrame与"渲染"都不属于这两类):- 宏任务(macrotask):
setTimeout、setInterval、I/O、MessageChannel、UI 事件回调、setImmediate(仅 Node); - 微任务(microtask):
Promise.then/catch/finally、queueMicrotask、MutationObserver、process.nextTick(仅 Node); - 独立的第三类 —— 渲染前回调:
requestAnimationFrame在浏览器 "update the rendering" 步骤之前触发,位置在本轮微任务清空之后、页面绘制之前;"渲染"是浏览器择机执行的步骤,并非队列里的一个宏任务;
- 宏任务(macrotask):
- 循环规则:每轮先执行一个宏任务 → 清空所有微任务 → (浏览器可能渲染)→ 取下一个宏任务;微任务中产生的微任务会继续在本轮清空。
console.log(1); // 1(同步)
setTimeout(() => console.log(2), 0); // 宏任务
Promise.resolve().then(() => console.log(3)); // 微任务
console.log(4); // 4
// 输出:1 4 3 2
Node 事件循环(libuv,阶段划分):timers → pending callbacks → idle/prepare → poll → check(setImmediate)→ close callbacks;每阶段间处理 nextTick 队列与微任务(nextTick 优先于 Promise 微任务)。
高频考题(async/await 微任务顺序):
async function a() { console.log(1); await b(); console.log(2); }
async function b() { console.log(3); }
a(); console.log(4);
// 输出:1 3 4 2
逐步拆解(这题是面试官最爱追问的"同步求值 vs 挂起"陷阱):
a()同步开跑 →console.log(1)→ 输出 1- 遇到
await b():b()会先被同步求值(await 的操作数不是微任务!)→ 进入b执行console.log(3)→ 输出 3,b返回已 resolve 的 Promise await在此处才挂起a,把a的后续(console.log(2))注册为微任务,控制权交还全局- 全局继续同步执行 →
console.log(4)→ 输出 4 - 同步栈清空 → 清空微任务队列 →
a的续体执行console.log(2)→ 输出 2
⚠️ 易错点:很多人把
b()的调用也当成微任务,从而错答成1 4 3 2。记住——只有await之后的代码才是微任务,await的操作数本身是先同步求值的。
考察点:能画事件循环图;说清宏任务/微任务边界;解释"为什么 setTimeout 不精准"(嵌套 5 层后最小 4ms、被宏任务阻塞)。
事件循环图解:
flowchart TD
Start(["调用栈同步代码执行"]) --> Stack{"调用栈空了?"}
Stack -->|继续同步| Start
Stack -->|空了| Micro["清空微任务队列<br/>Promise.then / queueMicrotask / MutationObserver"]
Micro --> MicroMore{"微任务中<br/>又产生微任务?"}
MicroMore -->|是| Micro
MicroMore -->|否| Render{"浏览器是否<br/>需要渲染?"}
Render -->|是| RAF["执行 requestAnimationFrame 回调<br/>(渲染前回调,独立一类)"]
RAF --> Paint["样式计算 → 布局 Layout → 绘制 Paint → 合成 Composite"]
Paint --> NextMacro
Render -->|否| NextMacro["取下一个宏任务<br/>setTimeout / I/O / 事件回调"]
NextMacro --> Start
style Micro fill:#ffe6cc,stroke:#d79b00
style RAF fill:#dae8fc,stroke:#6c8ebf
style NextMacro fill:#d5e8d4,stroke:#82b366
读图要点:一轮循环 =「一个宏任务 → 清空全部微任务 → 择机渲染 → 下一个宏任务」。
requestAnimationFrame挂在微任务之后、绘制之前,既不是宏任务也不是微任务——这是最容易被答错的位置。
26. Promise 原理
Promise 是异步编程的解决方案,表示一个未来才完成的操作,三状态:pending → fulfilled / rejected,状态一旦改变不可逆。
核心特性:
then(onFulfilled, onRejected)返回新的 Promise(链式);catch=then(undefined, onRejected);finally无论成败都执行;- 静态方法:
Promise.all(全部成功才成功,一个失败立即失败)、Promise.allSettled(等全部有结果)、Promise.race(第一个有结果)、Promise.any(第一个成功)、Promise.resolve/reject。
手写迷你版(体现对原理理解):
class MyPromise {
state = 'pending'; value; reason; onF = []; onR = [];
constructor(exec) {
const res = v => { if (this.state==='pending'){ this.state='fulfilled'; this.value=v; this.onF.forEach(f=>f()); } };
const rej = r => { if (this.state==='pending'){ this.state='rejected'; this.reason=r; this.onR.forEach(f=>f()); } };
try { exec(res, rej); } catch (e) { rej(e); }
}
then(onF, onR) {
onF = typeof onF === 'function' ? onF : v => v;
onR = typeof onR === 'function' ? onR : r => { throw r; };
return new MyPromise((res, rej) => {
const run = () => {
try {
const x = this.state==='fulfilled' ? onF(this.value) : onR(this.reason);
x instanceof MyPromise ? x.then(res, rej) : res(x);
} catch (e) { rej(e); }
};
this.state === 'pending'
? (this.onF.push(run), this.onR.push(run))
: queueMicrotask(run);
});
}
}
注意要点:
- 完整实现要处理 then 回调异步执行(微任务)、onFulfilled/onRejected 队列分离、返回值穿透、循环引用检测等,是经典"手写 Promise"题;
- 业务级掌握:async/await 是 Promise 的语法糖,错误处理用 try/catch 或 .catch。
Promise 状态机图解:
stateDiagram-v2
[*] --> pending: new Promise(executor)
pending --> fulfilled: resolve(value)
pending --> rejected: reject(reason) / throw
fulfilled --> [*]: .then(onFulfilled)
rejected --> [*]: .catch(onRejected)
note right of pending
状态一旦改变就<b>不可逆</b>
再次 resolve/reject 被忽略
end note
note right of fulfilled
.then 返回的是<b>新 Promise</b>
因此可以链式调用
end note
读图要点:三态、两个转移方向、单向不可逆。
rejected若无.catch会触发unhandledrejection。
27. V8 垃圾回收机制
V8 采用分代式垃圾回收:把堆分成新生代(年轻对象,存活短)与老生代(长生命周期对象)。
- 新生代(Scavenge,半区复制):把堆等分 from-space 与 to-space。分配在 from,回收时把存活对象复制到 to,清空 from,然后交换。对象经历一次回收仍存活 → 晋升到老生代。特点:空间换时间,快。优化:对象晋升阈值与 to-space 占用比;近年 V8 用指针压缩与并行清理提升吞吐。
- 老生代(Mark-Sweep & Mark-Compact):
- 标记-清除(Mark-Sweep):从根(全局、栈、闭包等)可达性分析标记存活对象,之后清除未标记对象——会产生内存碎片;
- 标记-整理(Mark-Compact):回收后把存活对象挪到连续空间,解决碎片(在碎片率高时触发);
- 使用增量标记/惰性清理/并发避免全停顿(stop-the-world 过长)。V8 通过 增量标记(incremental marking)、并行、并发清扫把 GC 停顿控制在毫秒级。
- 写屏障:记录老生代对象对新对象引用,避免跨代扫描漏标。
内存泄漏常见来源(面试必接):未清理的定时器/监听器、全局变量、闭包持有大对象、脱离 DOM 的引用、缓存无限增长。
考察点:分代思想、新生代复制 vs 老生代标记清除的取舍、如何用 DevTools Memory 面板排查泄漏。
28. 立即执行函数(IIFE)
IIFE = Immediately Invoked Function Expression,定义后立即执行:
(function () { /* ... */ })(); // 推荐写法
(function () { /* ... */ }()); // 也常见
(() => { /* ... */ })(); // 箭头函数 IIFE
作用与价值:
- 隔离作用域:ES6 之前没有块级作用域(var),IIFE 制造函数级作用域避免变量污染全局/变量冲突;
- 封装模块:经典模块模式(配合 return 暴露接口)——内部变量私有;
- 防止变量提升带来的问题(for 循环 + var 的闭包陷阱可用 IIFE 捕获每次迭代);
- 配合
void:void function(){...}()防末尾分号问题。
易混淆点:function(){}() 直接写会语法错误(函数声明不能立即调用),必须加括号变成表达式;function f(){}() 中前面无表达式上下文时是声明。这正说明括号(或 !、+、void)的作用是把函数声明变成表达式。
现代 JS:块级作用域(let/const)与模块化已大幅替代 IIFE 使用场景,但原理仍是高频考点。
29. 原型链
JavaScript 继承的实现机制:对象都有隐式原型 __proto__(指向其构造函数的 prototype),沿着 __proto__ 一路向上,最终到 Object.prototype(再往上为 null),这条链就是原型链。属性查找失败时沿链逐级找。
function Person(name) { this.name = name; }
Person.prototype.say = function () { return this.name; };
const p = new Person('tom');
p.say(); // 先在 p 上找 → 没有 → p.__proto__(=Person.prototype) 找到
p.toString(); // p → Person.prototype → Object.prototype 找到
p.__proto__ === Person.prototype; // true(实例的原型 = 构造函数的 prototype)
Person.prototype.__proto__ === Object.prototype; // true(构造函数原型也是对象)
关键结论(把几条关系说全):
prototype是函数独有的属性,指向"由该函数 new 出的实例共享的原型对象",存放公共方法/属性(实现继承,节省内存);__proto__是所有对象都有的隐式原型指针(实例指向构造函数的 prototype;函数也是对象,fn.__proto__ === Function.prototype);Object.prototype.__proto__ === null(原型链终点);- 通过原型链实现属性/方法的继承与共享;修改
Person.prototype会影响所有实例。
经典考题:new 的过程与原型的关系;instanceof 沿原型链查找;Object.create 指定原型;用 ES6 class 本质也是原型继承(语法糖)。
原型链图解:
flowchart LR
inst["实例对象<br/>obj"] -->|"__proto__"| proto["构造函数.prototype<br/>Foo.prototype"]
proto -->|"__proto__"| objProto["Object.prototype"]
objProto -->|"__proto__"| null["null<br/>(链条终点)"]
Foo["构造函数 Foo"] -->|"prototype"| proto
proto -->|"constructor"| Foo
style inst fill:#dae8fc,stroke:#6c8ebf
style proto fill:#d5e8d4,stroke:#82b366
style objProto fill:#ffe6cc,stroke:#d79b00
style null fill:#f8cecc,stroke:#b85450
读图要点:属性查找沿
__proto__一路向上,直到null仍未找到就返回undefined。instanceof就是沿这条链找prototype;hasOwnProperty只看自己不看链。
30. 浮点数精度
原因一句话:二进制无法精确表示某些十进制小数(如 0.1),计算机按 IEEE 754 双精度(64 位:1 符号 + 11 指数 + 52 尾数)存储,无限循环尾数被截断 → 误差累积。
0.1 + 0.2; // 0.30000000000000004
0.1 + 0.2 === 0.3; // false
解决方式:
- 转整数运算(金额按"分"存整数):
(0.1*10 + 0.2*10) / 10; - 比较用容差:
Math.abs(a - b) < Number.EPSILON(或 1e-10); - 项目用库:decimal.js / big.js / bignumber.js;
- 展示用
toFixed(n)/toPrecision(注意 toFixed 也有四舍五入坑:1.005.toFixed(2))。
进阶:大整数超过 Number.MAX_SAFE_INTEGER(2^53-1)会丢精度 → 用 BigInt(ES2020,123n);引擎对浮点乘法"去归一"的隐藏优化(0.1*10===1 恰好成立的原因)可点出。
31. 事件模型
前端事件机制分三部分:DOM 事件流 + 事件对象 + 事件委托。
DOM 事件流(三个阶段):
- 捕获阶段(capture):window → document → ... → 目标元素(从上往下);
- 目标阶段(target):到达目标元素;
- 冒泡阶段(bubble):目标 → ... → document → window(从下往上)。
addEventListener(type, fn, options):
- options 为
true(或 {capture:true})时监听捕获阶段;默认 false 监听冒泡; - options 还支持
once、passive(滚动性能)、signal; event.stopPropagation()阻止继续传播(捕获/冒泡),stopImmediatePropagation()还阻止同元素剩余监听器;event.preventDefault()阻止默认行为(不阻止传播)。
事件委托(代理):利用冒泡,把子元素事件绑定到父元素,通过 e.target 判断实际来源:
ul.addEventListener('click', e => {
const li = e.target.closest('li');
if (li) console.log(li.textContent); // 动态新增 li 无需重新绑定
});
优点:减少监听器、支持动态元素。注意 e.target 与 e.currentTarget 区别(target=实际触发者,currentTarget=绑定监听者)。
考察点:React 合成事件与原生冒泡的关系;事件委托与动态列表。
事件流图解(捕获 → 目标 → 冒泡):
flowchart TD
W["window"] -->|"① 捕获阶段<br/>(从上往下)"| D["document"]
D --> H["html"]
H --> B["body"]
B --> T["target 目标元素<br/>② 到达目标"]
T -->|"③ 冒泡阶段<br/>(从下往上)"| B2["body"]
B2 --> H2["html"]
H2 --> D2["document"]
D2 --> W2["window"]
style T fill:#ffe6cc,stroke:#d79b00
读图要点:
addEventListener(type, fn, true)第三个参数为true时在捕获阶段触发;默认(false)在冒泡阶段触发。
事件委托就是利用冒泡——把监听器挂在父元素上,靠e.target判断来源,省内存且能自动覆盖动态新增的子元素。
32. instanceof 原理
instanceof 判断构造函数的 prototype 是否出现在对象原型链上:
function instanceOf(obj, Ctor) {
let proto = Object.getPrototypeOf(obj); // obj.__proto__
const target = Ctor.prototype;
while (proto) {
if (proto === target) return true;
proto = Object.getPrototypeOf(proto); // 沿链上溯
}
return false;
}
instanceOf([], Array); // true
instanceOf([], Object); // true(沿链到 Object.prototype)
instanceOf(1, Number); // false(原始值,需 Object(1) 包装)
考察点:
- 原理即"沿原型链遍历查找 target.prototype";
- 局限:跨 realm/iframe 时 Array.prototype 不同 → instanceof 判断失效,用
Array.isArray; Symbol.hasInstance可自定义 instanceof 行为;- 与 typeof 的分工:typeof 判原始类型,instanceof 判引用类型归属。
33. 闭包(Closure)
定义:函数 + 其词法作用域的组合。内层函数引用了外层函数作用域的变量,即使外层函数已执行完,这些变量仍被内层函数"记住",不会被回收。
function counter() {
let n = 0; // 被闭包捕获
return function () { return ++n; };
}
const c = counter();
c(); // 1 c(); // 2 // n 持久存活且外部无法直接访问
特性与应用:
- 变量私有化:模块模式、封装;
- 持久状态:计数器、节流/防抖、柯里化、缓存(memoize);
- 回调/事件处理中保留上下文。
经典坑(for 循环 + var):
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 3 3 3(共享同一 i)
}
// 修复:let(块级作用域)或 IIFE 捕获,或 bind
内存问题:闭包持有大对象/长链会导致内存无法释放 → 及时置 null 断开引用。与垃圾回收联动:闭包变量存活于函数 [[Environment]],GC 从根可达则不清。
考察点:解释"闭包中的变量怎么存活"(作用域链/词法环境);能举出应用与泄漏场景。
34. 作用域(Scope)
作用域决定变量可见性与生命周期。JS 是词法作用域(静态作用域):作用域由代码书写位置决定,与调用位置无关。
层级类型:
- 全局作用域:
var全局、函数外声明;浏览器中window; - 函数作用域:
var/function在函数内可见; - 块级作用域:ES6
let/const产生({}、if、for 内),var 没有块级概念; - 作用域链:函数定义时捕获外层作用域形成链,变量查找由内向外,找不到报 ReferenceError。
区别要点:
var a = 1; let b = 2; const c = 3;
// var:可重复声明、函数作用域、变量提升并初始化为 undefined
// let/const:不可重复声明、块级、存在"暂时性死区(TDZ)"——声明前访问报错
考察点:词法作用域与动态作用域区别(JS 是前者);let/const 与 var 对比表;作用域链与闭包的关系(闭包 = 函数 + 词法环境引用);面试常问"下面代码输出什么"来验证。
35. this 的指向
this 在调用时确定(动态绑定,与定义位置无关)。四类规则 + 优先级:
- 默认绑定:普通函数独立调用,非严格模式 this = window/globalThis,严格模式 = undefined;
function f() { console.log(this); }
f(); // window / undefined(strict)
- 隐式绑定:作为对象方法调用,this = 该对象;
const o = { name:'o', f(){ console.log(this.name); } };
o.f(); // 'o'
const g = o.f; g(); // window(丢失绑定——取方法再单独调用)
- 显式绑定:
call/apply/bind指定 this; - new 绑定(最高优先级):new 调用时 this = 新实例。
箭头函数:没有自己的 this,捕获定义时外层词法作用域的 this(不可被 call/bind 改变)。
const o = { name:'o', f(){ const g = () => console.log(this.name); g(); } };
o.f(); // 'o'(箭头函数沿用 f 的 this)
易错场景:事件监听器中 this(DOM 元素)、setTimeout 回调中 this(默认绑定/丢失)、解构方法调用、React 组件方法(需 bind 或箭头)。
记忆口诀:谁调用指向谁(方法);独立调用全局;new 覆盖一切;call/apply/bind 显式指定;箭头函数看外层。
36. new 操作符
new Fn() 执行过程(手写 new 是高频题):
- 创建空对象,
__proto__指向Fn.prototype(继承原型); - 以该对象为 this 执行构造函数(this 绑定);
- 若构造函数显式返回对象则返回该对象;否则返回第 1 步的对象。
function myNew(Ctor, ...args) {
// 1. 以 Ctor.prototype 为原型创建对象
const obj = Object.create(Ctor.prototype);
// 2. 以 obj 为 this 执行构造函数
const ret = Ctor.apply(obj, args);
// 3. 构造函数返回对象则用之,否则用 obj
return (ret !== null && typeof ret === 'object') || typeof ret === 'function' ? ret : obj;
}
function Person(n) { this.n = n; }
const p = myNew(Person, 'x');
p instanceof Person; // true
易错:箭头函数不能 new(无 prototype、无自己的 this);构造函数 return 原始值 会被忽略;p.constructor === Person(原型链上 constructor)。
37. 变量提升(Hoisting)
变量提升:var 声明与函数声明在编译阶段被"提升"到作用域顶部,但提升方式不同:
var a:声明提升,初始化为 undefined(赋值不提升);function f(){}:整个函数提升(声明+定义),因此可在声明前调用;let/const:声明提升但进入暂时性死区(TDZ),声明前访问报 ReferenceError——看似未提升,实则提升到块顶但不初始化。
console.log(a); // undefined(var 提升+初始化)
var a = 1;
f(); // 'ok'(函数整体提升)
function f() { console.log('ok'); }
console.log(b); // ReferenceError(TDZ)
let b = 2;
易错:var f = function(){} 只提升变量名(undefined),调用会报错;if/for 内的 var 也提升到函数顶;函数声明提升优先于 var(同名冲突时)。
考察点:解释执行顺序(先编译后执行)、TDZ 意义(规范在声明前不允许访问)、与 let 块级作用域配合理解的经典输出题。
38. Generator 原理
Generator = 可暂停/恢复的函数。function* 定义,调用返回迭代器对象(不立即执行函数体),yield 暂停并向外传值,.next() 恢复。
function* gen() {
yield 1;
yield 2;
return 3;
}
const it = gen();
it.next(); // {value: 1, done: false}
it.next(); // {value: 2, done: false}
it.next(); // {value: 3, done: true}
原理要点:
- Generator 基于协程:函数可暂停挂起,保存执行上下文(作用域链/寄存器),恢复时从暂停处继续;
- 底层由状态机实现:JS 引擎把 generator 编译为状态机,yield 是状态切换点;
- 与 async/await 的关系:概念上可理解为 "Generator + 自动执行器 + Promise"——Babel/regenerator 转译 async 时确实会降级成 generator + 执行器。⚠️ 但要注意:V8 等引擎的原生 async/await 并不是基于 generator 实现的,它是独立的编译与状态机实现。所以准确说法是"思想上等价",而非"引擎内部就是";
- 手动实现"自动执行器"(co 思想)是进阶考点:
function run(gen) {
const it = gen();
return new Promise((res, rej) => {
const step = (fn, arg) => {
let r;
try { r = fn.call(it, arg); } catch (e) { return rej(e); }
if (r.done) return res(r.value);
return Promise.resolve(r.value).then(v => step(it.next, v), e => step(it.throw, e));
};
step(it.next);
});
}
应用:惰性序列(无限生成器)、按需取数、控制异步流程。
39. 合并二维有序数组成一维有序数组
题目:如 [[1,3,5],[2,4,6],[0,7]] → 输出一维有序 [0,1,2,3,4,5,6,7]。
思路:多路归并(与归并排序的 merge 同思想)。维护每个子数组指针,每轮取各指针所指最小值推进,或用最小堆加速(k 路)。
// 方案一:所有数组元素排序(简单,时间复杂度 O(N log N))
const mergeAll = (arrs) => arrs.flat().sort((a, b) => a - b);
// 方案二:多路归并(子数组都有序)
function mergeSortedArrays(arrs) {
const res = [];
const idx = new Array(arrs.length).fill(0);
while (true) {
let minVal = Infinity, minI = -1;
for (let i = 0; i < arrs.length; i++) {
if (idx[i] < arrs[i].length && arrs[i][idx[i]] < minVal) {
minVal = arrs[i][idx[i]]; minI = i;
}
}
if (minI === -1) break;
res.push(minVal);
idx[minI]++;
}
return res;
}
// 方案三:最小堆(k 个数组时 O(N log k),最优)——面试加分
mergeSortedArrays([[1,3,5],[2,4,6],[0,7]]); // [0,1,2,3,4,5,6,7]
考察点:能否想到利用"子数组已有序"而非无脑 flat+sort;讲清指针/堆的归并思想;顺带说明归并排序的 merge 步骤。
40. 实现 add(1)(2)(3)
柯里化应用:返回可连续调用的函数,取值时给出结果。两种目标:链式调用后直接返回函数继续传参 或 通过 valueOf/toString 隐式转数字。
写法一:纯柯里化(固定参数个数)
function curry(fn, arity = fn.length, ...args) {
return args.length >= arity
? fn(...args)
: (...more) => curry(fn, arity, ...args, ...more);
}
const add = curry((a, b, c) => a + b + c);
add(1)(2)(3); // 6
写法二:不限次调用,隐式求值(面试常考)
function add(...args) {
const f = (...rest) => add(...args, ...rest); // 收集所有参数
f.valueOf = () => args.reduce((s, n) => s + n, 0); // 触发隐式转换时求和
f.toString = f.valueOf;
return f;
}
add(1)(2)(3); // f(valueOf 后可参与运算)
+add(1)(2)(3); // 6
add(1)(2)(3) == 6; // true(== 触发 valueOf)
考察点:参数收集与闭包;valueOf/toString 参与隐式转换的时机;固定参数 curry 与不定长调用的差别。进阶:add(1,2,3)、add(1)(2,3) 混用。
41. 柯里化(Currying)
柯里化:把接收多个参数的函数,变换成接收单一参数的函数序列,每次只传一部分参数,收集够后再执行。核心价值:参数复用 + 延迟执行。
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) return fn(...args);
return (...more) => curried(...args, ...more);
};
}
const add = (a, b, c) => a + b + c;
const cAdd = curry(add);
cAdd(1)(2)(3); // 6
// 参数复用示例:日志函数
const log = (level, tag, msg) => console.log(`[${level}][${tag}] ${msg}`);
const info = curry(log)('info');
info('auth', '登录成功'); // 复用 level='info'
易错:fn.length 是"未含默认参数/rest 的形参数";柯里化 vs 偏函数(partial,固定部分参数一次)vs 闭包缓存,别混概念。
42. 大数相加
JS 数字超过 Number.MAX_SAFE_INTEGER(2^53-1)丢精度 → 大整数用字符串模拟竖式加法。
function addBig(a, b) {
a = String(a); b = String(b);
let i = a.length - 1, j = b.length - 1, carry = 0, res = '';
while (i >= 0 || j >= 0 || carry) {
const sum = (i >= 0 ? +a[i] : 0) + (j >= 0 ? +b[j] : 0) + carry;
res = (sum % 10) + res;
carry = Math.floor(sum / 10);
i--; j--;
}
return res; // 结果仍是字符串
}
addBig('9007199254740993', '9007199254740993');
// 正确结果:'18014398509481986'
// Number 直接加:9007199254740993 + 9007199254740993 = 18014398509481984 ❌
为什么 Number 会错:
Number.MAX_SAFE_INTEGER = 9007199254740991 = 2^53 - 1。9007199254740993(= 2^53 + 1)超出了安全整数范围,无法被精确表示,会被舍入成9007199254740992(2^53)。两个舍入值相加得2^54 = 18014398509481984,与真值18014398509481986差了 2。这正是必须用字符串做竖式的理由——精度丢失发生在数值被读入的那一刻,加法本身没问题。
考察点:从右往左逐位加、处理进位与不等长、结果可能为字符串。进阶:支持负数/小数(拆分整数与小数部分分别加)。ES2020 可直接用 BigInt,但面试想考竖式实现。
43. 拍平数组(flatten)
把多维数组转一维。多解法,按深度与性能选:
// 1) 递归(体现思路)
const flat = arr => arr.reduce((acc, v) => acc.concat(Array.isArray(v) ? flat(v) : v), []);
// 2) 栈迭代(不递归、不爆栈)
function flatten(arr) {
const stack = [...arr], res = [];
while (stack.length) {
const v = stack.pop();
Array.isArray(v) ? stack.push(...v) : res.push(v);
}
return res.reverse();
}
// 3) 指定深度
const flattenDeep = (arr, depth = 1) =>
arr.reduce((acc, v) => acc.concat(depth > 1 && Array.isArray(v) ? flattenDeep(v, depth - 1) : v), []);
// 4) 原生一行
[1,[2,[3,[4]]]].flat(Infinity); // [1,2,3,4]
考察点:reduce + concat 递归;对深度控制;Array.isArray 判断(不用 typeof);flat(Infinity) 原生。注意大数组深递归会爆栈,可提栈实现。
44. 模块化机制
前端模块化演进(考发展脉络 + 各自原理):
- IIFE / 全局函数:早期靠作用域隔离 + 全局命名空间(
window.App = {}); - CommonJS(Node):
module.exports+require。同步加载(本地磁盘快);require是运行时执行并缓存模块;模块对象module,exports是module.exports的引用(注意直接赋值 exports= 无效); - AMD(RequireJS):
define(['dep'], fn)浏览器异步加载依赖,提前声明; - CMD(Sea.js):就近依赖,
require用时加载; - ES Module(ESM,现代标准):
import/export,静态分析(依赖在编译期确定 → 支持 tree shaking、循环依赖友好);浏览器原生支持,<script type="module">自动 defer;Node 也支持(.mjs / package.json type=module)。
关键对比:
| 维度 | CommonJS | ESM |
|---|---|---|
| 加载 | 运行时同步 | 编译期静态、可异步 |
| 输出 | 值拷贝/缓存 | 引用(live binding) |
| Tree shaking | 不支持 | 支持 |
| 循环依赖 | 易出 undefined | 更健壮 |
考察点:说出 ESM 静态性带来的好处(可静态分析、可 tree-shake、可预加载);Node 中两者互操作(CJS require ESM 的限制);打包器(webpack/rollup/vite)如何转译模块。
45. 描述浏览器从输入网址到页面展现的整个过程
经典全过程题,按阶段答:
- 输入 URL 并解析:判断是搜索词还是 URL;解析协议/域名/端口/路径;
- DNS 解析:查浏览器缓存 → 系统 hosts → 本地 DNS → 递归/迭代解析,得到 IP;现代走 DNS-over-HTTPS;
- 建立 TCP 连接:三次握手(SYN → SYN+ACK → ACK);
- 发送 HTTP 请求(若 HTTPS 先 TLS 握手:ClientHello/证书校验/密钥交换);请求含 method/URL/headers/body;可能命中缓存直接返回;
- 服务器响应:返回状态码与资源(HTML 等);
- 浏览器解析与渲染:
- 解析 HTML 生成 DOM 树;解析 CSS 生成 CSSOM;两者合并 Render Tree;
- Layout:计算几何位置(重排);Paint:绘制(含合成);
- JS 执行会阻塞解析(遇
<script>暂停 DOM 构建,可用 async/defer);CSS 阻塞渲染;
- 加载子资源:解析中遇到图片/脚本/CSS 并行下载;
- 首屏呈现:在 DOMContentLoaded / load 事件间完成首次绘制;现代可用 FP/FCP/LCP 描述。
渲染关键优化点(引导):关键渲染路径(CRP)——减小 HTML/CSS 体积、关键 CSS 内联、JS defer、图片懒加载;CDN 加速静态资源。
考察点:能分"网络层 + 渲染层"两段讲;答出三次握手、DNS 缓存、CRP、重排重绘触发点;有经验的会说 HTTP/2、预加载、缓存策略。
完整链路时序图解:
sequenceDiagram
autonumber
participant U as 用户
participant B as 浏览器
participant D as DNS
participant S as 服务器
U->>B: 回车 / 导航
B->>D: ① DNS 解析域名 → IP(逐级查缓存)
D-->>B: 返回 IP
B->>S: ② TCP 三次握手
B->>S: ③ TLS 握手(HTTPS)
B->>S: ④ 发送 HTTP 请求
S-->>B: ⑤ 返回 HTML 响应
Note over B: ⑥ 解析 HTML → 构建 DOM
Note over B: ⑦ 解析 CSS → 构建 CSSOM
Note over B: ⑧ DOM + CSSOM → 渲染树 Render Tree
Note over B: ⑨ 布局 Layout(计算几何位置)
Note over B: ⑩ 绘制 Paint(生成绘制指令)
Note over B: ⑪ 分层 → 光栅化 → 合成 Composite
B-->>U: ⑫ 首屏像素上屏
读图要点:面试时把这段拆成 网络阶段(①–⑤)+ 渲染阶段(⑥–⑪) 两大块讲,比平铺直叙更有条理。
加分点:说出DOMContentLoaded与load分别卡在哪个节点、以及 JS 为什么会阻塞解析。
46. 平常的学习途径
软性问题,考察自驱力与信息获取能力。回答结构:渠道 + 方法 + 沉淀。
常用渠道:官方文档(MDN、React/Vue 官网)看原理;掘金/知乎/博客园看实战与源码解析,注意交叉验证;GitHub 上 star 高的开源项目(如 mqyqingfeng/Blog、前端九部),以及跟着写小 demo 验证;技术社区讨论(如 V2EX、stackoverflow)查疑难。方法上我会带着问题去学,看完一个知识点会写笔记或小项目落地,避免"收藏即学会";平时也会复盘面试题,把不懂的点单独开文档持续补充。
加分点:提到"英文一手资料 + 中文社区 + 实践验证"三层;能说出具体在看的书/专栏/开源仓库。
47. 你比较擅长哪一块,不足的地方在哪里
回答原则:优势要具体可证明,不足要真实但可控(避免"完美主义"式废话,也不要说致命短板)。
擅长:JS 基础与浏览器原理、性能优化、工程化(webpack 配置与构建提速)。比如近期把项目首屏 LCP 从 3s 优化到 1.5s(做了关键 CSS、路由级拆包、图片换 WebP)。不足:在 Node 服务端/复杂并发场景实战偏少,算法刷题量还不够(尤其动态规划),目前正用每天 1-2 道题 + 读 Node 源码的方式补,已经有明显进步。
考察点:真实性(别吹)、自我认知清晰、不足是否"可修复 + 有行动"、是否与岗位方向匹配(投中高级前端别把"技术广度"当唯一短板)。
48. 动画性能
动画卡顿的根源是主线程被长任务占用或触发高成本渲染。目标:让动画尽量只走 合成(composite),避免 layout 与 paint。
原则与手段:
- 只动合成属性:
transform(translate/scale/rotate)与opacity——由 GPU 合成,不触发 layout/paint,最流畅; - 避免动 layout 属性:
width/height/top/left/margin/padding会触发重排,每帧重新计算; - 用 rAF 而非 setTimeout:
requestAnimationFrame与屏幕刷新同步(60Hz/120Hz),浏览器可合并;定时器不精确且可能掉帧; - will-change 提示:
will-change: transform提前提升为合成层(谨慎:过多提升占内存); - 避免强制同步布局(layout thrashing):循环里"读后写、写后读"反复触发重排 → 先批量读再批量写;
- 测量:Performance 面板看 FPS、Main 线程火焰图;Performance Monitor。
/* 移动动画应写成: */
.box { transition: transform .3s; transform: translateX(0); }
.box.move { transform: translateX(100px); } /* 而非改 left */
易错:老代码改 left/top 做位移动画——每帧重排;transition 太多同时触发;GPU 合成层过多内存爆掉。