← 返回题库
前端核心·真题演练

前端真题 130 题 · JavaScript 核心

apply/call、事件循环、Promise、V8 GC、原型链、闭包、作用域、this、new、Generator、柯里化、大数相加、模块化等 24-48 题。

JavaScript事件循环Promise原型链闭包手写题

三、JavaScript 核心

本章分组导航(题目严格按原题表顺序排列,未重排;下表帮你按主题定位,避免按"JS 核心"一章复习时漏掉网络/工程化考点):

分组题号主题
A. JS 基本原理24–38this / 手写 call·apply·bind / new / instanceof / 原型链 / 闭包 / 作用域 / 变量提升 / Generator / Promise / 事件循环
B. 手写与算法入门39–43柯里化 / 大数相加 / 数组 flatten 等
C. 浏览器与渲染性能44–57URL 到页面 / 动画 / 缓存 / 白屏 / RAIL / 重排重绘 / 合成层 / 浏览器架构 / 进程线程
D. 网络协议58–63HTTP/2 / HTTP/3 / TCP / WebSocket / UDP / OSI 七层
E. 工程化与构建64–69UglifyJS / 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)调度异步任务。

浏览器事件循环

  1. 执行栈:同步任务依次执行,遇到异步(定时器、Promise、DOM 事件、网络)交给 Web API 登记;
  2. 任务分两类(注意:requestAnimationFrame 与"渲染"都不属于这两类):
    • 宏任务(macrotask)setTimeoutsetInterval、I/O、MessageChannel、UI 事件回调、setImmediate(仅 Node);
    • 微任务(microtask)Promise.then/catch/finallyqueueMicrotaskMutationObserverprocess.nextTick(仅 Node);
    • 独立的第三类 —— 渲染前回调requestAnimationFrame 在浏览器 "update the rendering" 步骤之前触发,位置在本轮微任务清空之后、页面绘制之前;"渲染"是浏览器择机执行的步骤,并非队列里的一个宏任务;
  3. 循环规则:每轮先执行一个宏任务 → 清空所有微任务 → (浏览器可能渲染)→ 取下一个宏任务;微任务中产生的微任务会继续在本轮清空。
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 挂起"陷阱):

  1. a() 同步开跑 → console.log(1) → 输出 1
  2. 遇到 await b()b() 会先被同步求值(await 的操作数不是微任务!)→ 进入 b 执行 console.log(3) → 输出 3b 返回已 resolve 的 Promise
  3. await 在此处才挂起 a,把 a 的后续(console.log(2))注册为微任务,控制权交还全局
  4. 全局继续同步执行 → console.log(4) → 输出 4
  5. 同步栈清空 → 清空微任务队列 → 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 采用分代式垃圾回收:把堆分成新生代(年轻对象,存活短)与老生代(长生命周期对象)。

  1. 新生代(Scavenge,半区复制):把堆等分 from-space 与 to-space。分配在 from,回收时把存活对象复制到 to,清空 from,然后交换。对象经历一次回收仍存活 → 晋升到老生代。特点:空间换时间,快。优化:对象晋升阈值与 to-space 占用比;近年 V8 用指针压缩与并行清理提升吞吐。
  2. 老生代(Mark-Sweep & Mark-Compact)
    • 标记-清除(Mark-Sweep):从根(全局、栈、闭包等)可达性分析标记存活对象,之后清除未标记对象——会产生内存碎片;
    • 标记-整理(Mark-Compact):回收后把存活对象挪到连续空间,解决碎片(在碎片率高时触发);
    • 使用增量标记/惰性清理/并发避免全停顿(stop-the-world 过长)。V8 通过 增量标记(incremental marking)、并行、并发清扫把 GC 停顿控制在毫秒级。
  3. 写屏障:记录老生代对象对新对象引用,避免跨代扫描漏标。

内存泄漏常见来源(面试必接):未清理的定时器/监听器、全局变量、闭包持有大对象、脱离 DOM 的引用、缓存无限增长。

