近一万字的ES6语法知识点补充
Category(分类): JavaScript Status: 未知
前言
ECMAScript 2015(通常简称 ES6)于 2015 年 6 月正式发布。它带来了块级声明、箭头函数、迭代器、模块、Promise、Proxy 等语言能力;之后 ECMAScript 按年度继续演进。本文保留原文对 ES6 核心语法的梳理,并在相应位置补充后续标准和现代运行环境的注意事项,希望对各位有所帮助。
本文重点补充 ES6 及后续语法特性,不会穷举所有 API;内容会结合规范语义和实际使用场景,说明这些特性解决了什么问题。
如有错误,欢迎指出,将在第一时间修改,欢迎提出修改意见和建议
话不多说开始ES6之旅吧~~~
let/const(常用)
let、const 用于声明块级绑定,很多场景可以替代 var。花括号会形成块级作用域(但对象字面量的花括号不是作用域);var 的绑定属于函数作用域,顶层 var 在经典浏览器脚本中还会成为 window 的属性,而模块中的顶层绑定不会。
这里外部的console.log(x)拿不到前面2个块级作用域声明的let:

在日常开发中多存在于使用if/for关键字结合let/const创建的块级作用域,值得注意的是使用let/const关键字声明变量的for循环和var声明的有些不同

for 循环通常由初始化、条件和更新三部分组成。使用 var 时,循环变量只有一个函数作用域绑定;使用 let 时,规范会为每轮迭代创建(或更新)一个独立的迭代绑定,因此异步回调可以保留当轮的值。const 也可以用于不需要在更新子句中重新赋值的循环,例如 for (const item of items)。

因此,下面的图片所表达的“每次循环创建一个块级作用域”是一个很有用的教学模型,但不要把它理解成每次都重新执行完整的声明语句;具体行为由 for 语句的迭代环境规则决定。
给每次循环创建一个块级作用域:

暂时性死区
使用let/const声明的变量,从一开始就形成了封闭作用域,在声明变量之前是无法使用这个变量的,这个特点也是为了弥补var的缺陷(var声明的变量有变量提升)

从规范角度说,let、const 绑定会在进入词法环境时创建,但在执行声明前处于未初始化状态;这段时间称为暂时性死区(TDZ)。与之相比,var 绑定会在函数环境初始化时赋值为 undefined。
let x;执行声明时初始化为undefined。const x = value;必须在声明时初始化,之后不能给这个绑定重新赋值。- TDZ 不是“变量不存在”,而是访问未初始化绑定会抛出
ReferenceError。
引用一篇博客对于ES6标准翻译出来的一段话
由let/const声明的变量,当它们包含的词法环境(Lexical Environment)被实例化时会被创建,但只有在变量的词法绑定(LexicalBinding)已经被求值运算后,才能够被访问
回到例子,这里因为使用 let 声明了变量 name,进入 if 块时该绑定已经创建但尚未初始化;在执行到声明语句之前读取它会触发 TDZ,因此抛出 ReferenceError。

上面使用 var 的例子中,绑定属于当前函数作用域(脚本顶层时属于全局环境的一部分),并在执行函数体前初始化为 undefined,所以声明前读取不会触发 TDZ。这里的“预编译”是便于理解的教学说法,规范实际描述的是执行上下文和环境记录的创建与初始化。
相当于这样

TDZ 是 let/const 的语义结果,能够尽早暴露“声明前使用”的错误;它不是专门为弥补某一种开发习惯而设计的。
关于执行上下文、全局环境、函数环境和词法环境,可以结合 ECMAScript 规范或浏览器开发者工具继续学习;“全局、函数、块级”是常用的入门分类,但模块、类、参数环境和闭包还会引入更细的边界。
const
使用 const 声明的是不可重新赋值的绑定,而不是深度冻结的值。const 和 let 的主要区别是:
const声明时必须初始化,之后不能给这个绑定重新赋值;但如果值是对象或数组,仍然可以修改其内部可变状态(除非另行冻结)。

- 如果值是引用类型,
const只保证引用本身不变,不能改变它指向的地址;它不等价于Object.freeze(),更不等价于深冻结。(对象深拷贝和浅拷贝可参考原文文章。)

有些人会有疑问,为什么日常开发中没有显式的声明块级作用域,let/const声明的变量却没有变为全局变量

这个其实也是let/const的特点,ES6规定它们不属于顶层全局变量的属性,这里用chrome调试一下

在浏览器经典脚本中,var 顶层声明通常会成为 window 的属性;let/const 顶层声明属于脚本的词法环境,不会成为 window 属性。ES 模块还有独立的模块作用域,不能用“是否挂到 window”概括所有宿主。
建议
现代代码通常优先使用 const,只有确实需要重新赋值时才使用 let;var 主要出现在旧代码或特定兼容场景。全大写命名更适合真正的固定配置,不必把所有 const 都写成大写。仍应理解 var 的函数作用域和提升行为,以便维护历史代码。
箭头函数(常用)
ES6 允许使用箭头(=>)定义函数
箭头函数对于使用function关键字创建的函数有以下区别
- 箭头函数没有自己的
arguments,需要参数集合时通常使用剩余参数(...args);它可以读取外层普通函数的arguments。 - 箭头函数没有
prototype属性,不能作为构造函数,不能用new调用。 - 箭头函数没有自己的
this、super、new.target,this通过词法作用域取得。它捕获的是创建箭头函数时所在执行上下文的this;对箭头函数调用call、apply、bind不会重新绑定这个this。外层普通函数本身的this仍可能由调用方式决定。

