☰
ES6新特性全面解析:let/const、深拷贝、Promise与工程化实践
2026/9/26 20:13:14 网站建设 项目流程

在ES6出来的那几年,前端圈子讨论最多的就是“这门新语法到底改了什么”。说实话,当时很多老项目还在用var满屏飞、回调地狱层层嵌套,大家对新语法既期待又担心。后来ES6全面落地,所有现代浏览器和Node.js都原生支持,它也成了面试必考、项目必用、源码必读的核心基础。这篇笔记不是API手册的搬运工,而是一个实战多年的人回过头来,把ES6里最该吃透的知识点、最容易踩的坑、以及深拷贝、Map这类高频使用场景做个彻底梳理,希望给正在学ES6的朋友一条可以直接跟着走的路。无论你是刚入行的新人,还是写了几年var想系统升级的老手,这篇内容都值得你花点时间完整过一遍。

1. 先聊聊ES6到底解决了什么问题

很多初学者学ES6的时候,只记住了“新写法”,却不理解“旧写法为什么不够用”。这不怪大家,因为网上大量教程都是直接丢语法,不讲背后的动机。但学一门新规范,如果不知道它要解决什么痛点,就很容易出现“学了用不上、用了不知道自己为什么要这样写”的尴尬。

1.1 为什么说ES6是JavaScript的分水岭

ES6(正式名称为ECMAScript 2015)是2015年发布的JavaScript语言标准。在它之前,JavaScript已经发展了近二十年,但语言本身的很多“历史包袱”一直没动过:变量声明只有var一个方式,词法作用域和块级作用域纠缠不清,函数内部this指向飘忽不定,回调函数是异步编程的唯一姿势,模块化只能靠社区约定或者CommonJS。ES6一次性引入了大量新语法、新内置对象和新的编程范式,包括但不限于let/const、解构赋值、模板字符串、箭头函数、Map/Set、Promise、class、import/export等等。

这套语法体系的引入,让JavaScript从“能用但别扭”的语言,变成了“工程化友好”的语言。过去写一个简单的异步流程要靠嵌套回调,现在有了Promise和async/await;过去复制一个对象要用手动遍历,现在有了Object.assign和展开运算符;过去模块化依赖各自团队的约定,现在有标准模块语法。可以这么说,ES6是JavaScript从脚本语言走向工程化语言的真正转折点。

1.2 我对ES6学习路线的建议

我的建议是不要按照API顺序一个个背,而是按业务场景去学:先掌握变量声明与块级作用域(解决“变量搞混”的痛点),然后学解构和模板字符串(解决“取数据、拼字符串太啰嗦”的痛点),接着学箭头函数和参数增强(解决“函数写法复杂”的痛点),再学数组/对象新方法和Map/Set(解决“数据操作不顺手”的痛点),之后是深拷贝(解决“引用类型复制错误”的痛点),最后攻克异步编程和模块化。按照这个顺序走下来,你不仅能记住语法,还会知道每个语法解决什么问题,用起来才会自然。

2. 变量声明与作用域:let/const才是真正的“块级思维”

2.1 let 与 var 的核心区别

先说一个非常经典的例子:

console.log(a); // undefined var a = 1;

var声明的变量会发生变量提升,也就是JavaScript引擎在代码执行前,会把var声明“提升”到作用域顶部,所以上面这段代码不会报错,只是打印undefined。这种效果容易造成“先使用后声明”的错觉,实际项目中非常容易埋雷。

换成let之后,同样的写法:

console.log(b); // ReferenceError: Cannot access 'b' before initialization let b = 1;

这里报错的原因不是let没有提升,而是let存在“暂时性死区”(Temporal Dead Zone,简称TDZ)。简单理解就是:从代码块开始到声明语句执行之前的这段范围内,该变量处于“已绑定但不可访问”的状态。这个设计的价值在于,它强制要求你在使用变量之前先完成声明,从语言层面杜绝了“悬空变量”的使用。

