前言

针对ES6+的一些设计进行学习,感觉知识点很杂,故进行一个梳理和记录。


作用域

作用域(scope)规定了变量能够被访问的“范围”,离开了这个“范围”变量便不能被访问,作用域分为全局作用域局部作用域

全局作用域

.html文件中,<script> 标签的最外层就是全局作用域,在此声明的变量在函数内部也可以被访问。

1
2
3
4
5
6
7
<script>
//外部作用域

function inner() {
//局部作用域
}
</script>

.js 文件中,最外层就是就是全局作用域,在此声明的变量在函数内部也可以被访问。

1
2
3
4
5
6
const name = '花猪';  //全局变量(在全局作用域中)

(function () {
//局部作用域
console.log('你好,' + name)
})();

注:该函数的写法为立即执行函数(匿名函数),其语法有以下两种方式:

  1. (function () { console.log('此处为函数体') })();
  2. (function () { console.log('此处为函数体') } ());

必须有分号;

局部作用域

局部作用域进一步被分为函数作用域块作用域

  • 函数作用域

    在函数内部声明的变量只能在函数内部被访问,外部无法直接访问。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function count(x, y) {
    //函数作用域
    var res = x + y //函数内部声明的变量
    console.log(res)
    }

    count(10, 8) //18

    console.log(res) //报错
  • 块作用域

    使用 {} 包裹的代码称为代码块,代码块内部声明的变量外部无法直接访问。

    1
    2
    3
    4
    5
    6
    7
    {
    //块作用域
    let name = '花猪' //代码块内部声明的变量
    console.log(name) //花猪
    }

    console.log(name) //报错

    注:

    1. 块作用域的例子:if()for()…中的 {} 均为块作用域
    2. 块作用域是函数作用域的子集

var、let和const

  • var

    1. var声明的范围是函数作用域,这意味着在块作用域外也可以访问到其声明的内容

      1
      2
      3
      4
      if(true) {
      var name = '花猪'
      }
      console.log(name) //花猪

      在函数作用域外无法访问

      1
      2
      3
      4
      function sayHi() {
      var name = '花猪'
      }
      console.log(name) //报错:name is not defined
    2. var定义一个变量后,可以不初始化,其值为undefined

    3. var赋值不仅可以改变保存的值,还可以改变值的类型

      1
      2
      3
      var age = 18
      age = '花猪'
      console.log(age) //花猪
    4. var存在声明提升:所有被var声明的变量会被拉到函数作用域的顶部。

      1
      2
      3
      4
      (function () {
      console.log(age)
      var age = 18
      })()

      上述代码不会报错,因为ECMAScript运行时把它看成等价于如下代码:

      1
      2
      3
      4
      5
      (function () {
      var age
      console.log(age)
      age = 18
      })()

      var存在声明提升。

    5. var在全局作用域中声明的变量会成为window对象的属性(这一点与let不同)

      1
      2
      3
      4
      <script>
      var name = '花猪'
      console.log(window.name) //花猪
      </script>
  • let

    1. let声明的范围是块作用域

      1
      2
      3
      4
      if(true) {
      let name = '花猪'
      }
      console.log(name) //报错:name is not defined
    2. let在全局作用域中声明的变量不会成为window对象的属性(这一点与var不同)

      1
      2
      3
      4
      <script>
      let name = '花猪'
      console.log(window.name) //undefined
      </script>
  • const

    1. constlet基本相同,唯一区别是const声明变量时必须赋值(初始化),且该变量不可修改

      1
      2
      const age = 18
      age = 20 //报错:给常量赋值

声明原则:

  1. 尽量不使用var

  2. const优先,let次之

  3. 可以不使用任何关键字在函数作用域内声明一个全局变量,但不推荐这么做。

    1
    2
    3
    4
    (function () {
    name = '花猪' //定义全局变量
    })()
    console.log(name) //花猪

作用域链

作用域链本质上是底层的变量查找机制

  • 在函数被执行时,会优先在当前函数作用域中查找变量
  • 如果当前作用域查找不到则会逐级查找父级作用域直到全局作用域
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 全局作用域
let a = 1
let b = 2

function f() {
// 局部作用域
let a = 1
function g() {
// 局部作用域
a = 2
console.log(a)
}
g() // 调用g
}
f() // 调用f

关于作用域链:

  • 嵌套关系的作用域串联起来形成了作用域链
  • 相同作用域链中按着从小到大的规则查找变量
  • 子作用域能够访问父作用域,父级作用域无法访问子级作用域