在经典脚本的非严格普通函数中,作为定时器回调调用时的 this 由宿主决定,浏览器通常提供定时器对象或全局对象;不要把这种行为概括成所有环境都指向 window。箭头函数则捕获创建它的 makeRequest 调用中的 this,所以当 controller.makeRequest() 调用外层方法时,箭头函数可以继续访问 controller.a。在模块、严格模式、Node.js 等宿主中,普通回调的 this 还可能是 undefined 或由宿主设定。
箭头函数的 this 即使使用 call、apply、bind 也无法改变;不能用 new 调用则是因为它没有构造器所需的 [[Construct]] 内部方法,而不仅仅是因为 this 已经确定。
建议
箭头函数替代了以前需要显式的声明一个变量保存this的操作,使得代码更加的简洁
ES5写法:

ES6箭头函数:

再来看一个例子

如果这里把本应由调用者提供动态 this 的普通 function 改成箭头函数,它会改为捕获更外层的 this,因此可能找不到 a。反过来,如果回调本来就需要继承外层 this,箭头函数才是合适的选择;关键是看回调是否依赖调用点的动态 this,而不是机械地认为某一种函数永远正确。
在数组的迭代中使用箭头函数更加简洁,并且省略了return关键字

不要在框架需要通过调用约定绑定 this 的位置随意使用箭头函数。例如 Vue 2 Options API 的 methods、计算属性和生命周期选项由框架提供实例上下文,箭头函数会捕获定义处的 this;Vue 3 Composition API 通常使用 ref、reactive 和闭包,不依赖组件方法的动态 this。

在把箭头函数作为日常开发的语法之前,个人建议是去了解一下箭头函数的是如何绑定this的,而不只是当做省略function这几个单词拼写,毕竟那才是ECMAScript真正希望解决的问题
iterator迭代器
Iterator(迭代器)是 ES6 的重要协议,也是解构赋值、剩余/扩展运算符、生成器和 for...of 等语法的基础。Map、Set 等内置集合也实现了这个协议,所以先理解它很有帮助。
可迭代对象具有一个可调用的 [Symbol.iterator] 方法;调用它会返回一个迭代器对象。迭代器对象需要提供 next(),每次调用返回 { value, done }。如果迭代器自身的 [Symbol.iterator]() 返回自己,它就是“迭代器同时也是可迭代对象”,这也是生成器对象的常见形态。
数组中的Symbol.iterator方法(iterator接口)默认部署在数组原型上:

常见的内置可迭代对象包括:
ArrayMapSetString- TypedArray
- 函数的
arguments对象(现代 ECMAScript 实现) - 浏览器中的
NodeList(具体取决于宿主实现)
普通对象默认没有 [Symbol.iterator];可以自行实现该方法,但“有键值”并不自动意味着“可被 for...of 遍历”。
iterator迭代器是一个对象,它具有一个next方法所以可以这么调用

next() 返回一个包含 value 和 done 的迭代结果。done: true 表示迭代结束;结束时的 value 通常是 undefined,但协议并不要求它必须是 undefined。
梳理一下
- 可迭代的数据结构会有一个Symbol.iterator方法
- Symbol.iterator执行后返回一个iterator对象
- iterator对象有一个next方法
- 每次执行
next()都会推进并消耗该迭代器的一步,返回一个有value、done属性的对象。迭代器一般是有状态且不可复用的;需要重新遍历时应重新调用[Symbol.iterator]()。
借用冴羽博客中ES5实现的迭代器可以更加深刻的理解迭代器是如何生成和消耗的

解构赋值(常用)
解构赋值允许从对象属性或可迭代对象中提取值。对象解构按属性键匹配(与变量名相同的属性可以简写),数组解构则按迭代顺序取值;它不是按对象属性的“位置”匹配。

对象解构左侧声明的是 titleOne、titleTwo,并通过 title: titleOne、test[0].title: titleTwo 这样的属性路径取值;找不到属性时结果默认为 undefined(也可以提供默认值)。数组解构会读取右侧可迭代对象的迭代结果,并把 value 依次赋给对应变量。
数组解构的一个用途是交换变量,避免以前要声明一个临时变量值存储值
ES6交换变量:

建议
同样建议使用,因为解构赋值语意化更强,对于作为对象的函数参数来说,可以减少形参的声明,直接使用对象的属性(如果嵌套层数过多我个人认为不适合用对象解构,不太优雅)
一个常用的例子是Vuex中actions中的方法会传入2个参数,第一个参数是个对象,你可以随意命名,然后使用<名字>.commit的方法调用commit函数,或者使用对象解构直接使用commit
不使用对象解构:

