ES6 精进之道

329 阅读23分钟

本文是针对于(近一万字的ES6语法知识点补充)[juejin.cn/post/684490…] 的学习笔记

let const

  1. 块级作用域
for (var i = 0;i < 10;i++) {
    // for循环分为3部分,第一部分包含一个变量声明,第二部分包含一个循环的退出条件,
  第三部分包含每次循环最后要执行的表达式,第一部分在这个for循环中只会执行一次var i = 0,
  而后面的两个部分在每次循环的时候都会执行一遍
    console.log(i)
}
for (let i = 0;i < 10; i++){
    //而使用使用let/const关键字声明变量的for循环,除了会创建块级作用域,
    // let/const还会将它绑定到每个循环中,确保对上个循环结束时候的值进行重新赋值
    // 可以理解为给每次循环创建一个块级作用域
    // do sth
}
  1. 暂时性死区

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

  • var在创建时就被初始化,赋值为undefined
  • let/const在进入块级作用域后,会因为提升的原因先创建,但不会被初始化,直到声明语句执行的时候才被初始化,初始化的时候如果使用let声明的变量没有赋值,则会默认赋值为undefined,而const必须在初始化的时候赋值。而创建到初始化之间的代码片段就形成了暂时性死区 由let/const声明的变量,当它们包含的词法环境(Lexical Environment)被实例化时会被创建,但只有在变量的词法绑定(LexicalBinding)已经被求值运算后,才能够被访问
  1. const
  • 声明时必须赋值
  • 基本类型不能改变,如果声明的是一个引用类型,则不能改变他的内存地址
  • ES6规定 let const 不属于顶层全局变量的属性,而是在一个script的作用域下
  1. 使用建议 全面拥抱let/const,一般的变量声明使用let关键字,而当声明一些配置项(类似接口地址,npm依赖包,分页器默认页数等一些一旦声明后就不会改变的变量)的时候可以使用const,来显式的告诉项目其他开发者,这个变量是不能改变的(const声明的常量建议使用全大写字母标识,单词间用下划线)

箭头函数

  1. 与使用function关键字创建的函数的区别:
  • 箭头函数没有arguments(建议使用更好的语法,剩余运算符替代)
  • 箭头函数没有prototype属性,不能用作构造函数(不能用new关键字调用)
  • 箭头函数没有自己this,它的this是词法的,引用的是上下文的this,即在你写这行代码的时候就箭头函数的this就已经和外层执行上下文的this绑定了(这里个人认为并不代表完全是静态的,因为外层的上下文仍是动态的可以使用call,apply,bind修改,这里只是说明了箭头函数的this始终等于它上层上下文中的this)
let controller = {
    makeRequest: function(){
    setTimeout(function () {
      console.log(this.a)  //undefined
    })
  },
  a:1
  
  //setTimeout会将一个匿名的回调函数推入异步队列,而回调函数是具有全局性的,即在非严格模式下this会指向window,就会存在丢失变量a的问题,而如果使用箭头函数,在书写的时候就已经确定它的this等于它的上下文(这里是makeRequest的函数执行上下文,相当于将箭头函数中的this绑定了makeRequest函数执行上下文中的this)因为是controller对象调用的makeRequest函数,所以this就指向了controller对象中的a变量
}
  1. 使用建议
  • 箭头函数替代了以前需要显式的声明一个变量保存this的操作,使得代码更加的简洁
  • 不要在可能改变this指向的函数中使用箭头函数,类似Vue中的methods,computed中的方法,生命周期函数,Vue将这些函数的this绑定了当前组件的vm实例,如果使用箭头函数会强行改变this,因为箭头函数优先级最高(无法再使用call,apply,bind改变指向)

iterator 迭代器

iterator迭代器是ES6非常重要的概念,它是另外4个ES6常用特性的实现基础(解构赋值,剩余/扩展运算符,生成器,for of循环),了解迭代器的概念有助于了解另外4个核心语法的原理,另外ES6新增的Map,Set数据结构也有使用到它

