understanding es6

568 阅读1小时+

title: 【翻译】理解 ES6(Understanding ECMAScript 6)
date: <2019-06-16 Sun>
updated: <2019-06-16 Sun>
comments: true
tags:

  • javascript
  • es6

categories: ecmascript
layout: post
permalink:
top: 10
copyright: true


{% note info %}
原书:leanpub.com/understandi…

该书为个人为学习而翻译的,文中几乎所有代码都是来自原书或在原书代码基础上修改而来。

该原文地址:blog.gcl666.com/2019/06/23/… {% endnote %}

简介

JavaScript 核心特性在 ECMA-262 标准中被定义,也叫做 ECMAScript ,我们所熟知的在浏览器端和 Node.js 实际上是 ECMAScript 的一个超集。

ES6 演变之路

1999 发布 v3

1999.TC39 年发布了 ECMA-262 第三版。

直到 2007 之前都没有任何变化。

2007 发布 v4, v3.1

2007 年发布了第四版,包含以下特性:

  • 新语法(new syntax)
  • 模块(modules)
  • 类概念(classes)
  • 类继承概念(classical inheritance)
  • 对象私有属性(private object members)
  • 更多类型
  • 其他

由于第四版涉及的内容太多,因此造成分歧,部分成员由此创建了

3.1 版本,只包含少部分的语法变化,聚焦在:

  • 属性
  • 原生 JSON 支持
  • 已有对象增加方法

但是两拨人在 v4 和 v3.1 版本之间并没有达成共识,导致最后不了了之。

2008 JavaScript 创始人决定

Brendan Eich 决定将着力于 v3.1 版本。

最后 v3.1 作为 ECMA-262 的第五个版本被标准化,即: ECMASCript 5

2015 年发布 ECMAScript 6 也叫 ECMAScript 2015

即本书要讲的内容(ES6)。

块级绑定(var, let, const)

Var 声明和提升

使用 var 来声明变量时,在一个作用域内,无论它在哪里声明的,都会被升到到该作用域的顶部。

比如:

function getValue(condition) {
  // 比如: var value; // undefined

  if (condition) {
    // 虽然在这里声明的,其实会被提升到函数顶端
    var value = 'blue'

    // code

    return value
  } else {
    // 这里依旧可以访问变量 `value` 只不过它的值是 `undefined`
    return null
  }
}

console.log(getValue(false)) // 'null'

上面的 getValue 相当于下面的变量声明版本(提升之后):

function getValue(condition) {
  var value; // undefined

  if (condition) {
    value = 'blue'

    // code

    return value
  } else {
    return null
  }
}

console.log(getValue(false)) // 'null'

+RESULTS:

null

块级声明 let/const 声明

块级作用域,如:函数,*{ … }* 大括号,等等都属于块级作用域,在该作用域下使用 let 声明的变量只在

该作用域下可访问。

声明提升问题

let 声明不会被提升,但是也有另一种说法是 let 会提升,并且在如果在提升处到赋值的中间范围内使用了该变量,

会使该区域成为一块临时死区(TDZ)。

在声明之前使用 let 变量:

VM88:4 Uncaught ReferenceError: Cannot access 'value' before initialization

function getValue(cond) {

  if (cond) {
    console.log(value)
    let value = 'blue'

    // code

    return value
  } else {
    // value 在该作用域不存在

    return null
  }

  // value 在该作用域不存在
}

getValue(true)

不能重复声明

使用 var 的时候是可以重复声明的:

var count = 39; var count;

这样是不会有问题的,只不过它的声明只会被记录一次而已,即只会记录 var count = 39; 这里声明,但是不会出现异常。

如果使用 let 就不一样了,如果出现重复声明则会异常:

var count = 39;let count;

异常结果:*SyntaxError: Identifier 'count' has already been declared*

两者差别

let 声明的值可变,const 声明的是个常量,值是不能发生改变的。

let name = 'xxx';

name = 'yyy'; // ok

const age = 100;

age = 88; // error

临时死区(TDZ)

使用 let/const 声明的变量,任何时候试图在其声明之前使用变量都会抛出异常。

即使是在声明之前使用 typeof 也会出现引用异常(ReferenceError)。

if (true) {
  console.log(typeof value)
  let value = 'blue'
}

img

循环中使用块级声明

我们都知道使用 var 声明的变量是不存在块级作用域的,即在 if/for 的 {} 作用域内使用 var

声明的变量其实是该全局作用下的全局变量。

比如:我们常见的 for 循环中的 i 的值

for (var i = 0; i < 10; i++) {
  // ...
}

console.log(i) // 10

+RESULTS:

10

结果为 10 表明在 console.log(i) 处是可以访问 i 变量的,因为 var i = 0; 的声明

被提升成了全局变量,即循环体中使用的一直是这一份全局变量。

如果是同步代码,可能没什么问题,但要是异步代码就会出现问题,如下结果:

for (var i = 0; i < 5; i++) {
  setTimeout(() => console.log(i))
}

+RESULTS:

5
5
5
5
5

很遗憾最后结果都成了 5,因为循环体是个异步代码 setTimeout

解决方法有:

  • 闭包:

形成一个封闭的作用域,将当前的 i 值传递进去。

for (var i = 0; i < 5; i++) {
  (v => {
    // 这里的 v 值即传递进来的当前次循环的 i 的值
    setTimeout(() => console.log(v))
  })(i)
}

+RESULTS:

0
1
2
3
4
  • let

每次循环相当于新创建了一个变量,因此变量的值都得以保存。

for (let i = 0; i < 5; i++) {
  setTimeout(() => console.log(i))
}

+RESULTS:

0
1
2
3
4

全局作用域声明

var, let, const 另一个区别是在全局环境下的声明作用域也是不一样,

我们都知道在全局作用域下使用 var 声明的话,浏览器端是可以通过 window.name 来访问该变量的,但是 let, const 却不行。

var age = 100

let name = 'xxx'

console.log(window.name)
console.log(window.age)

结果:

img

浏览器端作用域:

img

结论:

无论 let 在那里声明的它都是个块级作用域变量,只在其声明到该作用域之后才能使用。

var 声明的始终相对于当前作用域下是全局变量。

总结(var, let, const)

在 es6 之后尽量使用 let 和 const 去声明变量,严格控制变量的作用域。

  1. var 变量声明会提升,可重复声明,且在该作用域内为全局变量
  2. let/const 变量声明不会提升,不可重复声明,局部变量,且在 DTZ 范围内使用即使是 typeof 也会报错
  3. let/const 区别在于 const 声明的变量值不能发生改变
关键词 提升 作用域 值属性
var 有提升,声明提升(命名函数定义也提升) 范围内全局 可变
let 无提升 局部变量,作用域内声明处开始往下 可变
const 无提升 局部变量,作用域内声明处开始往下 不可变

字符串和正则表达式

更好的 Unicode 编码支持

UTF-16 编码

新增 str.codePointAt(n) 和 String.fromCodePoint(str)

已有的编码查询函数: str.charCodeAt 和 String.fromCodeAt 用来应对单字符一个字节的情况。

新增的两个函数可以处理单个字符串占两个字节的大小,比如一些特殊字符“𠮷”需要用到两个字节来存储。

即 2bytes = 16bits 大小。

charCodeAt 和 fromCodeAt 是以一个字节为单位来处理字符串的,因此如果遇到这些字就没法正常处理。

var name = '𠮷'

console.log(name.charCodeAt(0))
console.log(name.codePointAt(0))
console.log(String.fromCharCode(name.charCodeAt(0)))
console.log(String.fromCodePoint(name.codePointAt(0)))

+RESULTS:

55362
134071
�
𠮷

可以看到如果我们还用原来的函数 charCodeAt 和 fromCharCode 去处理这个字得到结果是不正确的。

normalize() 函数

参考链接:www.cnblogs.com/hahazexia/p…

repeat(n) 函数

将一个字符串重复 n 次后返回。

var c = 'x'

var b = c.repeat(3)

console.log(b, c, b === c)

+RESULTS:

xxx x false

正则表达式

y 标记

模板字符串

基本语法

let msg = `hello world`

console.log(msg)
console.log(typeof msg)
console.log(msg.length)

+RESULTS:

hello world
string
11