使用对象解构:

另外可以给使用axios的响应结果进行解构(axios默认会把真正的响应结果放在data属性中)

剩余/扩展运算符(常用)
剩余参数、数组扩展和对象扩展都使用 ... 语法,但语义不同:数组扩展需要可迭代对象;函数剩余参数和数组解构剩余项负责收集剩余值;对象扩展收集源对象的自有可枚举字符串键和 Symbol 键。
扩展运算符
以数组为例,使用扩展运算符使得可以"展开"这个数组,可以这么理解,数组是存放元素集合的一个容器,而使用扩展运算符可以将这个容器拆开,这样就只剩下元素集合,你可以把这些元素集合放到另外一个数组里面

扩展运算符可以代替ES3中数组原型的concat方法

这里将arr1,arr2通过扩展运算符展开,随后将这些元素放到一个新的数组中,相对于concat方法语义化更强
剩余运算符
剩余参数可以在需要数组参数集合时替代 arguments,表达更清晰、也能直接使用数组方法。arguments.callee 和 arguments.caller 已被废弃,严格模式下不可用;但 arguments 本身仍是合法的函数内置对象,维护旧代码时仍会遇到它。箭头函数没有自己的 arguments,应使用剩余参数或读取外层函数的 arguments。

数组解构中的剩余项必须位于最后,例如 const [first, ...rest] = values。const [...rest, last] = values 不是因为“迭代器被提前消耗”才报错,而是 ECMAScript 语法明确禁止剩余项后再出现模式;这样也避免了无法确定需要保留多少元素的问题。

可以把 first 理解为取得第一个迭代值,把 ...arr 理解为收集剩余值;但“后面没有迭代器可供消耗”只是直观比喻,真正导致 ...arr, last 形式失败的是语法约束。
剩余运算符和扩展运算符的区别就是,剩余运算符会收集这些集合,放到右边的数组中,扩展运算符是将右边的数组拆分成元素的集合,它们是相反的
在对象中使用扩展运算符
对象扩展是 ES2018(通常称为 ES9)标准的一部分。它不读取对象的 [Symbol.iterator],而是复制源对象的自有可枚举字符串键和 Symbol 键,创建到新对象中;因此普通对象也可以使用 { ...source }。

它与 ES2015(ES6)的 Object.assign 都能做浅层合并,但行为不完全相同:Object.assign 使用赋值语义,可能触发目标对象的 setter;对象扩展会在新对象上创建数据属性,通常不会触发目标对象的 setter。二者都会读取源对象的 getter。
建议
使用扩展运算符可以快速的将类数组转为一个真正的数组

合并多个数组

函数柯里化

对象属性/方法简写(常用)
对象属性简写
es6允许当对象的属性和值相同时,省略属性名

需要注意的是
- 省略的是属性名而不是值
- 值必须是一个变量
对象属性简写经常与解构赋值一起使用

结合上文的解构赋值,这里的代码会其实是声明了x,y,z变量,因为bar函数会返回一个对象,这个对象有x,y,z这3个属性,解构赋值会寻找等号右边表达式的x,y,z属性,找到后赋值给声明的x,y,z变量
方法简写
es6允许当一个对象的属性的值是一个函数(即是一个方法),可以使用简写的形式

在Vue中因为都是在vm对象中书写方法,完全可以使用方法简写的方式书写函数

for ... of循环
for...of 是 ES2015 新增的遍历语句,要求右侧对象可迭代,并取得迭代器返回的 value。与 for...in 的主要区别是:
for...of遍历值,for...in遍历可枚举属性键名。for...in可能枚举对象原型链上的可枚举属性,因此需要Object.hasOwn()或其他过滤;for...of是否经过原型链由对象自己的迭代器决定,内置数组迭代器只产生数组位置的值。- 稀疏数组要特别注意:
for...of会把空位按undefined读出,而forEach、map等许多数组方法会跳过空位。
for ... of循环的原理其实也是利用了可迭代对象内部部署的iterator接口,如果将for ... of循环分解成最原始的for循环,内部实现的机制可以这么理解

教学上可以把 for...of 理解为:先取得 [Symbol.iterator](),反复调用 next(),在 done 为 false 时把 value 赋给循环变量,直到 done 为 true。实际语义还包括异常处理和在 break、return 时调用迭代器的 return()(如果存在),因此不要把它简化成只检查 next 的普通 for 循环。
for... of循环同时支持break,continue,return(在函数中调用的话)并且可以和对象解构赋值一起使用

