JavaScript 这门语言在开发者圈子里一直有个有趣的现象:入门的人觉得它简单,写了两年代码的人反而越来越觉得它深不可测。我也经历过那个阶段——从照着教程摆弄var和function,到被this指向逼疯,再到彻底搞懂原型链之后回头看,发现大部分所谓的“JS 难点”,其实都集中在几个核心机制上。这篇文章我不打算给你罗列 API 文档,而是想聊聊我在实际项目里反复用到、也反复踩坑的那些 JS 核心知识点,包括闭包、异步、原型链,还有几个特别实用但容易被忽视的新特性。无论你是刚写完第一个交互页面、准备深入前端原理的初学者,还是已经写了几年业务代码、想系统梳理知识体系的中级开发者,这篇内容应该都能让你有些收获。
1. 先理解 JS 的运行逻辑,才能理解一切“诡异”行为
1.1 为什么 var 和 let 的行为不一样
很多初学者第一次被 JS 震惊,通常是因为var的变量提升。看这段代码:
console.log(a); // 输出 undefined,而不是报错 var a = 10;在大多数编程语言里,使用一个未声明的变量会直接报错。但 JS 在早期设计时,把这个行为定义成了“变量提升”——var a的声明会被提升到当前作用域的顶部,但赋值留在原地。所以上面那段代码实际执行时,相当于:
var a; console.log(a); a = 10;let和const也提升,但它们存在“暂时性死区”,在声明语句之前访问变量会直接抛ReferenceError。这个设计修正了var的怪异行为,所以在现代代码里,我一般默认用const,只有当变量确实需要重新赋值时才改用let,var基本可以退出日常使用。
1.2 闭包不是玄学,是作用域链的自然结果
闭包这个概念被面试题渲染得有点吓人,但它本质上就是:当一个函数记住了它所在作用域的变量引用,并且这个函数还能被外部访问到,就形成了闭包。JS 的函数在创建时就带上了对所在作用域的引用,这个引用链条称为作用域链。
看一个最经典的例子:
function createCounter() { let count = 0; return function () { count++; return count; }; } const counter = createCounter(); counter(); // 1 counter(); // 2createCounter执行完毕后,按道理它的局部变量count应该被垃圾回收了,但因为返回的函数还持有作用域链中对count的引用,所以count被保留了下来。这个特性在封装私有变量、防抖节流、React Hooks 的闭包陷阱等场景中都有具体体现。
我提一个实际中很容易踩坑的点:在循环里用var创建闭包,结果所有闭包共享同一个变量。
for (var i = 0; i < 3; i++) { setTimeout(function () { console.log(i); // 输出 3 次 3 }, 100); }这个问题的根源就是var没有块级作用域,循环结束后i变成了 3,所有回调函数读到的都是同一个最终值。把var改成let,每个循环迭代都会创建一个新的绑定,问题立刻消失。这也是为什么我强烈建议抛弃var。
1.3 this 绑定:调用方式决定一切
this的指向问题曾经让无数人抓狂。我早年也死记硬背“谁调用指向谁”,但真正理解了“绑定规则”之后,这个问题就彻底清楚了。
JS 中this的绑定有四种规则,按优先级从高到低排列:
| 绑定规则 | 触发方式 | 优先级 |
|---|---|---|
| new 绑定 | new Foo() | 最高 |
| 显式绑定 | call/apply/bind | 第二 |
| 隐式绑定 | obj.method() | 第三 |
| 默认绑定 | 独立调用foo() | 最低 |
看几个实际例子:
const obj = { name: 'demo', getName: function () { return this.name; } }; obj.getName(); // 'demo',隐式绑定 const fn = obj.getName; fn(); // undefined,默认绑定,此时 this 指向全局对象 fn.call(obj); // 'demo',显式绑定在 React 类组件时代,我们必须手动bind事件处理器,或者在构造函数里绑定this,就是因为事件系统触发回调时,方法被独立调用了,丢失了隐式绑定。现在函数组件配合 Hooks 已经很少遇到这个问题,但面试中和底层库开发中,这套绑定规则依然是核心知识。
另外一个常见场景是数组遍历方法里的thisArg参数:
const obj = { multiplier: 2 }; const arr = [1, 2, 3]; const doubled = arr.map(function (item) { return item * this.multiplier; }, obj); console.log(doubled); // [2, 4, 6]如果不传第二个参数obj,回调里的this就是undefined(严格模式下),容易报错。很多新手写箭头函数习惯了,反而不了解这些回调方法的thisArg参数,这是很可惜的。
1.4 从浏览器到 Node:事件循环机制是异步的基石
JS 是单线程的,但它不阻塞。靠的就是事件循环(Event Loop)。一段 JS 代码执行时,会先跑完当前调用栈里的所有同步任务,然后依次处理微任务队列,再取出宏任务队列的第一个任务执行,执行完后再次清空微任务队列,如此往复。
我之前在项目里遇到过一个问题:页面上有个按钮,点击后需要执行一个耗时计算,同时要展示 loading 状态。起初代码写在事件回调里直接死循环计算,结果点击后页面卡死,loading 根本渲染不出来。原因就是:渲染是宏任务,而同步的耗时计算在事件回调里已经占满了调用栈,浏览器根本没有机会去做渲染。
解决思路很简单:用setTimeout把耗时任务拆成异步块,或者干脆用 Web Worker 开线程。这个案例让我真正理解了“JS 虽然是单线程,但浏览器是多线程的”——渲染线程、JS 线程、网络线程、定时器线程各司其职,事件循环只是 JS 线程内部的任务调度机制。
关于微任务和宏任务的执行顺序,可以用一个经典代码来验证:
console.log('start'); setTimeout(() => { console.log('timeout'); }, 0); Promise.resolve().then(() => { console.log('promise'); }); console.log('end'); // 输出顺序:start -> end -> promise -> timeoutPromise.then是微任务,会在当前调用栈结束后立刻执行;setTimeout是宏任务,就算延迟是 0,也要等微任务清空后才轮到。理解了这个小例子,async/await的执行顺序就很好推演了。
2. 异步编程进化史:从回调地狱到 async/await
2.1 回调地狱的本质是控制反转
说起异步就绕不开回调。Node.js 早期大量使用回调 API,嵌套层级稍微深一点就变成所谓的“回调地狱”。我见过最夸张的一版老代码,五层嵌套的fs.readFile,每层还要处理错误分支,代码可读性极差。回调地狱的表面问题是缩进太深,实际上,它暴露的是控制反转带来的心智负担——你没法用同步的方式去理解异步流程。
当时社区出现了各种解决思路,比如Promise出现之前,有人用async库的series、waterfall来串行化任务,但这些终究是库层面的补丁。真正从语言层面解決问题,是 ES6 引入了Promise。
2.2 Promise 的三个状态与链式调用
Promise对象有三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)。状态一旦从pending转变,就再也不能变回去了,这个特性保证了链式调用的稳定性。
链式调用最核心的就是then方法的返回值传递:
fetch('/api/user') .then((res) => res.json()) .then((user) => fetch(`/api/orders?userId=${user.id}`)) .then((res) => res.json()) .then((orders) => { console.log(orders); }) .catch((err) => { console.error(err); });这种方法把嵌套回调变成了扁平链式结构,代码可读性大幅提升。但要注意,then回调里如果返回一个非 Promise 值,这个值会成为下一个then的输入;如果返回一个 Promise,后续链会等待它完成。这个机制是理解async/await的基础。
还有一个容易忽略的点:catch只能捕获它之前的链上错误。如果catch后面还有then,这个then依然会执行,只是它拿到的可能是catch里返回的值。所以在写复杂链式调用时,要特别注意错误处理的位置。
实际项目中,我还很常用这几个静态方法:
Promise.all:并发执行多个请求,全部完成才触发。注意,它是“全有或全无”,一个失败就整体失败。Promise.allSettled:同样并发执行,但会等所有请求都出结果,不因个别失败而中断。我在做批量数据上报时优先用这个。Promise.race:谁先出结果就用谁,常用于超时控制。Promise.any:第一个成功的为准,全部失败才走入失败分支。这是较新的方法,ES2021 引入的。
它们的使用场景差异很大。比如同时加载多个首屏资源,可以用Promise.all合并请求状态;但如果是上传多个文件,一个文件失败不应该影响其他文件,就应该用Promise.allSettled而不是all。
2.3 async/await 只是 Promise 的语法糖
async/await是 ES2017 加入的标准,它的本质是把 Promise 链封装成看起来像同步代码的样子。
async function getUserOrders(userId) { try { const user = await fetch(`/api/user/${userId}`).then((r) => r.json()); const orders = await fetch(`/api/orders?userId=${user.id}`).then((r) => r.json()); return orders; } catch (err) { console.error('拉取订单失败', err); return []; } }这段代码的书写体验和读同步代码几乎没有区别。但它有一个隐藏特点:async函数本身就是返回 Promise 的,所以你可以直接await另一个async函数,也可以把async函数作为回调传给Promise.all。
需要注意,await只能捕获同步异常和 Promise 的 rejection 吗?不一定,它也会捕获await之后抛出的错误。但如果在async函数内部用了forEach循环配合await,得不到你想要的效果——forEach不会等待异步操作完成。正确的做法是用for...of:
async function processItems(items) { for (const item of items) { await processItem(item); // 串行执行 } }同理,如果你希望并行处理所有 item,应该用Promise.all(items.map((item) => processItem(item))),而不是在forEach中用await。这个坑我在代码评审中见过不下五次,值得单独拿出来提醒。
2.4 事件监听与异步迭代器
除了async/await,ES2018 还引入了异步迭代器(for await...of),这个在真实项目中用得越来越多。比如你需要逐行读取一个大型文件,或者消费一个分页接口,每次返回下一个数据块,用for await...of非常顺手:
async function* createPageGenerator(pageSize) { let page = 1; let hasMore = true; while (hasMore) { const data = await fetch(`/api/data?page=${page}&size=${pageSize}`).then((r) => r.json()); yield data.items; hasMore = data.hasMore; page++; } } (async () => { for await (const items of createPageGenerator(20)) { console.log('当前页数据', items); } })();这里async function*定义了一个异步生成器,每次迭代都会等待前一个yield执行完再继续。处理无限流或者大数据分页时,这个模式比一次性拉回全部数据要优雅得多,内存占用也小得多。
3. 原型与面向对象:理解 JS 的“类”本质
3.1 原型链到底怎么工作的
ES6 的class语法让很多从 Java 转过来的开发者误以为 JS 和 Java 的面向对象机制一样。其实不是——class只是语法糖,JS 的对象继承走的是原型链机制。
每个对象都有一个内部属性[[Prototype]](通过__proto__可访问,现在推荐用Object.getPrototypeOf),它指向另一个对象。当访问对象的某个属性时,先查自身,如果没有,就沿着[[Prototype]]找到其原型对象,再没有就继续往上,直到Object.prototype,这个链条就是原型链。
function Animal(name) { this.name = name; } Animal.prototype.speak = function () { console.log(`${this.name} makes a sound`); }; function Dog(name) { Animal.call(this, name); } Dog.prototype = Object.create(Animal.prototype); Dog.prototype.constructor = Dog; Dog.prototype.speak = function () { console.log(`${this.name} barks`); }; const dog = new Dog('旺财'); dog.speak(); // 旺财 barks这里Dog.prototype是Object.create(Animal.prototype)创建的,所以 Simba 能访问Animal.prototype上的方法。Dog.prototype.constructor = Dog这行是修复constructor指针,因为Object.create之后,Dog.prototype(一个对象)原本的constructor指向了Animal。很多书会讲这行是必备的,实践中确实如此。
3.2 class 语法的注意点:方法、静态方法与私有字段
ES6 的class让上述原型链操作变得直观很多:
class Animal { static isAnimal(obj) { return obj instanceof Animal; } #secret = '我的私有秘密'; // 私有字段 constructor(name) { this.name = name; } speak() { console.log(`${this.name} makes a sound`); } getSecret() { return this.#secret; } }这里有几个实际项目中常见的坑:
第一,类上的方法(非静态方法)是定义在prototype上的,而实例字段(如#secret)是定义在实例自身的。两者访问机制不同,在使用Object.keys(instance)时,你只能拿到实例自身属性,拿不到原型上的方法。
第二,私有字段#前缀是 ES2022 标准化的。在它之前,大家用_secret这种命名约定来模仿私有,但那只是约定,外部依然可以访问。现在有了真正的私有字段,语言层面上就能保证外部无法访问。
第三,super关键字在构造器中必须位于this之前调用。如果你在子类构造函数里先用了this,再调super(),会触发运行时错误,这是 JS 引擎对“派生类必须先初始化父类”这个规则的强制约束。
3.3 组合优于继承:这是我在项目中总结出的经验
原型链虽然灵活,但过度使用继承会产生深层原型链,调试困难,代码也难以复用。我在实际项目中更倾向于“组合优于继承”的写法。
比如,需要一个可以飞行、也可以游泳的角色,如果用继承,可能会出现FlyingFish这种双继承矛盾。用组合的话:
const canFly = { fly() { console.log('飞行中'); } }; const canSwim = { swim() { console.log('游泳中'); } }; function createFlyingFish(name) { const fish = { name }; return Object.assign(fish, canFly, canSwim); }这种方式把行为拆成独立模块,按需组合,不产生复杂的继承链,代码更容易理解,也方便测试。前端框架里的 Mixin 思想就是基于类似逻辑。我并不是说继承一无是处——在层级关系稳定、子类确实属于父类的情况下(比如按车型继承汽车基类),继承依然清晰高效。但遇到“会飞”“会游泳”这种能力型特征,组合更可靠。
4. ES6 以后不可错过的 5 个新特性(以及我如何用它们重构旧代码)
4.1 可选链与空值合并:告别垃圾防御代码
以前从嵌套对象里取深层属性,得写一大堆判空:
if (data && data.user && data.user.profile && data.user.profile.city) { const city = data.user.profile.city; }现在只需要:
const city = data?.user?.profile?.city;?.在遇到null或undefined时直接短路返回undefined,不会抛异常。搭配空值合并操作符??,可以用默认值兜底:
const city = data?.user?.profile?.city ?? '未知城市';注意??跟||有本质区别:??只在左侧为null或undefined时取右侧,而||会在左侧为假值(比如空字符串、0、false)时取右侧。如果你的默认值是针对“没有数据”而不是“数值为0”的场景,用??更准确。
我把旧代码里大量if (obj && obj.xxx)改成可选链之后,代码量明显减少,且不容易漏判,这大概是我这几年最常用的一项重构技巧。
4.2 解构赋值的进阶用法
解构赋值不只是从对象里取属性这么简单。实际开发中我常用它来处理函数参数、交换变量、提取返回接口字段。
const { code, data, message = '成功' } = response;这样接口返回的字段直接变成局部变量,代码更清晰。数组解构的经典场景是交换变量:
let [a, b] = [b, a];这比传统的temp = a; a = b; b = temp简洁许多。解构还可以用于for...of循环:
for (const { id, name } of userList) { console.log(id, name); }这个写法的性能影响几乎可以忽略不计,但可读性提升非常明显。
4.3 展开运算符的深拷贝陷阱
展开运算符...能用一行代码复制数组或对象:
const copyArr = [...originalArr]; const copyObj = { ...originalObj };但这种复制只有一个层级,也就是浅拷贝。如果对象里嵌套了数组或对象,修改嵌套层会同时影响原数据。我在项目里遇到过不止一次因为直接用{ ...state }触发 React 状态更新异常,原因就是嵌套对象没有被正确拷贝,导致新旧 state 引用了同一个子对象。
如果确实需要深拷贝,推荐两种方案:
- 对于不包含函数、
undefined、循环引用的普通 JSON 数据,使用JSON.parse(JSON.stringify(data))。 - 对于更复杂的数据结构,使用
structuredClone(现代浏览器和 Node 17+ 原生支持)或者专门的工具库。
structuredClone能处理循环引用和许多内置类型,是现在优先推荐的原生方案。
4.4 新的集合类型:Map 和 Set 的日常优势
普通对象{}作为字典有两个麻烦:键只能是字符串(或 Symbol),而且会继承原型链上的属性。Map则没有这些限制,键可以是任何类型,且插入顺序保留。做数据缓存、状态映射时,我更喜欢Map。
const cache = new Map(); cache.set('user_' + id, userData); if (cache.has('user_' + id)) { return cache.get('user_' + id); }Set是去重的利器。以前去重一个数组得Array.from(new Set(arr)),或者用filter配合indexOf,后者复杂度还高。现在一行搞定:
const unique = [...new Set(arr)];Set内部基于同值零(SameValueZero)比较,所以NaN也能被正确去重,这是indexOf做不到的。
4.5 模块化升级:动态 import 和顶层 await
前端工程化进行到现在,import和export已经成为标配。ES2020 带来的动态import()则提供了按需加载的另一种思路。遇到路由懒加载、状态管理模块按需初始化等场景,动态导入能把首屏体积降下来。
// 点击按钮后再加载大型模块 button.addEventListener('click', async () => { const { initChart } = await import('./chart.js'); initChart(); });还有顶层await这一特性,它允许模块在顶层直接使用await而不需要包在async函数里。这尤其适合初始化依赖异步数据的服务类模块:
// config.js export const config = await loadConfig();需要用这个特性的项目要在构建配置里确认目标环境支持情况。有些比较老的浏览器版本不支持,上线前建议做好降级方案。
5. 实用工具函数与常见问题排查
5.1 几个我沉淀下来的代码片段
多年实战里,几个高频复用的小函数帮我把业务代码简化了很多。分享三个我觉得最有代表性的。
第一个是防抖函数。搜索框实时联想触发接口频率太高,我用它来限制请求次数:
function debounce(fn, delay = 300) { let timer = null; return function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); }, delay); }; }这里的关键是用apply把this传进去,否则在对象方法里调用时会丢失上下文。防抖的适用场景是输入、resize 这类高频事件;如果是滚动加载更多,我更倾向于用节流:每间隔一段时间拉一次接口,而不是等用户彻底停下才触发。
第二个是通用的深比较工具。有些场景需要对比两个对象的内容是否一致,手动遍历对象太麻烦,用JSON.stringify有键值顺序问题。可以写一个递归比较函数,也可以直接借助第三方库的isEqual方法。如果是项目里的高频依赖,引入成熟库比手写更稳。
第三个是sleep工具函数。在控制请求频率、模拟接口延迟时有奇效:
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));比如重试逻辑里,失败后等 1 秒再重试:
async function fetchWithRetry(url, retries = 3) { for (let i = 1; i <= retries; i++) { try { return await fetch(url).then((r) => r.json()); } catch (err) { if (i === retries) throw err; await sleep(1000 * i); } } }这个重试函数我在调第三方接口时经常用,解决了临时网络抖动导致的请求失败问题。
5.2 调试技巧:console 之外的价值信息
console.log谁都会用,但有几个调试技巧比平时打日志高效得多。
console.table能把数组里的对象渲染成表格,查看大量结构一致的数据时非常直观。console.time跟console.timeEnd用来测量某段代码的执行时间,排查性能瓶颈时极其有用。
console.time('数据处理'); processData(largeArray); console.timeEnd('数据处理');还有一个容易被忽视的工具:浏览器 DevTools 里的 Performance 面板。打开后录制一段交互操作,你能看到每个函数的耗时占比、调用顺序、以及是否有长任务阻塞了主线程。这个工具帮我定位过至少三次导致页面卡顿的性能问题,比单靠console.time更全面。
5.3 常见运行时错误排查清单
我在评审代码和排查线上问题时,总结了五个最高频的 JS 报错场景:
| 报错信息 | 常见原因 | 解决办法 |
|---|---|---|
Cannot read property 'x' of undefined | 异步数据未返回就取值 | 使用可选链?.,或先判空 |
xxx is not a function | 方法名拼写错误,或模块导入的是对象而非函数 | 检查导入语句和调用名 |
Unexpected token | 语法错误,最常见是括号或引号不匹配 | 使用编辑器自带的语法高亮和 lint 工具 |
Maximum call stack size exceeded | 无限递归调用 | 检查递归出口条件 |
Cannot find module | 路径错误或未安装依赖 | 检查import路径,重新安装依赖 |
这些报错信息看起来很基础,但在实际项目里出现频率极高。尤其第一个,几乎是异步编程初学者最常遇到的问题之一。养成“先判空再取值”的习惯,或者直接用可选链,能规避掉一大部分。
另外,严格模式是一个值得开启的选项。在文件头部或函数头部加'use strict',可以避免一些静默失败的行为,比如给未声明的变量赋值会直接报错,而不是默默创建一个全局变量。现代 ES Module 默认就是严格模式,但在传统脚本里,我还是会手动开启。
5.4 内存泄漏的三个常见来源
前端应用长期运行变卡,很多时候不是 JS 本身跑不动,而是内存泄漏积累导致 GC 压力过大。我排查过的泄漏案例中,三类来源最常见:
一是被遗忘的全局变量。未声明的变量或者挂在window上的临时数据,如果没有及时清理,会一直常驻。
二是被遗漏的事件监听器。使用第三方库或者手写addEventListener之后,组件卸载时没有removeEventListener,事件系统会持续持有对 DOM 和回调函数的引用,DOM 即使被移除,内存也释放不掉。
三是闭包持有大对象。比如闭包引用了包含大量数据的变量,即使外部已经不需要这个闭包了,只要闭包本身还被引用,那个大数据对象就释放不了。
给一个简单的自查思路:打开 DevTools 的 Memory 面板,重复执行一个操作若干次后记录堆快照,对比两次快照之间的对象数量和增长趋势,就能定位到泄漏源头。实际体验中,用 Chrome 的“Allocation instrumentation on timeline”录制功能,能直接看到哪些函数分配内存最多,排查效率比猜快很多。
5.5 前端框架为什么如此依赖 JS 特性
很多人在做 Vue 或 React 项目时,觉得 JS 基础跟框架关系不大,直到遇到响应式失效、状态更新异常才回头补基础。其实框架的很多机制,本身就建立在 JS 的核心特性上。
比如 Vue 2 的响应式系统利用了Object.defineProperty来劫持对象属性的读写;Vue 3 改用Proxy,原因就是defineProperty无法拦截新增属性和删除操作,而Proxy可以。React 的状态更新机制则依赖Object.is进行变更检测。
再比如 React Hooks 里,自定义 Hook 之所以能保存状态,本质上依赖闭包。每个 Hook 对象的引用会被保存在组件实例对应的 fiber 节点上,闭包让这些变量跨渲染周期存活。如果完全不懂闭包,就很难理解为什么 Hooks 的调用顺序不能改变、为什么有时会拿到陈旧的 state。
所以我的建议很明确:框架可以帮你快速出活,但要在框架里面游刃有余,值得花时间把 JS 的语言特性吃透。
6. 工程化视角:如何在团队中保持 JS 代码质量
6.1 lint 与格式化:在写代码时就把低级错误过滤掉
在工作里做代码评审时,我发现大量问题其实是低级笔误——拼写错误、多余逗号、缩进混乱、未使用的变量。这些问题不值得浪费人工评审时间去发现和纠正,应该靠工具解决。ESLint 和 Prettier 的组合几乎是现代前端工程的标准配置。
我的习惯是:
- ESLint 负责逻辑质量,包括检测未使用变量、禁止
var、强制===、限制console.log残留等。 - Prettier 负责代码风格统一,单引号、双引号、分号、缩进宽度,全部交给工具自动格式化。
- 提交代码前跑一遍
lint-staged,只检查暂存区里本次改动的文件,节省整个仓库全量检查的时间。
配置完成后,团队中的新人也能很快写出风格一致、没有低级错误的代码。关键是初始配置阶段不要过度约束规则,先用默认规则跑通,再根据团队项目实际逐步收严。
6.2 TypeScript 是 JavaScript 的“体检报告”
近两年我基本不写纯 JS 的业务代码了,新项目都从 TypeScript 开始。因为大型团队协作中,类型注解让函数参数、接口字段、状态结构一目了然,重构时不再担心改了一个对象字段、漏改了某个引用点。
这并不意味着 JS 被抛弃了——恰恰相反,TS 是 JS 的超集,你写的类型最终会被编译回 JS。理解 JS 的运行时行为,是写对 TS 类型的前提。比如:
type ApiResponse<T> = { code: number; data: T; message: string; }; async function fetchApi<T>(url: string): Promise<ApiResponse<T>> { const res = await fetch(url); return res.json(); }这个泛型的写法和 JS 原生的fetch().then(r => r.json())本质上一致,但多了一层编译时期的保障。如果你刚开始转型 TS,建议先给旧 JS 项目开启allowJs和checkJs,让 TS 只做静态检查而不阻止编译,渐进式迁移会更平滑。
6.3 测试:给 JavaScript 代码加一层安全网
我不知道多少团队给前端代码写了充分的单元测试,但说实话,业务逻辑里最值得测试的部分往往是纯函数和状态转换逻辑。比如把购物车里的商品数组、优惠券对象、用户积分输入一个函数,算出最终应付金额。这种函数没有 DOM 依赖、没有网络请求,非常适合单元测试。
常用的测试框架是 Jest 或者 Vitest。写测试的心得是:不要追求覆盖率数字,先把核心业务逻辑的关键分支测好。比如订单计算的临界值——满减门槛正好达到、优惠券刚好过期、积分抵扣上限——这些边界条件最容易出 Bug。
我还有一种实用做法:给接口的 response 写类型守卫函数,再用测试保证守卫的判断正确。后端返回的数据结构每次升级都可能破坏前端假设,类型守卫能把这种破坏尽早暴露在测试环境里。
6.4 性能优化:从网络加载到运行时开销
前端性能优化有两个方向:让代码包更小,以及让代码执行更快。
包体积层面,现代构建工具都自带 Tree Shaking,能把没用到的导出移除。但这要求你尽量用具名导出而不是默认导出,因为具名导出更容易被静态分析。动态import()也是常用手段,刚才讲模块化时提过。
运行时层面,主要关注长任务(Long Task)和重渲染。如果一段 JS 执行超过 50ms,用户就会感知到卡顿。可以把大计算拆到 Worker 线程,或者用requestIdleCallback在浏览器空闲时段处理低优先级任务:
requestIdleCallback(() => { // 执行低优先级处理:比如上报数据、预计算等 });这个 API 支持度一般,但可以在空闲时避开主线程高峰,值得一试。另外,React 项目里特别关注重新渲染的范围,用memo和useMemo减少无关组件的重复渲染,效果往往立竿见影。
7. 最后的经验之谈
JavaScript 这门语言发展得比我当年刚开始学的时候成熟太多了。当年我花大把时间在纠结this指向、事件循环、原型链,现在的新特性让很多坑从语言层面就被填平了。但有一点始终没变——需要深刻理解它的底层机制,才能写出既稳又快的应用。
如果你正在学习过程中,我的建议是:不要死记硬背面试题,而是多问自己“为什么这样设计”以及“这个特性解决的是什么问题”。比如闭包为什么会有内存不释放的问题?Promise 为什么设计成不可变状态?let为什么能解决循环闭包问题?这些问题想通了,知识就不再是一个个孤立的小岛,而是连成一片大陆。
还有个小技巧我一直很受用:遇到新特性,不要第一反应去找库解决,先查一下原生 JS 是否已经支持了。原生方案通常更轻量、性能也更好,还不需要引入额外的依赖。这几年我就靠这个习惯,移除了项目里不少不再需要的第三方工具库,包体积减了不少。码字到这儿,也希望这些经验对你的实际项目有帮助。