最近把手头的事放一放系统做了一次 JS 复习。写前端久了的人应该都有这种感觉Vue、React 用得很顺手响应式、虚拟 DOM、组件通信张口就来但真遇到复杂交互、性能瓶颈、跨端兼容这类问题最后还是要回到 JavaScript 本身去找答案。这次复习不是从零开始学而是把数据类型、运算符、条件循环、函数、对象、数组、异步这些基础内容重新过一遍顺手把平时项目里容易含糊的几个经典场景字符串包含判断、URL 验证、复制粘贴、三级联动也重新撸了一遍代码。这篇文章就是这次复习的完整记录适合准备系统过一遍 JS 基础、或者面试前想查漏补缺的朋友参考。文里的代码全部是我本地实测过的可以直接复制去跑。1. 复习思路与其零散刷题不如按知识地图过一遍1.1 为什么这次选择教科书式的过法说实话我以前复习 JS 的方式很随意今天看到一道数组去重的题就刷一题明天遇到一个 this 指向的坑就查一下。这种零散式学习有个明显问题知识点之间是割裂的内存里没有形成体系。比如闭包和异步看着没关系但for循环里用var声明变量配合setTimeout这个经典问题恰恰是闭包和事件循环一起作用的结果。所以在开始之前我先列了一张知识地图把 JS 基础拆成几个大块数据类型、运算符、语句结构、函数、对象、数组、异步、DOM 操作。每个大块下面再列子项复习的时候按顺序推进每过完一块就写几个小 demo 验证。1.2 复习范围的选择与取舍这次复习聚焦在最核心的语言层面框架相关的内容Vue 响应式原理、React 渲染机制这类不在范围内。虽然标题是JS 复习但每个人的 JS 知识树不一样我更倾向于把基础打扎实而不是去追新特性。项目里天天用的 ES6 语法箭头函数、解构赋值、模板字符串、扩展运算符自然要覆盖但像Proxy、Reflect这种更底层的 API 只做了解没有深挖。下面这张表格是我复习时的路线图后面所有内容都按这条线展开模块核心知识点验证方式数据类型原始类型与引用类型、typeof、类型转换控制台打印 类型判断小工具运算符比较运算、逻辑运算、短路求值边界条件测试语句条件语句、循环语句、break/continue 细节几个嵌套循环 demo函数声明方式、作用域、闭包、this 绑定手写防抖、节流对象创建方式、原型链、属性枚举手动实现一个简单继承数组遍历方法、map/filter/reduce、去重合并合并去重实战异步同步与异步、Event Loop、Promise、async/await手写带重试的请求封装实用场景字符串判断、URL 验证、剪贴板、联动下拉完整交互 demo2. 基础语法回顾数据类型、运算符与语句2.1 数据类型这件事比想象中容易翻车JavaScript 里有 8 种数据类型string、number、boolean、undefined、null、symbol、bigint这 7 种原始类型加上object引用类型。大多数时候我们用typeof来判断类型但typeof null返回的是object这个历史遗留 bug 几乎是面试必考题。typeof null; // object typeof undefined; // undefined typeof []; // object typeof {}; // object typeof function(){} // function我在复习时顺手写了一个更可靠的类型判断函数实际项目里判断数组、日期这类对象时比typeof好用function getType(value) { return Object.prototype.toString.call(value).slice(8, -1); } getType(null); // Null getType([]); // Array getType(new Date()); // Date getType(/regex/); // RegExp这个函数利用了Object.prototype.toString返回内部属性[[Class]]的特性比instanceof更稳因为instanceof在跨 iframe 场景下会因为原型链隔离而失效。复习数据类型时最容易忽略的是类型转换。1 2结果是121 - 0结果是11 true结果是2。这些规则的底层是 ToPrimitive 抽象操作虽然平时写代码不太会刻意用但遇到隐式转换导致的 bug 时不懂这个约定会很痛苦。我个人的建议是项目里尽量用显式转换Number()、String()、Boolean()避免依赖隐式转换的规则记忆。2.2 比较运算符搞清楚 和 的边界是全等比较类型不同直接返回false不进行类型转换。会先做类型转换再比较这正是很多 bug 的来源。复习的重点不是记的转换规则表而是要知道哪些场景下是安全的。注意NaN ! NaN这是 JavaScript 里一个特殊规则判断是否为NaN要使用Number.isNaN()而不是和NaN直接比较。另外Object.is()这个方法在 ES6 中引入它在绝大多数情况下和行为一致但有两个区别Object.is(NaN, NaN)返回trueObject.is(0, -0)返回false。比较运算里还有一个容易忽略的点字符串比较按 Unicode 码点逐位比较不是按长度。10 9返回true因为比较的是第一个字符1和9。如果需要按数字大小比较必须显式转成数字。这个坑我在处理表格排序时踩过不止一次。2.3 条件语句和循环语句写清楚比写聪明重要条件语句本身不复杂if、else if、else、switch、三元运算符但实际项目中容易出问题的是分支逻辑太复杂。我复习时给自己定了一个原则超过三个分支就用switch或查找表不要叠多层if。循环语句方面for、while、do...while是传统写法ES6 之后for...of逐渐成为遍历数组的首选因为它可以直接拿到值而不是索引。for...in主要用于遍历对象属性但要配合hasOwnProperty使用因为for...in会枚举原型链上的属性。我这里特别想提一下for...of和可迭代协议的关系。能使用for...of的前提是对象实现了Symbol.iterator方法数组、字符串、Map、Set都实现了而普通对象没有实现。这就是为什么普通对象不能用for...of遍历。如果想遍历对象可以用Object.keys()、Object.values()、Object.entries()配for...of或者直接用for...in。复习循环时我还专门重新确认了break和continue的行为。break只能跳出一层循环多层嵌套时需要配合标签语句比如outer: for (let i 0; i 5; i) { for (let j 0; j 5; j) { if (i * j 6) { break outer; // 直接跳出两层循环 } } }标签语句在工作中用得非常少但面试题喜欢考建议至少要能看懂。3. 核心机制函数、对象与数组3.1 函数从声明方式到 this 指向JavaScript 函数有几种声明方式函数声明、函数表达式、箭头函数、Function构造函数。函数声明会提升hoisting可以在声明之前调用函数表达式不会。理解这个差异对代码组织很重要尤其是多人协作时函数声明的位置会影响可读性。作用域方面var是函数作用域let和const是块级作用域。复习时我重新研究了一下闭包闭包是函数和其词法作用域的组合它能记住创建时的上下文。实际开发中闭包无处不在防抖节流、柯里化、模块模式都依赖它。function debounce(fn, delay) { let timer null; return function(...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, delay); }; }这个防抖函数就是闭包的经典应用内部函数访问了外部函数的timer变量即使外部函数已经执行完毕这个变量依然被内部函数引用着。this指向是 JS 复习里最让人头疼的部分之一。简单总结几条规则普通函数中this取决于调用方式obj.method()中this是obj独立调用fn()中this是全局对象或undefined箭头函数不绑定自己的this它会捕获定义时所在作用域的thiscall、apply、bind可以显式指定this。我复习时的建议是如果this的指向让你犹豫超过三秒直接用箭头函数或者提前把this存成变量别在运行时猜。3.2 对象与原型链每个对象都有原型对象创建方式有对象字面量、new Object()、Object.create()、构造函数等。复习重点不在创建方式而在原型链机制。每个普通对象都有__proto__属性指向其构造函数的prototype对象属性访问时如果对象自身没有就沿着原型链向上找。function Animal(name) { this.name name; } Animal.prototype.speak function() { console.log(this.name speaks); }; function Dog(name) { Animal.call(this, name); } Dog.prototype Object.create(Animal.prototype); Dog.prototype.constructor Dog; const dog new Dog(旺财); dog.speak(); // 旺财 speaks这段代码用 ES5 的方式实现了继承核心是Object.create(Animal.prototype)让Dog.prototype继承Animal.prototype同时通过Animal.call(this)继承实例属性。ES6 的class语法本质上是这个过程的语法糖但理解底层原型链仍然重要因为很多框架的源码里用的都是原型链操作。对象属性遍历也有讲究。Object.keys()返回自身可枚举属性for...in遍历自身和原型链上的可枚举属性Object.getOwnPropertyNames()返回自身所有属性包括不可枚举的。复习时我建议用Object.keys()作为默认选择行为最可预期。3.3 数组方法合并去重的若干姿势数组是前端开发里使用频率最高的数据结构相关方法非常多。复习时我按遍历方法查找方法增删改方法排序方法分组过了一遍。这里重点说说数组合并去重这是面试高频题也是实际项目经常遇到的需求。// 最常见的写法Set 展开运算符 const arr1 [1, 2, 3]; const arr2 [3, 4, 5]; const merged [...new Set([...arr1, ...arr2])]; // [1, 2, 3, 4, 5] // 只去重不合并 const duplicate [1, 1, 2, 2, 3]; const unique [...new Set(duplicate)]; // [1, 2, 3]Set的去重用的是 SameValueZero 比较规则对NaN也能正确去重这一点比用indexOf或includes去重更稳定。如果数组元素是对象Set只会去重引用相同的对象不会深度比较属性这是需要注意的。如果要求保留原数组顺序可以用filter配合Mapfunction uniqueBy(items, keyFn) { const seen new Map(); return items.filter(item { const key keyFn(item); if (seen.has(key)) return false; seen.set(key, true); return true; }); } const users [ { id: 1, name: A }, { id: 2, name: B }, { id: 1, name: A2 } ]; const result uniqueBy(users, u u.id);这个写法通用性很好指定一个 key 的提取函数就能对任意对象数组按某个字段去重实际项目中比单纯Set更实用。4. 异步编程Event Loop、Promise 与 async/await4.1 同步与异步Event Loop 到底怎么转JavaScript 是单线程语言同一时间只能执行一段代码这是由它的设计初衷浏览器脚本语言决定的。异步能力靠的是事件循环机制简单理解就是一个执行栈 任务队列的配合。我复习时画了一张事件循环的流程代码执行时同步代码按照调用栈顺序执行遇到异步操作如setTimeout、Promise、事件回调会交给浏览器或 Node 的相关模块处理回调函数进入任务队列当调用栈为空时事件循环从任务队列中取出回调执行。任务队列细分成宏任务macrotask和微任务microtask。setTimeout、setInterval、I/O操作属于宏任务Promise.then、queueMicrotask、MutationObserver属于微任务。每一轮事件循环先执行一个宏任务然后清空所有微任务再执行下一个宏任务。console.log(1); // 同步 setTimeout(() console.log(2), 0); // 宏任务 Promise.resolve().then(() console.log(3)); // 微任务 console.log(4); // 同步 // 输出顺序1、4、3、2这个顺序说明了关键点微任务在当前宏任务执行完、下一个宏任务开始前执行所以3排在2前面。setTimeout的 0 毫秒延迟并不是立即执行而是在事件循环排队实际延迟一般是 4 毫秒以上浏览器层面有 clamping 规则。4.2 Promise从传递回调到链式调用Promise解决了回调地狱问题核心是三个状态pending、fulfilled、rejected状态一旦改变就不可逆转。then方法返回一个新的Promise从而实现链式调用。复习Promise时我重点过了一下几个方法的区别Promise.all接收一个Promise数组全部成功才成功任何一个失败就失败Promise.race返回最先完成的结果不管成功还是失败Promise.allSettled等待所有任务完成后返回每个任务的结果不关心成功失败Promise.any返回第一个成功的全部失败才走到reject。项目里我用得最多的是Promise.all但它有个短板一个请求失败就整体失败没有重试机制。实际项目中接口偶发超时很常见所以我封装了一个带重试的版本下面这段代码是这次复习的成果之一。4.3 实战封装一个带超时和重试的异步请求function requestWithRetry(fn, { retries 3, timeout 5000 } {}) { return new Promise((resolve, reject) { let currentAttempt 0; function attempt() { let timer; const timeoutPromise new Promise((_, rejectTimeout) { timer setTimeout(() rejectTimeout(new Error(请求超时)), timeout); }); Promise.race([fn(), timeoutPromise]).then(result { clearTimeout(timer); resolve(result); }).catch(err { clearTimeout(timer); currentAttempt; if (currentAttempt retries) { console.log(第 ${currentAttempt} 次重试...); setTimeout(attempt, 500 * currentAttempt); // 简单退避 } else { reject(err); } }); } attempt(); }); } // 使用示例 function fetchData() { return fetch(https://api.example.com/data).then(res res.json()); } requestWithRetry(() fetch(/api/data), { retries: 3, timeout: 5000 }) .then(data console.log(data)) .catch(err console.error(最终失败:, err));这个封装的核心是Promise.race结合定时器实现超时控制重试时用setTimeout做简单退避避免连续快速重试加重服务器压力。实际项目中可以根据业务需求调整退避策略比如指数退避、加随机抖动。async/await本质上是Promise的语法糖让异步代码看起来像同步代码。我复习时的体会是async函数返回的永远是一个Promise即使在函数里返回字符串外部拿到的也是被包装后的Promise。错误处理上try/catch配合await是标准姿势但需要注意的是如果await的Promise被reject且没有try/catch包裹错误会成为未处理的Promise拒绝有些环境下会导致进程崩溃或全局错误。5. 复习顺手解决的四个经典场景5.1 判断字符串是否包含的多种方式字符串操作是日常开发绕不开的场景判断字符串是否包含某个子串有多种写法我这次复习时统一整理了一遍。const str Hello, JavaScript World; // 方式一includesES6推荐 str.includes(JavaScript); // true str.includes(javascript); // false区分大小写 // 方式二indexOf str.indexOf(JavaScript) ! -1; // true // 方式三match 配合正则 str.match(/JavaScript/) ! null; // true // 方式四search str.search(/world/i) ! -1; // truei 表示忽略大小写日常推荐使用includes语义明确返回布尔值直接作为条件判断。如果需要忽略大小写可以配合toLowerCase()或者像search、match那样用正则的i标志。startsWith和endsWith也是ES6新增的方法用于判断开头和结尾在做路径判断、文件后缀验证时非常方便。const path /assets/images/logo.png; path.startsWith(/assets); // true path.endsWith(.png); // true5.2 URL 有效性验证别只靠正则验证 URL 有效性是表单开发中很常见的需求。以前我习惯写一段复杂的正则后来发现一个问题正则只能判断字符串格式是否符合 URL 的字符模式判断不了 URL 本身是否合法。比如httpx://example.com这种协议拼错的字符串一些宽松的正则也会放行。更好的方案是利用浏览器内置的URL构造函数function isValidUrl(urlString) { try { new URL(urlString); return true; } catch (e) { return false; } } isValidUrl(https://example.com); // true isValidUrl(http://localhost:3000); // true isValidUrl(httpx://example.com); // false isValidUrl(not a url); // falsenew URL()会严格解析 URL 的各个部分协议、主机名、端口有任何不合法都会抛出异常。这个方法在浏览器和 Node 环境都可用比正则简单得多而且能顺便拿到protocol、hostname、pathname等拆解结果。如果需求是验证 URL 是否可访问而不是格式有效那就需要发网络请求去检测这就涉及到跨域和网络状态不在纯前端范畴内。格式验证用URL构造函数连通性验证交给服务端这是我实践下来的分工方式。5.3 复制粘贴功能实现与踩坑记录复制到剪贴板这个功能老一代的做法是document.execCommand(copy)新标准是navigator.clipboard.writeText()。这次复习我专门重新测了两个方案。// 现代方案需要 HTTPS 或 localhost且页面有焦点 async function copyWithClipboardAPI(text) { try { await navigator.clipboard.writeText(text); return true; } catch (e) { console.error(复制失败, e); return false; } } // 兼容方案execCommand function copyWithFallback(text) { const textarea document.createElement(textarea); textarea.value text; textarea.style.position fixed; textarea.style.opacity 0; document.body.appendChild(textarea); textarea.select(); try { document.execCommand(copy); return true; } catch (e) { return false; } finally { document.body.removeChild(textarea); } }实测下来navigator.clipboard在 HTTPS 环境下体验最好API 简洁不用手动创建隐藏元素。但它有权限限制不是所有浏览器都支持而且必须在用户点击等用户手势触发的调用栈里执行否则可能被浏览器拦截。execCommand虽然被标记为废弃但在兼容性要求高的场景下依然是最稳的兜底方案。我还遇到一个很具体的坑navigator.clipboard.writeText在iframe嵌着的页面里如果父页面没有给clipboard-write权限调用会静默失败。排查了半天才发现是权限策略的问题需要在iframe标签上加allowclipboard-write。5.4 三级联动下拉的代码思路三级联动比如省市区选择是前端面试里出现频率很高的题目也是电商、后台系统里的经典需求。它的核心不是 DOM 操作而是数据结构和联动逻辑。数据结构上用嵌套对象或数组最简单的形式是每一级的数据根据上一级的选择来过滤。const regionData { 广东省: { 广州市: [天河区, 海珠区], 深圳市: [南山区, 福田区] }, 浙江省: { 杭州市: [西湖区, 余杭区], 宁波市: [海曙区] } }; function getProvinces() { return Object.keys(regionData); } function getCities(province) { return regionData[province] ? Object.keys(regionData[province]) : []; } function getDistricts(province, city) { return regionData[province]?.[city] || []; }页面交互上三个下拉框select绑定事件省变化时重置市和区市变化时重置区。关键点是每级联动后必须清空下一级的选中值和选项不然会出现旧数据残留。用change事件监听城市列表和区县列表的innerHTML重新渲染。实际项目里数据量大的话不会把全量数据放前端而是根据省拿到省编码请求市的接口再根据市编码请求区的接口。前端只负责管理三级的选中状态和加载状态。两者思路一致都是上级变化触发下级刷新只是数据来源从本地变成了服务器。6. 复习过程中暴露的典型问题与心得6.1 这一轮复习暴露的几个问题把这次复习中反复出错的点整理成一个清单这些也是我访谈过不少同事后发现大家普遍容易含糊的地方问题点典型错误正确理解typeof null以为是null返回object历史遗留 bug和认为两者只是严格不严格会做类型转换规则复杂推荐默认this指向以为由定义位置决定普通函数由调用方式决定箭头函数由定义位置决定微任务与宏任务以为微任务就是全异步微任务优先于宏任务每轮事件循环都清空微任务队列数组去重以为Set能处理对象去重Set只按引用去重不深度比较对象属性URL 验证只写正则优先用URL构造函数做格式校验正则可做辅助复制粘贴只在 HTTPS 里用 Clipboard API需要做execCommand兜底注意 iframe 权限表格里列的这些问题几乎每个都是实际项目里碰到过、或者面试时被问到过的。复习的意义就是把这些零散的经验系统化从知道怎么改升级到知道为什么。6.2 给正在复习 JS 的人几个建议这次复习最大的感受是与其刷题不如手写。数组去重、防抖节流、深拷贝、Promise.all的实现、new运算符的过程这些核心题目只要动手写过一遍理解深度完全不一样。手写不是为了面试时背代码而是写的过程中会逼自己搞清楚每一个细节。另一个建议是合理地利用控制台。复习数据类型和运算符时直接在开发者工具里写表达式看结果比只看文档印象深得多。undefined null为什么返回true[] ![]为什么也是true这种问题控制台跑一下再看转换规则比死记答案有效率。最后一点复习时别贪多。JavaScript 的知识范围太广API 层出不穷一次复习不可能覆盖所有内容。挑出日常工作中实际用到的、理解还模糊的、面试常考的内容逐个击破。这次复习我把范围控制在基础语法、函数、对象、数组、异步这五块加上几个实用场景就已经花了不少时间。贪多嚼不烂基础打牢了新特性和框架知识学起来都会快很多。