arr数组每次使用for ... of循环都返回一对象({a:1},{a:2},{a:3}),然后会经过对象解构,寻找属性为a的值,赋值给obj.a,所以在每轮循环的时候obj.a会分别赋值为1,2,3
Promise(常用)
Promise作为ES6中推出的新的概念,改变了JS的异步编程,现代前端大部分的异步请求都是使用Promise实现,fetch这个web api也是基于Promise的,这里不得简述一下之前统治JS异步编程的回调函数,回调函数有什么缺点,Promise又是怎么改善这些缺点
回调函数
JavaScript 的每个执行代理(例如浏览器窗口的主线程)按顺序执行当前 job;浏览器仍可以在其他线程处理网络、计时器和渲染等宿主工作,Web Worker 也可以运行在独立代理中。异步操作完成后,宿主按照自己的事件循环规则安排回调。浏览器中的网络请求并不是简单地“进入一个 HTTP 线程再放入同一个队列”,具体队列和渲染时机由 HTML、Fetch 等规范共同定义。
在《你不知道的JavaScript》下卷中,这么介绍
使用回调函数处理异步请求相当于把你的回调函数置于了一个黑盒,虽然你声明了等到收到响应后执行你提供的回调函数,可是你并不知道这个第三方库会在什么具体会怎么执行回调函数
使用第三方的请求库你可能会这么写:

收到响应后,执行后面的回调打印字符串,但是如果这个第三方库有类似超时重试的功能,可能会执行多次你的回调函数,如果是一个支付功能,你就会发现你扣的钱可能就不止1000元了-.-
第二个众所周知的问题就是,在回调函数中再嵌套回调函数会导致代码非常难以维护,这是人们常说的“回调地狱”

另外,第三方 AJAX 库可能没有提供一致的错误回调,请求失败的信息可能被吞掉。Node.js 传统 API 常见 err-first 回调(第一个参数是错误),但这不是所有库的强制约定。新代码通常优先使用能明确表达成功/失败的 Promise,并在边界处统一处理错误。
总结一下回调函数的一些缺点
- 多重嵌套,导致回调地狱
- 代码跳跃,并非人类习惯的思维模式
- 信任问题,你不能把你的回调完全寄托于第三方库,因为你不知道第三方库到底会怎么执行回调(多次执行)
- 第三方库可能没有提供错误处理
- 不清楚回调是否一定异步调用。历史上同步 XHR 可以阻塞主线程,但它会冻结页面交互,已被 Web 平台强烈不建议并在许多场景受限;不要在新代码中使用。
// 第三个参数 false 表示同步 XHR;仅用于说明历史行为,不要在主线程的新代码中使用
const xhr = new XMLHttpRequest();
xhr.open('GET', '/try/ajax/ajax_info.txt', false);
xhr.send();
Promise
针对回调函数这么多缺点,ES6中引入了一个新的概念Promise,Promise是一个构造函数,通过new关键字创建一个Promise的实例,来看看Promise是怎么解决回调函数的这些问题

Promise 不是回调函数的“升级版”,而是一种表示异步结果的协议。把 callback 风格 API 封装成 Promise 风格通常称为 promisification(Promise 化);Axios、Node.js 的 fs/promises 等现代 API 都常见这种风格,Bluebird、Q 则是历史上的 Promise 库。
- 多重嵌套,导致回调地狱
Promise在设计的时候引入了链式调用的概念,每个then方法同样也是一个Promise,因此可以无限链式调用下去

配合箭头函数,明显的比之前回调函数的多层嵌套优雅很多
- 代码跳跃,并非人类习惯的思维模式
Promise使得能够同步思维书写代码,上述的代码就是先请求3000端口,得到响应后再请求3001,再请求3002,再请求3003,而书写的格式也是符合人类的思维,从先到后
- 信任问题,你不能把你的回调完全寄托于第三方库,因为你不知道第三方库到底会怎么执行回调(多次执行)
Promise 实例可以看作带有不可逆状态转换的抽象,具有以下 3 个状态
- pending(等待)
- fulfilled(成功)
- rejected(拒绝)
Promise 创建后通常是 pending。调用 resolve(value) 是“决议”而不是简单地强制变成 fulfilled:如果 value 是 Promise 或 thenable,外层 Promise 会跟随它的最终状态;调用 reject(reason) 则会使它拒绝。无论哪种方式,第一次完成决定生效,之后的调用不会再次改变该实例的状态。
一个Promise实例的状态只能从pending => fulfilled 或者从 pending => rejected,即当一个Promise实例从pending状态改变后,就不会再改变了(不存在fulfilled => rejected 或 rejected => fulfilled)
Promise 的处理函数由开发者通过 then、catch 或 finally 注册;注册发生在结果完成前也没有问题,处理函数会在完成后排队执行。Promise 对回调的调用次数和成功/失败通道提供了更明确的约束,但它不能替代幂等设计、取消机制或业务层重试策略。

- 第三方库可能没有提供错误处理
then(onFulfilled, onRejected) 可以分别注册兑现和拒绝处理函数;catch(onRejected) 是注册拒绝处理的常用写法。处理函数是在 Promise 完成后由运行时排队调用,并不要求 Promise 已经完成时才注册。
Promise 的拒绝不会同步阻塞当前 job,但如果没有被处理,会形成 unhandled rejection:浏览器可能触发 unhandledrejection,Node.js 的默认行为也可能终止进程。异步错误仍必须显式处理,不能因为“不阻塞主线程”就忽略。