另外一个核心区别是作用域。var是函数级作用域,let是块级作用域。块级作用域简单说就是{}花括号包裹的范围,包括if、for、while等语句块。最常见的经典面试题也与此相关:for循环里用var声明索引和用let声明索引的区别。

for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i)); // 3 3 3 } for (let j = 0; j < 3; j++) { setTimeout(() => console.log(j)); // 0 1 2 }

var的情况下,循环结束后只有一个全局的i;而let在每次循环中会创建一个新的绑定,闭包捕获的是每一次循环的独立值。这也是let在实际项目中价值最大的体现。

实操心得:老项目改造时,不要无脑把var全部替换成let,要重点排查那些在循环里用了闭包、依赖函数内var提升特性的代码。先把高风险的闭包场景改掉,再逐步推进。

2.2 const 的正确用法与常见误区

const声明一个常量,意味着“这个变量的绑定不能被重新赋值”,而不是“这个值完全不可变”。比如:

const obj = { name: 'ES6' }; obj.name = 'JavaScript'; // 可以正常修改 // obj = {}; // 会报错:Assignment to constant variable

很多新人刚学的时候,看到const对象还能改属性,就以为const没用。实际上const保护的是“指针的指向”,而不是“指针指向的内容”。你使用const声明一个对象的时候,你承诺的是“这个变量永远指向这个对象”,至于对象内部怎么变化,是对象自己的事情。如果你需要彻底锁定对象的所有属性,那要用Object.freeze,但Object.freeze也只做浅层冻结,嵌套对象还需要递归处理。

日常项目里我的习惯是:默认所有变量都用const声明,只有确定需要重新赋值的场景才用let。这样有一个好处:你不能意外地覆盖一个不该变的变量,代码的意图也更清晰。另外,const声明时必须给初始值,不能先声明后赋值。

2.3 我建议的项目内变量声明规范

结合我的项目经验,大致可以总结为下面这几条实践规范:

  • 所有引用类型变量,优先用const,除非你能证明它会被重新指向新对象。
  • 循环递增、累加器这类需要重新赋值的,使用let。
  • 永远不要在一个文件里混用var和let/const,哪怕老代码里有var,新增代码也不要再用它。
  • 声明位置尽量靠近使用位置,作用域范围越小越好,不要让一个变量活着的时间和范围超出它实际被需要的场景。

有一个真实场景我印象很深:同事在一个大函数顶部声明了一堆let变量,结果底部改了一个值,中间某处判断结果全变了。排查这种问题非常痛苦。如果把变量声明放在具体使用它的块里,这类问题基本能从结构上杜绝。

3. 解构赋值与模板字符串:代码瘦身神器

3.1 数组与对象解构的常见姿势

解构赋值是ES6里“用户体验”最好的语法之一,它让你从数组或对象中取数据的方式变得极其干净。先看数组解构:

const [first, second, ...rest] = [10, 20, 30, 40, 50]; console.log(first); // 10 console.log(second); // 20 console.log(rest); // [30, 40, 50]

这里...是剩余操作符(rest),它会抓取剩下的所有元素组成新数组。注意它必须放在数组解构的最后一位。这个写法在交换变量时也特别方便:

let a = 1, b = 2; [b, a] = [a, b]; console.log(a, b); // 2 1

不用临时变量,一行代码搞定三个步骤的逻辑,非常优雅。

对象解构在业务代码里的使用频率更高,尤其是接口返回的对象取字段时:

const user = { name: '张三', age: 30, address: { city: '北京', district: '朝阳' } }; const { name, age } = user; console.log(name, age); // 张三 30

对象解构还支持重命名,这在处理接口字段名不一致时特别有用:

const { name: userName, age: userAge } = user;

还可以给默认值:

const { gender = 'unknown' } = user;

如果user.gender是undefined,就会用默认值'unknown'。需要特别注意的是,默认值只有在属性严格等于undefined时才生效,如果是null,默认值不会被触发。