垃圾回收机制

垃圾回收机制(Garbage Collection),简称GC。

  • 垃圾回收的基本思路:确定哪个变量不会再使用,然后释放它的内存。

  • 垃圾回收的过程是周期性的。即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。

    JavaScript内存的生命周期:

    • 内存分配:当声明变量、函数、对象的时候,系统会自动为他们分配内存
    • 内存使用:即读写内存,也就是使用变量、函数等
    • 内存回收:使用完毕,由垃圾回收自动回收不再使用的内存

    说明:

    1. 全局变量一般不会回收(关闭页面回收);
    2. 一般情况下局部变量的值, 不用了, 会被自动回收掉
  • 垃圾回收机制的两个算法:

    1. 引用计数法(已淘汰):定义“内存不再使用”,就是看一个对象是否有指向它的引用,没有引用了就回收对象

      • 跟踪记录被引用的次数
      • 如果被引用了一次,那么就记录次数1,多次引用会累加
      • 如果减少一个引用就减1
      • 如果引用次数是0 ,则释放内存
    2. 标记清除法

      • 标记清除算法将“不再使用的对象”定义为“无法达到的对象”。

      • 就是从根部(在JS中就是全局对象)出发定时扫描内存中的对象。 凡是能从根部到达的对象,都是还需要使用的。

      • 那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收。

如果不再用到的内存没有及时释放,就叫做内存泄漏

闭包

  • 概念:一个函数对周围状态的引用捆绑在一起,内层函数中访问到其外层函数的作用域。

    简单理解:闭包 = 内层函数 + 外层函数的变量

  • 闭包的基本格式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    function outer() {
    let a = 1
    function fn() {
    console.log(a)
    }
    return fn //必须得返回函数。(不能是fn(),它表示立即执行)
    }
    const execute = outer()
    execute() //调用

    注意:这里let a = 1function fn()构成闭包范围

    化简写法:

    1
    2
    3
    4
    5
    6
    7
    8
    function outer() {
    let a = 1
    return function() { //必须得返回函数。(不能是fn(),它表示立即执行)
    console.log(a)
    }
    }
    const execute = outer()
    execute() //调用
  • 闭包的作用:封闭数据,提供操作,外部也可以访问函数内部的变量。

  • 闭包的应用:实现数据的私有。

    案例:函数计数器。

    如果想统计一个函数的调用次数,可以使用以下方式:

    1
    2
    3
    4
    5
    let count = 0
    function fn() {
    count++
    console.log(`函数被调用${count}次`)
    }

    但是这种方式会出现问题,即count作为全局变量被暴露在外,容易被篡改

    1
    2
    3
    4
    5
    6
    7
    > fn()
    函数被调用1次
    > fn()
    函数被调用2次
    > count = 999
    > fn()
    函数被调用1000次

    这显然不是期望的结果,那么如何将count被保护起来,使其除了fn()函数无法被篡改。可以通过闭包的方式实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function fn() {
    let count = 0
    function fun() {
    count++
    console.log(`函数被调用${count}次`)
    }
    return fun
    }

    const execute = fn()
    1
    2
    3
    4
    5
    6
    7
    > execute()
    函数被调用1次
    > execute()
    函数被调用2次
    > count = 999
    > execute()
    函数被调用3次

    这里count = 999实际上是重新声明了一个全局变量。

  • 闭包可能引起内存泄漏(分析上面的例子)

    正常情况下,变量countfn()函数作用域中的变量,当调用fn()之后,应该被回收,但是上述案例由于闭包的设计:首先变量execute为全局变量,除关闭的情况下不会被回收,但是execute调用fn(),而fn()返回fun(),这就导致全局变量execute可以指向fun(),而fun()使用了变量count,所以按照标记清除法,从全局(global)可以指向变量count,因此count不会被回收,导致内存泄漏。

函数

函数提升

函数提升类似于var声明的变量提升,是指函数在声明之前即可被调用。(函数提升出现在相同作用域当中)

1
2
3
4
5
6
// 调用函数
foo() // 可以调用
// 声明函数
function foo() {
console.log('声明之前即被调用...')
}

但是函数表达式不存在提升现象

1
2
3
4
bar()  // 错误
var bar = function() {
console.log('函数表达式不存在提升现象...')
}

函数参数

默认值

声明函数时为形参赋值即为参数的默认值,如果参数未自定义默认值时,参数的默认值为 undefined,调用函数时没有传入对应实参时,参数的默认值被当做实参传入。