- 不清楚回调是否都是异步调用的
Promise 的反应处理函数(then、catch、finally)会以异步 job 的方式执行,通常表现为当前同步代码完成后清空 Promise jobs/microtask 队列;但 new Promise(executor) 中的 executor 会在构造时同步执行,不能把 Promise 的所有代码都说成异步。queueMicrotask() 可用于显式安排同一类微任务。
对于“调用 resolve 后为什么不一定立即显示为 fulfilled”的问题,关键在于 Promise 会跟随传入的 Promise/thenable;下面的原文截图仍保留这一历史演示。


(这里用一个定时器在下轮事件循环中打印这个Promise实例的状态,否则会是pending状态)
调用 resolve 会使当前 Promise 进入“已决议”流程,但如果传入另一个 Promise 或 thenable,当前 Promise 会跟随它;被跟随的对象拒绝时,外层最终也会 rejected。
因此把 resolve 理解为“决议”比直接翻译成“成功”更准确:它可能最终兑现,也可能因为跟随对象而拒绝。
等同于这样

建议
在现代 Web 和 Node.js 代码中,Promise 是异步 API 的常见基础,但仍需根据场景选择回调、事件流或可取消任务。async/await 属于 ES2017:async 函数始终返回 Promise,await 会暂停当前 async 函数的后续执行而不会阻塞线程;它让顺序流程更易读,但不是所有并发场景的“终极方案”,并且仍需用 try/catch 或 catch() 处理拒绝。

关于Promise还有很多很多需要讲的,包括它的静态方法all,race,resolve,reject,Promise的执行顺序,Promise嵌套Promise,thenable对象的处理等,碍于篇幅这里只介绍了一下为什么需要使用Promise。但很多开发者在日常使用中只是了解这些API,却不知道Promise内部具体是怎么实现的,遇到复杂的异步代码就无从下手,非常建议去了解一下Promise A+的规范,自己实现一个Promise
ES Modules(ES2015 模块,常用)
在 ES Modules 出现之前,前端常见 AMD、CMD,Node.js 常见 CommonJS。ES Modules(ESM)提供了统一的静态模块语法;现代浏览器、Node.js 和构建工具都已支持它,但具体配置仍取决于宿主(例如浏览器的 type="module"、Node.js 的 .mjs 或 package.json 的 type)。
这段“浏览器默认不支持 ES Modules、必须使用 Babel”的说法属于早期历史背景。现代浏览器可以原生加载模块;只有面向旧浏览器或需要转换语法时才需要 Babel 等构建工具。

浏览器中可以使用 <script type="module" src="./main.js"></script> 原生加载模块。应通过 HTTP(S) 服务器提供文件,不能直接依赖 file://;跨源模块请求需要满足 CORS,而不只是“同域”这一条件。VS Code 等编辑器可通过 Live Server 或其他开发服务器运行示例。

ES Modules 使用 import 导入模块、export 导出模块,主要特点如下:
- ESM 的静态
import/export结构会在模块实例化阶段确定依赖关系,支持静态分析和 tree shaking;这不等于所有模块代码都在“编译阶段执行”。 - 模块代码自动采用严格模式,模块顶层
this为undefined。 - 可以使用
export { value }导出具名绑定,也可以使用export default value导出名为default的绑定;default不等同于“匿名值”。
module.js导出:

a.js导入:

具名导出和默认导出都遵循模块绑定规则;导入方得到的是只读的 live binding 视图,不能给导入标识符重新赋值。需要区分写法:export default x 会导出执行到该导出语句时的值,而 export { x as default } 导出的是 x 的 live binding;若希望观察后续对本地变量的重新赋值,应明确使用后者。
因此,使用 export { x } 或 export { x as default } 时,导入方看到的是导出绑定的当前值;而 export default x 是对默认导出表达式求值后得到的默认导出值。导入方本身不能重新赋值,但如果导入的是对象,仍可能修改对象内部的可变状态。
module.js:

a.js:

可以看到给module.js设置了一个一秒后改变x,y变量的定时器,在一秒后同时观察导入时候变量的值,可以发现x被改变了,但y的值仍是20,因为y是通过export default导出的,在导入的时候的值相当于只是导入数字20,而x是通过export {<变量>}导出的,它导出的是一个变量的引用,即a.js导入的是当前x的值,只关心当前x变量的值是什么,可以理解为一个"活链接"
export default这种导出的语法其实只是指定了一个命名导出,而它的名字叫default,换句话说,将模块的导出的名字重命名为default,也可以使用import <变量> from <路径> 这种语法导入
module.js导出:

a.js导入:

如果写成 export { x as default },即使导出名被重命名为 default,它仍然是 x 的 live binding;这与 export default x 的求值形式需要区分。
这里再比较一下目前常见的 ES Modules 和 CommonJS:
- CommonJS 的
require()返回module.exports对象;把它解构到局部变量后,局部变量不会自动变成 ESM 式的 live binding。ESM 的静态导入是只读 live binding 视图。 - CommonJS 通常在运行时执行
require(),ESM 的静态依赖关系在模块实例化阶段确定;两者都可能被构建工具转换,不能简单按“服务器/浏览器”二分。 - Node.js 的 CommonJS 模块通常首次加载并执行后缓存
module.exports;缓存、循环依赖和互操作细节由宿主和工具链决定。
import()
静态 import 不能放进普通条件语句,但动态 import(specifier) 已经是标准能力(ES2020),返回一个 Promise,兑现值是模块命名空间对象。它适合路由懒加载、按需加载和插件场景;具体分包效果仍由构建工具决定。