对于可迭代的数据解构,ES6在内部部署了一个[Symbol.iterator]属性,它是一个函数,执行后会返回iterator对象(也叫迭代器对象),而生成iterator对象[Symbol.iterator]属性叫iterator接口,有这个接口的数据结构即被视为可迭代的

默认部署iterator接口的数据结构有以下几个,注意普通对象默认是没有iterator接口的(可以自己创建iterator接口让普通对象也可以迭代) (Array,Map,Set,String,TypedArray(类数组),函数的arguments对象,NodeList对象)

iterator迭代器是一个对象,它具有一个next方法所以可以这么调用

let arr = [1,2,3] let iterator = arrSymbol.iterator //

需要使用键值的形式访问 iterator.next() // {value: 1,done: false} iterator.next() // {value: 2,done: false} iterator.next() // {value: 3,done: false} iterator.next() // {value: undefined,done: true}

ES5实现iterator

function createIterator(items) {
    var i = 0;
    return {
        next: function() {
            var done = i >= items.length
            var value = !done ? items[i++] : undefined
            return {
                done,
                value
            }
        }
    }
}

解构赋值

解构赋值可以直接使用对象的某个属性,而不需要通过属性访问的形式使用,对象解构原理个人认为是通过寻找相同的属性名,然后原对象的这个属性名的值赋值给新对象对应的属性

  1. 数组解构的原理其实是消耗数组的迭代器,把生成对象的value属性的值赋值给对应的变量, 一个用途是交换变量。例如[a,b] = [b, a]
  2. 使用建议解构赋值语意化更强,对于作为对象的函数参数来说,可以减少形参的声明,直接使用对象的属性(如果嵌套层数过多我个人认为不适合用对象解构,不太优雅) 常见的使用
  • axios响应结果的解构 let { data } = await axios.get("http://localhost:3000")
  • Vuex中actions方法会传入2个参数,第一个参数是对象,使用.commit的方法调用函数,或者使用对象解构直接使用commit
actions:  {
  setFontFamilyVisible: ({ commit }, visible) => {
    return commit('SET_FONT_FAMILY_VISIBLE', visible)
  }
}

剩余、拓展运算符

  1. 剩余/扩展运算符同样也是ES6一个非常重要的语法,使用3个点(...),后面跟着一个含有iterator接口的数据结构。可以代替ES3 数组原型的concat方法
  2. 剩余运算符最重要的一个特点是代替了之前的arguments,使用剩余运算符替代(箭头函数没有arguments,必须使用剩余运算符才能访问参数集合)
  3. 剩余运算符可以和数组的解构赋值一起使用,但是必须放在最后一个,因为剩余运算符的原理其实是利用了数组的迭代器,它会消耗3个点后面的数组的所有迭代器,读取所有迭代器生成对象的value属性,剩运算符后不能在有解构赋值,因为剩余运算符已经消耗了所有迭代器,而数组的解构赋值也是消耗迭代器,但是这个时候已经没有迭代器了,所以会报错

let [first, ...arr] = [1,2,3,4,5] first //1 let [...arr, last] = [1,2,3,4,5] // Uncaught SyntaxError

  1. 剩余运算符和扩展运算符的区别就是,剩余运算符会收集这些集合,放到右边的数组中,扩展运算符是将右边的数组拆分成元素的集合,它们是相反的
  2. 在对象中使用扩展运算符 这个是ES9的语法,对象中没有迭代器,可能是实现原理不同 其实他和另外一个ES6新增的API相似,即Object.assign. 但是还是有一些不同Object.assign会触发目标对象的setter函数,而对象扩展运算符不会
  3. 使用建议
  • 快速将类数组转为一个真正的数组
  • 合并多个数组
  • 函数柯里化
const curry = (fn) => {
    if (fn.length <= 1) return fn;
    const generator = (args) => (args.length === fn.length ? fn(...args) : arg => generator([...args, arg]) )
    return generator([])
}