如果需要用到反引号,则需要使用转义字符: \`

多行字符串

避免一行太长,进行换行书写,但是不影响最终结果显示在一行,可以使用反斜杠

var msg = `multiline \
string`

console.log(msg)

+RESULTS:

multiline string

多行字符串情况:

var msg = "multiline \n string"

console.log(msg)

+RESULTS:

multiline
 string

使用模板字符串,会按照模板字符串中的格式原样输出,而不再需要显示使用 `\n` 来进行换行:

var msg = `multiline
string`

console.log(msg)

+RESULTS:

multiline
string

在模板字符串中空格也会是字符串的一部分

var msg1 = `multiline
   string`

var msg2 = `multiline
string`

console.log(`len1: ${msg1.length}`)
console.log(`len2: ${msg2.length}`)

+RESULTS:

len1: 19
len2: 16

所以在书写模板字符串的时候必须慎重使用缩进。

模板字符串插值

var name = 'xxx'
const getAge = () => 100

console.log(`my name is ${name}`) // 普通字符串
console.log(`3 + 4  = ${3 + 4}`) // 可执行计算
console.log(`call function to get age : ${getAge()}`) // 可调用函数

+RESULTS:

my name is xxx
3 + 4  = 7
call function to get age : 100

标签模板

允许使用标签模板,该标签对应的是一个函数,后面的模板字符串会被解析成参数传递给该函数去进行处理,最后返回处理的结果。

比如: let msg = tag`Hello World`

定义标签:

function tag(literals, ...substitutions) {
  // 返回一个字符串
}

示例:

let count = 10,
    price = 0.25,
    msg = passthru`${count} items cost $${(count * price).toFixed(2)}.`

function passthru(literals, ...subs) {
  console.log(literals.join('--'))
  console.log(subs)

  // 将结果拼起来

  return subs.map((s, i) => literals[i] + subs[i]).join('')
    + literals[literals.length - 1]
}

console.log(msg)

+RESULTS:

-- items cost $--.
[ 10, '2.50' ]
10 items cost $2.50.

从结果可以看到,标签函数参数的内容分别为:

  1. literals 被插值({})分割成的字符串数组,比如上例的结果为: `["", " items const", "."]`
  2. subs 为插值计算的结果值作为第2, … 第 n 个参数传递给了 passthru

标签模板原始值(String.raw())

有时候需要在模板字符串中直接使用带有转义字符的内容,比如: `\n` 而不是使用其转义之后的含义。

这个时候则可以使用新增的内置 tag 函数来处理。

比如:

let msg1 = `multiline\nstring`
let msg2 = String.raw`multileline\nstring`

console.log(msg1)
console.log(msg2)

+RESULTS:

multiline
string
multileline\nstring

可看到在我们使用 String.raw 之后的 \n 并没有被转义成换行符,而是按照其原始的样子输出。

如果在不适用内置的 Strng.raw 该怎么做?

function raw(literals, ...subs) {

  // 将结果拼起来

  return subs.map((s, i) => literals.raw[i] + subs[i]).join('')
    + literals.raw[literals.length - 1]
}

let msg = raw`multiline\nstring`

console.log(msg)

+RESULTS:

multiline\nstring

nodejs 环境可能看起来不直观,通过下图我们来直观的查看下标签函数是怎么处理带转义字符的字符串的:

img

会发现其实 literals 的值依旧是转义之后的,看数组中第一个元素的字符串中是有一个回车标识的。

此外该数组对象本身上面多了一个 raw 属性,其值为没有转义的内容。

从这里我们得出,标签模板是怎么处理带转义字符串的模板的。

总结

  1. 完整的编码支持赋予了 JavaScript 处理 UTF-16 字符的能力(通过 codePointAt()String.fromCodePoint() 来转换)
  2. u 新增的标记使得正则表达式可以通过码点来代替 UTF-16 字符
  3. normalize()
  4. 模板字符串,支持原始字符串,插值支持计算表达式或函数调用
  5. 标签模板,第一个参数为分割后的字符串列表,后面的参数分别为插值结果
  6. 转义标签模板,转义标签的第一个参数数组对象上包含一个 raw 数组,其中包含了原始值列表

函数

参数默认值

function makeRequest(url, timeout = 2000, callback = () => {}) {
  // ...
}

默认参数值是如何影响 arguments 对象的?

严格非严格模式下的 arguments

只要记住一旦使用了默认值,那么 arguments 对象的行为将发生改变。

在 ECMAScript5 的非严格模式下,arguments 对象的内容是会随着函数内部函数参数值得变化而发生变化的,也就是说它

并不是在调用函数之初值就固定了,比如:

function maxArgs(first, second) {
  console.log(first === arguments[0])
  console.log(second === arguments[1])
  first = 'c'
  second = 'd'
  console.log(first === arguments[0])
  console.log(second === arguments[1])
}

maxArgs('a', 'b')

+RESULTS:

true
true
true
true

从结果我们会发现,参数值发生变化也会导致 arguments 对象跟着变化,这种情况只会在非严格模式下产生,

在严格模式下, arguments 对象是不会随着参数值改变而改变的。

function maxArgs(first, second) {
  'use strict';

  console.log(first === arguments[0])
  console.log(second === arguments[1])
  first = 'c'
  second = 'd'
  console.log(first === arguments[0])
  console.log(second === arguments[1])
}

maxArgs('a', 'b')

+RESULTS:

true
true
false
false

喏,后面结果为 false

带默认参数值情况下 arguments

在 es6 之后,arguments 的行为和之前严格模式下是一样的,即不会映射参数值得变化。

  1. 带默认值得参数,如果在调用的时候不传递,是不会计入到 arguments 对象当中

    即 arguments 的实际个数是根据调用的时候所传递的参数个数来决定的。

  2. arguments 对象不再响应参数值得变化

function mixArgs(first, second = 'b') {
  console.log(arguments.length)
  console.log(first === arguments[0]) // true
  console.log(second === arguments[1]) // false
  first = 'c'
  second = 'd'
  console.log(first === arguments[0]) // false
  console.log(second === arguments[1]) // false
}

mixArgs('a')

+RESULTS:

1
true
false
false
false

默认参数表达式

参数默认值不仅可以使用静态值,还可以赋值为调用函数的结果

function getValue() {
  console.log('get value...')
  return 5
}

function add(first, second = getValue()) {
  return first + second
}

console.log(add(1, 1)) // 2
console.log(add(1)) // 6

+RESULTS:

2
get value...
6

从结果显示:

  1. 如果 second 没传,会在调用 add() 时候执行 getValue() 获取默认值
  2. 如果传递了 second,那么 getValue() 是不会被执行的

即在默认参数中调用的函数,是由在调用时该对应的函数参数是否有传递来决定是否调用。

而不是传递了 second,先调用 getValue() 得到值,然后用传递的 second 值去覆盖。

也就是说 getValue() 返回的值不用每次都一样,是可以在每次调用的时候发生变化的,比如:

var n = 5

function getValue() {
  return n++
}

function add(first, second = getValue()) {
  return first + second
}

console.log(add(1, 1)) // 2
console.log(add(1)) // 6
console.log(add(1)) // 7

+RESULTS:

2
6
7

由于上面的特性,参数默认值可以是动态的,因此我们可以将前面参数值作为后面参数的默认值来使用,

比如:

function add(first, second = first) {
  return first + second
}

console.log(add(1, 1)) // 2
console.log(add(1)) // 2

+RESULTS:

2
2

甚至还可以将 first 作为参数传递给 getValue(first) 获取新值作为默认值来用。

默认参数值的临时死区(TDZ)

这里临时死区的意思是指,第二个参数在使用之前未进行声明,因为参数的声明相当于使用了 let

根据 let 的特性,在为声明之前使用属于在 TDZ 范围,会抛异常。

实例:

function add(first = second, second) {
  return first + second
}

console.log(add(1, 1)) // 2

try {
  add(undefined, 1) // error
} catch (e) {
  console.log(e.message)
}

+RESULTS:

2
second is not defined

既然都存在 TDZ 那为什么第一次调用就没事了,下面来分析下看看:

记住上一节所讲的:

默认值的调用(如: getValue() )只有在参数未传递的情况下才会发生,这里 first=second 的情况依旧适用。

那么将这句话应用到这里:

  1. add(1, 1) 这里 first 传递了 1

    那么 first 在 add 被调用的时候会被初始化成 1,根据上面那句话,即此时 first=second 这句相当于并没有被执行

    因此就不会去检测 second ,也就不会出现未定义了,从而能得出正确结果:2。

  2. add(undefined, 1) 传递了 `undefined` 相当于没传这个参数,只是占了个位

    那么既然没传, first=second 就会被执行, second 就会被检测是否定义,然而检测的结果就是“未定义”,

    因此抛出异常。

将 add 函数参数的变化用下来转声明来表示,问题就会更明显了:

// add(1, 1)

let first = 1 // first = second 未执行,不检测
let second = 1

// add(undefined, 1)
let first = second // 这句被执行,相当于这里提前使用了 second 变量,let 特性生效
let second = 1

{% note warning %}
函数参数是有它自己的作用域和TDZ的,并且和函数体作用域是区分开的,

这就意味着函数参数是无法访问函数体内的任何变量的,因为根据就是两个不同的作用域。
{% endnote %}

未命名参数

为什么会存在未命名参数?

因为 JavaScript 是没有限制调用函数的时候传递参数个数的。

比如:声明了一个函数 function add() {} 没任何参数,但是调用的时候是可以这样的 add(1, 2, 3, ...)

那么这些调用的时候传递给 add 的参数对应的函数参数就叫做未命名参数。

function add() {
  let n = 0
  ;[].slice.call(arguments).forEach(v => n += v)

  return n
}

console.log(add(1, 2, 3, 4, 5))

+RESULTS:

15

参数展开符(…)

未命名参数一般很少使用,因为这让使用者会很迷惑该函数的作用,因此参数没任何明显特征表示它是干什么用的,

在 es6 中增加了一个展开符号(…),在函数参数中的作用是将传递进的参数列表合并成一个参数数组。

适用于一个函数参数个数未知的情况下使用。

比如:

function pick(object, ...keys) {
  // 这里 keys 会成为一个包含传入的其余参数值的数组
  let result = Object.create(null)

  console.log(arguments.length)
  for (let i = 0; i < keys.length; i++) {
    result[keys[i]] = object[keys[i]]
  }

  return result
}

const book = {
  author: 'xxx',
  name: 'yyy',
  pages: 300
}

const res = pick(book, 'author', 'name')

console.log(JSON.stringify(res))

+RESULTS:

3
{"author":"xxx","name":"yyy"}

利用 …keys 将传入的 ('author', 'name') 合并成了一个数组: ['author', 'name'] ,方便应对

函数参数个数可变的情况。

参数展开符两种异常使用情况

  1. 展开符参数必须是最后一个,不能在其后面还有其他参数

    比如: function add(n, ...vals, more) {} 这会出现异常

  2. 不能用在对象的 setter 函数上

实例:

const obj = {
  set name(...val) {}
}

img

function add(n, ...vals, more) {

}

img

参数展开符对 arguments 的影响

记住一点:

arguments 总是由函数调用时传递进来的参数决定

function checkArgs(...args) {
  console.log(args.length);
  console.log(arguments.length);
  console.log(args[0], arguments[0]);
  console.log(args[1], arguments[1]);
}

checkArgs("a", "b");

+RESULTS:

2
2
a a
b b

函数构造函数能力增强

在实际编码过程,我们很少直接使用 Function() 构造函数去创建一个函数。

比如这么使用:

// 参数:参数一名称 first, 参数二名称 second,... 最后一个是函数体
var add = new Function('first', 'second', 'return first + second')

console.log(add(1, 2))

+RESULTS:

3

在 es6 中对构造函数的使用能力增强了,给其赋予了更多的功能,比如

  1. 默认参数值
  2. 展开符
var add = new Function("first", "second = first",
                       "return first + second");

console.log(add(1, 1));     // 2
console.log(add(1));        // 2

var pickFirst = new Function("...args", "return args[0]");

console.log(pickFirst(1, 2));   // 1

+RESULTS:

2
2
1

展开符(…)

在之前我们在函数参数中用到了展开符,这个时候的用途是将参数合并成数组来用。

普通参数传递

我们一般调用函数的时候都是将参数逐个传递:

let v1 = 20,
    v2 = 30

console.log(Math.max(v1, v2))

+RESULTS:

30

这仅仅两个参数,比较好书写,一旦参数多了起来就比较麻烦,在 es6 之前的做法可以利用 Function.prototype.apply 去实现:

apply 传递多个参数

let vs = [1, 2, 3, 4, 5]

console.log(Math.max.apply(Math, vs))

+RESULTS:

5

因为 apply 会将数组进行展开作为函数的参数传递个调用它的函数。

es6 之后展开符传递

在 es6 之后我们将使用展开符去完成这项工作,让代码更简洁和便于理解。

let vs = [1, 2, 3, 4]

console.log(Math.max(...vs))

+RESULTS:

4

展开符,传统方式相结合

let vs = [1, 2, 3, 4]

console.log(Math.max(10, ...vs)) // 10
console.log(Math.max(...vs, 0)) // 4
console.log(Math.max(3, ...vs, 10)) // 10

+RESULTS:

10
4
10

函数名字属性

以往,由于函数的各种使用方式使 JavaScript 在识别函数的时候成为一种挑战,并且匿名函数的

频繁使用使得程序的 debugging 过程异常痛苦,经常造成追踪栈很难理解。

因此在 es6 中给所有函数添加了一个 name 属性。

{% note info %}
name 属性只是对函数的一种描述特性,并不会有实际的引用特性,也就是说

在实际编程中不可能通过函数的 name 属性去干点啥。
{% endnote %}

选择合适的名称

JavaScript 会根据函数的声明方式去给其选择合适的名称,比如:

function doSomething() {
  // ...
}

var doAnotherThing = function() {
  // ...
};

var doThirdThing = function do3rdThing() {

}

console.log(doSomething.name);          // "doSomething"
console.log(doAnotherThing.name);       // "doAnotherThing"
console.log(doThirdThing.name);       // "do3rdThing"

+RESULTS:

doSomething
doAnotherThing
do3rdThing
  1. 如果是命名函数式声明方式,则使用的就是它的名字作为 name 属性值,如: doSomething

  2. 如果是表达式匿名方式声明函数,则将使用表达式中左边的变量名称来作为 name 属性值,如: doAnotherThing

  3. 表达式命名方式声明函数,则将使用命名函数的名称作为 name 属性,如: doThridThing 的名字是: do3rdThing

{% note info %}
通过第三个输出可知,命名函数的优先级高于表达式的变量名。
{% endnote %}

name 属性的特殊情况

  1. 对象的函数名称,即该函数的名字
  2. 对象的访问器函数名称,通过 Object.getOwnPropertyDescriptor(obj, 'keyname') 获取访问器对象
  3. 调用 bind() 之后的函数名称,总是在原始函数名前加上 bound
  4. 使用 new Function() 创建的函数名称,总是返回 anonymous
var doSth = function() {}

var person = {
  get firstName() {
    return 'Nicholas'
  },

  sayName: function() {
    console.log(this.name)
  }
}

console.log(person.sayName.name) // sayName
// 访问器属性,只能通过 getOwnPropertyDescriptor 去获取
var descriptor = Object.getOwnPropertyDescriptor(person, 'firstName')
console.log(descriptor.get.name) // get firstName

// 调用 bind 之后的函数名称总是会在原始的函数名称之前加上 `bound fname`
console.log(doSth.bind().name) // bound doSth
console.log((new Function()).name) // anonymous

+RESULTS:

sayName
get firstName
bound doSth
anonymous

澄清函数双重目的

函数使用方式

  1. 直接调用,当做函数来使用 Person()

  2. 使用 new 的时候当做构造函数来使用创建一个实例对象

在 es6 之后为了搞清楚这两种使用方式,添加了两个内置属性: [[Call]][[Constructor]]

当当做函数直接调用时,其实内部是调用了 [[Call]] 执行了函数体,

当结合 new 来使用是,调用的是 [[Contructor]] 执行了以下步骤:

  1. 创建一个新的对象 newObj

  2. this 绑定到 newObj

  3. 将 newObj 对象返回作为该构造函数的一个实例对象

也就是说我们可以在构造函数中去改变它的行为,如果它没有显示的 return 一个合法的对象,

则会默认走 #3 步,如果我们显示的去返回了一个对象,那么最后得到的实例对象即这个显示返回的对象。

function Person1(name) {
  this.name = name || 'xxx'
}

// 没有显示的 return 一个合法对象
// 返回的是新创建的对象,并且 this 被绑定到这个心对象上
const p1 = new Person1('张三')

// 因此这里访问的 name 即构造函数中的 this.name
console.log(p1.name)

function Person2(name) {
  this.name = name || 'xxx'

  return {
    name: '李四'
  }
}

// 按照构造函数的使用定义,这里返回的是
// 显示 return 的那个对象: { name: '李四' }
const p2 = new Person2('张三')

// 因此这里输出的结果为:李四
console.log(p2.name)

+RESULTS:

张三
李四

{% note warning %}
并不是所有的函数都有 [[Constructor]] ,比如箭头函数就没有,因此箭头函数

也就不能被用来 new 对象实例。
{% endnote %}

判断函数被如何使用?

有时候我们需要知道函数是如何被使用的,是当做构造函数?还是单纯当做函数直接调用?

这个时候 instanceof 就派上用场了,它的作用是用来检测一个对象是否在当前对象的

原型链上出现过。

比如:在 es5 中强制一个函数只能当做构造函数来使用,一般这么做

function Person(name) {
  if (this instanceof Person) {
    this.name = name
  } else {
    throw new Error('必须使用 new 来创建实例对象。')
  }
}

var person = new Person('张三')

// 这种调用,内部的 `this` 被绑定到了全局对象
// 而全局对象并非 Person 原型链上的对象,因此会
// 执行 else 抛出异常
var notAPerson = Person('李四')

img

但是有一种直接调用的情况,不会走 else ,即通过 call 调用指定 person 实例为调用元。

function Person(name) {
  if (this instanceof Person) {
    this.name = name
  } else {
    throw new Error('必须使用 new 来创建实例对象。')
  }
}

var person = new Person('张三')

// 这样是合法的,请 this instanceof Person 成立
// 因为 Person.call(person, ...) 指定了作用域为实例对象 person
// 因此函数内部的 this 会被绑定到这个实例对象 person 上,
// 而 person 确实是 Person 的实例对象,因此不会报错
var notAPerson = Person.call(person, '李四')

正常运行的结果

+RESULTS:

undefined

因此,如果是 Person.call(person, ...) 这种情况调用,函数内部同样无法判断它的被使用方式是如何。

new.target 元属性

为了解决上一节的“函数调用方式”判断的问题, es6 中引入了 new.target 元属性。

{% note info %}
元属性:一个非对象的属性,用来为他的目标(比如: new )提供额外的相关信息。
{% endnote %}

new.target 的取值??

  1. 如果函数当做构造函数

    使用 new 来调用,内部调用 [[Constructor]] 的时候, new.target 会被填充为 new 操作符

    指定的目标对象,这个目标对象通常是执行内部构造函数的时候新创建的那个对象实例(在函数体重一般是 this )。

  2. 如果函数当做普通函数直接调用,那么 new.target 的值为 undefined

从上面两点,那么我们就可以通过在函数内部判断 new.target 来判断函数的使用方式了。

function Person(name) {
  if (typeof new.target !== 'undefined') {
    this.name = name
  } else {
    throw new Error('必须使用 new 创建实例。')
  }
}

var person = new Person('张三')
console.log(person.name, 'new')

var notAPerson = Person.call(person, '李四')
console.log(notAPerson.name, 'call')

img

由图中的输出证明上面 #1 和 #2 的结论,也由此结论我们可以直接使用 new.target === Person 作为判定条件。

函数外部使用 new.target :

function Person() {

}

if (new.target === Person) {
  // ...
}
console.log(new.target)

块级函数

<= es3 行为

在 es3 或更早些时候,在块级作用域中声明函数会出现语法错误,虽然在之后默认允许这样使用(不会报错了),但是

各个浏览器之间的处理方式依旧不同,因此在实际开发过程中,应该尽量避免这么使用,如果非要在块级作用域声明函数

可以考虑使用函数表达式方式。

es5 行为

另外,为了尝试去兼容这种怪异情况,在 es5 的严格模式下如果在块级作用域声明函数,会爆出异常。

'use strict';

if (true) {
  // 在 es5 中会报语法错误, es6 中不会
  function doSth() {}
}

es6 行为

在 es6 之后,这种函数声明将会变的合法,且声明之后 doSth() 就成了一个局部函数变量,即

只能在 if (true) { ... } 这个作用域内部访问,外部无法访问,比如:

'use strict';

if (true) {
  // 因为有提升,且命名函数的提升包含声明和定义都会被提升
  console.log(typeof doSth) // function
  function doSth() {}

  doSth()
}

// es6 之后存在块级作用域,因此 doSth 是个局部变量,在
// 它的作用域范围之外无法访问
console.log(typeof doSth); // undefined

+RESULTS:

function
undefined

决定什么时候该用块级函数

4.7.3 一节中使用的是命名式函数声明方式,这种方式声明和定义均被提升,因此在

声明处至上访问能得到正常结果。

如果使用表达式 + let 方式,则结果会和用 let 声明一样存在 TDZ 的问题。

'use strict';

if (true) {
  // TDZ 区域,访问会异常
  console.log(typeof doSth) // error

  let doSth = function () {}

  doSth()
}

console.log(typeof doSth) // undefined

img

因此,我们可以根据需求去决定该使用哪种方式去声明块级函数,如果需要有提升则应该使用“命名式函数”,

如果不需要提升,只需要在声明之后的范围使用应该使用“函数表达式”方式去声明函数。

非严格模式块级函数

在 es6 中的非严格模式下,块级函数的提升不再是针对块级作用域,而是函数体或全局环境。

// 相当于提升到了这里

if (true) {
  console.log(typeof doSth)

  // 非严格模式,全局提升
  function doSth() {}

  doSth()
}

console.log(typeof doSth) // function

+RESULTS:

function
function

结果显示外面的 typeof doSth 也是 'function' 。

因此,在 es6 之后函数的声明只需要区分严格或非严格模式,而不再需要考虑浏览器的兼容问题,相当于统一了标准。

箭头函数

箭头函数特性

在 es6 中引入了箭头函数,大大的简化了函数的书写,比如

声明一个函数: function run() {}

现在: const run = () => {} 或者 const getName = () => '张三'

虽然用起来方便了,但是箭头函数与普通函数又很大的不同,使用的时候必须要注意以下几点:

特性 说明
1 this 减少问题,便于优化
2 super
3 arguments 箭头函数必须依赖命名参数或 rest 参数去访问函数的参数列表
4 new.target 元属性 不能被实例化,功能无歧义,不需要这个属性
5 不能 new 实例化
6 无原型 因为不能用 new 因此也不需要原型
7 不能改变 this 指向 此时指向不再受函数本身限制
8 不能有重复的命名参数 之前非严格模式下普通函数是可以有的

{% note info %}
箭头函数中如果引用 arguments ,它指向的不再是该箭头函数的参数列表,

而是包含该箭头函数的那个非箭头函数的参数列表(4.8.6)。
{% endnote %}

没有 this 绑定主要有两点理由:

  1. 不易追踪,易造成未知行为,众多错误来源

    函数内部 this 的值非常不容易追踪,经常会造成未知的函数行为,箭头函数去掉它可以避免这些烦恼

  2. 便于引擎优化

    限制箭头函数内部使用 this 去执行代码也有利于 JavaScript 引擎更容易去优化内部操作,而不像

    普通函数一样,函数有可能会当做构造函数使用或其他用途。

{% note info %}
同样,箭头函数也有自己的 name 属性,用来描述函数的名称特征。
{% endnote %}

const print = msg => {
  console.log(arguments.length, 'arguments')
  console.log(this, 'this')
  console.log(msg)
}

console.log(print.name)

print('...end')

+RESULTS:

print
0 'arguments'
Object [global] {
// ... 省略
        { [Function: setImmediate] [Symbol(util.promisify.custom)]: [Function] } } 'this'
...end
undefined

因为是 nodejs 环境,因此 this 被绑定到了 global 对象上。

第二行输出结果是 0 'arguments' 说明已经不能使用 arguments 去正确获取传入的参数了。

箭头函数语法

箭头函数语法非常灵活,具体如何使用根据使用场景和实际情况决定。

比如:

var reflect = value => value; 直接返回原值

相当于

var reflect = function(value) { return value; }

当只有一个参数时刻省略小括号 ()

多个参数时候:

var sum = (n1, n2) => n1 + n2;

函数体更多内容时候:

var sum = (n1, n2) => {
  // do more...
  return n1 + n2;
}

空函数:

var empty = () => {}

返回一个对象:

var getTempItem = id => ({ id: id, name: 'Temp' })

等等。。。

箭头立即函数表达式

在 es6 之前我们要实现一个立即执行函数,一般这样:

let person = function(name) {
  return {
    getName: function() {
      return name
    }
  }
  // 直接在函数后面加上小括号即成为立即执行函数
}('张三')

console.log(person.getName()) // 张三

+RESULTS:

张三

PS: 但是为了代码可读性,建议给函数加上小括号。

箭头函数形式的立即执行函数,不可以直接在 } 后面使用小括号方式:

let person = ((name) => {
  return {
    getName: function() {
      return name
    }
  }
})('张三')


console.log(person.getName()) // 张三

+RESULTS:

张三

没有 this 对象

在之前我们经常遇到的一个问题写法是事件的监听回调函数中直接使用 this ,这将导致引用错误问题,

因为事件的回调属于被动触发的,而触发调用该回调的对象是不确定的,这就会导致各种问题。

var PageHandler = {

  id: "123456",

  init: function() {
    document.addEventListener("click", function(event) {
      // 这里用了 this ,意图是想在点击事件触发的时候去调用 PageHandler 的
      // doSomething 这个函数,但实际却是事与愿违的
      // 因为这里的 this 并非指向 Pagehandler 而是事件触发调用回调时候的那个目标对象
      this.doSomething(event.type);     // error
    }, false);
  },

  doSomething: function(type) {
    console.log("Handling " + type  + " for " + this.id);
  }
};

以往解决方法:通过 bind(this) 手动指定函数调用对象

var PageHandler = {

  id: "123456",

  init: function() {
    // 经过 bind 之后,回调函数的调用上下文就被绑定到了 PageHandler 这个对象
    // 真正绑定到 click 事件的函数其实是执行 bind(this) 之后绑定了上下文的一个函数副本
    // 从而执行能得到我们想要的结果
    document.addEventListener("click", (function(event) {
      this.doSomething(event.type);     // no error
    }).bind(this), false);
  },

  doSomething: function(type) {
    console.log("Handling " + type  + " for " + this.id);
  }
};

虽然问题是解决了,但是使用 bind(this) 无疑多创建了一份函数副本,多少都会有些奇怪。

然后,在 es6 之后这个问题就很好的被箭头函数解决掉:

根据箭头函数没有 this 绑定的特性,在其内部使用 this 的时候这个指向将是包含该箭头函数的非箭头函数

所在的上下文,即:

var PageHandler = {

  id: "123456",

  init: function() {
    document.addEventListener(
      "click",
      // 箭头函数无 this 绑定,内部使用 this
      // 这个 this 的上下文将有包含该箭头函数的上一个非箭头函数
      // 这里即 init() 函数,而 init() 函数的上下文为 PageHandler 对象
      // 也就是说这里箭头函数内部的 this 指向的就是 Pagehandler 这个对象
      // 从而让代码按照预期运行
      event => this.doSomething(event.type), false);
  },

  doSomething: function(type) {
    console.log("Handling " + type  + " for " + this.id);
  }
};

箭头函数和数组

在使用数组的一些内置函数时,我们经常会碰到需要传递一个参考函数给他们,比如,排序函数 Array.prototype.sort 就需要

我们传递一个比较函数用来决定是升序还是降序等等。

如果用箭头函数将大大简化代码:

// es6 之前
const values = [1, 10, 2, 5, 3]

var res1 = values.sort(function(a, b) {
  // 指定为升序
  return a - b;
})

// es6 之后
var res2 = values.sort((a, b) => a - b)
console.log(res1.toString(), res2.toString())

+RESULTS:

1,2,3,5,10 1,2,3,5,10

或者 map(), reduce() 等等用起来会更方便更简洁许多。

无参数绑定(arguments)

看实例:

function createArrowFunctionReturningFirstArg() {
  return () => arguments[0]
}

var arrowFunction = createArrowFunctionReturningFirstArg(5)

console.log(arrowFunction()) // 5

+RESULTS:

5

从结果看出,返回的 arrowFunction() 箭头函数调用的时候并没有传递任何参数,但是执行结果得到了结果

这个结果正是包含它的那个非箭头函数(createArrowFunctionReturingFirstArt())所接受的参数值。

因此箭头函数内部如果访问 arguments 对象,此时该对象指向的是包含它的那个非箭头函数的参数列表对象。

箭头函数的识别

跟普通函数一样, typeofinstanceof 对齐依然使用。

var comparator = (a, b) => a - b;

console.log(typeof comparator) // function
console.log(comparator instanceof Function) // true

+RESULTS:

function
true

4.8.1 一节提到过箭头函数是不能改变 this 指向的,但是

并不代表我们就完全不能使用 call, apply, bind

比如:

var sum = (n1, n2) => (this.n1 || 0) + n2

console.log(sum.call(null, 1, 2)) // 3
console.log(sum.call({ n1: 10 }, 1, 2)) // 3

+RESULTS:

2
2

从这个例子中可以验证,箭头函数是无法修改它的 this 指向的,如果可以修改

第二个结果值就应该是 12 而不是和第一个一样为 2 ,因为在第二个中

我们手动将 sum 执行上下文绑定到了一个新的对象上 {n1: 10}

{% note warning %}
也就是说,并非不能使用,而是用了也不会有任何变化而已。
{% endnote %}

使用 bind 保留参数:

var sum = (n1, n2) => n1 + n2

console.log(sum.call(null, 1, 2)) // 3
console.log(sum.apply(null, [1, 2])) // 3

// 产生新的函数,这种和普通函数使用方式一样
var boundSum = sum.bind(null, 1, 2)

console.log(boundSum())

+RESULTS:

3
3
3

尾调用优化

尾调用:将一个函数的调用放在两一个函数的最后一行。

或许在 es6 中对于函数相关的最感兴趣的改动就是引擎的优化了,它改变了函数的尾调用系统。

function doSth() {
  return doSthElse() // tail call
}

在 es6 之前,它和普通的函数调用一样被处理:创建一个新的栈帧然后将它推到调用栈的栈顶等待被执行

也就意味着之前的每一个栈帧都在内存里面保留着,如果调用栈过大那这将可能是问题的来源。

有什么不同?

在 es6 之后优化了引擎,包含尾调用系统的优化(严格模式下,非严格模式下依旧未发生改变)。

优化之后,不再会为尾部调用创建一个新的栈帧,而是将当前的栈帧情况,然后将其复用到尾部调用,前提是满足下面几个条件:

  1. 尾调用函数不需要访问当前栈帧中的任何变量(即尾调用的函数不能是闭包,闭包的作用就是用来持有变量)

  2. 即在尾调用的函数之后不能有其他的代码,即尾调用函数必须是函数体的最后一行

  3. 尾调用函数的调用结果要作为当前函数的返回值返回

比如:下面的函数就满足尾调用优化的条件

'use strict'; // 1. 严格模式

function doSth() {

  // 2. 没有引用任何内部变量,非闭包

  // 3. 最后一行

  // 4. 调用结果被作为 doSth 的返回值返回
  return doSthElse()
}

以下情况不会被优化:

'use strict';

function doSth() {
  doSthElse() // 返回作为返回值,不会优化
}

function doSth1() {
  return 1 + doSthElse() // 在尾调用函数返回之后不能有其他操作,不会优化
}

function doSth2() {
  var res = doSthElse()
  return res // 不是最后一行,即不是将结果立即返回,不会优化
}

function doSth3() {
  var num = 1,
      func = () => num

  return func() // 闭包,不会优化
}

如何利用尾调用优化?

尾调用最经典的莫过于递归调用了,比如斐波那契数列问题。

function factorial(n) {

  if (n <= 1) {
    return 1;
  } else {

    // 不会被优化,因为函数返回之后还需要进行乘积计算才返回
    return n * factorial(n - 1);
  }
}

console.log(factorial(10))

+RESULTS:

3628800

上面的并不会被优化,因为尾调用函数并不是立即返回的,修改如下:

function factorial(n, p = 1) {

  if (n <= 1) {
    return 1 * p;
  } else {

    let res = n * p
    // 被优化
    return factorial(n - 1, res);
  }
}


console.log(factorial(10))

+RESULTS:

3628800

尾调用优化应该是我们在书写代码的时候时常应该考虑的问题,尤其是书写递归的时候,当使用递归涉及到大量的计算的时候,

尾调用优化的优势将会很明显。

总结

选项 功能 描述 其他
arguments
ES6之前非严格模式 值会随着函数体内参数的改变而改变
ES6之前严格模式 不会响应改变,调用之初就定了
ES6之后行为统一 不会响应改变,内容由实际调用者传递个数决定
函数默认参数 可以是常量值 function add(f, s = 3) {}
可以是变量 var n = 10; function add(f, s = n) {}
可以是函数调用 function getVal() {}; function add(f, s = getVal) {}
默认值参数的执行 调用时有传递则不会检测或执行,未传递则会检测和执行
相互引用 后面的参数可以引用前面的参数变量 function add(f, s = f) {}
临时死区(TDZ)
参数 rest 符号 接受多个参数,合并成数组供函数内部使用 function add(f, ...a) {}
异常使用一 不能用在访问器函数 obj = { set name(...val) {} } 非法。
异常使用二 必须作为函数最后一个参数使用 function add(f, ...s, t) {} 非法。
对arguments影响 非箭头函数没什么影响 arguments总是由调用者传递的参数决定个数
构造函数 new Function() 可以使用默认值,rest符号等功能
展开符(…) 普通多参数函数 Math.max(1, 2, 3, 4, ...)
普通多参数函数apply Math.max.apply(Math, [1, 2, 3, 4])
ES6展开符 Math.max(...[1, 2, 3, 4, ...])
name 属性 函数名称 仅辅助描述功能,易于跟踪函数
特殊情况: 访问器函数 get fnName
特殊情况:bind() 函数 bound fnName
特殊情况:new Function() 匿名函数 anonymous
new.target 函数可直接调用可new构造实例 因此造成函数内部如何识别使用释放问题?
如果作为函数调用 [[Call]] new.target = undefined
如果是 new 构造函数 [[Constructor]] new.target = Person 构造函数本身
块级函数 在 es6之情块级函数的声明处理并没有统一 严格模式必出异常,非严格不好说
es6之后统一标准 严格模式:块级函数只是局部函数 只在作用域内有效
非严格模式:块级函数会提升到函数顶部或全局环境 全局或函数体生效
箭头函数特性 this 不易追踪,易于引擎优化 内部可以使用,但是它指向的是当前箭头函数所在的非箭头函数所在的上下文
super 没有原型,继承等,不需要 super
arguments 内部访问的该对象,其实是当前环境函数的参数,而非箭头函数本身的参数列表
new.target 不支持 new 就不存在使用方式问题
无原型 不支持 new
不能改变 this 指向 其内部的 this 已经不是它管辖,可以调用 call, apply, bind 之流,但是不会有任何作用
不能有重复命名参数 非严格模式下ES6之前的普通参数可以用
箭头函数语法 使用方式灵活多变
立即表达式 必须括号包起来再执行,普通函数可直接在 } 后执行 (() => {})(), function(name){}('xxx')
typeof, instanceof 对箭头函数依旧有效, typeof fn = 'function', fn instanceof Function (true)
尾调用优化 必须满足三个条件 不满足条件不会优化,典型的递归调用
1. 非闭包,尾函数体内不能访问正函数体内任何变量
2. 结果值必须立即返回,不能参与其他计算后再返回
3. 必须是正函数的最后一个语句
优化之前 尾函数新建栈帧,放在调用栈顶等待调用
优化之后 清空调用栈,将它作为尾调用函数的栈帧复用

对象扩展

对象分类

类型 说明
普通对象(Ordinary) 拥有所有对象的默认行为
异类对象(Exotic) 和默认行为有所差异
标准对象(Standard) 那些由 ECMAScript 6 定义的,如: Array, Date 等等
内置对象(Built-in) 脚本当前执行环境中的对象,所有标准对象都是内置对象

对象字面量(literal)语法扩展

字面量语法在 JavaScript 中使用非常普遍

  1. 书写方便
  2. 简洁易懂
  3. JSON 就是基于字面量语法演变而来

es6 的来到是的对象字面量语法更加强大简洁易用。

对象属性简写

<= es5:

function createPerson(name, age) {
  return {
    name: name,
    age: age
  }
}

es6:

function createPerson(name, age) {
  return {
    name,
    age
  }
}

简洁函数写法

<= es5:

var person = {
  name: '张三',
  sayName: function() {
    console.log(this.name)
  }
}

es6:

var person = {
  name: '张三',
  sayName() {
    console.log(this.name)
  }
}

计算属性

在 es6 之前书写对象字面量的时候,可以直接使用多个字符串组成的字符串作为 key ,但是这种方式在实际使用中

是非常不方便的,假如说 key 是个很长的串呢??

var person = {
  'first name': '张三'
}

console.log(person['first name']) // 张三

+RESULTS:

张三

因此, es6 中支持了变量作为对象属性名去访问,根据变量的值动态决定使用什么 key 去访问对象的属性值,

这样不管 key 多长,只需要使用变量将它存储起来,直接使用变量名去使用将更加方便。

var person = {},
    lastName = "last name";

person["first name"] = "张三";
person[lastName] = "李四";

console.log(person["first name"]);      // "张三"
console.log(person[lastName]);          // "李四"

+RESULTS:

张三
李四

支持表达式计算属性名:

var suffix = ' name'

var person = {
  ['first' + suffix]: '张三',
  ['last' + suffix]: '李四'
}

console.log(person['first name']) // 张三
console.log(person['last name']) // 李四

+RESULTS:

张三
李四

新方法

方法 功能 其他
Object.is(value1, vlaue2) 比较两个值是否是同一个值 能弥补 === 无法判断 (+0, -0), (NaN, NaN) 问题
Object.assign(target, ...sources) 合并拷贝对象属性 自身且 enumerable: true 的属性

Object.is(value1, value2)

在以往我们判断两个值是否相等,经常使用的是 ===== ,一般推荐使用后者

因为前者会有隐式强转,会在比较之前将两个值进行强制转换成同一个类型再比较。

console.log('' == false) // true
console.log(0 == false) // true
console.log(0 == '') // true
console.log(5 == '5') // true
console.log(-0 == +0) // true
console.log(NaN == NaN) // true

+RESULTS:

true
true
true
true
true
false

对于 +0-0 使用 === 的结果是 true ,但实际上他们是有符号的,理论上应该是不相等的。

而两个 NaN 五路你是 ===== 都判定他们是不相等的。

为了解决这些差异, es6 中加入了 Object.is() 接口,意指将等式的判断更加合理化,它的含义是

两个值是否是同一个值。

我们看下各对值使用 Object.is() 比较的结果:

const is = Object.is
const log = console.log

// +0, -0
log('+0 == -0', +0 == -0)
log('+0 === -0', +0 === -0)
log('+0 is -0: ', is(+0, -0))

// NaN
log('NaN == NaN: ', NaN == NaN)
log('NaN === NaN: ', NaN === NaN)
log('NaN is NaN: ', is(NaN, NaN))

// number, string
log('5 == "5": ', 5 == '5')
log('5 == 5: ', 5 == 5)
log('5 === "5": ', 5 === '5')
log('5 === 5: ', 5 === 5)
log('5 is "5": ', is(5, '5'))
log('5 is 5: ', is(5, 5))

+RESULTS:

+0 == -0 true
+0 === -0 true
+0 is -0:  false
NaN == NaN:  false
NaN === NaN:  false
NaN is NaN:  true
5 == "5":  true
5 == 5:  true
5 === "5":  false
5 === 5:  true
5 is "5":  false
5 is 5:  true

因此, Object.is 能够弥补, === 无法判断出 +0, -0, NaN, Nan 相等的结果。

Object.assign(target, source, source1, source2, …)

参数:

  1. target 接受拷贝的对象,也将返回这个对象
  2. source 拷贝内容的来源对象
  3. 来源对象参数可以有多个,如果存在同名属性值,最后的值由最后一个拥有同名属性对象中的值为准

TC39.ECMA262 实现原理图:

img

合并对象,将 source 中自身的可枚举的属性浅拷贝到 target 对象中,返回 target 对象。

混合器(Mixins)在 JavaScript 中被广泛使用,在一个 mixin 中,一个对象可以从另个对象中

接受他们的属性和方法,即浅拷贝,许多 JavaScript 库都会有一个与下面类似的 mixin 函数:

const mixin = (receiver, supplier) => {
  Object.keys(supplier).forEach(
    key => receiver[key] = supplier[key])

  return receiver
}

function EventTarget() {}

EventTarget.prototype = {
  constructor: EventTarget,
  get name() {
    return 'EventTarget.prototype'
  },
  emit: function(msg) {
    console.log(msg, 'in EventTarget.prototype')
  },
  on: function(msg) {
    console.log(msg, 'on EventTarget.prototype')
  }
}


const myObj1 = {}
mixin(myObj1, EventTarget.prototype)

myObj1.emit('something changed from myObj1')
console.log(myObj1.name, 'obj1 name')

const myObj2 = {}
Object.assign(myObj2, EventTarget.prototype)

myObj2.on('listen from myObj1')
console.log(myObj2.name, 'obj2 name')

console.log(EventTarget.prototype, myObj1, myObj2)

+RESULTS:

something changed from myObj1 in EventTarget.prototype
EventTarget.prototype obj1 name
listen from myObj1 on EventTarget.prototype
EventTarget.prototype obj2 name

由于 mixin(), Object.assign 的实现都是采用的 = 操作符,因此是没法拷贝访问器属性的,或者说拷贝过来之后

就不会再是访问器属性了,看上面代码的运行结果对比图:

img

多个来源对象支持:

const receiver = {}
const res = Object.assign(receiver, {
  name: 'xxx',
  age: 100
}, {
  height: 180
}, {
  color: 'yellow',
  age: 80
})

console.log(receiver === res)
console.log(res)

+RESULTS:

true
{ name: 'xxx', age: 80, height: 180, color: 'yellow' }

最后 age: 80 值是最后一个来源对象中的值,返回值即第一个参数对象。

重复属性

<= es5 严格模式下,重复属性会出现语法错误:

'use strict';

var person = {
  name: 'xxx',
  name: 'yyy' // syntax error in es5 strict mode
}

es6 无论严格或非严格模式下都属合法操作,其值为最后一个指定的值:

'use strict';

var person = {
  name: 'xxx',
  name: 'yyy' // no error
}

console.log(person.name)

+RESULTS:

yyy

自有属性枚举顺序

<= es5 中是不会定义对象属性的枚举顺序的,它的枚举顺序是在实际运行时取决于所处的 JavaScript 引擎。

es6 中严格定义了枚举时返回的属性顺序,这将会影响在使用 Objct.getOwnPropertyNames()

Reflect.ownKeys 时属性该如何返回。a

枚举时基本顺序遵循:

  1. 所有数字类型的 keys 为升序排序

  2. 所有字符串类型的 keys 按照它添加的时机排序

  3. 所有符号类型(Symbols)的 keys 按照它添加的时机排序

三者的优先级为: numbers > strings > symbols

var obj = {
  a: 1,
  0: 1,
  c: 1,
  2: 1,
  b: 1,
  1: 1
}

obj.d = 1

console.log(Object.getOwnPropertyNames(obj).join('')) // 012acbd

+RESULTS:

012acbd

{% note warning %}
由于并非所有 JavaScript 引擎并非统一实现方式,导致 for-in 循环依旧无法确定枚举的顺序。

并且 Object.keys()JSON.stringify() 采用的枚举顺序和 for-in 一样。
{% endnote %}

var obj = {
  a: 1,
  0: 1,
  c: 1,
  2: 1,
  b: 1,
  1: 1
}

obj.d = 1

for (let prop in obj) {
  console.log(prop)
}

功能更强的原型对象

原型是 JavaScript 中实现继承的基石,早起的版本中严重限制了原型能做的事情,

然后随着 JavaScript 的逐渐成熟程序员们开始越来越依赖原型,我们现在能很清晰

地感受到开发者们对原型控制上和易用性的渴望越来越强烈,由此 ES6 对齐进行了加强。

改变对象原型

正常情况下,对象通过构造函数或 Object.create() 创建的同时原型也就被创建了。

ES5 中可以通过 Object.getPrototypeof() 方法去获取对象原型,但是依然

缺少一个标准的方式去获取失利之后的对象原型。

ES6 增加了 Object.setPrototypeof(source, target) 用来改变对象的原型指向,

指将 source.prototype 指向 target 对象。

let person = {
  getGreeting() {
    return "Hello";
  }
};

let dog = {
  getGreeting() {
    return "Woof";
  }
};

// prototype is person
let friend = Object.create(person);
console.log(friend.getGreeting());                      // "Hello"
console.log(Object.getPrototypeOf(friend) === person);  // true

// set prototype to dog
Object.setPrototypeOf(friend, dog);
console.log(friend.getGreeting());                      // "Woof"
console.log(Object.getPrototypeOf(friend) === dog);     // true

实际上,一个对象的原型是存储在它的内部属性 [[Prototype]] 上的, Object.getPrototypeOf()

获取的也是这个属性的值, Object.setPrototypeOf() 设置也是改变这个属性的值。

旧版原型的访问

比如:如果想在实例中重写原型的某个方法的时候,需要在重写的方法内调用原型方法时候,以往是这样搞

let person = {
  getGreeting() {
    return "Hello";
  }
};

let dog = {
  getGreeting() {
    return "Woof";
  }
};


let friend = {
  getGreeting() {
    return Object.getPrototypeOf(this).getGreeting.call(this) + ", hi!";
  }
};

// set prototype to person
Object.setPrototypeOf(friend, person);
console.log(friend.getGreeting());                      // "Hello, hi!"
console.log(Object.getPrototypeOf(friend) === person);  // true

// set prototype to dog
Object.setPrototypeOf(friend, dog);
console.log(friend.getGreeting());                      // "Woof, hi!"
console.log(Object.getPrototypeOf(friend) === dog);     // true

通过 Object.getPrototypeOf(this).getGreeting.call(this) … 去获取原型中的方法

通过 super 引用简化原型的访问

如之前所提,原型是 JavaScript 中一个很重要也很常用的一个对象,ES6 对他们的使用进行了简化。

另外 es6 对原型的另一个改变是 super 的引用,这让对象访问原型对象更加方便。

而在 es6 增加 super 之后就变得异常简洁了:

let friend = {
  getGreeting() {
    // in the previous example, this is the same as:
    // Object.getPrototypeOf(this).getGreeting.call(this)
    return super.getGreeting() + ", hi!";
  }
};

类似其他语言的继承, friend 是实例,它的原型是它的父类,在实例中的 super 其实是指向父类的引用

因此可以直接在子类中直接使用 super 去使用父类的方法。

只能在简写函数中访问 super

但是 super 只能在对象的简写方法中使用,如果是使用 “function” 关键词声明的函数中使用会出现

syntax error

比如:下面的方式是非法的

let friend = {
  getGreeting: function() {
    // syntax error
    return super.getGreeting() + ", hi!";
  }
};

因为 super 在这种函数的上下文中中不存在的。

Object.getPrototypeOf() 并不是所有场景都能使用的

因为 this 的指向是根据函数的执行上下文来决定了,因此使用 this 是完全靠谱的。

比如:

let person = {
  getGreeting() {
    return "Hello";
  }
};

// prototype is person
let friend = {
  getGreeting() {
    return Object.getPrototypeOf(this).getGreeting.call(this) + ", hi!";
  }
};
Object.setPrototypeOf(friend, person);


// prototype is friend
let relative = Object.create(friend);

console.log(person.getGreeting());                  // "Hello"
console.log(friend.getGreeting());                  // "Hello, hi!"
console.log(relative.getGreeting());                // error!

上面的 relative.getGreeting()) 会报错,原因是 relative 本身是个新的变量,

这个变量指向由 Object.create(friend) 创建的一个空对象,其原型为 friend

reletive.getGreeting() 的调用首先在 friend 中找但没找到,最后在

friend 中找到了,也就是说它实际上调用的就是原型上的 getGreeting() 然后原型方法里面

又是通过 this 去调用了原型的方法(也就自身),由于 this 始终是根据当前上下文发生变化的,

此时它的指向是 friend ,最终会导致循环调用。

而用 super 就不会有上面的问题,因为 super 指向是固定的,就是指向当前对象的原型对象(父对象),即

这里指向的是 person

super 引用的过程

一般情况下是没什么区别的,但是在我们做继承或者获取对象的原型的时候就很有用了,因为 super 的指向是和

[[HomeObject]] 密切相关的, super 获取指向的过程:

  1. 通过在当前方法的内部属性 [[HomeObject]] 上面调用 Object.getPrototypeOf() 去获取这个方法所在对象的原型对象;

  2. 在原型对象上搜与这个函数同名函数;

  3. 最后将这个同名函数绑定当前的 this 执行,然后执行这个函数。

let person = {
  getGreeting() {
    return "Hello";
  }
};

// prototype is person
let friend = {
  getGreeting() {
    return super.getGreeting() + ", hi!";
  }
};
Object.setPrototypeOf(friend, person);

console.log(friend.getGreeting());  // "Hello, hi!"

比如,上面的代码

  1. person 设置为 friend 的原型,成为它的父对象

  2. 调用 friend.getGreeting() 执行之后在其内部使用 super.getGreeting() 这个一开始会

找到 friend.getGreeting 这个方法的 [[HomeObject]] 也就是 friend

  1. 然后根据扎到的 friend ,通过 Object.getPrototypeOf() ,去找到原型对象,即 person ,找到之后再去这里面找同名函数 getGreeting

  2. 找到之后将该函数执行上下文绑定到 this (即 friend 所在的上下文)。

  3. 执行同名函数,此时这个虽是原型(person)上的函数,但是上下文已经被绑定到了 friend

过程简单描述就是:

设置继承
=> 重写方法
=> super 调用父级方法
=> 找当前函数的 [[HomeObject]]
=> Object.getPrototypeOf([[HomeObject]]) 找原型
=> 找原型上同名函数
=> 绑定找到的同名函数到当前的 this
=> 执行同名函数

var person = {
  fnName: 'person',
  getName() {
    return this.fnName
  }
}
var child = {
  fnName: 'child',
  getName() {
    return super.getName() + ',' + this.fnName
  }
}

Object.setPrototypeOf(child, person)

console.log(child.getName()) // child child

方法定义

在 es6 之前是没有“方法”这个词的定义的,但在 es6 之后对方法的定义才正式有了规定。

函数和方法定义

在对象中的函数才叫做方法,非对象中的叫做函数,且 es6 给方法增加了一个 [[HomeObject]] 内置属性,

它指向的是包含这个方法的那个对象。

比如:

let person = {
  // method
  getGreeting() {
    return 'xxx'
  }
}

// not method
function shareGreeting() {
  return 'yyy'
}

getGreeting 叫做方法,且其有个内部属性 [[HomeObject]] 指向了 person 说明这个对象拥有它。

shareGreeting 叫做函数,不是方法

总结

更新内容

内容 示例/说明
属性简写 {name, age} <=> {name: name, age: age}
计算属性 { [first + 'name']: '张三' }, { ['first name']: '张三' }
简写方法 { getName() {} }
重复属性名合法化 { age: 10, age: 100 } <=> { age: 100 }
Object.assign 合并对象 浅拷贝,内部 = 实现拷贝
Object.is 加强判断,弥补 === 不能判断 +0, -0NaN, NaN 问题
固定对象属性枚举顺序 number > string > symbol, string 和 symbol 按照增加先后顺序排列
Object.setPrototypeOf 可改变对象原型
super 指向原型对象,可通过它去访问原型对象中的方法

数据解构

解构优势

在 es5 及之前如果我们想要从对象中取出属性的值,只能通过普通的赋值表达式来实现,

一个还好,如果是多个的话就会出现很重复的代码,比如:

let options = {
  repeat: true,
  save: false
}

let repeat = options.repeat,
    save = options.save


// if more ???

上面只是取两个对象的属性,如果很多呢,十几个二十几个??

不仅代码量大,还不美观。

因此 es6 加入了解构系统,让这些操作变的很容易,很简洁。

对象解构

对象解构的时候,等号右边不能是 nullundefined ,这样会报错,这是因为,无论什么时候

去读取 nullundefined 的属性都会出发运行时错误。

声明式解构

解构的同时声明解构后赋值的变量:

let node = {
  type: 'Identifier',
  name: 'foo'
}

let { type, name } = node

console.log(type) // Identifier
console.log(name) // foo

在使用解构的过程中必须要有右边的初始值,而不能只是用来声明变量,这是不合法的操作

比如:

// syntax error!
var { type, name };

// syntax error!
let { type, name };

// syntax error!
const { type, name };

先声明后解构

有时候有些变量早已经存在了,只是后面我们需要将它的值改变,也正好是需要从对象中去取值,

这个时候就是先声明后解构:

let node = {
  type: "Identifier",
  name: "foo"
},
    // 这里变量已经声明好了
    type = "Literal",
    name = 5;

// assign different values using destructuring
({ type, name } = node);

console.log(type);      // "Identifier"
console.log(name);      // "foo"

这个时候必须用 () 将解构语句包起来,让其成为一个执行语句,如果不,左边就相当于

一个块级语句,然而块级语句是不能出现在等式的左边的。

在这基础上,另一种情况是将 {type, name} = node 作为参数传递给函数的时候,这个时候

传递给函数的参数其实就是 node 本身,例如:

let node = {
  type: "Identifier",
  name: "foo"
},
    type = "Literal",
    name = 5;

function outputInfo(value) {
  console.log(value === node);
}

outputInfo({ type, name } = node);        // true

console.log(type);      // "Identifier"
console.log(name);      // "foo"

解构默认值

在解构过程中,可能左边声明的变量在右边的对象中并不存在或者值为 undefined 的时候,这个变量的值将会

赋值为 undefined ,因此这个时候就需要针对这种情况有个默认处理,即这里的解构默认值。

let node = {
  type: "Identifier",
  name: "foo"
};

let { type, name, value } = node;

console.log(type);      // "Identifier"
console.log(name);      // "foo"
console.log(value);     // undefined

属性值为 undefined 的情况:

let node = {
  type: "Identifier",
  name: "foo",
  value: undefined
};

let { type, name, value = 0 } = node;

console.log(type);      // "Identifier"
console.log(name);      // "foo"
console.log(value);     // 0

属性变量重命名

解构出来之后,可能不想沿用右边对象中的属性名,因此需要将左边的变量名称重命名:

let node = {
  type: "Identifier",
  name: "foo"
};

let { type: localType, name: localName } = node;

console.log(localType);     // "Identifier"
console.log(localName);     // "foo"

重命名 + 默认值:

let node = {
  type: "Identifier",
  name: "foo"
};

let { type: localType, name: localName = 'xxx' } = node;

console.log(localType);     // "Identifier"
console.log(localName);     // "foo"

多级对象解构

右边对象中的属性的值不一定是普通类型,可能是对象,或对象中包含对象,数组等等类型,次数可以

使用内嵌对象解构来进行解构:

原则就是左边的变量的结构要和右边实际对象中的结构保持一致

let node = {
  type: "Identifier",
  name: "foo",
  loc: {
    start: {
      line: 1,
      column: 1
    },
    end: {
      line: 1,
      column: 4
    }
  }
};

let { loc: { start }} = node;

console.log(start.line);        // 1
console.log(start.column);      // 1

多层解构重命名:

let node = {
  type: "Identifier",
  name: "foo",
  loc: {
    start: {
      line: 1,
      column: 1
    },
    end: {
      line: 1,
      column: 4
    }
  }
};

// 重命名
let { loc: { start: localStart }} = node;

console.log(start.line);        // 1
console.log(start.column);      // 1

{% note info %}

语法陷阱

// no variables declared!
let { loc: {} } = node;

这种形式实际上是没任何作用的,因为左边的 loc 只是起到了站位的作用,实际起作用的

是在 {} 里面,但是里面没任何东西,也就是说这个不会解构出任何东西,也不会产生任何新的变量。

{% endnote %}

数组解构

数组解构和对象解构用法基本是一样的,无非就是讲 {} 改成数组的 [] ,和对象一样,右边不可以是 nullundefined

表达式 结果 说明
let [first, second] = [1, 2] first = 1, first = 2 普通解构
let [ , , third] = [1, 2, 3] third = 3 空置解构,只指定某个位置解构
let first = 1, second = 2 => [first, second] = [11, 22] first = 11, second = 22 先声明再解构
let a = 1, b = 2 => [a, b] = [b, a] a = 2, b = 1 替换值快捷方式
let [a = 1, b] = [11, 22] a = 11, b = 22 默认值
let [a = 1, b] = [, 22] a = 1, b = 22 默认值
let [a, b = 2] = [ 1 ] a = 1, b = 2 默认值
let [a, [b]] = [1, [2]] a = 1, b = 2 嵌套解构
let [a, [b]] = [1, [2, 3], 4] a = 1, b = 2 嵌套解构
let [a, [b], c] = [1, [2, 3], 4] a = 1, b = 2, c = 4 复杂解构
let [a, ...bs] = [1, 2, 3, 4, 5] a = 1, bs = [2, 3, 4, 5] rest 符号解构
[1, 2, 3].concat() => [1, 2, 3] => es6: [...as] = [1, 2, 3] as = [1, 2, 3] 克隆数组

混合解构

混合解构意味着被解构的对象中可能既包含对象由包含数组,也是按照对象和数组的解构原理进行解构就OK。

let node = {
  type: "Identifier",
  name: "foo",
  loc: {
    start: {
      line: 1,
      column: 1
    },
    end: {
      line: 1,
      column: 4
    }
  },
  range: [0, 3]
};

let {
  loc: { start },
  range: [ startIndex ]
} = node;

console.log(start.line);        // 1
console.log(start.column);      // 1
console.log(startIndex);        // 0

参数解构

参数解构,即函数在声明的时候,参数是采用解构等式左边的形式书写,这种就需要要求在调用的时候

这个参数位置必须有个非 null 和 Undefined 值,否则会报错,原因一样解构时候无法从 null 或 undefined 读取属性。

被解构的参数属性列表

实例:

function setCookie(name, value, { secure, path, domain, expires }) {

  // code to set the cookie
}

setCookie("type", "js", {
  secure: true,
  expires: 60000
})

不传值得非法操作:

// Error!
setCookie("type", "js");

这样第三个参数就是 undefined 报错。

优化参数解构写法有两种:

  1. 函数体内解构
  2. 解构体默认值方式(推荐)

函数体内解构:

function setCookie(name, value, options) {

  // 函数体内解构,给个默认值 || {} ,或者在参数那里这样: (name, value, options = {})
  let { secure, path, domain, expires } = options || {};

  // code to set the cookie
}

或者:

function setCookie(name, value, options = {}) {

  let { secure, path, domain, expires } = options;

  // code to set the cookie
}

直接参数解构体给默认值:

function setCookie(name, value, { secure, path, domain, expires } = {}) {

  // ...
}

默认值,如果不传第三个参数,那么它的默认值就是 {} 避免解构出错。

解构的参数默认值

和普通对象一样,解构出来的参数我们还可以给他们一个默认值:

function setCookie(name, value,
                   {
                     secure = false,
                     path = "/",
                     domain = "example.com",
                     expires = new Date(Date.now() + 360000000)
                   } = {}
                  ) {

  // ...
}
  1. 第三个参数没传,四个参数都取默认值
  2. 第三个参数有传递,根据普通对象定义解构

总结

  1. 对象,先声明再解构,表达式必须用 () 包起来,作为表达式执行
  2. 对象数组解构都可以给默认值,重命名,多层解构,混合解构
  3. 解构遵循左侧最内层的变量声明,如果左侧最内层无任何变量,则解构表达式无任何意义
  4. 参数解构,要么给当前参数默认值,要么保证调用时该参数都有传入非 nullundefined 的值,推荐参数默认值

符号和符号属性(Symbols)

符号类型值(Symbol())是 es6 新增的一种原始数据类型和 strings, numbers, booleans, nullundefined 属于原始值类型。

它相当于数字的 42 或字符串的 "hello" 一样,只是单穿的一些值,因此不能对其使用 new Symbol() 否则会报错。

img

符号类型是作为一种创建私有对象成员的类型,在 es6 之前是没有什么方法可以区分普通属性和私有属性的。

创建符号

符号类型会创建一个包含唯一值得符号变量,这些变量是没有实际字面量表示的,也就是说一旦符号变量创建之后,只能通过这个变量

去访问你所创建的这个符号类型。

创建符号

通过 Symbol([ description ]) 来创建符号,创建过程:

  1. 如果 descriptionundefined, 让 descString = undefined
  2. 否则 descString = ToString(description)
  3. 让内部值 [[Description]]descString
  4. 返回一个唯一的 Symbol 值
let firstName = Symbol();
let secondName = Symbol();
let person = {};

person[firstName] = "Nicholas";
console.log(person[firstName]);     // "Nicholas"

console.log(firstName)
console.log(secondName)
console.log(firstName == secondName)
console.log(firstName === secondName)
console.log(Object.is(firstName, secondName))

+RESULTS:

Nicholas
Symbol()
Symbol()
false
false
false

firstName 是存放了一个唯一值得符号类型变量,并且用来作为 person 对象的一个属性使用。

因此,如果要访问对象中的对应的这个属性的值,每次都必须使用 firstName 这个符号变量去访问。

{% note info %}
如果需要实在需要符号类型对象,可以通过 new Object(Symbol()) 去创建一个对象,而不能

直接 new Symbol() 因为 Symbol() 得到的是一个原始值,就像你不能直接 new 42 一个道理。

img

{% endnote %}

带参数的 Symbol(arg)

有时候可能需要对创建的符号做一些简单的区分,或者让其更加语义化,可以在创建的时候给 Symbol() 函数

一个参数,参数本身并没有实际的用途,但是有利于代码调试。

let firstName = Symbol("first name");
let person = {};

person[firstName] = "Nicholas";

console.log("first name" in person);        // false
console.log(person[firstName]);             // "Nicholas"
console.log(firstName);                     // "Symbol(first name)"
console.log(firstName.description) // undefined
console.log(Symbol('xxx').description) // undefined

+RESULTS:

false
Nicholas
Symbol(first name)
undefined
undefined

如输出,参数会一并输出,因此推荐使用的时候加上参数,这样在调试的时候你就能区分开哪个符号

来自哪里,而不至于输出都是 Symbol() 无法区分。

参数作为符号的一种描述性质特征被储存在了内部 [[Description]] 属性中,这个属性会在对符号调用 toString()

(隐式或显示调用)的时候去读取它的值,除了这个没有其他方法可以直接去访问 [[Description]]

符号类型检测(typeof)

由于符号属于原始值,因此可以直接通过 typeof 就可以去判断变量是不是符号类型,es6 对 typeof 进行了扩展,

如果是符号类型检测的结果值是“symbol”

let symbol = Symbol("test symbol")

console.log(typeof symbol) // "symbol"

+RESULTS:

symbol

使用符号

之前的例子中使用变量作为对象属性名的,都可以使用符号来替代,并且还可以对符号类型的属性

进行定制,让其变成只读的。

// 创建符号,唯一
let firstName = Symbol('first name')

let person = {
  // 直接当做计算属性使用
  [firstName]: '张三'
}

// 让属性只读
Object.defineProperty(person, firstName, { writable: false })

let lastName = Symbol('last name')

Object.defineProperties(person, {
  [lastName]: {
    value: '李四',
    writable: false
  }
})

console.log(person[firstName])
console.log(person[lastName])

+RESULTS:

张三
李四

分享符号

在使用过程中我们需要考虑一个问题:

假设某个地方声明了一个符号类型及一个使用了这个符号作为属性 key 的对象,哪天

如果我想在其他地方去使用它,该怎么办??

如今模块化得到普及,现在经常都是一个文件一个模块,用的时候导入这个文件得到相应的对象

但由于符号值是唯一的,那外部模块又怎么知道另一个模块内部用了怎样的符号值作为对象??

这就是下面要讲的“符号分享”问题。

{% note warn %}
全局符号注册表(Global Symbol Registry) 会在所有代码执行之前就创建好,且列表为空。

它和全局对象一样属于环境变量,因此不要去假设它是什么或它不存在之类的,因此它在所有代码执行之前

就创建好了,所以它是确确实实存在的。
{% endnote %}

Symbol.for()

在之前我们通过 let firstName = Symbol('first name'); 来创建一个符号变量,但是在使用的时候必须的用

firstName 去使用这个变量,而现在我们想将符号分享出去需要用到 Symbol.for()

Symbol.for(description) 会针对 description 去创建一个唯一的符号值:

let uid = Symbol.for("uid");
let object = {};

object[uid] = "12345";

console.log(object[uid]);       // "12345"
console.log(uid);               // "Symbol(uid)"

Symbol.for(desc) 在第一次调用的时候,首先会去“全局符号注册表(global symbol registry)” 中去查找

这个 desc 对应的符号值,找到了就返回这个符号值,如果没找到会创建一个新的符号值并且将它注册到全局符号注册表中,

供下次调用时使用。

-—

Symbol.for(key) 内部实现步骤(伪代码):

Symbol.for = function (key) {

  // 1 key 转字符串
  let stringKey = ToString(key);

  // 2. 遍历 GlobalSymbolRegistryList 注册表
  for (let e in GlobalSymbolRegistryList) {
    // 符号值已经存在
    if (SameValue(e.[[Key]], stringKey)) {
      return e.[[Symbol]];
    }
  }

  // 3. 注册表中不含 `stringKey` 的符号值,则创建新的符号值
  // 3.1 新建符号值
  let newSymbol = Symbol(stringKey);
  // 3.1 给 [[Description]] 赋值
  newSymbol.[[Description]] = stringKey;

  // 4. 注册到符号注册表中去
  GlobalSymbolRegistryList.push({
    [[Key]]: stringKey,
    [[Symbol]]: newSymbol
  });

  // 5. 返回新建的符号值
  return newSymbol;

}

总结起来为3个步骤: 查找 -> 新建 -> 注册

注册表中的每个符号片段是以对象形式存在(对象中包含 KeySymbol 两个属性分别表示创建时的描述和符号值)。

使用分享符号

在上一节7.3.1 中我们描述过了用来创建分享符号的 Symbol.for(desc) 接口,这里将探讨如何具体使用它来分享符号值。

let uid = Symbol.for("uid");
let object = {
  [uid]: "12345"
};

console.log(object[uid]);       // "12345"
console.log(uid);               // "Symbol(uid)"

let uid2 = Symbol.for("uid");

console.log(uid === uid2);      // true
console.log(object[uid2]);      // "12345"
console.log(uid2);              // "Symbol(uid)

在当前代码运行的全局作用域中都可以分享到一份 Symbol.for("uid") 符号,只需要调用它就可以拿到那个

唯一的值。

比如:


function createObj1() {
  let uid = Symbol.for("uid");
  let object = {
    [uid]: "12345"
  };

  return object
}

function createObj2() {
  let uid = Symbol.for("uid");
  let object = {
    [uid]: "67890"
  };

  return object
}


let uid1 = Symbol.for("uid");
const obj1 = createObj1()

let uid2 = Symbol.for("uid");
const obj2 = createObj2()

console.log(uid1 === uid2);
console.log(obj1[uid1]);
console.log(obj1[uid2]);
console.log(obj2[uid1]);
console.log(obj2[uid2]);

+RESULTS:

true
12345
12345
67890
67890

Symbol.keyFor(symbolValue)

我们如果想创建或获取全局注册表中的符号是可以通过 7.3.1 中的 Symbol.for(key) ,但是

如果我们只知道一个符号值变量的情况下,使用 Symbol.for(key) 就没法从注册表中取值了。

因此,这里将介绍如何使用 Symbol.keyFor(symbolValue) 去根据符号变量查找注册表中的值。

在这之前需要知道

  1. Symbol.for(key) 创建的符号才会进入全局注册表
  2. Symbol() 直接创建的是不会加入全局注册表的

也就有了下面的代码及结果:

let uid = Symbol.for("uid");
console.log(Symbol.keyFor(uid));    // "uid"

let uid2 = Symbol.for("uid");
console.log(Symbol.keyFor(uid2));   // "uid"

let uid3 = Symbol("uid");
console.log(Symbol.keyFor(uid3));   // undefined

+RESULTS:

uid
uid
undefined

因此 Symbol("uid"); 结果不会加入注册表,因此结果是 undefined

符号强制转换

在 JavaScript 中类型强制转换是经常会被用到的一个特性,也让 JavaScript 使用起来会很灵活地可以将一个

数据类型转成另一种数据类型。

但是符号类型不支持强制转换。

let uid = Symbol.for("uid")

console.log(uid) // Symbol(uid)

// 在输出的时候实际上是调用了 uid.toString()

+RESULTS:

Symbol(uid)

当我们将符号变量加入计算或字符串操作时会报错,因为两个不同类型的值进行操作会发生隐式转换,但是符号类型不支持强转

的,因此会报异常。

let uid = Symbol.for('uid'),
    desc = '',
    sum = 0

try {
  desc = uid + ""
} catch (e) {
  console.log(e.message)
}

try {
  sum = uid / 1
} catch (e) {
  console.log(e.message)
}

+RESULTS: 异常信息

Cannot convert a Symbol value to a string
Cannot convert a Symbol value to a number

获取对象符号属性

获取对象属性的方法:

  1. Object.keys() 会获取所有可枚举的属性
  2. Object.getOwnPropertyNames() 获取所有属性,忽略可枚举性

但是为了兼容 es5 及以前的版本,他们都不会去获取符号属性,因此需要使用 Object.getOwnPropertySymbols()

去单独获取对象所有的符号属性,返回一个包含所有符号属性的数组。

let uid = Symbol.for("uid");
let object = {
  [uid]: "12345",
  [Symbol.for("uid2")]: "67890"
};

let symbols = Object.getOwnPropertySymbols(object);

console.log(symbols.length);        // 1
console.log(symbols[0]);            // "Symbol(uid)"
console.log(object[symbols[0]]);    // "12345"

+RESULTS:

2
Symbol(uid)
12345

符号内部操作(方法)

在 es6 中 JavaScript 的许多特性中其内部的实现都是使用到了符号内部方法。

比如下表涉及到的内容

符号方法 类型 JavaScript 特性 描述
Symbol.hasInstance boolean instanceof 7.6.1 实例(原型链)检测
Symbol.isConcatSpreadable boolean Array.prototype.concat 7.6.2 检测参数合法性
Symbol.iterator function 调用后得到迭代器 遍历对象或数组(等可迭代的对象)的时候会用到
Symbol.asyncIterator function 调用后得到异步迭代器(返回一个 Promise ) 遍历对象或数组(等可迭代的对象)的时候会用到
Symbol.match function String.prototype.match 7.6.3 正则表达式对象内部属性
Symbol.matchAll function String.prototype.matchAll 7.6.3 正则表达式对象内部属性
Symbol.replace function String.prototype.replace 7.6.3 正则表达式对象内部属性
Symbol.search function String.prototype.search 7.6.3 正则表达式对象内部属性
Symbol.split function String.prototype.split 7.6.3 正则表达式对象内部属性
Symbol.species constructor - 派生对象生成
Symbol.toPrimitive function - 7.6.4 返回一个对象的原始值
Symbol.toStringTag string Object.prototype.toString() 7.6.5 返回一个对象的字符串描述
Symbol.unscopables object with 7.6.8 不能出现在 with 语句中的一个对象

{% note info %}
通过改变对象的上面的内部符号属性的实现,可以让我们去修改对象的一些

默认行为,比如 instanceof 一个对象的时候可以改变它的行为让它返回一个非预期值。
{% endnote %}

Symbol.hasInstance

每个函数都有一个内部 Symbol.hasInstance 方法用来判断给定的对象是不是这个函数的一个实例。

这个函数定义在 Function.prototype 上,因此所有的函数都会继承 instanceof 属性的默认行为,

并且这个方法是 nonwritable, nonconfigurable, 和 nonenumerable 的,确保它不会被错误的

重写。

因此下面的中的两句 obj instanceof ArrayArray[Symbol.hasInstance](obj) 是等价的。


const obj = {}

let v1 = obj instanceof Array;

// 等价于

let v2 = Array[Symbol.hasInstance](obj);

console.log(v1, v2)

+RESULTS:

false false

在 es6 中实际上已经对 instanceof 操作做了重定义,其内部还让它支持了函数调用方式,即

其内部的 Symbol.hasInstance 不再限定只是 boolean 类型,它还可以是函数类型,因此

我们可以通过重写这个方法来改变 instanceof 的默认行为。

比如:让一个对象的 instanceof 操作总是返回 false

function MyObj() {
  // ...
}

Object.defineProperty(MyObj, Symbol.hasInstance, {
  value: function(v) {
    console.log('override method')
    return false;
  }
})

let obj = new MyObj();

console.log(obj instanceof MyObj); // false

+RESULTS:

override method
false

由于 Symbol.hasInstance 属性是 nonwritable 的因此需要通过 Object.defineProperty

去重新定义这个属性。

{% note warn %}
虽然 es6 赋予了这种可以重写一些 JavaScript 特性的默认行为的能力,但是依旧不推荐

去这么做,很可能让你的代码变得很不可控,也不容易让人理解你的代码。
{% endnote %}

Symbol.isConcatSpreadable

对应着 Array.prototype.concat 的内部使用 Symbol.isConcatSpreadable

concat 使用示例:

let colors1 = [ "red", "green" ],
    colors2 = colors1.concat([ "blue", "black" ]);

console.log(colors2.length);    // 4
console.log(colors2);           // ["red","green","blue","black"]

+RESULTS:

4
[ 'red', 'green', 'blue', 'black' ]

我们一般用 concat 去扩展一个数组,把他们合并到一个新的数组中去。

根据 Array.prototype.concat(value1, ...valueNs) 的定义,它是可以接受 n 多个参数的,比如:

[].concat(1, 2, 3, ...) > =[1, 2, 3, ...]

并且并没有限定参数的类型,即这些 value1, ...valuesNs 可以是任意类型的值(数组,对象,纯值等等)。

另外,如果参数是数组的话,它会将数组项一一展开合并到源数组中区(且只会做一级展开,数组中的数组不会展开)。

比如:

let colors1 = [ "red", "green" ],
    colors2 = colors1.concat(
      [ "blue", "black", [ "white" ] ], "brown", { color: "red" });

console.log(colors1 === colors2)
console.log(colors2.length);    // 5
console.log(colors2);           // ["red","green","blue","black","brown"]

+RESULTS:

false
7
[ 'red',
  'green',
  'blue',
  'black',
  [ 'white' ],
  'brown',
  { color: 'red' } ]

但是,如果我们需要的是将 { color: 'red' } 中的属性值 'red' 合并到数组末尾,该如何做??

->>> Symbol.isConcatSpreadable 就是它

和其他内置符号不一样,这个在所有的对象中默认是不存在的,因此如果我们需要就得手动去添加,让这个对象

变成 concatable 只需要将这个属性值置为 true 即可:

let collection = {
  0: 'aaa',
  '1': 'bbb',
  length: 2,
  [Symbol.isConcatSpreadable]: true
}

let objNoLength = {
  0: 'xxx',
  1: 'yyy',
  [Symbol.isConcatSpreadable]: true
}


let objNoNumberAttrs = {
  a: 'www',
  b: 'vvv',
  length: 2,
  [Symbol.isConcatSpreadable]: true
}

let words = [ 'somthing' ];

console.log(words.concat(collection).toString())
console.log(words.concat(objNoLength).toString())
console.log(words.concat(objNoNumberAttrs).toString())

+RESULTS:

somthing,aaa,bbb
somthing
somthing,,

分析结果得出,对象要变的可以被 Array.prototype.concat 使用,

需要满足以下条件:

  1. 必须有 length 属性,否则对结果没任何影响,如结果第二行输出: somthing
  2. 必须有以数字为 key 的属性,否则数组中将使用空值代替追加的值追加到数组中去,如第三行输出: somthing,,
  3. 必须增加符号属性 Symbol.isConcatSpreadable 且值为 true

同理,我们可以将数组对象的 Symbol.isConcatSpreadable 符号属性置为 false 来阻止数组的 concatable 行为。

Symbol.match, Symbol.replace, Symbol.search, Symbol.split

和字符串,正则表达式有关的一些符号,对应着字符串和正则表达式的方法:

  • match(regex) 字符串是否匹配正则
  • replace(regex, replacement) 字符串替换
  • search(regex) 字符串搜索
  • split(regex) 字符串切割

这些都需要用到正则表达式 regex

在 es6 之前这些方法与正则表达式的交互过程对于开发者而已都是隐藏了其内部细节的,也就是

说开发者无法通过自己定义的对象去表示一个正则。

在 es6 中定义了四个符号便是用来实现 RegExp 内部实现对象,即可以通过对象的方式去

实现一个正则表达式规则。

这四个符号属性是在 RegExp.prototype 原型上被定义的,作为以上方法的默认实现。

{% note info %}
意思就是 math, replace, search, split 这四个方法的 regex 正则

表达式的内部实现基于对应的四个符号属性函数 Symbol.math, Symbol.replace,

Symbol.search, Symbol.split
{% endnote %}

  • Symbol.match 接受一个字符串参数,如果匹配会返回一个匹配的数组,未匹配返回 null
  • Symbol.replace 接受一个字符串参数和一个用来替换的字符串,返回一个新的字符串。
  • Symbol.search 接受一个字符串,返回匹配到的数字所以呢,未匹配返回 -1。
  • Symbol.split 接受一个字符串,返回以匹配到的字符串位置分割成的一个字符串数组

// 等价于 /^.${10}$/
let hasLengthOf10 = {
  [Symbol.match]: function(value) {
    return value.length === 10 ? [value] : null
  },

  [Symbol.replace]: function(value, replacement) {
    return value.length === 10 ? replacement : value
  },

  [Symbol.search]: function(value) {
    return value.length === 10 ? 0 : -1
  },

  [Symbol.split]: function(value) {
    return value.length === 10 ? ["", ""] : [value]
  }
}

let msg1 = "Hello World", // 11 chars
    msg2 = "Hello John"; // 10 chars


let m1 = msg1.match(hasLengthOf10)
let m2 = msg2.match(hasLengthOf10)

console.log(m1)
console.log(m2)

let r1 = msg1.replace(hasLengthOf10, "Howdy!")
let r2 = msg2.replace(hasLengthOf10, "Howdy!")

console.log(r1)
console.log(r2)


let s1 = msg1.search(hasLengthOf10)
let s2 = msg2.search(hasLengthOf10)

console.log(s1)
console.log(s2)

let sp1 = msg1.split(hasLengthOf10)
let sp2 = msg2.split(hasLengthOf10)

console.log(sp1)
console.log(sp2)

+RESULTS:

null
[ 'Hello John' ]
Hello World
Howdy!
-1
0
[ 'Hello World' ]
[ '', '' ]

通过这几个正则对象的内部符号属性,使得我们有能力根据需要去完成更复杂的正则匹配规则。

Symbol.toPrimitive

在 es6 之前,如果我们要使用 == 去比较两个对象的时候,其内部都会讲对象转成原始值之后再去比较,

且此时的转换属于内部操作,我们是无法知晓更无法干涉的。

但在 es6 出现之后,这种内部实现通过 Symbol.toPrimitvie 被暴露出来了,从而使得我们有能力取

改变他们的默认行为。

Symbol.toPrimitvie 是定义在所有的标准类型对象的原型之上,用来描述在对象被转换成原始值之前的

都做了些什么行为。

当一个对象发生原始值转换的时候, Symbol.toPrimitive 就会带上一个参数(hint)被调用,这个参数值为

"number", "string", "default" 中的一个(值是由 JavaScript 引擎所决定的),分别表示:

  1. "number" :表示 Symbol.toPrimitive 应该返回一个数字。
  2. "string" :表示 Symbol.toPrimitvie 应该返回一个字符串。
  3. "default" : 表示原样返回。

在大部分的标准对象中, number 模式的行为按照以下的优先级来返回:

  1. 先调用 valueOf() 如果结果是一个原始值,返回它。
  2. 然后调用 toString() 如果结果是一个原始值,返回它。
  3. 否则,抛出异常。

同样, string 模式的行为优先级如下:

  1. 先调用 toString() 如果结果是一个原始值,返回它。
  2. 然后调用 valueOf() 如果结果是一个原始值,返回它。
  3. 否则,抛出异常。

在此,可以通过重写 Symbol.toPrimitive 方法,可以改变以上的默认行为。

{% note info %}
"default" 模式仅在使用 ==, + 操作符,以及调用 Date 构造函数的时候

只传递一个参数的时候才会用到。大部分的操作都是采用的 "number" 或 "string" 模式。
{% endnote %}

实例:

function Temperature(degrees) {
  this.degrees = degrees
}

let freezing = new Temperature(32)

console.log(freezing + "!") // [object Object]!
console.log(freezing / 2) // NaN
console.log(String(freezing)) // [object Object]

输出结果:

img

因为默认情况下一个对象字符串化之后会变成 [object Object] 这是其内部的默认行为。

通过重写原型上的 Symbol.toPrimitive 函数可以改写这种默认行为。

比如:

function Temperature(degrees) {
  this.degrees = degrees
}

Temperature.prototype[Symbol.toPrimitive] = function(hint) {
  switch (hint) {
  case 'string':
    return this.degrees + '\u00b0'
  case 'number':
    return this.degrees
  case 'default':
    return this.degrees + " degrees"
  }
}

let freezing = new Temperature(32)

console.log(freezing + "!")
console.log(freezing / 2)
console.log(String(freezing))

+RESULTS:

32 degrees!
16
32°

结果就像我们之前分析的, 只有 ==+ 执行的是 “default" 模式,

其他情况执行的要么是 "number" 模式(如: freezing / 2)

要么是 "string" 模式(如: String(freezing))

Symbol.toStringTag 介绍

在 JavaScript 的一个有趣的问题是,能同时拥有多个全局执行上下文的能力。

这个发生在 web 浏览器环境下,一个页面可能包含一个 iframe ,因此当前页面和这个 iframe 各自

都拥有自己的执行环节。

通常情况下,这并不是什么问题,因为数据可以通过一些手段让其它当前页和 iframe 之间进行传递,

问题是如何去识别这个被传递的对象是源自哪个执行环境??

比如,一个典型的问题是在 pageiframe 之间互相传递一个数组。在 es6 的术语中, 页面和

iframe 每一个都代表着一个不同的领域(realm, JavaScript 执行环境)。每个领域都有它自己的全局

作用域包含了它自己的一份全局对象的副本。

无论,数组在哪个领域被创建,它都很明确的是一个数组对象,当它被传递到另一个领域的时候,使用

instanceof Array 的结果都是 false ,因为数组是通过构造函数在别的领域所创建的,而

Array 代表的仅仅是当前领域下的构造函数,即两个领域下的 Array 不是一回事。

这就造成了在当前领域下去判断另一个领域下的一个数组变量是不是数组,得到的结果将是 false

Symbol.toStringTag 延伸(不同 realm 下的对象识别)

对象识别的应对之策(Object.prototype.toString.call(obj))

function isArray(value) {
  return Object.prototype.toString.call(value) === "[object Array]";
}

console.log(isArray([]));   // true

+RESULTS:

true

这种方式虽然比较麻烦,但是却是最靠谱的方法。

因为每个类型的 toString() 可能有自己的实现,返回的值是无法统一的,但是 Object.prototype.toString

返回的内容始终是 [object Array] 这种,后面是被检测数据代表的类型的构造函数,它总是能得到正确且精确的

结果。

Object.prototype.toString 内部实现的伪代码:

// toString(object)

function toString(obj) {
  // 1. 判断 undefined 和 null
  if (this === undefined) {
    return '[object Undefined]';
  }

  if (this === null) {
    return '[object Null]';
  }

  let O = ToObject(this); // 上下文变量对象化
  let isArray = IsArray(O); // 先判断是不是数组类型
  let builtinTag = ''

  let has = builtinName => !!O.builtinName;

  // 2. 根据内置属性,检测各对象的类型
  if (isArray === true) { // 数组类型
    builtinTag = 'Array';
  } else if ( has([[ParameterMap]]) ) { // 参数列表,函数参数对象
    // 函数的参数 arguments 对象
    builtinTag = 'Arguments';
  } else if ( has([[Call]]) ) { // 函数
    builtinTag = 'Function';
  } else if ( has([[ErrorData]]) ) { // Error对象
    builtinTag = 'Error';
  } else if ( has([[BooleanData]]) ) { // Boolean 布尔对象
    builtinTag = 'Boolean';
  } else if ( has([[StringData]]) ) { // String 对象
    builtinTag = 'String';
  } else if ( has([[DateValue]]) ) { // Date 对象
    builtinTag = 'Date';
  } else if ( has([[RegExpMatcher]]) ) { // RegExp 正则对象
    builtinTag = 'RegExp';
  } else {
    builtinTag = 'Object' // 其他
  }

  // 3. 最后检测 @@toStringTag - Symbol.toStringTag 的值
  let tag = Get(O, @@toStringTag);

  if (Type(tag) !== 'string') {
    tag = builtinTag;
  }

  return `[object ${tag}]`;
}

从伪代码中我们知道,最后的实现中使用到了 @@toStringTag 即对应这里的 Symbol.toStringTag 属性值,

并且这个放在最后判断,优先级最高,即如果我们重写了 Symbol.toStringTag 那么重写之后的返回值将

最优先返回。

Symbol.toStringTag 的 ES6 实现

正如 7.6.6 中的伪代码所示,在 es6 中对于 Object.prototype.toString.call(obj)

的实现中加入了 @@toStringTag 内部属性的检测,即对应着这里的 Symbol.toStringTag ,那么我们便

可以通过改变这个值来修改它的默认行为,从而得到我们想要的类型值。

比如:我们有一个 Person 构造函数,我们希望在使用 toString() 的时候得到结果是 [object Person]

function Person(name) {
  this.name = name
}

Person.prototype[Symbol.toStringTag] = 'Person'

let me = new Person('xxx')

Person.prototype.toString = () => '[object Test]'

console.log(me.toString()) // [object Person]
console.log(Object.prototype.toString.call(me)) // [object Person]
console.log(me.toString === Object.prototype.toString) // true

+RESULTS: 未重写 Person.prototype.toString 结果

[object Person]
[object Person]
true

+RESULTS: 重写 Person.prototype.toString 的结果

[object Test]
[object Person]
false

我们发现就算重写了 Person.prototype.toString 也不会影响 Symbol.toStringTag 赋值后的运行结果,

如后面调用 Object.prototype.toString.call(me) 结果依旧是 [object Person]

因为我们重写了 Symbol.toStringTag 属性值,因此7.6.6实现部分:

// 3. 最后检测 @@toStringTag - Symbol.toStringTag 的值
let tag = Get(O, @@toStringTag); // 这里的结果就成了 'Person'

if (Type(tag) !== 'string') {
  tag = builtinTag;
}

return `[object ${tag}]`

因此得到 [object Person] 返回结果。

我们还可以通过重写 Person 自身的 toString() 的实现让其拥有自己的默认行为,上面的第三行

结果表明 me.toString() 最终调用的是 Object.prototype.toString

Symbol.unscopables

with 语句在 JavaScript 世界中是最具争议的一项特性之一。

原本设计的初衷是避免重复书写一样的代码,但是在实际使用过程中,却是让代码更难理解,很容易出错,

也有性能上的影响。

虽然,极力不推荐使用它,但是在 es6 中为了考虑向后兼容性问题,在非严格模式下依旧对它做了支持。

比如:

let values = [1, 2, 3],
    colors = ["red", "green"],
    color = "black";

with(colors) {
  push(color);
  push(...values);
}

console.log(colors.toString())

+RESULTS:

red,green,black,1,2,3

上面代码,在 with 里面调用的两次 push 等价于 colors.push 调用,

因为 with 将本地执行上下文绑定到了 colors 上。

values, color 指向的均是在 with 语句外面创建的 valuescolor

但是在 ES6 中给数组增加了一个 values 方法,这个方法会返回当前数组的迭代器对象: Array Iterator {}

这就意味着在 ES6 的环境中, values 指向的将是数组本身的 values() 方法而不是外面声明的

values = [1, 2, 3] 这个数组,将破坏整个代码的运行。

这就是 Symbol.unscopables 存在的原因。

Symbol.unscopables 被用在 Array.prototype 上用来指定那些属性不能在 with 中创建绑定:

// built into ECMAScript 6 by default
Array.prototype[Symbol.unscopables] = Object.assign(Object.create(null), {
  copyWithin: true,
  entries: true,
  fill: true,
  find: true,
  findIndex: true,
  keys: true,
  values: true
});

上面是默认情况下 ES6 内置的设定,即数组中的上列属性不允许在 with 中创建绑定,从列表能发现这些被

置为 true 的属性都是 es6 中新赠的方法,这主要是为了兼容以前的代码只针对新增的属性这么使用。

{% note warn %}
一般情况下,不需要重新定义 Symbol.unscopables ,除非代码中存在 with 语句并且

需要做一些特殊处理的时候,但是建议尽量避免使用 with
{% endnote %}

总结

  1. Symbols 是一种新的原始值类型,用来创建一些属性,这些属性只能使用对应的符号或符号变量去访问。
  2. Symbol([description]) 用来创建一个符号,推荐传入描述,便于识别。
  3. Symbol.for(key) 首先查找注册表(GSR),如果 key 对应的符号存在直接返回,如果不存在则创建新符号并加入到注册表,然后返回新创建的符号。
  4. Symbol.keyFor(symbolValue) 通过符号变量从注册表中找到对应的符号值,没有返回 undefined
  5. 符号共享通过 Symbol.for(key)Symbol.keyFor(symbolValue) 可以让符号达到共享的目的,因为全局注册表在所有代码运行之前就已经创建好了。
  6. 符号不允许类型转换(或隐式转换)。
  7. Object.keys()Object.getOwnPropertyNames() 不能获取到符号属性。
  8. Object.getOwnPropertySymbols(obj) 能获取到对象的所有符号属性。
  9. Object.defineProperty()Object.defineProperties() 对符号属性也有效。
  10. 知名符号7.6,以往的内部实现是不对开发者开放的,如今有了这些知名符号属性,可以让开发者自信改变一些功能和接口的默认行为。

Sets 和 Maps

  • set 集合是一组没有重复元素的一个序列。
  • map key 值得集合,指向对应的值

ECMAScript 5 中的 Sets 和 Maps

在 es6 之前会有各种 sets/maps 的实现方式,但是大都或多或少有所缺陷。

背景

比如: 使用对象属性实现

let st = Object.create(null)

set.foo = true

if (set.foo) {
  // sth
}

在将对象作为 set 或 map 使用的时候唯一的区别在于:

map 里面的 key 有存储对应的具体内容,而不像 set 仅仅用来存储 true or false,

用来标识 key 是否存在。

let map = Object.create(null)

map.foo = 'bar'

let value = map.foo

console.log(value) // 'bar'

+RESULTS:

bar

潜在问题

使用对象实现 set/map 的问题:

  1. 无法避免字符串 key 的唯一性问题
  2. 无法避免对象作为 key 的唯一性问题

字符串作为 key :

let map = Object.create(null)

map[5] = 'foo'

console.log(map["5"]) // 'foo'

+RESULTS:

foo

因为对于对象来说,使用数字下表去访问的时候,实际上是将下标数值转成字符串去访问了,

即相当于 map[5] 等价于 map['5'] 因此,有上面的结果输出。

但是,你偏偏想使用 5 和 '5' 去标识两个 key 的时候就无法达到目的了。

对象作为 key :

let map = Object.create(null),
    key1 = {},
    key2 = {}

map[key1] = 'foo'

console.log(map[key2]) // 'foo'

+RESULTS:

foo

对象作为 key 值得时候,内部会发生类型转换,将对象转成 "[object Object]"

因此无论用 key1 还是 key2 去访问 map ,最后的结果都是 map["[object Object]"] 去访问了

因此,结果都是 'foo'。

Sets 集合

  1. 创建使用 new Set() 创建实例。
  2. 添加使用 set.add() 方法。
  3. 集合区分数值的数字类型和字符串类型,不会发生类型强转。
  4. -0+0 在集合中会被当做一样处理
  5. 对象可以作为 set 的元素,且两个 {} 会被当做两个不同的元素处理

set 初始化

new Set() 创建了一个空的 set

可以在初始化的时候传入一个数组。

{% note info %}
实际上, Set 构造函数可以接受任意一个 iterable 对象作为参数。
{% endnote %}

let set = new Set([1, 2, 3, 4])

console.log(set.size) // 4

+RESULTS:

4

添加元素 set.add()

添加的元素区分类型,不会做类型转换,即 5'5' 是不一样的,重复添加也只会执行一次,

set 的元素是不会重复的。

let set = new Set()

set.add(5)
set.add('5')
set.add(5)

console.log(set.size, set)

+RESULTS:

2 Set { 5, '5' }

对象元素:

let set = new Set(),
    key1 = {},
    key2 = {}

set.add(key1)
set.add(key2)
set.add(key1)

console.log(set.size) // 2

+RESULTS:

2

set apis

  1. set.has(v) 判断 set 中是否有元素 v ,返回 true/false
  2. set.add(v) 添加元素
  3. set.size 集合大小
  4. set.delete(v) 删除元素
  5. set.clear() 清空集合

集合迭代(forEach)

对集合使用 forEach 和对数组使用的方法一样,它接受一个函数,抓个函数又三个参数:

  1. 第一个参数:集合的当前值
  2. 第二个参数:和第一个参数一样是当前元素的值,跟数组不一样,数组使用 forEach 抓个参数是当前索引值
  3. 第三个参数:被遍历的集合本身。

Sets 没有 Key 值。

let set = new Set(['a', 'b', 'c', 'd', 'e'])

console.log(set[0]) // undefined, 没有下标值
set.forEach(function(idx, v, ownerSet) {
  console.log(idx, v, ownerSet === set, ownerSet)
})

+RESULTS:

undefined
a a true Set { 'a', 'b', 'c', 'd', 'e' }
b b true Set { 'a', 'b', 'c', 'd', 'e' }
c c true Set { 'a', 'b', 'c', 'd', 'e' }
d d true Set { 'a', 'b', 'c', 'd', 'e' }
e e true Set { 'a', 'b', 'c', 'd', 'e' }

结果所示:

  1. 集合的 key 就是 value。
  2. 遍历的函数第三个参数 ownerSet 就是被遍历的 set 集合本身。

在使用 forEach 可以给它传递一个上下文参数,让绑定回调函数里面的 this

let set = new Set([1,2])

let processor = {
  output(value) {
    console.log('output from processor: ' + value)
  },

  process(dataSet, scope = 1) {
    const obj = {
      output(value) {
        console.log('output from obj: ' + value)
      }
    }
    dataSet.forEach(function(value) {
      this.output(value)
    }, scope === 1 ? this : obj)
  }
}

processor.process(set) // scope: processor
processor.process(set, 2) // scope: obj

+RESULTS:

output from processor: 1
output from processor: 2
output from obj: 1
output from obj: 2
  1. this 传递给回调,从而 output 来自 processor
  2. obj 传递给回调,从而 output 来自 obj

结论:*我们可以通过给 forEach 传递第二个参数来改变回调函数的执行上下文。*

使用箭头函数解决 this 指向问题:

let set = new Set([1,2])

let processor = {
  output(value) {
    console.log('output from processor: ' + value)
  },

  process(dataSet) {
    // this 总是绑定到 processor
    dataSet.forEach(value => this.output(value), {})
  }
}

processor.process(set) // scope: processor

+RESULTS:

output from processor: 1
output from processor: 2

无论第二个参数 {} 传或不传结果都一样,箭头函数里的 this 指向不会发生改变。

{% note warning %}
集合不能直接使用索引访问元素,如果需要使用到索引访问元素,那最好将集合转成数组来使用。
{% endnote %}

Set 和 Array 之间的转换

  1. 集合转数组 let set = new Set([1, 2, 3, 2]); ,且会将重复的元素去掉只余一个。
  2. 数组转集合,最简单的就是展开符了 let arr = [...set];

展开符(…)可以作用域任何 iterable 的对象。即任何可 iterable 的对象都可以通过 ... 转成数组。

也因为有了 Set... 从而是数组的去重变得异常简单:

const eleminateDuplicates = items => [...new Set(items)]

let nums = [1, 2, 3, 2, 4, 3, 4]

console.log(eleminateDuplicates(nums).toString())

+RESULTS:

1,2,3,4

弱集(Weak Sets)

因为它存储对象引用的方式,集合类型也可以叫做强集合类型。

即集合中对于对象的存储是存储了该对象的引用而不是被添加到集合是的那个变量名而已,

类似对象的属性的值为对象一样,就算改变了这个属性的值,那个对象如果有其他变量指向它,

那他一样存在(类似 C 的指针概念,两个指针同时指向一块内存,一个指针的指向发生变化并不会

影响另一个指针指向这块内存)。

比如:

let animal = {
  dog: {
    name: 'xxx',
    age: 10
  }
}

let dog1 = animal.dog

console.log(dog1.name) // 'xxx'
// 引用发生变化
animal.dog = null

// 并不影响别的变量指向 { name: 'xxx', age: 10 } 这个对象
console.log(dog1.age) // 10

// 指回去,依旧是它原来指向的那个对象
animal.dog = dog1
console.log(animal.dog.name) // 'xxx'
console.log(animal.dog.age) // 10

+RESULTS:

xxx
10
xxx
10

根据引用的特性,对于集合元素也一样实用:

let set = new Set();
let key = {};

set.add(key) // 实际将对象的引用加到集合中

console.log(set) // 1
console.log(set.size) // 1

key = null // 改变了变量值而已,实际引用的那个对象还在
console.log(set.size) // 1

key = [...set][0]

console.log(key)// {}

+RESULTS:

Set { {} }
1
1
{}
undefined

这种强引用在某些情况下很可能会出现内存泄漏,比如,在浏览器环境中

集合中保存了一些 DOM 元素的引用,而这些元素本身可能会被其他地方的

代码从 DOM 树中移除,同时你也不想再保有这些 DOM 元素的引用了,或者说以后

都不会用到它了,应该被释放回收才对,但是实际上集合中仍然保有这些元素的引用(实际已经不存在的东西),

这种情况就叫做内存泄漏(memory leak)。

为了解决这种情况, ECMAScript 6 中增加了一种集合类型: weak sets ,弱引用只会保存对象的弱引用

而不会保存引用的原始值。弱引用不会阻止垃圾回收如果它仅仅只是保存了引用而不是原始值。

创建 Weak Sets(WeakSet)

弱引用集合构造函数: WeakSet

let set = new WeakSet(),
    key = {}, key1 = key

set.add(key)

console.log(set)
key = null
console.log(set.has(key))
console.log(set.has(key1))
console.log(set.has(null))
console.log(set)

+RESULTS:

WeakSet { [items unknown] }
false
true
false
WeakSet { [items unknown] }
undefined

浏览器环境输出结果:

img

Set 和 WeakSet 对比

Set 中添加对象,添加的是对该对象的引用,因此保存该对象的变量值发生变化,并不影响该对象在集合中的事实。

WeakSet 中添加的是该变量的原始值??变量值一旦改变,集合中的内容将随之改变(由 JavaScript 引擎处理)。

{% note info %}
TODO: Set 保存引用?WeekSet 保存原始值??有啥区别??
{% endnote %}

这里我们将对比两种集合在不同形式下的运行结果,通过对比分析来搞清楚集合中引用和原始值的概念。

Set, WeakSet 添加对象的结果

let set = new Set()
let key = { a: 1 }

set.add(key)
console.log(set)
console.log(set.has(key)) // true

let wset = new WeakSet()
let wkey = { a: 1 }

wset.add(wkey)
console.log(wset)
console.log(wset.has(wkey))

+RESULTS:

Set { { a: 1 } }
true
WeakSet { [items unknown] }
true
undefined

这里 WeakSet 结果不直观,下面是浏览器结果:

img

从浏览器端的结果分析:

  1. 两者在内部属性 Entries 中都有一个我们添加的 {a : 1} 对象元素。
  2. WeakSet 没有 size 属性, Set 有 size 属性。

改变对象 key/wkey 的值

let set = new Set()
let key = { a: 1 }

set.add(key)
console.log(set) // 改变之前
key = null
console.log(set) // 改变之后
console.log(set.has(key)) // true

let wset = new WeakSet()
let wkey = { a: 1 }

wset.add(wkey)
console.log(wset) // weak key 改变之前
wkey = null
console.log(wset) // weak key 改变之后
console.log(wset.has(wkey))

+RESULTS: emacs nodejs

Set { { a: 1 } }
Set { { a: 1 } }
false
WeakSet { [items unknown] }
WeakSet { [items unknown] }
false
undefined

浏览器环境输出结果:

img

结果:

  1. 对于 Set 对象变量 key 值得改变并不会影响 Set 中 {a:1} 对象

    Set 存放的是对象 {a:1} 的引用,即在 set.add(key) 之后,实际上是有两个引用指向了
    {a:1} 对象,一个是 key 这个变量,一个是集合 set 中的某个位置上的变量(假设为: fkey)。
    根据引用的特性, key 的释放并不会影响 {a:1} 这个对象本身在内存中的存在,即不会影响 fkey
    对这个对象的影响,从而并不影响 set 的内容。

  2. WeakSet 中的 {a:1} 没有了

    WeakSet 我们说它添加的是 wkey 的原始值,即使直接和 wkey 这个变量的原始值挂钩的,
    执行 wkey = null 就是讲它的原始值发生改变,最终将影响 WeakSet 。

针对 #2 中的 WeakSet 情况,将程序改造一下:

let set = new Set()
let key = { a: 1 }
let key1 = key

set.add(key)
console.log(set) // 改变之前
key = null
console.log(set) // 改变之后
console.log(set.has(key)) // true

console.log('-------- 楚河汉界 ---------')
let wset = new WeakSet()
let wkey = { a: 1 }
let wkey1 = wkey

wset.add(wkey)
console.log(wset) // weak key 改变之前
wkey = null
console.log(wset) // weak key 改变之后
console.log(wset.has(wkey))
console.log(wset.has(wkey1))

+RESULTS:

Set { { a: 1 } }
Set { { a: 1 } }
false
-------- 楚河汉界 ---------
WeakSet { [items unknown] }
WeakSet { [items unknown] }
false
true
undefined

再来看看输出结果:

img

我们得到了令人意外的结果:

  1. 并没有显示的 wset.add(wkey1) 但是最后的 wset.has(wkey1) 的结果却是 true
  2. wset 集合中的 {a:1} 依然存在。

要理解这个问题,则需要知道“强引用”和“弱引用”的区别:

强引用和弱引用

我们都知道 JavaScript 的垃圾回收机制中有一个相关知识点就叫做引用计数,即一个对象如果有被其他变量

引用那么这个对象的引用计数就 +1 如果这个变量被释放该对象的引用计数就 -1 一旦引用计数为 0

垃圾回收机制就会将这个对象回收掉,因为没有人再使用它了。

*强引用(Set)*:相当于让该对象的引用计数 +1 ,如 Set 集合保存了对象的引用导
致引用计数 +1 ,在拥有该对象的变量 key 的值怎么变化都不会导致引用计数为 0 从而阻止了垃圾回收器将其回收掉。

弱引用(WeakSet): 对对象的引用不会计入到引用计数中,即将 wkey 加入到 WeakSet 中,并不会引起

wkey 指向的那个对象的引用计数 +1 ,因此只要释放了 wkey 对其的引用,对象的引用计数就变成 0 了,因此

此时只有 wkey 指向 {a:1} 这个对象,改变 wkey 就会改变 WeakSet 中的内容,因为这个内容已经被

回收掉了。

根据上面的结论,我们就知道为什么我们增加了一行 let key1 = key 之后, {a:1} 对象依然会在 wset 中因为此时 {a:1} 引用计数不为 0 并没有被释放掉。

Maps

es6 的 Map 类型是一个有序的键值对列表, key 和 value 可以是任意类型,并且 key
不会发生类型强转,也就是说 5"5" 属于不同的两个键,和对象不一样(对象把他
们当做一个键,因为对象的 key 最终表示形式为 string 内部有发生强制转换)。

map.set(key, value)map.get(key)

Map 实例可以通过 setget 方法去设置键值对然后获取该值。

let map = new Map()
map.set('title', 'u es6')
map.set('year', 2019)

console.log(map)
console.log(map.get('title'))
console.log(map.get('year'))
console.log(map[0])

+RESULTS:

Map { 'tit
u es6
2019
undefined

map 数据的内部存储格式({ 'key' => value }):

img

新增函数列表

分类 函数名 描述 其他
Function
Object Object.is(v1, v2) v1 是否是 v2 弥补 === 不能判断 +0,-0 和 NaN,NaN
Object.assign(target, ...sources) 合并对象,浅拷贝,赋值运算
Object.getPrototypeOf(obj) 取原型对象
Object.setPrototypeOf(obj, protoObj) 设置原型对象
Object.getOwnPropertySymbols(obj) 获取对象所有符号属性 Object.keys, Object.getOwnPropertyNames 不能取符号属性
String str.codePointAt(n) Unicode编码值 str.charCodeAt(n)
str.fromCodePoint(s) 根据编码转字符 str.fromCharCode(s)
str.normalize() 将字符的不同表示方式统一成一种表示形式 undefined, "NFC", "NFD", "NFKC", or "NFKD"
str.repeat(n) 将字符串重复 n 遍,作为新字符串返回 'x'.repeat(3) => 'xxx'
RexExp