考察点:分代思想、新生代复制 vs 老生代标记清除的取舍、如何用 DevTools Memory 面板排查泄漏。


28. 立即执行函数(IIFE)

IIFE = Immediately Invoked Function Expression,定义后立即执行:

(function () { /* ... */ })();     // 推荐写法
(function () { /* ... */ }());     // 也常见
(() => { /* ... */ })();           // 箭头函数 IIFE

作用与价值

  1. 隔离作用域:ES6 之前没有块级作用域(var),IIFE 制造函数级作用域避免变量污染全局/变量冲突;
  2. 封装模块:经典模块模式(配合 return 暴露接口)——内部变量私有;
  3. 防止变量提升带来的问题(for 循环 + var 的闭包陷阱可用 IIFE 捕获每次迭代);
  4. 配合 voidvoid 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 就是沿这条链找 prototypehasOwnProperty 只看自己不看链。


30. 浮点数精度

原因一句话:二进制无法精确表示某些十进制小数(如 0.1),计算机按 IEEE 754 双精度(64 位:1 符号 + 11 指数 + 52 尾数)存储,无限循环尾数被截断 → 误差累积。

0.1 + 0.2;            // 0.30000000000000004
0.1 + 0.2 === 0.3;    // false

解决方式

  1. 转整数运算(金额按"分"存整数):(0.1*10 + 0.2*10) / 10
  2. 比较用容差:Math.abs(a - b) < Number.EPSILON(或 1e-10);
  3. 项目用库:decimal.js / big.js / bignumber.js;
  4. 展示用 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 事件流(三个阶段):

  1. 捕获阶段(capture):window → document → ... → 目标元素(从上往下);
  2. 目标阶段(target):到达目标元素;
  3. 冒泡阶段(bubble):目标 → ... → document → window(从下往上)。

addEventListener(type, fn, options)

  • options 为 true(或 {capture:true})时监听捕获阶段;默认 false 监听冒泡;
  • options 还支持 oncepassive(滚动性能)、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 持久存活且外部无法直接访问

特性与应用

  1. 变量私有化:模块模式、封装;
  2. 持久状态:计数器、节流/防抖、柯里化、缓存(memoize);
  3. 回调/事件处理中保留上下文。