还有一个非常实用的嵌套解构配合技巧,处理深层数据时能省下大串中间变量:

const { address: { city } } = user; console.log(city); // 北京

这里要先确保user.address是存在的,否则会报错。实战中如果数据结构不稳定,建议先用一个空对象兜底再解构,比如:

const { userInfo = {} } = response; const { nickname } = userInfo;

3.2 模板字符串在真实业务中的三种妙用

模板字符串用反引号包裹,它可以做普通字符串拼接待办的事,还自带换行保留能力。第一类常见场景是HTML片段拼接,以前写一大串'<div class="item">' + name + '</div>',现在直接这样:

const html = ` <div class="item"> <span class="name">${name}</span> <span class="price">${price}</span> </div> `;

无论是可读性还是维护性都提升了一大截,多行文本不再需要手动加\n。

第二类场景是拼接带条件的字符串。模板字符串里可以放三元表达式,甚至直接放函数调用:

const statusText = `${status === 1 ? '已启用' : '已禁用'} - ${formatTime(updateTime)}`;

第三类用法有一定技巧性:带标签的模板字符串(Tagged Template)。你可以在模板字符串前加一个函数名,函数会收到“字符串片段数组”和“插入的值数组”,这样能实现一些模板引擎的底层逻辑。很多第三方库内部就是这么干的。举个例子:

function highlight(strings, ...values) { return strings.reduce((acc, str, i) => { const v = values[i - 1]; return acc + (v ? `<mark>${v}</mark>` : '') + str; }); } const name = 'ES6'; const msg = highlight`今天学习了${name}`; console.log(msg); // 今天学习了<mark>ES6</mark>

虽然日常业务里用得不多,但看懂这个机制,你阅读某些源码时就会轻松很多。

注意:模板字符串中的插值表达式会先被求值再转为字符串,如果里面放的是一个对象,默认会调用toString(),得到的往往是[object Object]。拼接非字符串类型时,建议先显式处理一下。

4. 箭头函数、默认参数与Rest参数:函数写法的进化

4.1 箭头函数的this绑定机制

箭头函数大概是ES6里争议最大的一个特性。它有两个显著特点:写法简洁,以及不绑定自己的this。先说写法:

// 传统写法 function add(a, b) { return a + b; } // 箭头函数写法 const add = (a, b) => a + b;

参数只有一个时还可以省略括号,函数体只有单个表达式时可以省略花括号和return。这些规则让代码非常紧凑。

但真正重要的是this绑定问题。普通函数里this取决于“怎么被调用”的;箭头函数里的this取决于“在哪里定义”的,它沿用了外层作用域的this。所以箭头函数本质上是词法this。举个业务中常见的例子:

