JavaScript面试题场景化整理:从闭包到工程化调试的实战解析
写 JavaScript 面试题整理这件事其实挺私人的。我干了这些年前端既被人面过也面过人最后发现市面上那些“JavaScript 面试题合集”最大的问题不是题不够多而是太零散——今天背个闭包明天背个原型链问的时候好像都会真到项目里写起来又是另一回事。所以我自己整理了一套题目不按“八股文”的顺序来而是按实际开发会遇到的场景去组织基础语法和执行机制、ES6 的日常操作、浏览器和 DOM 的交互、工程化报错排查全都揉在一起。这篇就把我整理的这版题目和对应的答题思路完整放出来适合准备面试的前端新人也能给带团队的朋友当内部考核素材。1. 为什么要按“题目场景化”来整理 JavaScript1.1 背八股文为什么容易翻车我面过不少简历写得挺漂亮的候选人问“闭包是什么”能一字不落背出“函数嵌套函数、内部函数引用外部变量”这个标准答案。但让他写一个 3 秒内连续点击只触发一次的“防抖函数”立刻就卡住了。这个现象很典型题目背熟了但不理解背后的运行机制。传统的面试题集往往按知识点列表排列var/let/const 一题、闭包一题、原型链一题、事件循环一题彼此之间没有联系。但真实开发里这些东西是叠加在一起的你写一个 React 组件里面用到 useState涉及闭包函数里用了 this涉及调用方式组件卸载时要清理监听器又涉及事件机制。所以我整理题目时刻意把“知识点”升级成“场景”每一道题都能对应到一个真实会写的代码片段。1.2 这套题覆盖到的四个高频维度这套题我按四个维度来收拢基本对应前端日常开发最常碰到的 JavaScript 部分语言核心机制变量提升、作用域、闭包、this、原型链这些是面试必问也是理解一切上层框架的地基。ES6 日常操作合并对象、filter、箭头函数、解构这些是写业务代码时每天都在用的语法糖但很多人只知其然。浏览器与 DOM 交互事件监听、表单提交、伪协议链接、地址栏调试这些属于“和浏览器打交道”的 JavaScript容易被纯框架开发者忽略。工程化与报错排查模块加载失败、运行时报错、开发工具配置这些是真正工作时消耗时间最多的地方面试官也很爱问。我会把热搜词里那些零散的问题比如“html、css、javascript 配置”“合并两个对象”“filter 函数”“javascript 监听”“表单提交和 H5 的区别”“未 new 完的对象为何能使用 prototype”“failed to load module script”等全部塞进这四个维度里每道题给出我的答题模板和背后的原理。2. 基础语法与执行机制必考题背后的原理2.1 var、let、const 与变量提升这道题几乎是 JavaScript 面试的第一道开胃菜但想答好并不容易。核心要掌握两个概念变量提升hoisting和暂时性死区TDZ。先说变量提升。var声明的变量会被提升到当前作用域顶部赋值留在原地。所以下面这段代码不会报错只会输出undefinedconsole.log(a); // undefined var a 10;但let和const不一样。它们同样会被提升但在真正声明之前处于“暂时性死区”提前访问会直接抛ReferenceErrorconsole.log(b); // ReferenceError: Cannot access b before initialization let b 20;我面试时一般会接着问一句“为什么要有暂时性死区”这是个加分点。答案在于var提升后初始化为undefined你可以在声明前使用let/const提升后处于未初始化状态JavaScript 引擎为了防止你拿到一个未定义的值干脆禁止访问。这个设计主要是为了减少隐式错误让代码更可预测。函数声明也有提升而且优先级高于变量。经典题目是这段console.log(typeof foo); // function function foo() {} var foo 1;这里typeof foo输出function因为函数声明整体提升而var foo只是变量名提升值还是undefined不会覆盖函数。很多人在这一步会混淆我自己的记忆办法是函数声明是“整块搬上去”变量声明是“只搬名字上去”。2.2 闭包与 this 指向闭包为什么常考因为它既是基础又是实际开发中的“坑王”。简单说闭包就是一个函数记住了它创建时所处作用域的变量即使外部函数已经执行完了内部函数仍然能访问那些变量。最经典的计数器function createCounter() { let count 0; return function () { count; return count; }; } const counter createCounter(); console.log(counter()); // 1 console.log(counter()); // 2这里的count不会因为createCounter执行完就被回收而是被返回的函数一直引用着。这个机制可以用于封装私有变量、实现缓存、写防抖节流等。但闭包也容易造成内存泄漏尤其是把闭包挂到全局或者长期存活的 DOM 元素上时不注意释放。this 的指向是另一座大山。我总结的规律是this 指向取决于函数怎么被调用而不是定义在哪。普通函数默认调用时 this 指向全局对象浏览器里是 window严格模式下是 undefined对象方法调用时 this 指向这个对象用 call/apply/bind 可以显式指定箭头函数没有自己的 this而是继承外层作用域的 this。这里有个对比表方便记忆调用方式this 指向普通函数调用fn()严格模式 undefined非严格模式 window/全局对象对象方法调用obj.fn()objcall/apply 调用fn.call(obj)指定的 objbind 生成的函数bind 时绑定的对象箭头函数定义时外层作用域的 thisnew 调用new Fn()新建的对象实例2.3 原型链为什么 new 到一半也能用 prototype原型链相关的题里热搜词有一条很刁钻“未 new 完的对象为何能使用 prototype”。这其实问的是 new 操作符的执行过程。很多人以为 new 就是“调用一下构造函数”其实 new 干了四件事创建一个全新的空对象。把这个空对象的原型__proto__指向构造函数的prototype属性。把构造函数里的 this 绑定到这个新对象上执行构造函数体。如果构造函数没有显式返回对象就把这个新对象作为 new 的结果返回。关键就在第 2 步。在执行构造函数体之前新对象和构造函数 prototype 的链接已经建立好了。所以在构造函数内部哪怕第一行代码就访问this.somePrototypeMethod也能顺着原型链找到。举个例子function Person(name) { // 此时 this 已经通过原型链能访问到 sayHello console.log(this.sayHello); // [Function: sayHello] this.name name; } Person.prototype.sayHello function () { console.log(hello, this.name); }; const p new Person(Tom); p.sayHello(); // hello, Tom输出[Function: sayHello]而不是报错就是因为 new 在第 2 步就把原型链接好了。这也是为什么“没 new 完就能用 prototype”——对象和原型的关联发生在构造函数执行之前而不是之后。理解这个顺序对后续理解继承、instanceof的原理都有帮助。3. ES6 高频操作合并对象、filter、箭头函数3.1 合并两个对象的几种方式业务开发里合并对象实在太常见了比如合并默认配置和用户传入的配置、合并分页参数和筛选条件。ES6 之后主要有两种写法const obj1 { a: 1, b: 2 }; const obj2 { b: 3, c: 4 }; // 方式一Object.assign const merged1 Object.assign({}, obj1, obj2); // 方式二展开运算符 const merged2 { ...obj1, ...obj2 };两种方式都是浅拷贝后边的属性会覆盖前边的同名属性上面merged的结果都是{ a: 1, b: 3, c: 4 }。展开运算符写起来更简洁现在项目里基本都用它。面试时通常会接着问“如果对象里嵌套了对象或者数组浅拷贝会不会有问题”这个问题的标准答案是浅拷贝只拷贝一层引用内层对象修改会互相影响。看这个例子const objA { info: { age: 18 } }; const objB { ...objA }; objB.info.age 20; console.log(objA.info.age); // 20被改了要真正独立需要深拷贝。新版浏览器可以直接用structuredCloneconst objC structuredClone(objA); objC.info.age 30; console.log(objA.info.age); // 18互不影响或者用JSON.parse(JSON.stringify(obj))但这种方式会丢失函数、undefined、Date 等特殊类型面试时要提一嘴它的局限。我一般在项目里能浅拷贝就浅拷贝真要深拷贝优先structuredClone老环境才用 lodash 的cloneDeep。3.2 filter 函数从使用到原理filter 是数组三兄弟map、filter、reduce里出镜率最高的一个也是面试基础题里必考的。用法一句话就能说清遍历数组把回调函数返回真值truthy的元素收集起来组成新数组返回原数组不变。const nums [1, 2, 3, 4, 5, 6]; const evens nums.filter((num) num % 2 0); console.log(evens); // [2, 4, 6] console.log(nums); // [1, 2, 3, 4, 5, 6]注意回调返回的是“真值”而不是严格的true所以返回1、a、{}都会被当成通过。我自己踩过的坑是想过滤掉数组里的空值直接写arr.filter(Boolean)这个写法会把0、、null、undefined、NaN全过滤掉如果业务上需要保留0就会出问题。正确做法是先明确过滤条件。filter 经常和 map 链式用比如先筛出符合条件的商品再提取名称const products [ { name: 鼠标, price: 50, inStock: true }, { name: 键盘, price: 200, inStock: false }, { name: 显示器, price: 1000, inStock: true } ]; const names products.filter((p) p.inStock).map((p) p.name); // [鼠标, 显示器]这个链式调用很能体现对数组方法的熟练度。面试官如果追问 filter 的原理可以答它内部就是遍历数组、执行回调、把满足条件的 push 进新数组。能自己手写一个简易 filter基本就过关了。3.3 箭头函数与普通函数的差异箭头函数是 ES6 里最常用的语法之一但它和普通函数的差别如果只说“写法更简洁”肯定不够。我总结成四个差异第一this 绑定方式不同。普通函数的 this 是调用时确定的箭头函数的 this 是定义时从外层作用域继承的而且无法通过 call/apply/bind 改变。第二没有 arguments 对象。在箭头函数里用 arguments实际取的是外层函数的 arguments。第三不能用作构造函数不能 new也没有 prototype 属性。第四不能作为生成器函数不能使用 yield。实际开发里最常见的记忆点就是 this。比如在定时器里想访问外层 this普通函数要先用var self this或者用 bind箭头函数直接写就行function Timer() { this.seconds 0; // 普通函数写法需要缓存 this setInterval(() { this.seconds; console.log(this.seconds); }, 1000); }什么时候不该用箭头函数对象方法里如果用箭头函数this 不会指向对象而是指向外层作用域const obj { name: obj, getName: () { return this.name; // this 是外层作用域通常不是 obj } }; console.log(obj.getName()); // undefined这类题我在面试时必问因为它能快速判断候选人是对语法熟悉还是真正理解执行机制。4. 浏览器 API 与 DOM 交互题4.1 JavaScript 监听addEventListener 与事件委托“javascript 监听”这个热词指的就是事件监听。现代前端开发基本都用addEventListener而不是老式的onclick属性。区别在于addEventListener可以给同一个元素绑定多个事件处理函数而且可以控制事件在捕获阶段还是冒泡阶段触发。基础写法const btn document.getElementById(btn); btn.addEventListener(click, function (e) { console.log(按钮被点击了, e.target); });面试进阶题是事件委托。原理是事件有冒泡机制子元素触发的事件会一路传到父元素。利用这一点可以只在父元素上绑定一次监听统一处理所有子元素的事件。典型的例子是动态列表const list document.getElementById(list); list.addEventListener(click, function (e) { if (e.target.tagName LI) { console.log(点击了, e.target.textContent); } });这样后续往列表里新增的 li 元素不需要重新绑定事件因为监听器在父元素上。好处很实际一是动态元素也能触发二是减少内存占用。面试时再追问“如何移除监听”答案是用removeEventListener但注意传入的函数必须是同一个引用所以匿名函数无法移除需要先把处理函数提出来。4.2 表单提交与 HTML5 表单验证的区别表单这块传统做法和 HTML5 原生方案差别很大。老式写法是给表单加onsubmit事件或者按钮加onclick在 JavaScript 里手动校验校验通过再提交不通过就return false或者调preventDefault()form.addEventListener(submit, function (e) { const input document.getElementById(email); if (!input.value.includes()) { e.preventDefault(); // 阻止提交 alert(邮箱格式不正确); } });HTML5 之后很多基础校验浏览器已经内置了不需要写 JavaScript。比如input typeemail required会自带格式校验和“必填”提示。还有pattern属性可以用正则限定格式input typetext pattern[0-9]{11} title请输入11位数字 required /这两套方案的关系不是替代而是互补。HTML5 解决的是“格式对不对”的问题业务校验比如“用户名是否已占用”“两次密码是否一致”仍然需要 JavaScript。有一个关键点很多新手不知道如果给表单加了novalidate属性HTML5 内置校验会整体失效提交时不再自动拦格式错误这时候所有校验都必须用 JavaScript 处理适合需要自定义报错样式的场景。说到“表单提交和 H5 的区别”我还被问过submit事件和click事件的关系。直接 submit 按钮触发表的 submit 事件但button typebutton不会触发 submit这点容易踩坑。实际开发里我习惯用 submit 事件而不是按钮 click 事件来做表单提交因为前者能兼顾回车键提交的情况。4.3 那些带 javascript: 前缀的链接与地址栏小技巧javascript:;是很多老项目里常见的写法一般出现在 a 标签的 href 里目的是点击链接时不让页面跳转、不让页面滚动到顶部a hrefjavascript:;点击/a原理是把 href 当成一段 JavaScript 代码来执行;表示空语句所以点了什么也不发生。现在更推荐的写法是直接用 button 标签或者给 a 标签的事件回调里加e.preventDefault()因为javascript:伪协议在部分场景下会和内容安全策略CSP冲突也不利于可访问性。其实浏览器地址栏本身也支持执行 JavaScript 代码这就是热词里那条v document.querySelector(video); v.style.rotate -90deg的玩法。在网页里按下 F12 打开控制台或者直接在地址栏输入javascript:开头的内容回车就能操作页面上的元素。以前想在网页上看视频但视频方向横了就可以用类似代码把视频旋转 90 度。这些属于调试技巧对排查页面样式和 DOM 状态很有用但要注意几件事一是只能在自己的页面或者公开页面做调试不要用在别人网站里搞破坏二是有一些浏览器出于安全考虑会在地址栏输入javascript:时自动忽略建议直接用开发者工具控制台三是如果页面里嵌套了 iframe直接document.querySelector可能拿不到跨域 iframe 里的元素需要先切换到对应的 frame 上下文。另外提醒一句如果收到“添加账号 javascript:;”这类链接或者是来源不明的网站把javascript:写进链接诱导点击要格外警惕这类代码有可能会执行不可信的脚本。遇到不熟悉的链接先看代码再决定要不要点。安全意识和业务能力一样重要。5. 工程化与调试报错排查与工具配置5.1 HBuilder 里配置 html、css、javascriptHBuilder现在主要是 HBuilderX是不少用 uni-app 或者做跨端开发的人会碰到的 IDE。其实配不配 JavaScript关键看项目类型。如果建的是“Web 项目”或者“基本 HTML 项目”HBuilderX 默认会给你生成 index.html里面自动关联了 css 和 js 文件目录结构类似├── css │ └── style.css ├── js │ └── main.js └── index.htmlindex.html 里默认会引入这两个文件link relstylesheet hrefcss/style.css script srcjs/main.js/script日常开发时改完 js 或 css保存后切到浏览器刷新就行。HBuilderX 自带“运行到浏览器”功能点一下会起一个本地服务器并把页面打开用这个方式会比直接双击 HTML 文件好一些因为部分浏览器 API比如 fetch 请求本地 json在 file:// 协议下会被拦截。需要注意的一点是HBuilderX 的代码提示和 ESLint 检查默认没有完全打开想在保存时自动格式化 JavaScript需要在工具设置里开启“保存时自动格式化”以及安装对应的插件。如果项目用了 uni-app那 js 文件的运行环境比较特殊不能直接当浏览器环境来调得用微信开发者工具或者内置浏览器预览。这些配置不算复杂但第一次用的人容易找不到入口我习惯直接记住菜单路径工具 - 设置 - 插件配置。5.2 Failed to load module script 报错热词里有一条很具体“failed to load module script: expected a javascript module script but the server responded with a MIME type of ...”。这个报错我见过好多次第一次遇到时也懵了半天。出现这个报错说明你的 HTML 里用script typemodule加载 ES Module但服务器返回的文件类型不对。常见原因有三个第一个是文件路径写错或者文件不在预期位置服务端返回了 404 页面但 404 页面是 HTML不是 JavaScript于是浏览器报“预期的 module script 但服务器返回的 MIME 是 text/html”。解决办法是先打开浏览器开发者工具的 Network 面板看那个 script 请求到底返回了什么状态码和 Content-Type。第二个是用了 file:// 协议直接打开 HTML 文件。ES Module 有跨域限制file:// 协议下模块加载会被浏览器拦截报 CORS 错误。解决办法是起一个本地服务器比如npx serve或者用 VSCode 的 Live Server 插件。第三个是服务器没配对的 MIME 类型尤其是一些静态服务器对.js文件返回了text/plain。解决办法是改服务器配置让.js文件返回application/javascript或text/javascript。如果是用 Python 的http.server之类简易服务器有时候就会遇到这种问题。排查这个报错我建议的路线是先看请求状态码404 就是路径问题再看 Content-Type不是 JavaScript 就是 MIME 问题最后看是不是跨域策略拦截。按这个顺序排查基本几分钟就能定位。5.3 JavaScript 运行时报错排查思路JavaScript 报错是每个前端er的家常便饭但很多人一遇到报错就慌其实排查思路很固定。我把常见报错整理成了一张速查表报错信息常见原因排查方向ReferenceError: xxx is not defined变量未声明或作用域不对检查拼写、是否在正确作用域内TypeError: Cannot read property xx of undefined某个对象是 undefined检查接口返回、数组越界TypeError: xxx is not a function调用了一个不是函数的值检查变量是否被覆盖、方法名是否正确SyntaxError: Unexpected token语法错误看报错行号检查括号、引号RangeError: Maximum call stack size exceeded死循环递归检查递归终止条件Failed to fetch网络请求失败看接口地址、跨域配置、后端服务排查工具方面我用得最多的是 Chrome DevTools 的三块Console 看报错信息、Sources 打断点、Network 看请求。打断点是个好习惯在 Sources 里找到对应 js 文件点行号设置断点刷新页面后代码会停在断点处这时候可以在 Scope 面板看到所有变量的值比console.log一条条打出来高效得多。还有个功能叫“异常时暂停”在 Sources 面板右侧有个 Pause on exceptions 按钮开启后代码一报错就会自动停在出错位置非常适合排查运行时错误。至于“禁用 JavaScript”这个热词——Chrome 开发者工具设置里有禁用 JavaScript 的选项Settings - Debugger - Disable JavaScript开启后页面会以纯 HTML/CSS 的状态展示。这个功能一般用来测试网站的无障碍体验和降级方案看页面在没有 JavaScript 的时候是否还能展示内容。正常的业务站点通常不推荐禁用 JavaScript 使用但做前端性能和可访问性评估时这个功能很有参考价值。6. 这套题怎么用起来最有效6.1 自测方法先动手写再对照答案题目整理出来不是用来看的是用来练的。我的建议是不要直接看答案先写代码再对照。比如闭包那道题你先自己写一个计数器写一个防抖函数再回头看我上面解释的原理印象会深得多。JavaScript 是一门“动手语言”光看不会只有敲过、报错过、修好过才算真的会。具体来说我把这套题分成三档第一档是基础语法题要求能默写并讲清原理适合面试前一周快速过第二档是 ES6 操作题要求能在白板上手写实现比如合并对象、用 filter 处理数据第三档是工程化排查题要求能口述排查思路最好结合自己项目里真实遇到的报错来讲面试官很吃“真实经历”这一套。6.2 带团队和出题时的小技巧如果你和我一样需要面试别人或者带新人这套题也能直接用。我一般不会只问“什么是闭包”而是改成“这段代码输出什么”for (var i 0; i 3; i) { setTimeout(() console.log(i), 0); }这个题输出是3 3 3因为 var 没有块级作用域循环结束后 i 已经是 3 了。改成let i就会输出0 1 2。一道题能带出变量作用域、闭包、事件循环三个知识点比干巴巴问“var 和 let 的区别”有效得多。出题的时候还有一个心得不要只给对错要让人说出“为什么”。候选人答对了更要追问一句“如果不这么写会怎样”这一问能过滤掉很多背题选手。6.3 从题目整理到长期学习的习惯我整理这套题的初衷其实很简单每次面试前临时抱佛脚太痛苦不如平时遇到一个坑就记一道题遇到一个经典场景就沉淀一个答案。这个习惯坚持下来之后我发现自己对 JavaScript 的理解比刚入行时扎实了很多——因为能把题目讲清楚本身就是最好的复习方式。如果你也想这么做我的建议是准备一个个人文档或者博客按我上面的四个维度分类每遇到新问题就往里补充。坚持半年之后再看那些曾经让你头疼的 Callback、原型链、事件循环都会变成你的肌肉记忆。这也算是我在这行摸爬滚打多年最想分享给你们的一点经验了。