对象属性、方法简写

  1. es6允许当对象的属性和值相同时,省略属性名,值必须是一个变量。 常和解构赋值一起使用
  2. es6允许当一个对象的属性的值是一个函数(即是一个方法),可以使用简写的形式 在Vue中因为都是在vm对象中书写方法,完全可以使用方法简写的方式书写函数

for of

for ... of是作为ES6新增的遍历方式,允许遍历一个含有iterator接口的数据结构并且返回各项的值,和ES3中的for ... in的区别如下

  • for ... of只能用在可迭代对象上,获取的是迭代器返回的value值,for ... in 可以获取所有对象的键名
  • for ... in会遍历对象的整个原型链,性能非常差不推荐使用,而for ... of只遍历当前对象不会遍历它的原型链
  • 对于数组的遍历,for ... in会返回数组中所有可枚举的属性(包括原型链上可枚举的属性),for ... of只返回数组的下标对应的属性值 for ... of 的原理其实也是利用了可迭代对象内部部署的i terator接口

for... of循环同时支持break,continue,return(在函数中调用的话)并且可以和对象解构赋值一起使用

let arr1 = [1,2,3,4,5]
let iterator1 = arr1[Symbol.iterator]()
for (let value, res; (res = iterator.next()) && !res.done;) {
    value = res.value
}

Promise

Promise作为ES6中推出的新的概念,改变了JS的异步编程,现代前端大部分的异步请求都是使用Promise实现,fetch这个web api也是基于Promise的,这里不得简述一下之前统治JS异步编程的回调函数,回调函数有什么缺点,Promise又是怎么改善这些缺点

回调函数

众所周知,JS是单线程的,因为多个线程改变DOM的话会导致页面紊乱,所以设计为一个单线程的语言,但是浏览器是多线程的,这使得JS同时具有异步的操作,即定时器,请求,事件监听等,而这个时候就需要一套事件的处理机制去决定这些事件的顺序,即Event Loop(事件循环),这里不会详细讲解事件循环,只需要知道,前端发出的请求,一般都是会进入浏览器的http请求线程,等到收到响应的时候会通过回调函数推入异步队列,等处理完主线程的任务会读取异步队列中任务,执行回调 在《你不知道的JavaScript》下卷中,这么介绍

使用回调函数处理异步请求相当于把你的回调函数置于了一个黑盒,虽然你声明了等到收到响应后执行你提供的回调函数,可是你并不知道这个第三方库会在什么具体会怎么执行回调函数

比如你使用第三方的请求库你可能会这么写:

ajax("http://localhost:3000", () => {
    console.log("我扣了1000¥")
})

但是如果这个第三方库有类似超时重试的功能,可能会执行多次你的回调函数,如果是一个支付功能,你就会发现你扣的钱可能就不止1000元了

第二个问题是回调地域,难以阅读和维护

Promise对于回调函数缺点的改进

  1. 多重嵌套,导致回调地狱 Promise在设计的时候引入了链式调用的概念,每个then方法同样也是一个Promise,因此可以无限链式调用下去, 配合箭头函数,明显的比之前回调函数的多层嵌套优雅很多
  2. 代码跳跃,并非人类习惯的思维模式 Promise使得能够同步思维书写代码
  3. 信任问题,你不能把你的回调完全寄托与第三方库,因为你不知道第三方库到底会怎么执行回调(多次执行) Promise 本身是一个状态机,具有三个状态
  • pending(等待)
  • fulFilled(成功)
  • rejected(拒绝)