使用动态 import() 可以改写上面的 a.js,在条件满足时再加载模块;静态 import 仍不能直接放在普通条件语句中,因为静态依赖关系必须在模块实例化阶段确定。动态导入返回 Promise,兑现值是模块命名空间对象。
Vue中路由的懒加载的ES6写法就是使用了这个技术,使得在路由切换的时候能够动态的加载组件渲染视图
函数默认值
ES6允许在函数的参数中设置默认值
ES5写法:

ES6写法:

相比ES5,ES6函数默认值直接写在参数上,更加的直观
使用默认参数时,函数会有一个独立的参数环境;它与函数体的词法环境有关联,但不应简单等同于普通的块级作用域。默认参数按从左到右求值,也会受到 TDZ 等词法规则影响。

这里当运行func的时候,因为没有传参数,使用函数默认参数,y就会去寻找x的值,在沿着词法作用域在外层找到了值为1的变量x
再来看一个例子

这里同样没有传参数,使用函数的默认赋值,x通过词法作用域找到了变量w,所以x默认值为2,y同样通过词法作用域找到了刚刚定义的x变量,y的默认值为3,但是在解析到z = z + 1这一行的时候,JS解释器先会去解析z+1找到相应的值后再赋给变量z,但是因为暂时性死区的原因(let/const"劫持"了这个块级作用域,无法在声明之前使用这个变量,上文有解释),导致在let声明之前就使用了变量z,所以会报错
这样理解函数的默认值会相对容易一些

只有实参为 undefined(包括省略实参)时才使用默认值;显式传入 null、0、false 或空字符串都不会触发默认值。
在举个例子:

这里的关键是:默认参数表达式在参数环境中求值,默认参数内部函数的词法作用域也从定义处开始查找,不会反过来读取函数体中后来声明的同名变量。因此示例中的 foo 若既不在参数环境也不在外层作用域,就会抛出 ReferenceError。

通过debugger可以更加直观的发现在这个函数内部可以通过词法作用域访问func函数,foo变量,还有this,但是当查看func函数的词法作用域时,发现它只能访问到Global,即全局作用域,foo变量并不存在于它的词法作用域中
函数默认值配合解构赋值

第一行给 func 传入两个空对象,因此参数级默认对象不会生效;随后对象解构找不到 x,使用属性级默认值 10,y 没有属性级默认值所以是 undefined。后续各行分别展示了省略实参、显式传入 undefined 和传入实际属性的区别。
第二行第一个参数显式的传入了一个undefined,所以会使用函数默认值为一个空对象,随后和第一行一样尝试解构x发现x为undefined,但是设置了默认值所以x的值为10,而y和上文一样为undefined
第三行2个参数都会undefined,第一个参数和上文一样,第二个参数会调用函数默认值,赋值为{y:10},然后尝试解构出变量y,即y为10
第四行和第三行相同,一个是显式传入undefined,一个是隐式不传参数
第五行直接使用传入的参数,不会使用函数默认值,并且能够顺利的解构出变量x,y
Proxy
Proxy 可以在目标对象外部建立一层可编程的拦截器,拦截读取、写入、调用、构造、属性描述、原型操作等行为。它通过 new Proxy(target, handler) 创建代理。Reflect 不是“返回拦截结果”的配套对象,而是提供默认内部操作的函数式入口;在 trap 中使用 Reflect.get、Reflect.set 等,通常能正确保留 receiver 和返回值语义。

Object.defineProperty
提到 Proxy 时可以回顾 ES5 的 Object.defineProperty。它可以定义数据属性或访问器属性,二者不能在同一个描述符中同时出现:数据描述符包含 configurable、enumerable、writable、value;访问器描述符包含 configurable、enumerable、get、set。访问器的 getter/setter 会参与属性读写,但不会自动覆盖其他属性或建立深层响应式。

定义了obj中a属性的表示为只读,且不可枚举,obj2定义了get,但没有定义set表示只读,并且读取obj2的b属性返回的值是getter函数的返回值
Proxy 不是 Object.defineProperty 的简单“增强版”:前者代理对象的多种内部操作,后者修改单个属性的描述符。Proxy 的 handler 约有 13 类 trap,覆盖读取、写入、调用、构造和元对象操作,但仍受不可变属性等不变式约束。
handler.apply
apply可以让我们拦截一个函数(JS中函数也是对象,Proxy也可以拦截函数)的执行,我们可以把它用在函数节流中

调用拦截后的函数:

handler.construct
construct可以拦截通过new关键字调用这个函数的操作,我们可以把它用在单例模式中

