ES6实战指南:从let/const到Promise,JavaScript现代写法与踩坑经验

发布时间:2026/10/13 4:43:41
ES6实战指南:从let/const到Promise,JavaScript现代写法与踩坑经验 我到现在都记得前两年带一个内部项目让新人写一个简单的数据筛选器。功能不复杂但代码里“ES6基础”没过关的地方一眼就看出来了循环里用var导致计数器泄漏异步回调里this乱飞。这种画面在团队里太常见了。ES6ECMAScript 2015与其说是一套新语法不如说是JavaScript这门语言的一次“换血”变量生命周期、代码组织方式、异步流程全部被重新定义了一遍。这篇文章我不想再搞成一份考试提纲式的“ES6基础汇总”而是打算从实际项目里的使用经验出发把let/const、箭头函数、解构、模板字符串、class、模块化、Promise这些基础特性的“为什么”讲清楚顺带把踩过的坑也一起列出来。不管你是刚转前端的新手还是写了好几年业务代码想系统巩固一遍的老人应该都能从中找到自己需要的那部分。1. 为什么说ES6是JavaScript生态的分水岭1.1 在ES6之前代码组织方式有多“原始”如果你没经历过ES6之前的时代可能很难理解为什么这套新标准会引起那么大的震动。我最早写JavaScript的时候一个页面里的逻辑靠几个全局函数撑着变量名取成configValue、dataList这种带前缀的名字防止跟别人定义的重名。那时候没有模块的概念所有人共享同一个全局作用域谁先加载谁就能占住这个名字后加载的只能默默覆盖排查起来特别痛苦。变量作用域也是老大难。var是函数作用域不是块级作用域所以在if、for、switch这类代码块里声明的变量在外面照样能访问。最典型的就是for (var i 0; i 5; i) { // 对i做一些操作 } console.log(i); // 5i泄漏到了函数作用域的顶层这在业务代码里偶尔还能忍但一旦和异步回调、闭包组合在一起就会变成经典的“循环里绑定事件点击之后永远取到最后一个值”的bug。我印象最深的一次是在一个管理后台里做表格行点击展开用var i绑定行号结果所有行点开都是最后一行。这种问题不深入理解作用域机制根本无从下手。异步流程也不省心。一个请求回来之后还要再发一个请求回调里套回调三层四层以后代码就开始往屏幕右下方歪着长。当时有句调侃的话叫“回调地狱”真的不是夸张。至于继承和复用靠的是构造函数、prototype、Object.create一套组合拳写出来的代码像拼积木少拼一块就崩。1.2 ES6的设计不是“新增特性”而是“回应积怨”理解了上面的痛点再回头看ES6那一长串新特性就会发现它不是一个堆砌功能的版本而是把开发者积累多年的抱怨系统性地回应了一遍ES6新增能力与历史痛点对照历史痛点ES6的回应为什么这是关键改进var作用域混乱、变量泄漏let/const块级作用域变量边界清晰杜绝意外的跨块访问this指向飘忽、回调嵌套箭头函数、Promise、async/await代码表达与执行逻辑合拍可读性大幅提升字符串拼接噩梦模板字符串多行文本和插值表达式直接写不再堆加号全局污染、无法按需加载export/import模块化每个文件自带边界依赖关系直接可见继承写法晦涩class/extends/super面向对象编程的成本被压到最低数据去重、映射管理繁琐Set/Map/Symbol语言本身提供容器不用造轮子这些特性并不是孤立存在的它们互相配合之后才真正支撑起了现在的组件化开发、状态管理、工程化构建这些现代前端实践。可以这么说没有ES6就没有今天的主流前端开发方式。我在面试新人时也常常把ES6基础当做一个分水岭——不是看你会不会背语法而是看你能不能讲出为什么需要这套语法、旧写法到底差在哪里。2. let和const先弄懂变量生命周期再谈怎么选2.1 var时代作用域混乱的根因var的核心问题是什么它不是没有作用域而是作用域的粒度和直觉相悖。程序员看到一对花括号天然会认为括号里是一个独立的边界但var根本不理这一套它只认函数边界。于是函数里的if分支、for循环、switch语句在var眼里都是同一层“大房间”里面声明的变量随手就能被外面摸到。当时的处理办法是用“立即执行函数”手动制造边界(function() { var privateValue 1; })(); console.log(privateValue); // Uncaught ReferenceError这算是一个workaround能解决一部分问题但每个模块都包一层匿名函数样板代码太多。而且一旦忘记包变量照样泄漏。我见过不少老项目里因为这种泄漏导致两个模块之间互相污染数据最后定位到根因的时候大家都哭笑不得。2.2 let/const的规则、暂时性死区、选择原则let和const出现之后块级作用域成了语言的基本盘。花括号内的变量在外面就是访问不到if (true) { let inner 1; } console.log(inner); // ReferenceError但真正让很多人容易忽略的是暂时性死区TDZ。简单说let和const声明的变量在“声明执行之前”是处于一种不可访问状态的——你如果提前读取它不会像var那样得到一个undefined而是直接抛引用错误console.log(a); // undefined这是var的行为 var a 1; console.log(b); // ReferenceError: Cannot access b before initialization let b 1;这两种行为差别巨大。var的“提升”把变量名挂在作用域顶端赋值为undefined程序不报错但往往埋下逻辑隐患let/const则用报错的方式逼着你把声明放在使用之前从语言层面消灭了“顺序写错但程序不崩溃”的糊涂账。那let和const怎么选我现在的习惯是默认用const只有明确需要重新赋值时才用let。原因很朴素——const本身就是一份“此值不该被替换”的声明读代码的人一看到它就知道这个变量的指向不会变心智负担就小。不过要注意const防的是“绑定关系被重新赋值”不是“对象内容不可修改”const user { name: 张三 }; user.name 李四; // 完全合法对象内容可以变 user {}; // 报错不能重新赋值给const变量2.3 实际项目中选型要搭配场景别再一刀切在循环里let还有一个特性值得单独说每次迭代都会绑定一个独立的变量。这意味着你不用再为了保留某一轮的i值而额外包一层闭包直接写就行for (let i 0; i 3; i) { setTimeout(() console.log(i), 100); // 0 1 2而不是 3 3 3 }这个行为背后的逻辑是规范对for循环做了特殊处理每次迭代创建新的绑定。实际业务里绑定事件、发送请求、注册定时器都会受益。踩坑经验也有几条第一别指望const能替代不可变数据的方案该用深拷贝还是要用深拷贝第二在IE这类老环境里直接跑ES6代码是会崩的必须经过编译转换第三新手经常在函数体内把let声明放在return之后这属于典型的死区误用会直接报错。这些都是“看起来小、实际很疼”的细节。3. 箭头函数、解构赋值与模板字符串这些“语法糖”里藏着细节3.1 箭头函数为什么能改变this的“宿命”箭头函数是ES6里大家用得最爽、也最容易用错的特性之一。它最核心的一点是箭头函数自己没有this它会从“定义位置”的外层作用域继承this。这跟普通函数的“调用时决定this”完全相反所以箭头函数特别适合用在一段需要保留外部this的代码里。例如在一个对象方法里内部有个回调要访问对象的属性const brand { name: 某品牌, items: [水杯, 背包], show() { this.items.forEach((item) { console.log(this.name item); }); } }; brand.show();换成普通函数这里的this会变成undefined严格模式下或者指向全局对象必须靠let self this或者bind(this)绕一圈。箭头函数直接用词法作用域的规则帮你把this固定住了。但它也有三条限制很多人忽略不能当作构造函数用new调它会直接报错没有自己的arguments对象在箭头函数里取到的arguments是外层函数的不能做Generator函数没有自己的yield所以对象方法、事件处理器这类需要“谁调用就指向谁”的场景就别用箭头函数而回调、数组map/filter/reduce的参数、定时器回调这种“不用关心调用者”的场景用箭头函数最顺手。3.2 解构赋值最常用的三种形式与默认值陷阱解构赋值本质上是“按模式提取”的语法。数组解构按位置取值对象解构按属性名取值。我在业务里最常用的三种场景第一种交换变量。不用再写临时变量了let a 1, b 2; [a, b] [b, a];第二种函数返回多个值。把结果组织成对象调用方按需取用function getConfig() { return { host: localhost, port: 8080, debug: false }; } const { host, port, debug } getConfig();第三种函数参数解构。当参数是对象时直接在形参处拆开函数体里不用反复写option.xxxfunction createPoint({ x, y, label 未命名 }) { return { x, y, label }; }这里必须留意默认值的一个经典陷阱默认值只在值为undefined时才会生效。如果传入的是null默认值不会启动function foo(value 默认值) { console.log(value); } foo(undefined); // 默认值 foo(null); // null默认值没有生效很多业务bug就出在这——后端返回的数据某个字段是null前端想着“我设置了默认值应该兜底”结果界面显示成了“null”字符串。排查下来是对默认值触发条件理解不到位。3.3 模板字符串它救回的不仅是视觉整洁模板字符串带来的最大改变不是“能换行了”而是彻底终结了“字符串加变量”的拼接噩梦。以前拼一段HTML结构加号和引号之间经常漏掉一个空格或者多一层转义改起来眼睛疼。现在直接反引号加${}const goods { name: 保温杯, price: 129 }; const card div classgoods-card h3${goods.name}/h3 p价格${goods.price}元/p /div ;多行文本里换行和缩进会原样保留。这在拼表格、拼邮件模板、拼配置文本的时候优势非常明显。这里必须提醒一句模板字符串只负责“把值塞进字符串”它不做任何转义。如果是用户输入的内容直接拼进去就有注入风险。我在早期一个内部工具里就干过这种事把搜索关键字拼进HTML界面结果用户输入了一段带特殊字符的内容页面直接崩了。从那以后我都养成了习惯动态数据进模板字符串之前先过一个转义函数或者用专门的渲染方案。另外模板字符串还有一个进阶玩法叫标签模板——在一个函数名后面直接跟模板字符串函数可以收到拆开的部分和插值。用它可以实现简单的国际化、自定义格式化甚至做一个超轻量模板引擎。基础阶段可以先了解等真正需要时再深入。4. 展开运算符、Set与Map数据操作里的低调实用派4.1 展开运算符浅拷贝与剩余参数的双重身份三个点的展开运算符在数组和对象上有不同的表现。数组场景里最常用的是复制、合并、把数组展开成函数参数const base [1, 2, 3]; const copy [...base]; const merged [...base, 4, 5]; console.log(Math.max(...base)); // 3对象场景里可以用展开运算符做浅拷贝和合并const defaultConfig { theme: light, lang: zh }; const userConfig { lang: en }; const finalConfig { ...defaultConfig, ...userConfig }; // finalConfig { theme: light, lang: en }注意我特意说“浅拷贝”。因为扩展运算符只复制一层数组里的元素如果本身就是对象复制后的新数组里那些元素仍然指向同一批对象。你改嵌套对象里的属性原数据也会跟着变。很多人以为[...arr]就是“完全复制”于是放心大胆地改数据结果改了源头这种坑我在团队里处理过不止一次。顺便说一下剩余参数rest——三个点也能把“剩余的部分”收拢成一个数组function sum(first, ...rest) { return rest.reduce((acc, n) acc n, first); }rest是数组可以直接用数组方法比以前依赖arguments再转换要清爽得多。4.2 Set和Map两个被低估的容器Set最大的价值是去重。数组去重以前要写indexOf判断或者用一个对象记标记有了Set一行搞定const unique [...new Set([1, 2, 2, 3, 3, 4])]; // [1, 2, 3, 4]同时Set也适合做“已访问列表”“已选择列表”这类集合判断它的has方法比数组的includes在语义上更清晰性能通常也更好。Map的价值在于键可以是任意类型。普通对象做映射时键会被强制转成字符串数字键最后都变成了字符串键对象键更是直接变成[object Object]四五个对象键放在一起分不清谁是谁。Map就没有这个问题它严格按“键是否相等”来存取而且插入顺序也有保证。比如管理表单校验状态用字段名做键管理缓存数据用请求参数对象做键都比用普通对象干净。还有一个细节是WeakSet和WeakMap它们的键是“弱引用”的不会阻止垃圾回收。从内存角度看它们更适合做“临时标记”和“私有数据存储”但在基础阶段只要知道它们的存在就够了真正需要时再查细节也不迟。5. class不是纯语法糖它改变了代码组织方式5.1 原型链写法到底哪里别扭ES5时代要做“类”通常是这样构造函数里给实例挂属性prototype上挂方法继承还得手动搭原型链function OldModel(name) { this.name name; } OldModel.prototype.say function() { console.log(this.name); }; function NewModel(name) { OldModel.call(this, name); } NewModel.prototype Object.create(OldModel.prototype); NewModel.prototype.constructor NewModel;这段代码在当时的教科书里很常见但写起来每一步都是细节漏掉Object.create那步继承关系就断掉。ES6的class把这套东西收紧成了统一语法5.2 class的写法与注意事项class Base { constructor(name) { this.name name; } say() { console.log(this.name); } } class Child extends Base { constructor(name, extra) { super(name); this.extra extra; } describe() { super.say(); console.log(this.extra); } }extends背后仍然是通过原型链实现的super在这里的作用是调用父类构造函数或父类方法。写class的时候最需要注意的是子类构造函数里必须先调用super()然后才能使用this——这是一条硬性规则不遵守它引擎会直接抛异常。class的静态方法用static声明可以直接通过类名调用适合放一些工具方法或者工厂方法。实例属性可以直接在constructor里挂也可以声明成类字段。近年来的语言版本还加入了私有字段#xxx用#前缀约定了“私有”边界比以往在命名前加下划线约定的方式更可靠。5.3 class还是函数式关键看业务场景在真实项目里我并不会把一整个项目都装进类的壳子里。我的习惯是有明确状态、有生命周期、有多个步骤需要绑定在一起的结构用类纯粹的数据转换、拆分、格式化逻辑写成纯函数。比如做个购物车实例它有商品列表、数量、总价这些状态还有增删改查这些操作用类就特别自然。而把商品名称格式化成展示文案、计算折扣这类无状态的逻辑用纯函数组合反而更清晰。还有一种常见误解是“用了class就是面向对象不用class就是函数式”。其实两者不是对立关系一个项目里完全可以混合使用——业务模型用类工具函数用纯函数各取所长。6. export/import与模块化ES6真正改变了工程化6.1 模块化之前的“手动管理”模式在ES6模块化方案出现之前浏览器里的JavaScript根本没有“文件即模块”这种说法。想要隔离作用域靠的是函数包一层想要共享变量就挂到全局对象上。这种方式有两个硬伤依赖关系只能靠加载顺序约定模块边界只能靠自觉。后来也有一些模块规范在服务端和打包工具里流行起来但浏览器端一直没有原生支持。ES6的export/import是把模块化写进了语言标准里这带来的一个关键好处是静态结构——打包工具在编译时就能看清哪些模块被谁依赖、哪些导出被谁使用于是可以做“摇树优化”tree-shaking去掉代码里真正没被用到的部分。6.2 模块语法的基础用法与差异化习惯模块的基本用法不复杂// utils.js export function formatTime(ts) { return new Date(ts).toLocaleString(); } export const version 1.0.0; export default function helper() {}// app.js import helper, { formatTime, version } from ./utils.js;具名导出要用花括号包住默认导出可以是任意名字。导入时也可以重命名import { formatTime as format }。我个人的习惯是一个模块优先提供一个主功能作为默认导出其他辅助能力用具名导出这样调用方的意图更清楚。6.3 循环依赖模块化最隐蔽的坑循环依赖是指A模块导出了某样东西B模块又反过来依赖A二者互相引用。如果是函数体内才发生调用问题不大但如果在模块顶层直接使用对方导出的变量就可能出现“拿到了一个还没初始化的值”的现象。这个问题在小型项目里不太容易出现但项目一复杂几个模块互相引用就成了一个环。解决办法有几个思路第一抽公共依赖把双方都要用的那部分提取到另一个模块第二延迟引用把对依赖的使用放到函数体内等到运行时再取第三重新审视模块边界看看是不是把两个本不该耦合的东西强行揉在了一起。模块化不是“把代码拆成文件”就完了拆完之后依赖关系要像水管一样单向流动。7. Promise与async/await异步编程的现代姿势7.1 从回调地狱到Promise状态机Promise把异步操作抽象成一个状态机进行中pending、已完成fulfilled、已失败rejected。状态一旦从pending变成fulfilled或rejected就不能再反转这一条保证了后续代码只执行一次。链式调用解决的是“嵌套结构”问题。之前回调地狱长这样requestA(function(resA) { requestB(resA, function(resB) { requestC(resB, function(resC) { // 再往下就歪了 }); }); });用Promise链之后requestA() .then(resA requestB(resA)) .then(resB requestC(resB)) .then(resC { // 后续逻辑 }) .catch(err handleError(err));看起来只是缩进变平了本质区别是每一层都显式地返回Promise数据流变得肉眼可见地清晰。这里有个高频错误必须提在.then里调用了异步函数但忘记return链就断了后面的.then拿到的不是预期结果。我见过很多次排查时才发现在某个.then里漏掉了return。这条真的要刻在脑子里requestA() .then(resA { requestB(resA); // 忘记return后面的链拿不到requestB的结果 })7.2 async/await并发与串行的把握async/await是Promise之上的语法糖但并不是所有异步场景都应该无脑用。await会“暂停”当前函数执行直到Promise结果返回。如果多个互不依赖的请求你一个接一个await那就成了串行总耗时是几个请求之和async function loadData() { const user await fetchUser(); // 1秒 const list await fetchList(); // 等上面完成后再开始又是1秒 }两个请求完全可以并发执行时正确做法是Promise.allasync function loadData() { const [user, list] await Promise.all([fetchUser(), fetchList()]); // 两个请求同时发出总耗时约1秒 }在循环里也很容易踩这个坑。要并发处理一批异步任务应该先收集Promise再统一等待而不是在for循环里逐项await那等于把并发变成了串行。错误处理方面async/await配try/catch是最直观的async function run() { try { const data await fetchData(); return data; } catch (err) { logError(err); throw err; } }要注意的是如果async函数内部没有捕获错误rejected的Promise最终还是会冒出来。调用方要对Promise的rejection有兜底否则可能出现“静默失败”或“未处理的Promise rejection”警告。7.3 我自己在项目里调整异步写法时最大的感触刚接触Promise那会我也走过一段弯路把回调时代的“嵌套思维”硬搬到Promise里链上还是写出一大堆内联逻辑可读性反而更差。后来慢慢才找到舒服的节奏——单个请求用async/await多个独立请求用Promise.all需要“谁先完成谁先处理”用Promise.race要等待所有结果无论成败用Promise.allSettled。这几个工具不是背下来就行关键是先问自己这段异步流程到底是串行依赖还是并行无依赖还是需要容错想清楚这个问题选择自然就出来了。8. 我在实际项目中踩过的几个ES6坑8.1 默认值陷阱null与undefined的混淆解构默认值和函数默认参数只在undefined时生效null却会原样传进来。有一次接口返回的配置对象里某个字段被后端写成了null前端同学以为默认值会兜住结果页面直接渲染了“null”三个字母。从那以后我对所有外部数据源都多了一层警惕默认值挡不住null真想兼容要在函数入口或者数据转换层显式处理。8.2 const不是“不能变”是“不能换”我一开始带新人的时候经常遇到有人以为const声明的对象“完全不能修改”然后写代码时连对象属性的赋值都小心翼翼。其实const只管“变量名和值的绑定关系不能换”对象内部属性完全可以增删改。反过来也有同事想用const当“不可变数据”用结果在某个深层嵌套对象里直接改了属性引发了一连串重渲染问题。结论是想约束对象内容不可变得靠Object.freeze或者深拷贝别指望const。8.3 展开运算符的浅拷贝误区有一次排一个表格数据同步的bug定位到最后发现是[...originalList]复制数组之后新数组里的对象仍然和原数组共享引用。某个字段被一处逻辑改了另一处读到的数据也变了。那次排查给我的教训很深刻浅拷贝只适合“数组里都是基本类型”的场景如果数组里是对象要么业务上接受共享引用要么用结构化克隆做深拷贝。一定不要在高频操作里无脑用深拷贝那是性能陷阱要分清数据结构里到底有没有嵌套引用。8.4 for循环里的let与闭包配合旧代码里有一处经典场景事件处理器里取了循环变量因为用的是var所有处理器共享同一个变量点击结果全是最后一次循环的值。当时是靠包一层函数传入当前i解决的代码看起来就很绕。后来项目全面切到ES6我特意把这类代码改成了for...of或者let声明问题从根上消失。这也是为什么我一直觉得let在循环里的每次迭代独享绑定是一个默默无闻但改变很大的改进。8.5 模块循环依赖的“undefined”事故有一次在两个配置模块之间互相引用某个模块在顶层直接解构了对方的导出。结果运行时拿到的是undefined整个应用启动失败。定位了好一阵才发现是循环依赖A模块被加载时B模块还没初始化完。最后我把公共配置抽到了第三个文件依赖关系从循环变成单向问题解除。这件事让我对“模块边界设计”有了新的认知拆文件不能只图“代码少”更要图“依赖清晰”。8.6 新特性不是“写完就行”还要看运行环境ES6写起来当然爽但在某些老内核的运行环境里let/const、箭头函数、class这些特性原生支持有限。我的习惯是新语法照用但构建阶段必须配好语法转换和运行环境适配不能指望所有用户的设备都跟上标准。另一个细节是真机上做过一轮完整回归再上线别只在自己的现代浏览器里测一遍就够了。这些年折腾下来我的体会是ES6的这些基础特性每一个都对应着一批真实的开发痛点。语法只是表面背后的作用域规则、异步模型、代码组织方式才是真正值得花时间理解的东西。如果你正在学ES6基础建议别急着背API先找一个老项目里的“丑陋写法”然后亲手用新语法重写一遍。在改写的过程里你会自然体会到每条设计背后的用意也能踩到那些只有动手才会遇到的坑。这是我看过的最有效的学习方法没有之一。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询