当请求发送没有得到响应的时候为pending状态,得到响应后会resolve(决议)当前这个Promise实例,将它变为 fulfilled/rejected(大部分情况会变为fulfilled)。当请求发生错误后会执行reject(拒绝)将这个Promise实例变为 rejected 状态,且此过程不可逆只能变化一次。 而Promise实例必须主动调用then方法,才能将值从Promise实例中取出来(前提是Promise不是pending状 态),这一个“主动”的操作就是解决这个问题的关键,即第三方库做的只是改变Promise的状态,而响应的 值怎么处理,这是开发者主动控制的,这里就实现了控制反转,将原来第三方库的控制权转移到了开发者上

  1. 第三方库可能没有提供错误处理 Promise的then方法会接受2个函数,第一个函数是这个Promise实例被resolve时执行的回调,第二个函数是这个Promise实例被reject时执行的回调,而这个也是开发者主动调用的。使用Promise在异步请求发送错误的时候,即使没有捕获错误,也不会阻塞主线程的代码(准确的来说,异步的错误都不会阻塞主线程的代码)
  2. 不清楚回调是否都是异步调用的 Promise在设计的时候保证所有响应的处理回调都是异步调用的,不会阻塞代码的执行,Promise将then方法的回调放入一个叫微任务的队列中(MicroTask),确保这些回调任务在同步任务执行完以后再执行

使用建议

建议使用ES7的async/await进一步的优化Promise的写法,async函数始终返回一个Promise,await可以实现一个"等待"的功能,async/await被成为异步编程的终极解决方案,即用同步的形式书写异步代码,并且能够更优雅的实现异步代码顺序执行以及在发生异步的错误时提供更精准的错误信息,详细用法可以看阮老师的ES6标准入门

ES6 Module

在ES6 Module出现之前,模块化一直是前端开发者讨论的重点,面对日益增长的需求和代码,需要一种方案来将臃肿的代码拆分成一个个小模块,从而推出了AMD,CMD和CommonJs这3种模块化方案,前者用在浏览器端,后面2种用在服务端,直到ES6 Module出现。

ES6 Module默认目前还没有被浏览器支持,需要使用babel。

可以在script标签中使用tpye="module"在同域的情况下可以解决(非同域情况会被同源策略拦截,webstorm会开启一个同域的服务器没有这个问题,vscode貌似不行)

ES6 Module使用import关键字导入模块,export关键字导出模块,它还有以下特点

  • ES6 Module是静态的,也就是说它是在编译阶段运行,和var以及function一样具有提升效果(这个特点使得它支持tree shaking)
  • 自动采用严格模式(顶层的this返回undefined)
  • ES6 Module支持使用export {<变量>}导出具名的接口,或者export default导出匿名的接口这两者的区别是,export导出的是一个变量的引用,export default导出的是一个值 目前为止主流的模块化方案ES6 Module和CommonJs的一些区别
  • CommonJs输出的是一个值的拷贝,ES6 Module通过export输出的是一个变量的引用,export default输出的是一个值
  • CommonJs运行在服务器上,被设计为运行时加载,即代码执行到那一行才回去加载模块,而ES6 Module是静态的输出一个接口,发生在编译的阶段
  • CommonJs在第一次加载的时候运行一次并且会生成一个缓存,之后加载返回的都是缓存中的内容

关于ES6 Module静态编译的特点,导致了无法动态加载,但是总是会有一些需要动态加载模块的需求,所以现在有一个提案,使用把import作为一个函数可以实现动态加载模块,它返回一个Promise,Promise被resolve时的值为输出的模块。Vue中路由的懒加载的ES6写法就是使用了这个技术,使得在路由切换的时候能够动态的加载组件渲染视图

函数默认值

ES6允许在函数的参数中设置默认值,当传入的参数为undefined时才使用函数的默认值(显式传入undefined也会触发使用函数默认值,传入null则不会触发)

如果使用了函数默认参数,在函数的参数的区域(括号里面),它会作为一个单独的块级作用域,并且拥有let/const方法的一些特性,比如暂时性死区

function bar (func = () => foo) {
    let foo = 'inner'
    console.log(func())
}
bar() //ReferenceError : foo is not defined

在这里,func的默认值为一个函数,执行后返回foo变量,而在函数内部执行的时候,相当于对foo变量的一次变量查询(LHS查询),而查询的起点是这个单独的块级作用域,即JS解释器不会去查询去函数内部查询变量foo,而是沿着词法作用域先查看同一作用域(前面的函数参数)中有没有foo变量,再往函数的外部寻找foo变量,最终找不到所以报错了,这个也是函数默认值的一个特点