这里通过一个闭包保存了instance变量,每次使用new关键字调用被拦截的函数后都会查看这个instance变量,如果存在就返回闭包中保存的instance变量,否则就新建一个实例,这样可以实现全局只有一个实例
handler.defineProperty
defineProperty可以拦截对这个对象的Object.defineProperty操作
注意:代理赋值是否触发 defineProperty trap 与 receiver、目标对象和赋值路径有关;在某些普通对象场景中,默认 [[Set]] 会进一步通过 receiver 定义属性,因此可能观察到额外的 trap。不要把它概括成所有赋值都会固定触发这两个 trap。

这里有几个知识点
- 这里使用了递归的操作,当需要访问对象的属性时候,会判断代理的对象属性的值仍是一个可以代理的对象就递归的进行代理,否则通过错误捕获执行默认的get操作
- 定义了defineProperty的拦截方法,当对这个代理对象的某个属性进行赋值的时候会执行对象内部默认的[[SET]]操作进行赋值,这个操作会间接触发defineProperty这个方法,随后会执行定义的callback函数
这样就实现了无论对象嵌套多少层,只要有属性进行赋值就会触发get方法,对这层对象进行代理,随后触发defineProperty执行callback回调函数
其他的使用场景
Proxy 还可以用于输入验证、日志和兼容层等场景。但用 get 拦截模拟“私有属性”不是安全边界,代理也不能拦截语言级私有字段 #field;生产代码应优先使用类私有字段、模块闭包或明确的 API。
Vue
原文写作时 Vue 3 仍未发布。现在 Vue 3 已经使用基于 Proxy 的响应式系统,Vue 2 的 Object.defineProperty 方案只应作为历史背景理解;Vue 2 已结束维护,现有项目应按迁移指南评估升级。
我相信了解过一点Vue响应式原理的人都知道Vue框架在对象拦截上的一些不足
<template>
<div>
<div>{{arr}}</div>
<div>{{obj}}</div>
<button @click="handleClick">修改arr下标</button>
<button @click="handleClick2">创建obj的属性</button>
</div>
</template>
<script>
export default {
name: "index",
data() {
return {
arr:[1,2,3],
obj:{
a:1,
b:2
}
}
},
methods: {
handleClick() {
this.arr[0] = 10
console.log(this.arr)
},
handleClick2() {
this.obj.c = 3
console.log(this.obj)
}
},
}
</script>

这段示例描述的是 Vue 2 的限制:其基于 Object.defineProperty 的转换无法直接观察后加的根属性和数组下标赋值,因此 Vue 2 提供了 Vue.set/this.$set 等补救方式,并改写部分数组变异方法。Vue 3 基于 Proxy 后可以拦截属性添加、删除、数组下标和 length 等操作;但它仍有响应式解包、组件边界和第三方对象等使用规则,不能简单理解为“任何变化都自动刷新”。
//源码位置:src/core/observer/array.js
methodsToPatch.forEach(function (method) {
// cache original method
var original = arrayProto[method];
def(arrayMethods, method, function mutator () {
var args = [], len = arguments.length;
while ( len-- ) args[ len ] = arguments[ len ];
var result = original.apply(this, args);
var ob = this.__ob__;
var inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args;
break
case 'splice':
inserted = args.slice(2);
break
}
if (inserted) { ob.observeArray(inserted); }
// notify change
ob.dep.notify(); //这一行就会主动调用notify方法,会通知到渲染watcher进行视图更新
return result
});
});
在掘金翻译的尤大Vue3.0计划中写到
3.0 将带来一个基于 Proxy 的 observer 实现,它可以提供覆盖语言 (JavaScript——译注) 全范围的响应式能力,消除了当前 Vue 2 系列中基于 Object.defineProperty 所存在的一些局限,如: 对属性的添加、删除动作的监测 对数组基于下标的修改、对于 .length 修改的监测 对 Map、Set、WeakMap 和 WeakSet 的支持
Proxy 能覆盖上述 Vue 2 的一部分限制,但代理对象仍不能完全替代所有 defineProperty 用途(例如精细控制单个属性描述符)。现代浏览器和 Node.js 均支持 Proxy,IE 不支持;如果还要兼容 IE,只能使用编译降级、旧版响应式方案或明确放弃相关能力。理解 Proxy 有助于阅读 Vue 3 响应式实现,但不等于需要复制框架源码。
Object.assign
Object.assign 是 ES2015(ES6)新增的静态方法,用于把一个或多个源对象的自有可枚举属性复制到目标对象,并返回目标对象。它是浅复制,不会递归克隆。

可以把它理解为依次枚举源对象的自有可枚举字符串键和 Symbol 键,再以赋值语义写入 target。后面的源对象会覆盖前面的同名属性;如果目标对象有 setter,赋值可能触发 setter。
这里罗列了一些这个API的需要注意的知识点
常见注意点:
Object.assign是浅复制,引用类型属性仍指向同一个对象。- 会复制源对象自有的可枚举 Symbol 属性,但不会复制不可枚举属性或继承属性。
target会先通过ToObject转为对象;null/undefined不能转换并会抛错,字符串等原始值会产生临时包装对象。- 源字符串的索引字符是可枚举自有属性,因此
Object.assign({}, 'abc')会得到{ 0: 'a', 1: 'b', 2: 'c' };这与字符串是否可迭代是两回事。 - 复制源属性值会触发源对象的 getter;写入目标会使用赋值语义,可能触发目标 setter。它复制的是 getter 的返回值,不是访问器描述符本身;若要保留描述符,可使用
Object.getOwnPropertyDescriptors()与Object.defineProperties()。