1
2
3
4
5
6
function sum(x = 0, y = 0) {
console.log(x + y)
}

sum() // 0
sum(4, 6) // 10

参数扩展与收集

如果遇到不定参数的情况,可以使用动态参数剩余参数

动态参数

arguments 是函数内部内置的伪数组变量,它包含了调用函数时传入的所有实参。可以通过下标访问的方式得到传入函数的实参。

1
2
3
4
5
6
7
8
9
10
function sum() {  // 形参可以写,也可以不写
let result = 0
for(let i = 0; i < arguments.length; i++) {
result += arguments[i]
}
console.log(result)
}

sum() // 0
sum(4, 6) // 10

因为arguments 是一个伪数组,因此不可直接进行迭代,如果需要,可以将arguments对象转换为数组再进行迭代:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function sum() {  // 形参可以写,也可以不写
let result = 0
const arr = Array.from(arguments) // 方式一
// const arr = [...arguments] //方式二
arr.forEach(function(item, index) {
result += item
console.log(item)
console.log(index)
})
console.log(result)
}

sum() // 0
sum(4, 6) // 10

剩余参数

可以借助展开运算符...实现剩余参数。剩余参数应该置于函数形参的末尾处,用于获取多余的实参,它是一个真数组(Array实例)。

扩展:展开运算符...最主要的作用就是展开数组

1
2
3
const arr = [1, 2, 3]
// 展开运算符 可以展开数组
console.log(...arr) //1 2 3

虽然输出为1 2 3,但实际上...arr等价于1,2,3

应用:合并数组:

1
2
3
4
5
6
7
8
9
10
function count() {
console.log(arguments.length)
}

let arr = [1, 2, 3]

count(-1, ...arr) // 4
count(...arr, 5) // 4
count(-1, ...arr, 5) // 5
count(...arr, ...[5, 6, 7]) // 6
1
2
3
4
5
6
7
8
9
10
11
function sum(x = 0, y = 0, ...arr) {
let result = x + y
arr.forEach(item => {
result += item
})
console.log(result)
}

sum() // 0
sum(4, 6) // 10
sum(20, 30, 50) // 100

剩余参数并不影响arguments的使用:

1
2
3
4
5
6
7
8
function sum(x = 0, y = 0, ...arr) {
let result = x + y
arr.forEach(item => {
result += item
})
console.log(result)
console.log(arguments.length) // 输出参数的总数
}

在实际开发中,通常使用剩余参数。

箭头函数

基本语法格式:

1
2
3
4
const sum = (x, y) => {
console.log(x + y)
}
sum(4, 6) // 10

当只有一个参数的时候,参数括号()可以省略:

1
2
3
4
const square = x => {
console.log(x * x)
}
square(10) // 100

记忆:没有参数多参数的时候需要括号。

当函数体只有一行代码时,可以省略函数体的大括号{}

1
2
const square = x => console.log(x * x)
square(10) // 100

当函数体只有一行代码时,可以省略return

1
2
const square = x => x * x
console.log(square(10)) // 100

加括号的函数体返回对象字面量表达式,即箭头函数可以直接返回一个对象:

1
2
3
const person = (name, age) => ({ Name: name, Age: age})
console.log(person('花猪', 18)) // { Name: '花猪', Age: 18 }
console.log(typeof person('花猪', 18)) // object