class Timer { constructor() { this.seconds = 0; } start() { // 传统函数写法会因为this指向变化而报错 setInterval(() => { this.seconds++; }, 1000); } }

在setInterval的回调里,普通函数this会指向全局对象,而箭头函数直接继承start()方法里的this,所以this.seconds不会丢失。这就是箭头函数被大量用在事件回调、定时器回调、数组遍历回调里的原因。

箭头函数不能当作构造函数使用,也没有arguments对象。如果你需要动态获取函数入参,要么用普通函数,要么用Rest参数。

4.2 默认参数与Rest参数配合使用

ES6之前给函数写默认值,基本靠的是||或者三元判断,容易把0、''、false这些合法值也误判成“没传”。ES6的默认参数只在参数为undefined时才生效:

function greet(name = '陌生人', greeting = '你好') { return `${greeting},${name}`; } console.log(greet()); // 你好,陌生人 console.log(greet('张三')); // 你好,张三 console.log(greet('张三', '早上好')); // 早上好,张三

默认参数不仅能写字符串,还可以是表达式,也可以引用前面的参数:

function createUser(name, role = 'normal', tag = `${name}_${role}`) { return { name, role, tag }; }

Rest参数用来收集“剩余的”所有参数,它是一个真正的数组,而旧的arguments只是一个类数组对象,还得转换一下才能用map、filter这些方法:

function sum(...numbers) { return numbers.reduce((acc, n) => acc + n, 0); } console.log(sum(1, 2, 3, 4)); // 10

Rest参数配合默认参数使用时,Rest必须放最后,且一个函数里只能有一个Rest参数。这套组合在实际项目中用来封装工具函数非常舒服,比如写一个日志函数,前面若干参数是配置,剩下的都算作日志内容。

5. ES6 Map与Set:别再用对象当万能容器

热搜词里有“es6 map”,这确实是个高频话题。很多人在ES6里最陌生的对象之一就是Map,因为它跟普通的Object长得太像了,新手会疑惑“为什么要多一个Map出来”。其实Map和Object的设计目标完全不同。

5.1 Map 与普通对象的本质差异

普通对象有一个明显的限制:键只能是字符串(或者Symbol)。你就算给对象设置一个数字键,引擎也会先把它转成字符串。而Map的键可以是任意类型,包括对象、函数、甚至NaN。这个差异在缓存场景里特别有价值:

const cacheMap = new Map(); const keyObj = { id: 1 }; cacheMap.set(keyObj, '对应的缓存数据'); console.log(cacheMap.get(keyObj)); // 对应的缓存数据

对象的键会被强制转成字符串,两个内容相同的对象作为键时,后面的会把前面的覆盖掉。这个问题让对象无法可靠地做“以对象为键”的缓存。而Map按引用识别键,每个对象都是独一无二的身份,所以能精确命中。

看一个经典面试题,为什么object不适合做Map的替代品:

