Javascript 构造函数、new、bind()、call() 原理理解

其它语言开发者初接触 Js 时,会对构造函数、new 关键字有些迷糊,因为这两个概念在其它 OO 语言中是很基本的概念,但在 Js 中用起来和在以前熟悉的语言中有些不一样,引起一些困惑。

问题

在 C++/Java/PHP 中都有 class 的概念,class 有构造函数以及通过 new 实例化对象看起来都很理所当然,没什么可疑惑的。

为什么会对 Js 中的 new 构造函数就会产生一些疑惑呢,因为我们对这两个概念先入为主了。

我们先抛开一般 OO 语言中的 class 等这些语法、概念,从更 “原始” 的角度去理解这两个概念及其作用,理解起来就变得非常简单。

构造函数

构造函数 在 C++/java/PHP 中构造函数在形式上比较特殊,它们需要定义在一个 class 中,并且往往还有固定的函数名等。

抛开这些说,构造函数 首先是一个函数,这个函数基本作用是用来初始化一个实例对象。

在 Js 中,构造函数就是一个普通函数,它的定义也和普通的函数一样:

1
2
3
function Person(name){
this.name = name
}

那么我们要怎么用这个函数 Person 来初始化一个对象呢?
这就要提到 执行环境 这个概念了,也就是 Person 函数中的 this 关键字,它就代表当前函数执行时的 执行环境,在这里也就是我们要初始化的那个对象。

1
2
3
let obj = {}
Person.call(obj, 'laogen')
console.log(obj) // {name: 'laogen'}

这段代码就实现了——调用 Person 这个函数初始化了 obj 这个对象。
也就是说,我们通过 call 调用函数,第一个参数就是该函数的 执行环境,也就是 Person 中的 this

但上面这段实例化对象的代码 “不好看”,不像在 Java/C++ 中直接使用 new 来的简洁,我们用以下代码来模拟实现一个类似 Java/C++ 的实例化语法:

  • 指定一个构造函数及其参数
  • 返回一个实例化的对象
1
2
3
4
5
6
7
8
function New(constructor, ...args){
let obj = {}
constructor.call(obj, ...args)
return obj
}
// 用法如下
let obj3 = New(Person, 'laogen3')
console.log(obj3) // { name: 'laogen3' }

这个用法看起来就很 “像” Java/C++/PHP 中实例化一个对象的用法了。

当然,在 Js 中有同样简洁的方式创建和初始化一个实例:

1
2
let obj2 = new Person('laogen2')
console.log(obj2) // { name: 'laogen2' }

到这里,你肯定就明白 new 一个构造函数到底是怎么回事了。你可以把 new 理解为一个语法糖,其实际工作就是我们上面模拟的 New 函数所做的。

也就是说 new 关键字首先创建了一个对象,然后调用 Person 函数初始化这个对象。

另外一个问题,我们直接调用构造函数 Person() 而不用 new 来调用会是什么样呢? Person() 里面的 this 到底指向什么对象呢?

1
2
3
4
5
function Person(name){
this.name = name
}
Person('laogen4')
console(name) // "laogen4"

我们直接调用 Person() 而不通过 new 调用,结果就是在当前执行环境中创建并初始化了 name 属性,所以直接打印 name 输出了 "laogen4"

也就是说,如果我们直接调用 Person(),那么它的 this 就指向这个函数调用所在的执行环境,如果是在全局环境中调用的,那么这个执行环境: 在 Node.js 中就是 global 对象;在浏览器中通常是 window 对象。

this & call()

我们上面是通过调用 call 函数来完成的,那这个 call 又是怎么实现的呢?

这个 call 函数当然是 Js 本身就支持的,我们通过下面代码模拟实现它,以便更好的理解其原理,因为 this 是 Js 关键字,不可用做标识符,我们用 that 来模拟代替 this:

1
2
3
4
5
6
7
8
9
10
11
12
function Person(name){
// 在这里约定最后一个参数是 `that`
let that = arguments[arguments.length - 1]
that.name = name
}
Person.call = function(that, ...args) {
Person(...args, that)
}

let obj3 = {}
Person.call(obj3, 'laogen3')
console.log(obj3) // { name: 'laogen3' }

通过这段代码可以看出, call() 把它的第一个参数以某种方式传给了被调用的函数 Person(),并作用 Person() 函数的 this (也就是代码中的 that)。

这段代码只是模拟过程,实际上 this 对象并不是通过被调用函数的最后一个传数来传递的,这个是 Js 编译器层面去实现的,这里不细究。

bind()

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

Person.bind(target, ...args){
return () => Person.call(target, ...args)
}

let obj4 = {}
let func = Person.bind(obj4, 'laogen4')
func()
console.log(obj4) // { name: 'laogen4' }

这段代码模拟实现了 bind() 函数的逻辑,bind()call() 的区别在于: bind() 并不调用 Person() 函数,它只是返回一个新函数,在这个新函数里调用了 call()

总结

这篇文主要说明了 Js 构造函数只是普通的函数,和其它 OO 语言不同的是,它并没有很特别的地方,构造函数的主要作用就是初始化实例对象;

通过模拟代码解释了 new 关键字的内部逻辑,以更好的理解构造函数的工作原理;

通过模拟 this 更进一步的理解 执行环境 的概念;

通过模拟 call()bind() 方法,更直观深入的理解两者的逻辑和使用;