函数默认值配合解构赋值

function func({x = 10} = {}, {y} = {y:10}) {
    console.log(x, y)
}
func({},{}) //10, undefined
func(undefined, {}) // 10 undefined
func(undefined, undefined) // 10,10
func() //10,10
func({x:1},{y:2}) //1,2
  • 第一行给func函数传入了2个空对象,所以函数的第一第二个参数都不会使用函数默认值,然后函数的第一个参数会尝试解构对象,提取变量x,因为第一个参数传入了一个空对象,所以解构不出变量x,但是这里又在内层设置了一个默认值,所以x的值为10,而第二个参数同样传了一个空对象,不会使用函数默认值,然后会尝试解构出变量y,发现空对象中也没有变量y,但是y没有设置默认值所以解构后y的值为undefined
  • 第二行第一个参数显式的传入了一个undefined,所以会使用函数默认值为一个空对象,随后和第一行一样尝试解构x发现x为undefined,但是设置了默认值所以x的值为10,而y和上文一样为undefined
  • 第三行2个参数都会undefined,第一个参数和上文一样,第二个参数会调用函数默认值,赋值为{y:10},然后尝试解构出变量y,即y为10
  • 第四行和第三行相同,一个是显式传入undefined,一个是隐式不传参数
  • 第五行直接使用传入的参数,不会使用函数默认值,并且能够顺利的解构出变量x,y

Proxy

Proxy作为一个"拦截器",可以在目标对象前架设一个拦截器,他人访问对象,必须先经过这层拦截器,Proxy同样是一个构造函数,使用new关键字生成一个拦截对象的实例,ES6提供了非常多对象拦截的操作,几乎覆盖了所有可能修改目标对象的情况(Proxy一般和Reflect配套使用,前者拦截对象,后者返回拦截的结果,Proxy上有的的拦截方法Reflect都有)

let obj = {}
obj = new Proxy(obj, {
    set(target, key, val) {
        console.log('oops')
        return Reflect.set(target, key, val)
    }
})
obj.foo = 'bar' //'oops

提到Proxy就不得不提一下ES5中的Object.defineProperty,这个api可以给一个对象添加属性以及这个属性的属性描述符/访问器(这2个不能共存,同一属性只能有其中一个),属性描述符有configurable,writable,enumerable,value这4个属性,分别代表是否可配置,是否只读,是否可枚举和属性的值,访问器有configurable,enumerable,get,set,前2个和属性描述符功能相同,后2个都是函数,定义了get,set后对元素的读写操作都会执行后面的getter/setter函数,并且覆盖默认的读写行为

个人理解Proxy是Object.defineProperty的增强版,ES5只规定能够定义属性的属性描述符或访问器.而Proxy增强到了13种,具体太多了我就不一一放出来了,这里我举几个比较有意思的例子

  • handler.apply

apply可以让我们拦截一个函数(JS中函数也是对象,Proxy也可以拦截函数)的执行,我们可以把它用在函数节流中 我们可以把它用在函数节流中

const proxy = (func, time) => {
    let previous = new Date(0).getTime()
    let handler = {
        apply(target, context, args) {
            let now = new Date().getTime()
            if (now - previous > time) {
                previous = now
                Reflect.apply(func, context, args)
            }
        }
    }
    return new Proxy(func, handler)
}
  • handler.contruct

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

function proxy(func) {
    let instance
    let handler = {
        construct(target, args) {
            if (!instance) {
                // 没有实例就创建一个
                instance = Reflect.construct(func, args)
            }
            return instance
        }
    }
    return new Proxy(func, handler)
}
function Person(name, age) {
    this.name = name
    this.age = age
}
const SingletonPerson = proxy(Person)
let person1 = new SingletonPerson('bai', 22)
let person2 = new SingletonPerson('qiao', 22) //这个实例不会生成,会返回person1
console.log(person1 === person2)  //true