  • Map可以直接通过map.size拿到元素数量,而对象只能自己数Object.keys(obj).length。
  • Map的迭代顺序是“插入顺序”,对象虽然现代引擎也基本有序,但传统上并不保证。
  • Map提供了原生方法get/set/has/delete/clear,代码可读性和健壮性都更高。
  • 频繁增删键值对时,Map在大多数引擎里性能表现更好。

实际项目中,如果数据是一个纯粹的“键值对集合”,而且键的类型不限于字符串,那么Map永远是更合理的选择。即使键都是字符串,当你需要频繁查数量、循环、删除时,Map也会让代码更简洁。

5.2 Set 与数组的去重、交集、并集、差集操作

Set是ES6里另一个人气很高的数据结构,它保证“元素不重复”,这在很多场景里直接解决了数组去重的问题:

const nums = [1, 2, 2, 3, 4, 4, 5]; const uniqueNums = [...new Set(nums)]; console.log(uniqueNums); // [1, 2, 3, 4, 5]

这里的[...new Set(nums)]是展开运算符结合Set的经典写法。注意Set的去重是基于严格相等(SameValueZero)判断的,所以NaN和NaN会被视为相同,而{}和{}是两个不同的对象,不会被去重。

除了去重,Set还能很方便地实现集合运算:

const setA = new Set([1, 2, 3, 4]); const setB = new Set([3, 4, 5, 6]); // 交集:A中有且B中也有的 const intersection = new Set([...setA].filter(item => setB.has(item))); console.log([...intersection]); // [3, 4] // 差集:A中有但B中没有的 const difference = new Set([...setA].filter(item => !setB.has(item))); console.log([...difference]); // [1, 2] // 并集:合并去重 const union = new Set([...setA, ...setB]); console.log([...union]); // [1, 2, 3, 4, 5, 6]

每次检查set.has(item)的时间复杂度是O(1),而数组的includes是O(n),所以在大数据量场景下,用Set做集合运算性能优势非常明显。

5.3 WeakMap 和 WeakSet 在实战中的价值

WeakMap和WeakSet也是ES6家族成员,它们的键(或元素)必须是对象,而且弱引用。所谓“弱引用”,指的是只要没有其他强引用指向这个键对象,垃圾回收器就可以回收它,WeakMap中的对应条目也会被清除。

这个特性特别适合两个场景。第一个是私有数据存储,比如在不改变对象结构的前提下,给对象关联额外信息:

const _privateData = new WeakMap(); class Example { constructor(value) { _privateData.set(this, value); } getValue() { return _privateData.get(this); } }

this对象作为键存在_privateData里,外部代码无法直接访问到这个内部数据。第二个场景是对象关联的缓存,比如记录某个DOM元素的自定义信息,当DOM被移除后,WeakMap里的条目也会自动回收,不会造成内存泄漏。

WeakSet和WeakMap一样支持弱引用,但它是元素的集合,适合做“标记”用途,比如标记某个对象已经被处理过:

const processed = new WeakSet(); function process(obj) { if (processed.has(obj)) return; // 处理逻辑 processed.add(obj); }

注意:WeakMap和WeakSet都不可迭代,也没有size属性,因为它们的内容是不确定的,随时可能被垃圾回收。这是“弱引用”必须要做出的取舍,不必觉得功能缺失是缺陷。

6. 从浅拷贝到深拷贝:ES6时代的拷贝实践

热搜词里“es6深拷贝”排名很高,说明这是大家实际写代码时遇到最多的痛点之一。我在项目评审里见过太多因为浅拷贝而引发的数据串扰问题,所以这部分值得单独拿出来好好聊。

6.1 为什么浅拷贝还在到处“埋雷”

先明确两个概念。浅拷贝是复制“值的引用”,如果源对象里某个属性值本身是对象或数组,浅拷贝得到的新对象和源对象会共享这一层引用。深拷贝是递归复制所有层级的属性,让新旧对象之间完全隔离。

日常开发中最常见的浅拷贝写法是展开运算符:

const original = { list: [1, 2, 3], info: { name: 'ES6' } }; const copy = { ...original }; copy.info.name = 'changed'; console.log(original.info.name); // changed

改的是copy.info,结果original.info也变了,因为没有复制到深层。这种问题在有复杂配置对象、状态管理、或者把数据传给子组件时非常容易发生。一旦审查不严格,就会“改一处坏一片”。

另一个常见场景是函数入参。如果你直接把一个对象传给函数,函数内部改了它,外部对象的对应内容也会跟着变。有时候这是你想要的效果,有时候则是灾难。为了避免“意外副作用”,函数接收外部对象时,应当根据业务意图决定是否需要先做一次拷贝。

6.2 基于ES6 API实现深拷贝的几种方案对比

深拷贝的实现方案有很多,我用一个表格把主流方案对比一下,方便你按场景选择:

方案优点缺点适用场景
JSON.parse(JSON.stringify(obj))一行代码,写法简单无法处理undefined、函数、Symbol、循环引用;Date会变成字符串;RegExp会变成空对象纯JSON结构的数据,数据不包含函数和特殊类型时
递归手写深拷贝可控性强,可自定义处理各种类型需要自己处理边界条件,代码量较大面试、学习原理、特殊类型定制场景
结构化克隆(structuredClone)浏览器原生API,支持Date、RegExp、Map、Set等较新的API,老环境可能不支持;不支持函数和Symbol;循环引用会被复制现代浏览器环境,数据含有内置对象时
第三方库(如lodash的cloneDeep)功能最全,最稳定需要引入依赖项目里已经有lodash时

最常用的是一行代码的JSON方案,但它有非常明显的局限。看这段代码:

const obj = { name: 'ES6', fn: () => console.log('hello'), symbolKey: Symbol('key'), date: new Date(), value: undefined }; const copy = JSON.parse(JSON.stringify(obj)); console.log(copy); // { name: 'ES6', date: '2025-01-01T...' }

函数、Symbol、undefined全都没了,Date也变成了字符串。所以这个方案只适合这种纯数据对象:

const data = { userId: 1024, tags: ['a', 'b', 'c'], settings: { theme: 'dark', notify: true } }; const dataCopy = JSON.parse(JSON.stringify(data));

structuredClone是后来浏览器原生提供的能力,它能处理更多类型:

const originalMap = new Map([['key', 'value']]); const clonedMap = structuredClone(originalMap); console.log(clonedMap.get('key')); // value

它对循环引用和内置类型都有完整的支持,是“JSON大法”之外更现代的选择。

6.3 深拷贝的边界情况与我的避坑清单

我曾经在一个数据导入功能里用过手写递归深拷贝,踩了几个坑之后把边界情况都总结出来了。这里分享给你:

  • 循环引用:对象里某个属性直接或间接指向自己。用递归手写时必须维护一个WeakMap记录已拷贝过的对象,否则会无限递归导致栈溢出。
  • 数组与对象的区分:拷贝时要用Array.isArray(value)判断,避免数组被拷贝成普通对象,丢失length和数组方法。
  • Date、RegExp、Map、Set等内置对象需要单独构造,不能直接原样复制。
  • 原型链:一般业务场景深拷贝不要求保留原型,但如果框架代码里会有基于类的实例,直接拷贝会丢失原型方法。
  • 性能:递归深度过深的对象(几百层)可能造成性能问题,生产环境建议用第三方库。

下面是我在实际项目中用的一个手写版深拷贝的核心结构,它已经能覆盖大部分业务场景:

function deepClone(target, hash = new WeakMap()) { if (target === null || typeof target !== 'object') { return target; } if (target instanceof Date) return new Date(target); if (target instanceof RegExp) return new RegExp(target); if (hash.has(target)) return hash.get(target); const clone = Array.isArray(target) ? [] : {}; hash.set(target, clone); Object.keys(target).forEach(key => { clone[key] = deepClone(target[key], hash); }); return clone; }

这个实现里最重要的一行就是hash.set(target, clone)。它用WeakMap把“原对象”和“拷贝对象”的映射记录下来了,一旦遇到循环引用,直接从映射里取回上次拷贝的结果,就不会陷入无限递归。这就是为什么深拷贝的边界问题不是“写不写得出来”,而是“有没有把所有边界情况想清楚”。

7. Promise与async/await:异步流程控制的进化

7.1 Promise的三种状态与链式调用

ES6把异步编程从回调时代带进了Promise时代。Promise代表一个尚未完成但预期会完成的操作,它有三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)。状态一旦从pending变为fulfilled或rejected,就不可再变。

最基本的使用方式:

const p = new Promise((resolve, reject) => { setTimeout(() => { const success = true; if (success) { resolve('操作成功'); } else { reject(new Error('操作失败')); } }, 1000); }); p.then(result => console.log(result)) .catch(error => console.error(error));

Promise的链式调用解决了“回调地狱”问题。以前的多层嵌套回调,写成这样后就能保持扁平:

getUser() .then(user => getOrders(user.id)) .then(orders => getDetails(orders[0].id)) .then(details => console.log(details)) .catch(error => console.error(error));

注意每个.then里都可以返回一个新的值或一个新的Promise,返回值会被下一个.then接收。这种数据“流水线”式传递方式,让异步流程的阅读体验接近同步代码。还有一个容易忽视的点:.catch会捕获整条链路上所有前面的错误,所以只要在链条末尾放一个.catch,就能统一处理所有异常。

7.2 async/await让异步代码“看起来像同步”

async/await是ES2017加入的语法,但它是基于Promise的语法糖。async函数会返回一个Promise,函数内部使用await等待一个Promise完成,并且会让出线程,不阻塞页面。

看一个直观对比:

async function fetchData() { const user = await getUser(); const orders = await getOrders(user.id); console.log(orders); } fetchData().catch(error => console.error(error));

顺序和逻辑都非常直观,不用再连缀.then()。await只能用在async函数里,这是硬性规则。另外await的结果也可以是普通值,不一定非得是Promise,引擎会把它包装成已完成的Promise`。

错误处理方面,我习惯用try/catch包裹:

async function loadPageData() { try { const data = await request('/api/page'); render(data); } catch (error) { showError(error.message); } }

如果多个请求彼此独立,就不应该用await排队执行,那样会浪费并发性能。这种场景应该用Promise.all:

const [user, config, banners] = await Promise.all([ fetchUser(), fetchConfig(), fetchBanners() ]);

三个请求并行发出,总耗时接近最慢的那个请求,而不是三个请求的时间总和。

7.3 我在业务中常用的异步模式

实际业务里,异步逻辑远比教程里的例子复杂,分享两个我总结出的实用模式。

第一个是“带超时的请求”。接口偶尔会迟迟不返回,但用户不能一直等。用Promise.race配合一个定时器:

function withTimeout(promise, timeoutMs = 8000) { const timeout = new Promise((_, reject) => { setTimeout(() => reject(new Error('请求超时')), timeoutMs); }); return Promise.race([promise, timeout]); } try { const data = await withTimeout(request('/api/data')); console.log(data); } catch (error) { console.error(error.message); }

Promise.race只要有一个Promise先完成,就采用它的结果。超时在8秒内先到,就会走拒绝分支。

第二个是“串行执行一批任务并收集结果”。比如上传多个文件,每次只能上传一个,全部传完再汇总:

async function uploadFiles(files) { const results = []; for (const file of files) { const result = await upload(file); results.push(result); } return results; }

在for...of循环里用await,代码是串行的,不会像forEach配合await那样因为回调不等待而并行执行。这是一个非常容易踩的坑:forEach里写await,外部函数根本不会等它执行完。

8. ES6模块化:import/export的工程化意义

8.1 为什么CommonJS满足不了现代前端

在ES6模块化之前,Node.js使用的是CommonJS规范,靠require和module.exports导入导出。这种方式在服务端运行良好,但在浏览器端无法直接使用,因为浏览器运行时不支持同步的文件读取。前端工程化早期必须借助打包工具才能把CommonJS模块转换成浏览器可执行的代码。

ES6的import/export是语言层面的官方模块语法,做到了静态化。所谓“静态化”,指导入和导出的关系在编译阶段就能确定,这让打包工具可以做“摇树优化”(Tree Shaking),即只打包那些真正被使用到的代码,减小最终产物体积。而CommonJS的require是运行时调用,导入关系没法在编译阶段静态分析。

8.2 import/export的使用细节与坑

命名导出使用起来最直接:

// utils.js export const formatTime = (date) => date.toLocaleTimeString(); export const formatNumber = (num) => num.toLocaleString(); // 使用 import { formatTime, formatNumber } from './utils';

如果只想精确导入其中一个,也可以在导入部分用as重命名:

import { formatTime as format } from './utils';

默认导出比较适合一个模块只导出一个主要功能的情况:

// logger.js export default function log(message) { console.log(message); } // 使用 import log from './logger';

一个常见混淆是默认导出和命名导出的混用:

// 同时使用默认导出和命名导出是允许的 export default log; export const LEVEL = 'info';

导入时默认导入和命名导入可以配合使用。但如果写组合导入,容易在重构时因为忘记某个命名而导致打包报错。我的建议是:如果模块里有多个工具函数,全部用命名导出;如果一个模块核心职责是导出一个类或一个函数,优先默认导出。保持统一风格,团队协作时心智负担最小。

还有两个容易忽视的点:导入语句里的路径必须包括文件扩展名(尤其是现代浏览器原生ESM和部分打包工具的strict模式),以及import会被提升到当前模块顶部,所以你写在哪一行都行,但一定要让代码结构清晰、路径可读。

实操心得:当你拆分一个大文件时,先列出这个模块的公共API(导出的东西),再反向去写内部实现。先想清楚“外部会用到什么”,再动手拆分,模块边界会清晰很多,代码复用性也会更好。

9. 常见问题速查表与我的排坑经验

这个部分就直接上干货,我在学习和一线开发中遇到的高频问题、排查思路和解法都整理出来了。建议收藏备用。

9.1 我实测踩过的高频问题

先说几个堪称“新人必踩”的问题。

第一个是const对象属性被修改。很多人以为const对象就不能改了,结果改了属性发现没报错,就怀疑自己写错了。前面已经解释过,const管的是绑定关系,不是对象内容。

第二个是typeof判断不出数组。数组用typeof检测会返回'object',所以判断是否数组要用Array.isArray(arr),不要用typeof。

第三个是数组解构时undefined和越界。解构不存在的数组下标,拿到的是undefined,不是默认值。默认值只对undefined生效。

第四个是JSON.parse(JSON.stringify())丢失函数和undefined。这个前面已经详细说过了,属于深拷贝系列的经典坑,改写业务之前一定要先确认数据里没有这些特殊类型。

第五个是箭头函数的this被过度使用。比如在需要动态this的事件监听器里,用普通函数反而更合适。别把箭头函数当万能药。

第六个是Map和Object的取属性方式搞混。Map要用.get()和.set(),不是直接用点号或者中括号访问属性名。

第七个是Set去重后忘记转回数组。很多人去重后直接返回了Set,结果下游代码当成数组用,导致length是undefined。

第八个是Promise.all遇到一个失败就全部失败。如果你希望某个请求失败不影响其他结果,可以改用Promise.allSettled。

第九个是async/await误用场景。在forEach里使用await不会按预期串行执行,要改用for...of。

9.2 排查ES6问题的三条经验

第一,先确认运行环境对ES6语法的支持情况。虽然现在主流环境都支持,但如果你在老旧浏览器或低版本Node环境里运行,比如某些内嵌WebView或者老款移动设备,部分语法可能直接报错。排查这类问题先看浏览器控制台有没有SyntaxError或者TypeError,“依赖于现代语法特性”的报错通常指向环境兼容性。

第二,统一使用严格模式。ES6的模块默认就是严格模式,所以你不必手动加'use strict'。严格模式下,很多旧式写法会直接报错,比如给未声明的变量赋值、删除不可删除的属性等。这能帮你尽早暴露问题,而不是让错误悄悄蔓延。

第三,善用调试工具和断点。很多人用console.log打一堆日志,但面对复杂对象时,最好用console.dir展开查看对象结构,或者直接在Sources面板打断点,一步步看每个变量的实时值。排查引用类型问题时,注意观察“同一份引用”导致的连带修改,经验不足的人经常会漏掉这个细节。

这些坑里面,我踩得最惨的是forEach+await那次。需求是轮流请求一个分页接口,每次把结果合并,我一开始写成items.forEach(async item => { await fetch(...) }),结果函数立刻返回,页面数据一直加载不出来。排查半天才发现是因为forEach的回调不会等待异步任务。换成for...of之后问题立刻消失。这个经历让我养成一个习惯:凡是涉及异步操作的任务,先想清楚“这个循环体要不要等上一次完成”,再去选循环方式。

10. 收尾:我的ES6学习体会

作为一个已经用ES6写了多年业务代码的人,我的感受是:ES6真正改变的不仅是语法,更是一种思维习惯。它让JavaScript的代码变得更清晰、更可靠、也更接近工程化语言的标准。学习时不要只背API,一定要拿着真实业务场景去练,比如把一个老模块用新语法重构一遍,把一段回调改成async/await,把一组普通对象改成Map操作。只有当你真正经历过“旧写法带来的痛点”,才会理解新语法设计的巧妙之处。

最后再分享一个小技巧:学习ES6的时候,自己准备一个notes.md文件,每学会一个知识点就写下一个真实业务场景的例子,用文字解释为什么要用这种写法,以及不用会踩什么坑。等你积累到一定量级再回看,就会发现自己对这个语言的理解已经远超“会用”的层面。这篇笔记里的很多内容,本质上就是我那个notes.md的更新迭代。希望它对正在学ES6的你也有同样的帮助。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询