经典坑(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 是词法作用域(静态作用域):作用域由代码书写位置决定,与调用位置无关。

层级类型

  1. 全局作用域var 全局、函数外声明;浏览器中 window
  2. 函数作用域var/function 在函数内可见;
  3. 块级作用域:ES6 let/const 产生({}、if、for 内),var 没有块级概念;
  4. 作用域链:函数定义时捕获外层作用域形成链,变量查找由内向外,找不到报 ReferenceError。

区别要点

var a = 1; let b = 2; const c = 3;
// var:可重复声明、函数作用域、变量提升并初始化为 undefined
// let/const:不可重复声明、块级、存在"暂时性死区(TDZ)"——声明前访问报错

考察点:词法作用域与动态作用域区别(JS 是前者);let/const 与 var 对比表;作用域链与闭包的关系(闭包 = 函数 + 词法环境引用);面试常问"下面代码输出什么"来验证。


35. this 的指向

this调用时确定(动态绑定,与定义位置无关)。四类规则 + 优先级:

  1. 默认绑定:普通函数独立调用,非严格模式 this = window/globalThis,严格模式 = undefined;
function f() { console.log(this); }
f(); // window / undefined(strict)
  1. 隐式绑定:作为对象方法调用,this = 该对象;
const o = { name:'o', f(){ console.log(this.name); } };
o.f();                    // 'o'
const g = o.f; g();       // window(丢失绑定——取方法再单独调用)
  1. 显式绑定call/apply/bind 指定 this;
  2. 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 是高频题):

  1. 创建空对象,__proto__ 指向 Fn.prototype(继承原型);
  2. 以该对象为 this 执行构造函数(this 绑定);
  3. 若构造函数显式返回对象则返回该对象;否则返回第 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 - 19007199254740993(= 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. 模块化机制

前端模块化演进(考发展脉络 + 各自原理):

  1. IIFE / 全局函数:早期靠作用域隔离 + 全局命名空间(window.App = {});
  2. CommonJS(Node)module.exports + require同步加载(本地磁盘快);require 是运行时执行并缓存模块;模块对象 moduleexportsmodule.exports 的引用(注意直接赋值 exports= 无效);
  3. AMD(RequireJS)define(['dep'], fn) 浏览器异步加载依赖,提前声明;
  4. CMD(Sea.js):就近依赖,require 用时加载;
  5. ES Module(ESM,现代标准)import/export静态分析(依赖在编译期确定 → 支持 tree shaking、循环依赖友好);浏览器原生支持,<script type="module"> 自动 defer;Node 也支持(.mjs / package.json type=module)。

关键对比

维度CommonJSESM
加载运行时同步编译期静态、可异步
输出值拷贝/缓存引用(live binding)
Tree shaking不支持支持
循环依赖易出 undefined更健壮

考察点:说出 ESM 静态性带来的好处(可静态分析、可 tree-shake、可预加载);Node 中两者互操作(CJS require ESM 的限制);打包器(webpack/rollup/vite)如何转译模块。


45. 描述浏览器从输入网址到页面展现的整个过程

经典全过程题,按阶段答:

  1. 输入 URL 并解析:判断是搜索词还是 URL;解析协议/域名/端口/路径;
  2. DNS 解析:查浏览器缓存 → 系统 hosts → 本地 DNS → 递归/迭代解析,得到 IP;现代走 DNS-over-HTTPS;
  3. 建立 TCP 连接:三次握手(SYN → SYN+ACK → ACK);
  4. 发送 HTTP 请求(若 HTTPS 先 TLS 握手:ClientHello/证书校验/密钥交换);请求含 method/URL/headers/body;可能命中缓存直接返回;
  5. 服务器响应:返回状态码与资源(HTML 等);
  6. 浏览器解析与渲染
    • 解析 HTML 生成 DOM 树;解析 CSS 生成 CSSOM;两者合并 Render Tree
    • Layout:计算几何位置(重排);Paint:绘制(含合成);
    • JS 执行会阻塞解析(遇 <script> 暂停 DOM 构建,可用 async/defer);CSS 阻塞渲染;
  7. 加载子资源:解析中遇到图片/脚本/CSS 并行下载;
  8. 首屏呈现:在 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: ⑫ 首屏像素上屏

读图要点:面试时把这段拆成 网络阶段(①–⑤)+ 渲染阶段(⑥–⑪) 两大块讲,比平铺直叙更有条理。
加分点:说出 DOMContentLoadedload 分别卡在哪个节点、以及 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。

原则与手段:

  1. 只动合成属性transform(translate/scale/rotate)与 opacity——由 GPU 合成,不触发 layout/paint,最流畅;
  2. 避免动 layout 属性width/height/top/left/margin/padding 会触发重排,每帧重新计算;
  3. 用 rAF 而非 setTimeoutrequestAnimationFrame 与屏幕刷新同步(60Hz/120Hz),浏览器可合并;定时器不精确且可能掉帧;
  4. will-change 提示will-change: transform 提前提升为合成层(谨慎:过多提升占内存);
  5. 避免强制同步布局(layout thrashing):循环里"读后写、写后读"反复触发重排 → 先批量读再批量写;
  6. 测量:Performance 面板看 FPS、Main 线程火焰图;Performance Monitor。
/* 移动动画应写成: */
.box { transition: transform .3s; transform: translateX(0); }
.box.move { transform: translateX(100px); }   /* 而非改 left */

易错:老代码改 left/top 做位移动画——每帧重排;transition 太多同时触发;GPU 合成层过多内存爆掉。