理解:上述代码的大括号可以理解为对象(object)的大括号,但是为了不让ECMAScript混淆,需要在对象外用小括号包()起来。

  • 箭头函数没有arguments动态参数,但可以使用剩余参数:

    1
    2
    3
    4
    5
    6
    7
    8
    const sum = (...arr) => {
    let result = 0
    for (let i = 0; i < arr.length; i++) {
    result += arr[i]
    }
    console.log(result)
    }
    sum(4, 6) // 10
  • 箭头函数不会创建自己的this,它只会从自己的作用域链的上一层沿用this(可以理解为箭头函数的this实际上调用的是父级的this

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const person = {
    Name: '花猪',
    Age: 18,
    saySomething: function (words) {
    console.log(this) // { Name: '花猪', Age: 18, saySomething: [Function: saySomething] }
    const say = () => {
    console.log(`${this.Name}说:${words}`)
    console.log(this) // { Name: '花猪', Age: 18, saySomething: [Function: saySomething] }
    }
    say()
    }
    }
    person.saySomething('你好') // 花猪说:你好

    如果使用DOM事件回调函数,箭头函数的this为全局的window,因此操作DOM时不推荐使用箭头函数。

解构赋值

解构赋值是一种快速为变量赋值的简洁语法,本质上仍然是为变量赋值,分为数组解构对象解构两大类型。

数组解构

数组解构可以将数组的单元值快速批量赋值给一系列变量,语法如下述代码所示:

1
2
3
4
5
6
let arr = [1, 2, 3]
let [a, b, c] = arr

console.log(a); // 1
console.log(b); // 2
console.log(c); // 3

声明和赋值可以合并:

1
2
3
4
let [a, b, c] = [1, 2, 3]
console.log(a); // 1
console.log(b); // 2
console.log(c); // 3
  • 变量的数量大于单元值数量时,多余的变量将被赋值为 undefined

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

    为了防止undefined的出现,可以设置参数默认值:

    1
    2
    3
    4
    let [a = 0, b = 0, c = 0] = [1]
    console.log(a); // 1
    console.log(b); // 0
    console.log(c); // 0
  • 变量的数量小于单元值数量时,可以通过展开运算符...获取剩余单元值,但只能置于最末位

    1
    2
    3
    4
    5
    6
    let [a, b, c, ...arr] = [1, 2, 3, 4, 5]
    console.log(a); // 1
    console.log(b); // 2
    console.log(c); // 3
    console.log(arr[0]) // 4
    console.log(arr[1]) // 5
  • 可以按需赋值:

    1
    2
    3
    4
    let [a, b, , d] = [1, 2, 3, 4]
    console.log(a) // 1
    console.log(b) // 2
    console.log(d) // 4
  • 可以结构多维数组:

    1
    2
    3
    4
    5
    6
    const [a, b, c] = [1, 2, [3, 4]]
    console.log(a) // 1
    console.log(b) // 2
    console.log(c) // [3,4]
    console.log(c[0]) // 3
    console.log(c[1]) // 4
    1
    2
    3
    4
    5
    const [a, b, [c, d]] = [1, 2, [3, 4]]
    console.log(a) // 1
    console.log(b) // 2
    console.log(c) // 3
    console.log(d) // 4

对象解构

对象解构可以将对象属性和方法快速批量赋值给一系列变量,语法如下述代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
const person = {
Name: '花猪',
Age: 18,
sayHi: function () {
console.log('你好')
}
}

const {Name, Age, sayHi} = person // 必须和对象中的属性名以及方法名保持一致

console.log(Name) // 花猪
console.log(Age) // 18
sayHi() // 你好

注意:对象结构的变量名必须和对象中的属性名和方法名一致

  • 对象中找不到与变量名一致的属性时变量值为 undefined,允许初始化变量赋默认值:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    const person = {

    }

    const {Name = '张三', Age = '20', sayHi = () => {console.log('Hello')}} = person

    console.log(Name) // 张三
    console.log(Age) // 20
    sayHi() // Hello
  • 支持多维解构赋值:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    const person = {
    Name: '花猪',
    Age: 18,
    Addr: {
    Province: '四川',
    City: '成都',
    }
    }

    const {Name, Age, Addr: {Province, City}} = person

    console.log(Name) // 花猪
    console.log(Age) // 18
    console.log(Province) // 四川
    console.log(City) // 成都

下面以常用的JSON数据为例进行解构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const msg = {
"code": 200,
"msg": "获取新闻列表成功",
"data": [
{
"id": 1,
"title": "5G商用自己,三大运用商收入下降",
"count": 58
},
{
"id": 2,
"title": "国际媒体头条速览",
"count": 56
},
{
"id": 3,
"title": "乌克兰和俄罗斯持续冲突",
"count": 1669
},
]
}
  • 解构msg对象,并赋值给函数receive()

    1
    2
    3
    4
    5
    6
    function receive ({code, msg, data}) {
    console.log(code)
    console.log(msg)
    console.log(data)
    }
    receive(msg)
  • 为了避免变量名混淆,还可以起别名:

    1
    2
    3
    4
    5
    6
    function receive ({code: Mycode, msg: Mymsg, data: Mydata}) {
    console.log(Mycode)
    console.log(Mymsg)
    console.log(Mydata)
    }
    receive(msg)

原型

构造函数

在介绍原型之前先来看一下JavaScript中的构造函数,JavaScript可以通过构造函数实现面向对象思想中的封装特性。

下面是构造函数的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function Animal(name) {
// 创建实例成员(包括属性和方法)
this.name = name
this.setName = function (name) {
this.name = name
}
this.getName = function () {
console.log(this.name)
}
this.print = function () {
console.log(this) // this指向实例化对象
}
}
// 创建静态成员(包括属性和方法)
Animal.eyes = 2
Animal.walk = function () {
console.log('正在走路...')
console.log(this)
}

let cat = new Animal('cat')
cat.getName() // cat
cat.setName('猫猫')
cat.getName() // 猫猫
cat.print() // 指向实例化对象cat

let dog = new Animal('dog')
dog.getName() // dog
dog.setName('狗狗')
dog.getName() // 狗狗
dog.print() // 指向实例化对象dog

console.log(Animal.eyes) // 2
Animal.walk() // 指向构造函数Animal

构造函数在技术上相当于常规函数,为了区分,有几个约定:

  • 构造函数的命名以大写字母开头。
  • 创建实例对象的时候需要用new关键字来执行,该过程被称为实例化
  • 构造函数内部无需写return(返回值无效),构造函数会在实例化的过程中自动返回创建的对象。
  • 实例成员:可以通过this关键字添加实例对象的属性方法,构造函数的this会指向新的实例化对象。
  • 静态成员:可以通过构造函数名.属性构造函数名.方法的方式创建构造函数的属性方法,静态成员方法中的this会指向构造函数本身。

上述例子的构造函数方法很好用,但是在具体使用中会存在浪费内存的问题,参考下面的案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Animal(name) {
this.name = name
this.sleep = function () {
console.log(`${this.name} 正在睡觉`)
}
}

let cat = new Animal('cat')
cat.sleep() // cat 正在睡觉

let dog = new Animal('dog')
dog.sleep() // dog 正在睡觉

console.log(cat.sleep === dog.sleep) // false

所有动物(Animal)都有sleep方法,但是由于该sleep方法是实例成员,因此cat.sleepdog.sleep不同,这就会导致每次实例化一个Animal对象,相应的都会为一个sleep方法开辟一块内存空间。但实际上不希望如此,我们希望将共有的东西抽取出来,以上述案例为例:我们更希望为sleep方法只开辟一块内存空间,以后所有的实例对象调用此方法都会指向该地址,这样可以节省内存。

原型对象 prototype

(事实上,构造函数中的公共方法是通过原型进行函数分配的,这可以解决上述案例中浪费内存的问题。)

JavaScript规定,每一个构造函数都有一个prototype属性,指向另一个对象,被称为原型对象。在对象实例化的过程中不会多次创建原型上的函数,因此可以将公共的方法直接定义在原型对象(prototype)上,所有的实例对象可以共享这些方法。

可以通过构造函数名.prototype.方法为原型对象添加方法,可以通过实例对象调用该方法,下面来看具体用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Animal(name) {
this.name = name
}
Animal.prototype.print = function () {
console.log(this)
}
// 给原型对象添加公共方法sleep
Animal.prototype.sleep = function () {
console.log(`${this.name} 正在睡觉`)
}

let cat = new Animal('cat')
cat.sleep() // cat 正在睡觉
cat.print() // 指向实例化对象cat

let dog = new Animal('dog')
dog.sleep() // dog 正在睡觉
dog.print() // 指向实例化对象dog

console.log(cat.sleep === dog.sleep) // true

同构造函数中的this一样,原型对象中的this会指向新的实例化对象,因此cat.sleepdog.sleep相同。

constructor属性

在每个原型对象中都有一个constructor属性(constructor构造函数),该属性指向该原型对象的构造函数:

1
2
3
4
5
6
function Animal(name) {
this.name = name
}

console.log(Animal.prototype.constructor) // [Function: Animal]
console.log(Animal.prototype.constructor === Animal) // true

利用constructor就可以通过原型对象找到该原型对象的构造函数。

至此可以找到一个双向关系:

  • 构造函数通过prototype找到该构造函数的原型对象
  • 原型对象通过constructor找到该原型对象的构造函数

分析如下应用场景:想要为Animal构造函数添加多个共享方法,有下面两种选择:

  1. 通过Animal.prototype.方法在Animal的原型对象上逐个添加方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function Animal(name) {
    this.name = name
    }

    Animal.prototype.sleep = function () {
    console.log(`${this.name} 正在睡觉`)
    }
    Animal.prototype.eat = function () {
    console.log(`${this.name} 正在吃饭`)
    }
    // ...

    但是如果有很多方法,这样的写法代码冗余且不直观。

  2. 可以考虑给prototype原型对象以对象赋值的形式统一添加多个方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    function Animal(name) {
    this.name = name
    }

    Animal.prototype = {
    sleep: function () {
    console.log(`${this.name} 正在睡觉`)
    },
    eat: function () {
    console.log(`${this.name} 正在吃饭`)
    },
    // ...
    }

    但是这种方式存在一个问题,原有的prototype原型对象中包含constructor属性,它可以指向构造函数。但是通过上述方法,相当于创建了一个新的对象,把原有的prototype原型对象给覆盖掉了,这样就抹去了constructor属性,使得没有办法通过该原型对象找回构造函数了:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    function Animal(name) {
    this.name = name
    }

    Animal.prototype = {
    sleep: function () {
    console.log(`${this.name} 正在睡觉`)
    },
    eat: function () {
    console.log(`${this.name} 正在吃饭`)
    },
    }

    console.log(Animal.prototype.constructor) // [Function: Object]
    console.log(Animal.prototype.constructor === Animal) // false

    使用这种方式当然没有问题,只是我们还需要利用constructor手动指回构造函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    function Animal(name) {
    this.name = name
    }

    Animal.prototype = {
    constructor: Animal, // 利用constructor手动将该原型对象指回Animal
    sleep: function () {
    console.log(`${this.name} 正在睡觉`)
    },
    eat: function () {
    console.log(`${this.name} 正在吃饭`)
    },
    }

    console.log(Animal.prototype.constructor) // [Function: Animal]
    console.log(Animal.prototype.constructor === Animal) // true

对象原型 _proto_

为什么实例对象可以访问到原型对象中的共有方法呢?因为在每个实例对象中都有一个__proto__属性,被称为对象原型,它可以指向创建该实例对象的构造函数的prototype原型对象。

对象原型 __proto__中同样包含constructor属性,指向构造函数。

1
2
3
4
5
6
7
8
9
10
11
function Animal(name) {
this.name = name
}

let cat = new Animal('猫猫')

// 实例对象cat中的__proto__属性 指向 构造函数Animal的原型对象prototype
console.log(cat.__proto__ === Animal.prototype) // true

// __proto__属性中的constructor属性 指向 造函数Animal
console.log(cat.__proto__.constructor === Animal) // true

构造函数、原型对象、对象原型的关系

至此,我们可以画出构造函数、原型对象(prototype)以及对象原型(__proto__)三者之间的关系:

原型继承

可以通过子类.prototype = new 父类构造函数()的形式实现对象间的继承关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function Animal(name) {
this.name = name
this.sleep = function () {
console.log(`${this.name} 正在睡觉`)
}
}

function Cat(name) {
this.name = name
this.eat = function () {
console.log(`${this.name} 正在吃鱼`)
}
}
// 通过prototype继承Animal
Cat.prototype = new Animal()
// Cat的原型对象的constructor属性 指向 Animal构造函数
console.log(Cat.prototype.constructor === Animal) // true

function Dog(name) {
this.name = name
this.eat = function () {
console.log(`${this.name} 正在啃骨头`)
}
}
// 通过prototype继承Animal
Dog.prototype = new Animal()
// Dog的原型对象的constructor属性 指向 Animal构造函数
console.log(Dog.prototype.constructor === Animal) // true

let cat = new Cat('狸花')
cat.sleep() // 狸花 正在睡觉
cat.eat() // 狸花 正在吃鱼

let dog = new Dog('柯基')
dog.sleep() // 柯基 正在睡觉
dog.eat() // 柯基 正在啃骨头

原型链

基于原型对象的继承使得不同构造函数的原型对象关联在一起,并且这种关联的关系是一种链状的结构。将原型对

象的链状结构关系称为原型链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function Animal(name) {
this.name = name
}

function Cat(name) {
this.name = name
}
// Cat通过prototype继承Animal
Cat.prototype = new Animal()

let cat = new Cat('狸花') // 创建cat实例对象

// cat实例对象的对象原型 是 Cat构造函数的原型对象
console.log(cat.__proto__ === Cat.prototype) // true

// Cat构造函数的原型对象的对象原型 是 Animal构造函数的原型对象
console.log(cat.__proto__.__proto__ === Animal.prototype) // true
console.log(Cat.prototype.__proto__ === Animal.prototype) // true

// Animal构造函数的原型对象的对象原型 是 Object构造函数的原型对象
console.log(cat.__proto__.__proto__.__proto__ === Object.prototype) // true
console.log(Animal.prototype.__proto__ === Object.prototype) // true

// Object是顶级父类,其原型对象的对象原型 是 null
console.log(Object.prototype.__proto__) // null

原型链的查找规则:

  1. 当访问一个对象的属性(包括方法)时,首先查找这个对象自身有没有该属性。
  2. 如果没有就查找它的原型(也就是__proto__指向的prototype原型对象)
  3. 如果还没有就查找原型对象的原型,以此类推直到找到Object为止(null)

__proto__原型对象的意义就在于为对象成员查找机制提供一个方向,或者说一条路线。

可以使用instanceof运算符检测实例对象或者构造函数的prototype原型对象是否出现在某个实例对象的原型链上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Animal(name) {
this.name = name
}

function Cat(name) {
this.name = name
}
// Cat通过prototype继承Animal
Cat.prototype = new Animal()

let cat = new Cat('狸花') // 创建cat实例对象

console.log(cat instanceof Cat) // true

console.log(cat instanceof Animal) // true
console.log(Cat.prototype instanceof Animal) // true

console.log(cat instanceof Object) // true
console.log(Animal.prototype instanceof Object) // true

深拷贝

注:浅拷贝和深拷贝只针对引用类型。

谈及深拷贝,肯定先介绍一下浅拷贝,可以使用Object.assign(target, source)方法将source浅拷贝至target,下面来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 声明一个person
const person = {
name: '花猪',
age: 18,
addr: {
province: 'SiChuan',
city: 'ChengDu',
},
}

// 声明一个another,用于复制person
const another = {}

// 将person浅拷贝至another
Object.assign(another, person)
// 修改another的属性
another.name = '张三'
another.age = 80
another.addr.province = 'ZheJiang'
another.addr.city = 'HangZhou'

console.log(person)
// { name: '花猪', age: 18, addr: { province: 'ZheJiang', city: 'HangZhou' } }

console.log(another)
// { name: '张三', age: 80, addr: { province: 'ZheJiang', city: 'HangZhou' } }

可以看到,这种方式成功修改了nameageaddr三个属性,但是在修改anotheraddr属性时,personaddr属性也被修改了,这是因为遇到对象属性,该方法实际上还是拷贝的对象属性的地址,显然不是我们希望的结果。

我们希望拷贝的是对象,而不是地址,这就是深拷贝

深拷贝有三种方式可以实现:

  1. 通过递归实现。
  2. 通过Lodash库中的cloneDeep()方法实现。
  3. 通过JSON.stringify()方法实现。

递归实现

遍历对象中的属性,如果遇到对象类型就递归拷贝:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 声明一个person
const person = {
name: '花猪',
age: 18,
addr: {
province: 'SiChuan',
city: 'ChengDu',
},
}

// 声明一个another,用于复制person
const another = {}

// 编写递归函数实现深拷贝
function deepCopy(targer, source) {
for (let item in source) {
// 如果属性是对象类型,实现递归拷贝
if(source[item] instanceof Object) {
targer[item] = {}
deepCopy(targer[item], source[item])
} else {
targer[item] = source[item]
}
}
}

// 利用递归函数将person深拷贝至another
deepCopy(another, person)
// 修改another的属性
another.name = '张三'
another.age = 80
another.addr.province = 'ZheJiang'
another.addr.city = 'HangZhou'

console.log(person)
// { name: '花猪', age: 18, addr: { province: 'SiChuan', city: 'ChengDu' } }

console.log(another)
// { name: '张三', age: 80, addr: { province: 'ZheJiang', city: 'HangZhou' } }

cloneDeep() 实现

可以通过第三方库Lodash中的cloneDeep()函数实现:

Lodash库地址:Lodash 简介 | Lodash中文文档 | Lodash中文网

node环境下载Lodash库:npm i --save lodash

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Load the full build.
var _ = require('lodash');

// 声明一个person
const person = {
name: '花猪',
age: 18,
addr: {
province: 'SiChuan',
city: 'ChengDu',
},
}

// 声明一个another,利用cloneDeep()将person深拷贝至another
const another = _.cloneDeep(person)

// 修改another的属性
another.name = '张三'
another.age = 80
another.addr.province = 'ZheJiang'
another.addr.city = 'HangZhou'

console.log(person)
// { name: '花猪', age: 18, addr: { province: 'SiChuan', city: 'ChengDu' } }

console.log(another)
// { name: '张三', age: 80, addr: { province: 'ZheJiang', city: 'HangZhou' } }

JSON.stringify() 实现

首先利用JSON.stringify()把 person对象转换为JSON字符串,然后再利用JSON.parse()将字符串重新解析为JOSN,并赋给新对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 声明一个person
const person = {
name: '花猪',
age: 18,
addr: {
province: 'SiChuan',
city: 'ChengDu',
},
}

// 声明一个another
// 首先利用JSON.stringify()把 person对象 转换为 JSON 字符串
// 再利用JSON.parse()将 字符串重新解析为JOSN,并赋给 another
const another = JSON.parse(JSON.stringify(person))

// 修改another的属性
another.name = '张三'
another.age = 80
another.addr.province = 'ZheJiang'
another.addr.city = 'HangZhou'

console.log(person)
// { name: '花猪', age: 18, addr: { province: 'SiChuan', city: 'ChengDu' } }

console.log(another)
// { name: '张三', age: 80, addr: { province: 'ZheJiang', city: 'HangZhou' } }

this 关键字

关于 this

普通函数

如果是普通函数中的this,它的指向遵循一个原则:谁调用了函数,那么函数中的this就指向谁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 普通函数
function sayHi() {
console.log(this === global) // true
}
sayHi()

// 普通函数(函数表达式)
const sayHello = function () {
console.log(this === global) // true
}
sayHello()

const person = {
name: '花猪',
// person对象中的普通函数
say: function () {
console.log(this === person) // true
}
}
person.say()

注:

  • 在node环境中,最外层的是global
  • 在浏览器中,最外层的是window

箭头函数

事实上箭头函数并不存在this,箭头函数中访问的 this 不过是箭头函数所在作用域的 this 变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 普通箭头函数(函数表达式)
const sayHello = () => {
console.log(this === global) // false
console.log(this) // {} // 实际上指向为空
}
sayHello()

const person = {
name: '花猪',
// person对象中的箭头函数
say: () => {
console.log(this === global) // false
console.log(this) // {} // 实际上指向为空
}
}
person.say()

注:

  • 在node环境中,this指向为空
  • 在浏览器中,this指向为window

改变 this 指向

事实上在JavaScript中允许指定函数中this的指向,有三种方法可以动态指定普通函数中this的指向,分别为:call()apply()bind()方法。

call()

普通函数可以通过调用call()方法改变this指向,语法如下:

fun.call(thisArg, arg1, arg2, ...)

  • thisArg:在fun函数运行时指定的this
  • arg1arg2...:传递的其他参数
  • 返回值就是fun函数的返回值,它就是调用函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function eat(foodOne, foodTwo) {
console.log(this)
console.log(`${this.name}${foodOne}${foodTwo}`)
}

const cat = {
name: '猫猫'
}

const dog = {
name: '狗狗'
}

eat.call(cat, '鱼', '肉干')
eat.call(dog, '骨头', '包子')

输出如下:

1
2
3
4
{ name: '猫猫' }
猫猫吃鱼和肉干
{ name: '狗狗' }
狗狗吃骨头和包子

apply()

普通函数可以通过调用apply()方法改变this指向,语法如下:

fun.apply(thisArg, [argsArray])

  • thisArg:在fun函数运行时指定的this
  • [argsArray]:传递的参数,必须以数组形式传入,但是fun函数本身的形参并非数组形式
  • 返回值就是fun函数的返回值,它就是调用函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function eat(foodOne, foodTwo) {
console.log(this)
console.log(`${this.name}${foodOne}${foodTwo}`)
}

const cat = {
name: '猫猫'
}

const dog = {
name: '狗狗'
}

eat.apply(cat, ['鱼', '肉干'])
eat.apply(dog, ['骨头', '包子'])

输出如下:

1
2
3
4
{ name: '猫猫' }
猫猫吃鱼和肉干
{ name: '狗狗' }
狗狗吃骨头和包子

bind()

普通函数可以通过调用bind()方法改变this指向,语法如下:

fun.bind(thisArg, arg1, arg2, ...)

  • thisArg:在fun函数运行时指定的this
  • arg1arg2...:传递的其他参数
  • 不同于call()apply()方法,bind() 方法并不会调用函数,而是创建一个指定了 this 值的新函数。因此返回值为由指定的this值和初始化参数改造的原函数拷贝(新函数)
1
2
3
4
5
6
7
8
9
10
11
function food(foodOne, foodTwo) {
console.log(this)
console.log(`${this.name}${foodOne}${foodTwo}`)
}

const dog = {
name: '狗狗'
}

const eat = food.bind(dog, '骨头', '包子')
eat()

输出如下:

1
2
{ name: '狗狗' }
狗狗吃骨头和包子

bind() 是最常用的方法。可以理解为bind() 方法就是创建了一个新函数,与原函数唯一的变化就是改变了this指向。