可以看到这里成功的复制了obj对象中a属性的getter/setter
为了加深了解我自己模拟了Object.assign的实现,可供参考

这里有一个坑不得不提,对于target参数传入一个字符串,内部会转换为基本包装类型,而字符串基本包装类型的属性是只读的(属性描述符的writable属性为false),这里感谢木易杨的专栏


打印对象属性的属性描述符可以看到下标属性的值都是只读的,即不能再次赋值,所以尝试以下操作会报错

字符串 abc 会转为临时的 String 包装对象;如果再把 def 的索引属性写入同一个目标,目标已有的只读索引可能导致 TypeError。这不是因为 Object.assign“默认开启严格模式”,而是规范内部写入使用了会报告失败的语义;不要把该临时包装对象当作可持久化目标使用。
和 ES2018(ES9)的对象扩展运算符对比
ES2018(ES9)支持对象扩展,{ ...source } 与 Object.assign({}, source) 都是浅层复制,但在 getter/setter 和属性创建语义上不同:


(控制台为了显示对象,可能再次读取属性并触发 getter;图片中的最后一个字符串 get 不应当当作对象扩展本身额外读取的证据。)
分析一下这个例子
对象扩展:
- 读取每个源对象的自有可枚举属性,因此会触发源 getter。
- 在新对象上创建数据属性,不会触发“目标对象 setter”;同名属性由后面的源覆盖。
Object.assign:
- 读取源 getter,并通过赋值语义写入已有目标,因此可能触发目标 setter。
- 目标对象原有的不可写属性、代理 trap 等可能导致不同结果。
除 getter/setter、代理和属性描述符等边界外,二者都属于浅层复制;需要新对象且不希望触发目标 setter 时,对象扩展通常更直观。
建议
- Vue中重置data中的数据
这个是我最常用的小技巧,使用Object.assign可以将你目前组件中的data对象和组件默认初始化状态的data对象中的数据合并,这样可以达到初始化data对象的效果

在 Vue 2 组件实例中,$data 保存当前数据,$options.data 可以取得初始化函数;但这属于 Vue 2 Options API 的实现约定。Vue 3 或组合式 API 应把初始状态封装为工厂函数,再对可变表单对象使用 Object.assign(form, createInitialState()),并注意它仍是浅合并。
- 给对象合并需要的默认属性

可以封装一个函数,外层声明一个DEFAULTS常量,options为每次传入的动态配置,这样每次执行后会合并一些默认的配置项
- 在传参的时候可以多个数据合并成一个对象传给后端

现代补充(ES2018+)
原文的重点是 ES6;下面补充几项如今经常与这些语法一起使用的标准能力:
- 动态模块和顶层 await:
import()已是标准动态导入,ES 模块还支持顶层await(ES2022)。模块依赖仍应优先使用静态import,动态导入适合按需加载。 - 对象与数组的非变异方法:对象扩展只是浅复制;需要深复制且数据满足结构化克隆算法时,可考虑
structuredClone(),但函数、DOM 节点等值不能按普通对象直接克隆。 - 现代数组方法:
Array.prototype.at()、findLast()、findLastIndex()和toReversed()、toSorted()、toSpliced()、with()已在现代运行时提供;后三类复制方法不会修改原数组,适合不可变数据流。 - Promise 能力:除
all、race、resolve、reject外,还可以使用allSettled、any、AggregateError、withResolvers(ES2024)等;网络请求还应结合AbortController或AbortSignal.timeout()做取消和超时。 - Proxy 的边界:代理适合观察和校验对象操作,但会增加复杂度,且存在不可变属性不变式、私有字段不可拦截、性能和调试成本等边界。能用普通数据流和明确函数解决的问题,不必强行使用 Proxy。
const initial = { user: { name: 'Ada' }, tags: ['js'] }
const next = structuredClone(initial)
next.user.name = 'Grace'
next.tags.push('web')
console.log(initial.user.name) // Ada
console.log(next.user.name) // Grace
const values = [3, 1, 2]
const sorted = values.toSorted((a, b) => a - b)
console.log(values) // [3, 1, 2]
console.log(sorted) // [1, 2, 3]
这些 API 仍应根据目标浏览器、Node.js 版本和构建目标确认兼容性;“支持 ES6”不等于自动支持所有后续标准。
参考资料
- 阮一峰:ES6标准入门
- MDN:JavaScript Guide - Modules
- MDN:JavaScript Reference
- Vue:响应式基础
- 慕课网:ES6零基础教学
- 你不知道的JavaScript下卷
作者:yeyan1996链接:https://juejin.cn/post/6844903775329583112来源:稀土掘金著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。