1. 写在前面:这个 js 系列(2)到底要解决什么问题
上一篇文章我把 JavaScript 的基础数据类型、作用域、原型链这些地基性质的内容梳理了一遍,评论区不少读者留言说“面试时真正卡壳的不是背概念,而是面试官拿着代码让我猜输出、让我手写实现”,这句话我太有共鸣了。前端面试走到现在这个阶段,八股文早就不是“背下来就行”,而是要用自己的话把原理讲清楚,并且能在白板上写出能跑通的代码——JS 系列(2)就是冲着这个目标去的。
这篇文章我会把面试里出现频率最高的几个 JS 方向挨个拆开,包括 this 指向与闭包、事件循环里的宏任务微任务、Promise 和 async/await 的手写与原理、数组方法的高阶用法,以及几道经常会作为压轴题出现的综合场景题(比如 iframe 刷新父页面、大文件上传里的 Worker 应用)。每一块我都会先讲清楚面试官到底想听到什么,再给出一套可以直接背下来然后“翻译”成自己话的答题框架,最后配上实操代码和我在面试别人时实际遇到的“加分点”和“扣分点”。
如果你正在准备前端面试,或者已经工作两三年但觉得 JS 基础还有不少模糊地带,这篇文章应该能帮你把零散的知识点串成一张网。阅读之前你最好已经知道变量提升、原型链、闭包的基本定义,但我也会在关键位置做回顾,纯新手不会完全看不懂,只是建议先过一遍 JS 基础语法再来读,效果会更好。
2. 工具类方法面试题:字符串、URL、数组操作的高频变体
面试刚开始的阶段,面试官通常不会直接甩一道“说说闭包”这种大题目,而是先用几个小函数题热热身,顺便看看你写代码的基本功。这类题看似简单,但恰恰能暴露很多问题——比如是否知道 ES6+ 的 API,是否注意边界条件,是否写过真正在生产环境里能用的代码,而不是只在 LeetCode 里“通关”。
2.1 判断字符串是否包含某个子串:别只答 indexOf
“js 判断字符串是否包含”这个话题在热搜词里出现了好几次,面试题也喜欢从这个角度切入。最朴素的写法是用indexOf,返回值大于等于 0 就代表包含:
const str = 'Hello, frontend engineer'; console.log(str.indexOf('frontend') !== -1); // true但到了 2026 年再这样回答,面试官大概率会追问一句:“还有更语义化的方式吗?”这时候你必须要说出includes方法:
console.log(str.includes('frontend')); // true console.log(str.includes('Frontend')); // false,大小写敏感includes和indexOf的核心区别有两个。第一是返回值语义,includes直接返回布尔值,不用再手动做!== -1判断;第二是 NaN 的处理,includes能正确判断字符串中是否包含NaN,但indexOf做不到,这在数组场景里也一样:
const arr = [1, 2, NaN]; console.log(arr.indexOf(NaN)); // -1,找不到 console.log(arr.includes(NaN)); // true这个细节非常经典,很多面试者在这里翻车。如果面试官进一步追问“如何忽略大小写判断包含”,那就把字符串统一转换成小写再判断就行。还有一种更进阶的需求是“统计子串出现次数”,可以用split的返回值长度减一,也可以用matchAll配合正则,我个人在实际项目中更推荐split,因为可读性最好:
function countOccurrences(str, sub) { return str.split(sub).length - 1; } console.log(countOccurrences('a-b-c-', '-')); // 32.2 校验 URL 是否有效:从正则到浏览器 API 的演进
“js 验证 url 有效性”也是常见面试题,很多人第一反应就是“写个正则”。但如果面试官把题面展开——请你封装一个isValidUrl函数,并说明它能在浏览器和 Node 环境都工作——情况就复杂了。
最直接的方案是用URL构造函数,它能解析合法 URL 并暴露协议、域名、路径等属性,同时一旦传入非法字符串就会抛异常:
function isValidUrl(url) { try { new URL(url); return true; } catch { return false; } } console.log(isValidUrl('https://example.com/path?a=1#hash')); // true console.log(isValidUrl('not-a-url')); // false但注意,new URL对相对路径会报错,所以如果你要判断的是“相对路径是否合法”,这个方案就不适用了。更精益求精的写法可以自己实现一个相对宽松的判定逻辑:先判断是否有协议头,再判断域名部分是否包含点号或是否为 localhost,同时利用new URL做兜底解析。这里的关键考点是让面试官看到你有“异常处理意识”和“边界场景思维”。
还有一个进阶追问是“如何判断 URL 的协议是 http 还是 https”,这个直接用URL.protocol就行:
function isHttpProtocol(url) { try { const parsed = new URL(url); return parsed.protocol === 'http:' || parsed.protocol === 'https:'; } catch { return false; } }实际工作中这类工具函数经常被封装在utils/url.js里,所以面试时体现出代码的健壮性和可复用性,是很加分的。
2.3 用扩展运算符合并数组:不只是“把值加进去”
“js 怎么用扩展运算符把一个数组里面的值都添加到另外一个数组”,这个热搜关键词对应的题目其实很简单,但衍生出来的考点特别多。基础写法大家都会:
const arr1 = [1, 2, 3]; const arr2 = [4, 5]; const merged = [...arr1, ...arr2]; console.log(merged); // [1, 2, 3, 4, 5]面试官真正想考察的是,你知不知道扩展运算符只能做“浅拷贝”。如果数组元素是引用类型,合并后的两个数组会共享同一个对象引用:
const user = { name: 'Alice' }; const list1 = [user]; const list2 = [...list1]; list2[0].name = 'Bob'; console.log(list1[0].name); // 'Bob',被改到了这时候你得能引出“深拷贝 vs 浅拷贝”的比较。展开运算符在拷贝对象时也有同样的局限,真正做深拷贝要么用structuredClone,要么用JSON.parse(JSON.stringify(obj)),但后者有缺陷——函数、undefined、Symbol、循环引用都会丢失或报错。面试中如果能主动把这些边界条件说出来,面试官对你的评价会明显上一个档次。
还有一个高频衍生题是“合并数组并去重”,常用解法是Array.from(new Set([...arr1, ...arr2])),或者用[...new Set([...arr1, ...arr2])],后者更简洁。这里也顺便提一下,扩展运算符同样适用于函数参数传递的场景,比如Math.max(...arr),面试题也喜欢拿它来让你写“求数组最大值”的优雅解法。
3. this、闭包与作用域:每次面试都躲不开的送命题
如果说工具方法题是“热身”,那 this 和闭包就是前端面试正餐里的“硬菜”。几乎每一轮技术面都会出现,而且经常是连环追问,问到你怀疑人生。好好理解这一章的内容,你在面试现场的底气会完全不一样。
3.1 this 指向的四种绑定规则:别再死记硬背口诀
关于 this,最经典的一句话是“this 指向函数被调用时的上下文,而不是定义时的上下文”。但这句话太抽象了,我一般会让面试者掌握四类规则,按优先级排列。
第一是new绑定,用new调用构造函数时,this 指向新创建出来的实例对象。第二是显式绑定,call、apply、bind会强制指定 this。第三是隐式绑定,通过对象方法调用的函数,this 指向该对象,比如obj.run()里 run 的 this 是obj。第四是默认绑定,在非严格模式下,独立函数调用的 this 指向全局对象(浏览器里是window,Node 里是global),严格模式下是undefined。
我面试时特别爱问的一道题是:
const person = { name: 'Alice', greet() { console.log(this.name); } }; const fn = person.greet; fn();很多人脱口而出Alice,但实际上输出的是undefined(严格模式下会直接报错),因为fn是独立调用,走了默认绑定,this 指向全局对象。这里有一个很实用的判断技巧:函数前面有“点”就看点前面的对象,没有点就看有没有call/apply/bind/new,都没有就是默认绑定。
函数嵌套也是高频考法:
const obj = { name: 'obj', arrow: () => console.log(this.name), regular() { console.log(this.name); } }; obj.arrow(); // 取决于定义时外层作用域的 this,不是 obj obj.regular(); // 'obj'箭头函数没有自己的 this,它捕获的是定义时所在词法作用域的 this。所以在对象方法里嵌套箭头函数时,箭头函数能访问外层方法的 this,这是实际开发中非常常见的用法,比如在setTimeout回调里想访问外层 this,用箭头函数最省事。这也是面试官最想听到的“为什么用箭头函数”的场景。
3.2 闭包是什么:面试官想听的答案其实就三层
闭包的书面定义是“函数与其词法作用域的组合”,但这样回答基本拿不到高分,因为太“教科书”。我在面试时更希望听到拆成三层的解释。
第一层是概念:当一个内部函数引用了外部函数的变量,并且在外部函数执行结束后仍然被保留下来,就形成了闭包。第二层是原理:JavaScript 作用域是词法作用域,函数定义时的作用域链会保留下来,即使外层函数执行完,内部函数依然可以通过作用域链访问到那些变量。第三层是应用:闭包常用于数据私有化、模块化开发、防抖节流、柯里化等。
举个最经典的计数例子:
function createCounter() { let count = 0; return function () { count += 1; return count; }; } const counter = createCounter(); console.log(counter()); // 1 console.log(counter()); // 2这里的count就是私有变量,外部无法直接访问,只能通过返回的函数去修改。这个模式在真实开发里对应的是“模块封装”——比如一个状态管理工具,用闭包把状态藏在函数内部,对外只暴露getState和setState。
面试官接着大概率会问“闭包有什么缺点”,这时候要答出两点:一是内存占用,闭包会让变量长期保存在内存中,处理不当会造成内存泄漏;二是性能问题,频繁创建闭包可能带来额外的内存开销。然后一定要补一句:现代浏览器引擎对闭包做了很多优化,不能一杆子打死,关键在于“用完的引用要置为 null”或者“避免过长的闭包链”。能主动说出这个“优化视角”的候选人,在我这里会加分不少。
3.3 经典送命题:循环 + 定时器 + var/let
这道题是闭包和 var/let 区别的终极结合体,我必须单独拉出来写,因为它出现频率实在太高了:
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); } // 输出:3 3 3原因拆开讲有两点。第一,var声明的变量是函数作用域,循环里就一个i,循环结束后i变成 3;第二,setTimeout的回调是宏任务,会在当前同步代码全部执行完之后才执行,所以执行回调时看到的i已经是 3 了。
如果把var换成let:
for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); } // 输出:0 1 2因为let是块级作用域,每一轮循环都会生成一个独立的i绑定,定时器回调闭包捕获的是各自那一轮的i。这背后还有一个面试官可能追问的细节:for 循环的圆括号和花括号之间的作用域关系——其实每次迭代都会用上一轮的值创建一个新的词法环境。
如果面试官说“我已经知道原因了,那如果坚持用var,你还有什么办法让它输出 0 1 2”?这时候你有两种思路:一是立即执行函数(IIFE)创建独立闭包,二是用bind预先传参:
for (var i = 0; i < 3; i++) { setTimeout(console.log.bind(null, i), 0); }这道题能延伸出很多变体,比如“把console.log换成console.log(i, i)呢”“把延迟设为 1000 呢”,但核心原理都是一样的,理解事件循环和块级作用域之后,灵活应对就好。
4. 事件循环与异步:宏任务微任务的底层逻辑
很多面试者面对事件循环题时,只背了一个“先同步、再微任务、再宏任务”的口诀,但一旦遇到await、Promise、setTimeout混在一起的代码,就会算错输出顺序。这一章我把底层模型掰开来讲,并配两道足够典型的代码题。
4.1 浏览器事件循环里的两类任务
首先要明确,JavaScript 是单线程语言,一次只能执行一个任务。事件循环可以理解成一个大仓库里的分拣员,它从“任务队列”里不断取出任务执行。任务分两类:
- 同步任务:立即执行,阻塞后续代码。
- 异步任务:先挂起,等时机成熟后被推入对应队列。
异步任务又分宏任务(setTimeout、setInterval、I/O、UI 渲染等)和微任务(Promise.then、queueMicrotask、MutationObserver等)。事件循环的核心规则是:每一个宏任务执行完毕后,必须先把当前微任务队列清空,再取下一个宏任务。微任务可以继续产生微任务,所以理论上如果微任务递归不停,宏任务永远等不到执行。
这里有一个很关键的比喻:宏任务队列就像“顾客排队窗口”,微任务队列就像“每个顾客手里的加急订单”。顾客(宏任务)结账后,营业员必须先把这位顾客的加急订单(微任务)处理完,才喊下一位顾客。
4.2 经典输出顺序题:一次讲透
直接上一道非常典型的题目:
console.log('script start'); setTimeout(() => { console.log('setTimeout'); }, 0); Promise.resolve() .then(() => { console.log('promise 1'); }) .then(() => { console.log('promise 2'); }); console.log('script end');正确的输出顺序是:
script start script end promise 1 promise 2 setTimeout过程拆解如下:同步代码先执行,输出script start;遇到setTimeout就把回调放入宏任务队列;遇到Promise.resolve().then就把回调放入微任务队列;继续执行同步代码输出script end;当前宏任务结束,清空微任务队列,输出promise 1,然后promise 1的回调执行时又注册了promise 2的微任务,于是继续输出promise 2;微任务清空后,从宏任务队列取出setTimeout回调,输出setTimeout。
很多背了口诀但原理不透的人会把promise 2排到setTimeout后面,这就是因为没有意识到微任务队列是“链式处理”的,新产生的微任务会排到当前队列尾部继续执行,而不是跑到下一轮。面试时如果能在回答中主动加上“这里 promise 2 仍然属于同一轮事件循环的微任务阶段”,基本就稳了。
题目再来一个升级版,混入async/await:
async function foo() { console.log('foo start'); await bar(); console.log('foo end'); } async function bar() { console.log('bar'); } foo(); console.log('main end');输出的顺序是foo start、bar、main end、foo end。这里的考点是await后面的代码不会立即执行,而是被当成微任务延迟执行。await bar()会先同步执行bar()内部的代码,只有bar()返回的 Promise 完成之后,foo end才会进入微任务队列。
4.3 async/await 的本质与错误处理技巧
async/await本质上是 Promise 的语法糖。await后面可以跟任意值,如果跟的不是 Promise,会先被Promise.resolve()包裹,再进入微任务调度节奏。理解这一点后,很多“为什么await 1也会异步执行”这类问题就迎刃而解了。
实际开发里,async/await最关键的坑是错误处理。有些人这样写:
async function loadData() { const response = await fetch('/api/data'); const data = await response.json(); console.log(data); }如果接口报错,这个函数里没有 try/catch,错误会变成未处理的 Promise rejection。面试官会问:“你怎么处理 await 抛出的异常?”两个方案:一是 try/catch 包裹,二是用.catch链式兜底:
async function loadData() { try { const response = await fetch('/api/data'); const data = await response.json(); console.log(data); } catch (error) { console.error('请求失败:', error); } }还有一种比较优雅的方式是写一个to函数,把错误和数据解构返回,类似 Node 风格:
async function to(promise) { try { const data = await promise; return [null, data]; } catch (error) { return [error, null]; } } const [error, data] = await to(fetch('/api/data'));这种写法在团队规范里很实用,面试时聊到错误处理方案,主动提出这种封装思路也会让人眼前一亮。
5. Promise 与手写实现:从会用到能写
面试进行到中段,面试官大概率会让你手写一个 Promise 相关的方法,不一定从零实现整个Promise,但Promise.all、Promise.allSettled、Promise.race这些静态方法都是高频考题。我建议你在准备时不要只看答案,最好自己从零写一遍原生Promise的核心逻辑,这样面试时无论怎么变题,你都能接得住。
5.1 从使用场景入手,理解 Promise.all 的背景
先举个实际需求:页面要在同一时间请求用户信息、订单列表、消息通知三个接口,等三个都返回后再统一渲染。这种情况最合适的 API 就是Promise.all:
const [user, orders, messages] = await Promise.all([ fetch('/api/user').then((res) => res.json()), fetch('/api/orders').then((res) => res.json()), fetch('/api/messages').then((res) => res.json()) ]);Promise.all的特点是“有一个失败就整体失败”,并且只要有一个 Promise 变 rejected,整个 Promise 立即进入 rejected 状态,不会等待其他 Promise 完成。这个特性在面试中经常被追问:“如果你希望其中一个失败不影响其他请求,应该怎么办?”答案就是Promise.allSettled。它不会因为某个 Promise 失败而短路,会等所有 Promise 都结束,然后返回每个任务的结果数组:
const results = await Promise.allSettled([ fetch('/api/user').then((res) => res.json()), fetch('/api/orders').then((res) => res.json()), fetch('/api/messages').then((res) => res.json()) ]); // 每个结果都是 { status: 'fulfilled', value } 或 { status: 'rejected', reason }理解了使用场景,手写实现就顺理成章了。Promise.all的手写思路很简单:遍历传入的 Promise 数组,用then收集结果,用计数器判断是否全部完成,一旦出现 rejected 就直接 reject:
function myPromiseAll(promises) { return new Promise((resolve, reject) => { const results = []; let count = 0; if (promises.length === 0) { resolve(results); return; } promises.forEach((promise, index) => { Promise.resolve(promise).then((value) => { results[index] = value; count += 1; if (count === promises.length) { resolve(results); } }).catch(reject); }); }); }这里有几个细节要特别留意。第一个是Promise.resolve(promise)包裹,因为传入的元素不一定是 Promise,可能是普通值,做一次包装能保证.then一定存在。第二个是结果数组的下标赋值,要保证结果的顺序和传入顺序一致,不能因为请求完成先后不同就乱序。第三个是count计数器而不是直接判断results.length === promises.length,因为如果某个值是undefined,results的长度仍然会增长,但用稀疏数组判断长度会出现错误。
5.2 Promise.allSettled 与 Promise.race 的手写思路
Promise.allSettled的手写和Promise.all很相似,区别在于不用提前 reject,而是每个任务结束后把状态记录到结果对象中:
function myPromiseAllSettled(promises) { return new Promise((resolve) => { const results = []; let completed = 0; if (promises.length === 0) { resolve(results); return; } promises.forEach((promise, index) => { Promise.resolve(promise).then( (value) => { results[index] = { status: 'fulfilled', value }; completed += 1; if (completed === promises.length) resolve(results); }, (reason) => { results[index] = { status: 'rejected', reason }; completed += 1; if (completed === promises.length) resolve(results); } ); }); }); }Promise.race更简单,字面意思就是“赛跑”,谁先落定谁说了算:
function myPromiseRace(promises) { return new Promise((resolve, reject) => { promises.forEach((promise) => { Promise.resolve(promise).then(resolve, reject); }); }); }实际场景里Promise.race常用来做超时控制,比如某接口 5 秒内没返回就报超时。面试时能结合这个场景讲,代码的实用感会更强。
5.3 从手写 Promise 到理解 async/await 的关系
手写Promise本身也经常被当成一道完整面试题。我不建议面试者在白板上把整个Promise现场写出来,那是几百行的工作量,但你需要理解几个核心机制:状态机(pending、fulfilled、rejected 只能单向流转)、then的链式调用返回新 Promise、微任务调度onFulfilled和onRejected的回调、以及错误穿透(.catch能捕获链上任意一个前置 Promise 的异常)。
一个能反映你理解程度的简版实现是这样的:
class MyPromise { constructor(executor) { this.state = 'pending'; this.value = undefined; this.reason = undefined; this.onFulfilledCallbacks = []; this.onRejectedCallbacks = []; const resolve = (value) => { if (this.state === 'pending') { this.state = 'fulfilled'; this.value = value; this.onFulfilledCallbacks.forEach((fn) => fn()); } }; const reject = (reason) => { if (this.state === 'pending') { this.state = 'rejected'; this.reason = reason; this.onRejectedCallbacks.forEach((fn) => fn()); } }; try { executor(resolve, reject); } catch (error) { reject(error); } } then(onFulfilled, onRejected) { if (this.state === 'fulfilled') { onFulfilled(this.value); } if (this.state === 'rejected') { onRejected(this.reason); } if (this.state === 'pending') { this.onFulfilledCallbacks.push(() => onFulfilled(this.value)); this.onRejectedCallbacks.push(() => onRejected(this.reason)); } } }这个版本是不支持链式调用的简化版,面试中如果被追问链式调用如何实现,核心就是then内部返回一个新的MyPromise,并把 onFulfilled 的返回值传给下一个 then。你能把这个思路说出来,面试官就知道你是真的懂了,而不是背代码。
6. 数组方法进阶:map、reduce、forEach 的底层差异与实际应用
js 的数组方法也是热搜里的高频区,尤其是 map、reduce、forEach 这几个方法,面试题基本都会问“它们之间有什么区别”“什么场景用哪个”。这题看似简单,但很多工作两三年的开发也说不清底层本质。
6.1 forEach 与 map 的本质区别:返回值决定一切
一句话说清楚:forEach是“遍历并执行副作用”,不返回新数组;map是“遍历并映射成新数组”,返回一个长度相同的新数组。所以如果只是对数组元素做打印、修改外部变量等操作,用forEach;如果你想要一个基于原数组变换后的新数据,用map。
面试里常考的一道题是:
const arr = [1, 2, 3]; const mapResult = arr.map((item) => item * 2); const forEachResult = arr.forEach((item) => item * 2); console.log(mapResult); // [2, 4, 6] console.log(forEachResult); // undefined另外要注意,map不会改变原数组,它生成的是新数组。但如果你在map的回调里修改了对象类型元素的属性,原数组中的对象会被改到,因为那是浅拷贝。这一点和之前扩展运算符的浅拷贝问题遥相呼应,面试官很可能把它们串起来问。
6.2 reduce 的高阶用法:从求和到手写 flatMap
reduce是面试官最喜欢“考用法”的方法,因为它的变形太多。最基础的用途是数组求和:
const total = [1, 2, 3, 4].reduce((acc, cur) => acc + cur, 0); // 10进阶用法包括:数组去重、数组扁平化、按条件分组、统计元素出现次数。我实习带人的时候发现,很多人对reduce望而生畏,其实是没理解它的两个参数:第一个是回调函数,回调的第二个参数cur表示当前元素,第一个参数acc表示“上一次处理完积累下来的中间结果”;第二个是初始值,如果不传,第一次会把数组的第一个元素当初始值,从第二个元素开始遍历。
用reduce做扁平化是一个非常典型的面试题:
function flat(arr) { return arr.reduce((acc, cur) => { return acc.concat(Array.isArray(cur) ? flat(cur) : cur); }, []); } console.log(flat([1, [2, [3, [4]]]])); // [1, 2, 3, 4]这题其实也能引出“手写 flat”的完整考点,配合面试官追问“如果不允许递归呢”,可以用栈来实现,这里我就不展开了,但建议你在面试前把这两种思路都在本地跑一遍。
6.3 手写 map 和 filter 的核心逻辑
如果面试官让你手写map,直接写实现:
function myMap(arr, callback) { const result = []; for (let i = 0; i < arr.length; i++) { result.push(callback(arr[i], i, arr)); } return result; }手写filter也类似:
function myFilter(arr, callback) { const result = []; for (let i = 0; i < arr.length; i++) { if (callback(arr[i], i, arr)) { result.push(arr[i]); } } return result; }这两道手写题难不在代码本身,而在于你是否考虑到回调的第二个参数index和第三个参数arr。很多候选人在白板上写出了callback(arr[i]),没有把索引传进去,这样到真正使用自定义实现时,回调里拿不到index就会出问题。所以哪怕代码看起来“简单”,也要表现出对 API 签名完整度的把控。
7. 综合场景题:把 JS 原理放进真实业务里看
面试临近尾声,面试官有时会抛出几个“结合业务场景”的题目,考察你能否把 JS 的底层能力映射到实际开发中。这类题没有标准答案,但答得好的人往往能同时展现出知识宽度和落地能力。
7.1 iframe 操作:关闭 iframe 并刷新父页面
“iframe 关闭并刷新父页面”是一个经典业务场景题。假设你的页面里嵌了一个子应用 iframe,用户操作完以后点击一个按钮,需要关闭这个 iframe,同时刷新父页面。最直接的写法是在父页面里调用:
// 父页面 const iframe = document.getElementById('childFrame'); iframe.remove(); location.reload();但实际工作中,子页面和父页面常常属于不同域,跨域环境下子页面不能直接操作父页面的 DOM。这时候要绕道window.postMessage通信。子页面里这样发消息:
// 子页面 window.parent.postMessage({ type: 'close-iframe-and-refresh' }, 'https://parent-site.com');父页面监听消息:
window.addEventListener('message', (event) => { if (event.origin !== 'https://child-site.com') return; if (event.data && event.data.type === 'close-iframe-and-refresh') { document.getElementById('childFrame').remove(); location.reload(); } });这里有两个安全细节:一是子页面发送时指定目标 origin,不要用*;二是父页面接收时一定要校验event.origin,防止任意网站向你的父页面伪造消息。面试时能主动讲出这两点,说明你做过真实跨域通信,不是只会背 API。
7.2 大文件上传:为什么用 Web Worker 处理分片
“前端使用 worker 上传大文件”也是近期热度很高的面试场景。大文件上传的关键是分片,把一个 2GB 的文件切成若干片,每片单独上传,断点续传时只需要重传失败的分片。分片后的一个重要步骤是计算每片的哈希值(比如用 SparkMD5),用于校验完整性、实现秒传。但计算大文件哈希是 CPU 密集型任务,放在主线程里会卡住界面。
<script> const worker = new Worker('hash-worker.js'); const file = document.querySelector('input[type=file]').files[0]; worker.postMessage({ file }); worker.onmessage = (event) => { console.log('文件的 hash 是:', event.data.hash); }; </script>在hash-worker.js里:
self.onmessage = async (event) => { const { file } = event.data; const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB const chunks = Math.ceil(file.size / CHUNK_SIZE); let hash = ''; for (let i = 0; i < chunks; i++) { const chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE); // 这里可以累加计算每一片的 hash 值 } self.postMessage({ hash }); };这道题的考点有多个层次:你知道用 Blob.slice 切分文件;你知道用 FileReader 或者更现代的方式读取片数据;你知道用 Web Worker 把耗时的哈希计算放到子线程,避免阻塞 UI;你还知道主线程和 Worker 之间的通信依赖postMessage。能把这些串起来讲,就在“综合能力”这一项上拿到了高分。
7.3 微前端场景里的 JS 沙箱问题
“微前端”在热搜里热度很高。面试官如果问“微前端解决了什么问题,核心难点是什么”,除了 CSS 隔离,JS 隔离一定是避不开的。子应用的全局变量不能泄漏到主应用,主应用的全局也不能被子应用随意篡改,这就要做沙箱。面向面试,你至少要能说出三种方案。
第一种是window快照沙箱:子应用启动时记录 window 上的属性快照,卸载时恢复快照。适合不需要长时间共存多个子应用的场景。第二种是代理沙箱,通过 Proxy 拦截对 window 属性的读写,把子应用的全局变量存储在一个独立对象上,页面切换时销毁即可。第三种是 iframe 原生隔离,因为 iframe 天然拥有独立的 JS 执行环境,所以很多微前端方案会把子应用放进 iframe,通信走postMessage。
面试官通常会追问“iframe 隔离和 JS 沙箱相比有什么缺点”,你可以答:iframe 的通信成本高、状态管理复杂、部分弹窗和拖拽交互体验不如真实 DOM 页面;所以很多团队选择“单实例 JS 沙箱 + 共享 DOM 结构”的 hybrid 方案。能把这个权衡过程讲出来,就非常加分了。
8. 常见面试追问与避坑记录
最后这一章我把自己当面试官时,常听到的错误答案和经常追问的问题整理成了一份速查表,顺便说说哪些坑是候选人最容易踩的。
| 高频追问点 | 常见错误 | 正确思路 |
|---|---|---|
| 闭包一定会造成内存泄漏? | 是 | 不一定,关键在于是否有外部引用长期持有闭包变量,现代引擎有优化 |
Array.prototype.sort默认怎么排序? | 按数字大小 | 默认转字符串后按 Unicode 码点排,所以 10 会排在 2 前面 |
typeof null是什么? | object 或 null | object,这是历史遗留 bug,面试时最好主动补一句 |
==和===区别? | 前者比较值,后者比较类型 | ==会做类型转换,===不转换,只在类型相同后比较值 |
| 深拷贝 JSON 方法的缺陷? | 没考虑缺陷 | 会丢失函数、undefined、Symbol,循环引用会报错 |
const声明的对象能修改吗? | 不能 | 能,const只能保证引用地址不变,对象内部属性可以改 |
new一个箭头函数会发生什么? | 可以创建实例 | 不行,箭头函数没有 constructor,也没有 prototype |
如果你能在回答这些问题时不仅给出答案,还顺带说出背后的原理或使用场景,面试官对你的技术深度评估会显著提升。比如被问到typeof null,如果你能说“这是 JS 诞生初期的遗留 bug,因为在第一版实现里用低位存储类型信息,null 的标记是 0,而对象也是 0,后来 ECMAScript 规范为了兼容就保留了这个行为”,那这道题就不只是拿分,而是形成记忆点。
还有几个我实际面试中常遇到的情况:候选人能把Promise.all背得很熟,但一问手写就卡住,原因是没理解Promise.resolve包裹的意义;候选人能说出闭包定义,但一到“循环 + setTimeout”就懵,原因是没把事件循环和执行上下文串起来。这些都是因为“背概念”和“懂原理”之间差了太多实操训练。我建议面试前把本文里所有手写代码都脱离编辑器重新默写一遍,写不出来就再看一遍原理,直到能顺畅写出为止。
我在实际项目开发里还有一个习惯:遇到不确定的 JS API 行为,不会只靠记忆,而是主动打开控制台跑一遍,把输出结果记录下来。这个方法听起来笨,但长期坚持下来,对语言的理解会比只刷面试题扎实得多。希望这篇“js 系列(2)”能帮你在面试里少踩几个坑,就算踩了,下次也能自己爬出来。