这里通过一个闭包保存了instance变量,每次使用new关键字调用被拦截的函数后都会查看这个instance变量,如果存在就返回闭包中保存的instance变量,否则就新建一个实例,这样可以实现全局只有一个实例

  • handler.defineProperty

defineProperty可以拦截对这个对象的Object.defineProerty操作注意对象内部的默认的[[SET]]操作(即对这个对象的属性赋值)会间接触发defineProperty和getOwnPropertyDescriptor这2个拦截方法

function onChange(obj, callback) {
    const handler = {
        get(target, key) {
            try {
                return new Proxy(target[key], handler)
            } catch (e) {
                Reflect.get(target, key)
            }
        },
        defineProperty(target, key, descriptor) {
            callback()
            return Reflect.defineProperty(target, key, descriptor)
        }
    }
    return new Proxy(obj, handler)
}
let obj = onChange({}, () => {
    console.log('oops')
})
obj.a = {} //oops
obj.a.b = 1 //oops
    • 使用了递归的操作,当需要访问对象的属性时候,会判断代理的对象属性的值仍是一个可以代理的对象就递归的进行代理,否则通过错误捕获执行默认的get操作
    • 定义了defineProperty的拦截方法,当对这个代理对象的某个属性进行赋值的时候会执行对象内部默认的[[SET]]操作进行赋值,这个操作会间接触发defineProperty这个方法,随后会执行定义的callback函数
  • Vue 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内部使用Object.defineProperty进行的数据劫持,而这个API无法探测到对象根属性的添加和删除,以及直接给数组下标进行赋值,所以不会通知渲染watcher进行视图更新,而理论上这个API也无法探测到数组的一系列方法(push,splice,pop),但是Vue框架修改了数组的原型,使得在调用这些方法修改数据后会执行视图更新的操作

3.0将带来一个基于 Proxy 的 observer 实现,它可以提供覆盖语言 (JavaScript——译注) 全范围的响应式能力,消除了当前 Vue 2 系列中基于 Object.defineProperty 所存在的一些局限,如: 对属性的添加、删除动作的监测 对数组基于下标的修改、对于 .length 修改的监测 对 Map、Set、WeakMap 和 WeakSet 的支持

Object.assign

这个ES6新增的Object静态方法允许我们进行多个对象的合并

  • Object.assign是浅拷贝,对于值是引用类型的属性,拷贝仍旧的是它的引用
  • 可以拷贝Symbol属性
  • 不能拷贝不可枚举的属性
  • Object.assign保证target始终是一个对象,如果传入一个基本类型,会转为基本包装类型,null/undefined没有基本包装类型,所以传入会报错
  • source参数如果是不可枚举的数据类型会忽略合并(字符串类型被认为是可枚举的,因为内部有iterator接口)
  • 因为是用等号进行赋值,如果被赋值的对象的属性有setter函数会触发setter函数,同理如果有getter函数,也会调用赋值对象的属性的getter函数(这就是为什么Object.assign无法合并对象属性的访问器,因为它会直接执行对应的getter/setter函数而不是合并它们,如果需要合并对象属性的getter/setter函数,可以使用ES7提供的Object.getOwnPropertyDescriptors和Object.defineProperties这2个API实现)

和ES9的对象扩展运算符对比

ES9支持在对象上使用扩展运算符,实现的功能和Object.assign相似,唯一的区别就是在含有getter/setter函数的对象的属性上有所区别

ES9:

  • 会合并2个对象,并且只触发2个对象对应属性的getter函数
  • 相同属性的后者覆盖了前者,所以a属性的值是第二个getter函数return的值

ES6:

  • 同样会合并这2个对象,并且只触发了obj上a属性的setter函数而不会触发它的getter函数(结合上述Object.assgin的内部实现理解会容易一些)
  • obj上a属性的setter函数替代默认的赋值行为,导致obj2的a属性不会被复制过来

使用建议

  1. Vue中重置data中的数据
  2. 给对象合并需要的默认属性
  3. 在传参的时候可以多个数据合并成一个对象传给后端