在《JavaScript忍者秘籍》书中提到,我们有四种不同的方式进行函数调用:
- 作为一个函数进行调用,这是最简单的形式。
- 作为一个方法进行调用,在对象上进行调用,支持面向对象编程。
- 作为构造器进行调用,创建一个新对象。
- 通过
apply()
或call()
方法进行调用。
作为一个曾经的小镇做题家,一些关键词自然而然就冒出来了,this、闭包、原型......这些概念都不陌生,但总感觉有些散乱。我发现,顺着书中这几种函数调用方式,层层递进,倒是可以帮助更好的理解和串联这些知识点。
1. 作为函数调用
这里指的就是最简单直接的调用方式:
js
function sum(a, b) {
return a + b;
}
sum(1, 2)
函数封装了一段逻辑,给一个输入,得到一个输出。函数最大的好处是它是可以复用的,而不是在每个需要的地方都写一段重复的代码。用好函数往往可以让代码变得更清晰。
使用函数时必然要关注它的作用域 。简单来说,函数内是局部变量,只能在这个函数中访问,而全局变量可以在程序的任何代码中访问。但在JS中比这复杂,因为函数是可以嵌套 的,也可以作为参数传递。比如下面的例子:
js
function outer() {
let value = 0;
function inner(n) {
value += n;
return value;
}
return inner;
}
const fn = outer();
fn(1); // 1
fn(2); // 3
此时inner()
作为内部函数 是可以访问到外部函数outer()
内定义的局部变量的,即使它们是两个不同的函数 。另外,正常来说,一个函数执行完成后,所有函数内定义的局部变量都会被回收,但这里不同:outer()
执行完成了,返回了一个函数,而这个函数内仍然能访问value
这个局部变量(value
并没有被回收 )------即"闭包 "。因为JS支持更灵活的函数使用,所以也就引出了这些概念,这在Go语言中也是一摸一样的。
2. 作为方法进行调用
即函数作为对象的一个属性,如下所示:
js
const dog = {
name: "wangcai",
age: 1,
sayHi: function () {
console.log("Hello~");
},
};
dog.sayHi(); // Hello~
JS中可以用对象表示一些属性的集合,比如一只狗有名字和年龄。当然这个对象也可以有方法,比如这只狗会打招呼。如果想在打招呼的时候报出自己的名字怎么做呢?当然我们可以直接用对象名访问,即dog.name
,但还有一种更友好的方式------this
(在Java中,关键字"this"表示当前对象的引用,而在JS中,"this"是支持面向对象编码的主要手段之一)。
js
const dog = {
name: "wangcai",
age: 1,
sayHi: function () {
console.log("Hello,my name is ", this.name);
},
};
dog.sayHi(); // Hello,my name is wangcai
这样就清晰多了,不用管变量名的dog是哪个dog,this就是本狗了。那普通函数不在对象中,它调用时的this是什么呢?
js
function getThis() {
return this;
}
getThis() === window // true
在浏览器中,答案就是全局环境window。回到前面dog的例子,下面代码中this又是什么呢?是本狗吗?答案我们在后面揭晓。
js
const dog = {
name: "wangcai",
age: 1,
sayHi: function () {
console.log("Hello,my name is ", this.name);
},
};
const sayHi = dog.sayHi;
sayHi();
3. 作为构造器进行调用
如果我有很多条狗,那么用对象来一个个声明就略显笨拙了。你可能会写一个createDog
的函数,来按模式批量生产dog。
js
function createDog(name, age) {
return {
name,
age,
sayHi: function () {
console.log("Hello,my name is ", this.name);
},
};
}
const dog = createDog("wangcai", 1);
但同样,有一种更"面向对象",更友好的方式------构造函数(constructor,ES6的"class"就是构造函数的语法糖) 。
js
function Dog(name, age) {
this.name = name;
this.age = age;
this.sayHi = function () {
console.log("Hello,my name is ", this.name);
};
}
const dog = new Dog("wangcai", 1);
构造函数需要搭配"new"关键字使用 ,它将自动返回一个新的对象。另外,构造函数一般用大写开头,同时它应该是一个名词,而不是动词。
构造函数只是一个约定,语言本身并没有限制你如何使用它。你可以直接执行Dog("wangcai",i)
,但这样没有什么意义,还会产生一些意外的全局变量。
我们可以结合前一节的"对象方法"来理解构造器的调用过程:
- 创建一个空对象
- 在该对象上执行这个函数(函数调用时的this指向这个对象)
- 最后将这个对象返回,如果没有显式的返回值(还差了一个步骤,暂时没涉及,后面再说)
JS中几乎所有对象都有自己的构造函数,对于使用字面量语法 声明的对象,它的构造函数就是Object。
js
const dog = { name: "wangcai" };
JS中有三种方式创建一个对象。
- 字面量语法,如
const obj = { value: 100 }
Object.create()
- 使用new调用构造函数初始化一个对象
只有通过Object.create(null)
创建的对象是没有构造函数的,也没有"原型"。
对象与构造函数之间通过原型进行关联。还是以我们的dog为例:
js
const dog = new Dog("wangcai", 1);
Object.getPrototypeOf(dog) === Dog.prototype // true
"当构造函数搭配 new
使用时,该函数的 prototype数据属性将用作新对象的原型 。默认情况下,函数的prototype是一个普通的对象 。这个对象具有一个属性:constructor,它是对这个函数本身的一个引用。 constructor 属性是可编辑、可配置但不可枚举的"。所以,我们通过Object.getPrototypeOf(dog).constructor
可以直接获取到对象的构造函数。下面就是浏览器控制台打印出的dog对象:
那对象的原型又是什么呢?它是JS中一种独特的机制,它的特点如下:
- 每个对象都有一个私有属性指向另一个名为原型 (prototype)的对象。当访问一个对象属性时,如果属性不存在,就会继续查找这个对象的原型属性。
- 原型对象也有一个自己的原型,层层向上直到一个对象的原型为null 。
Object.prototype
的原型始终为null且不可更改。
所以,一个对象不仅有实例属性,还有原型属性,它们都可以被访问到,只是原型属性是不可枚举的。
js
const o = { value: 100};
Object.keys(o); // ['value']
o.valueOf(); // valueOf是构造函数Object的prototype上定义的方法,可以正常访问
再回顾new
进行初始化的过程,是缺了什么步骤呢?就是将对象的原型指向构造函数的prototype。我们可以按这个规则模拟一个new函数。
js
function Dog(name, age) {
this.name = name;
this.age = age;
}
Dog.prototype.sayHi = function () {
console.log("Hello,my name is ", this.name);
};
function myNew(fn, ...args) {
const obj = Object.create(fn.prototype); // 创建一个对象,将对象的原型指向fn.prototype
const res = fn.apply(obj, args);
return res ?? obj;
}
const dog = myNew(Dog, "wangcai", 1);
dog.sayHi(); // Hello,my name is wangcai
Object.getPrototypeOf(dog) === Dog.prototype; // true
大家细心会发现,这个例子中将sayHi
函数放在了Dog的prototype 对象上,而不像之前在函数内通过this.sayHi
声明。两种方式new出来的对象都是能调用sayHi()
的,唯一的区别是:一个是通过对象的实例属性访问,而另一个是通过原型属性访问。后者看起来是这样的:
原型属性具有一些优点,比如在这个例子中,每个new出来的对象访问的都是同一个sayHi
函数(定义在Dog的prototype对象上),而不是重新拷贝,这样节省内存。同时,sayHi
函数只需修改一次,就能应用到所有实例化的对象中。
js
Dog.prototype.sayHi = function () {
console.log("Good morning~");
};
dog.sayHi(); // Good morning~
原型链的特性还能支持我们实现对象的"继承"。首先我们用class语法试下继承的效果。
js
// 基类
class Animal {
constructor(name) {
this.name = name;
}
eat() {
console.log(`${this.name} is eating`);
}
}
//派生类
class Dog extends Animal {
constructor(name, age) {
super(name);
this.age = age;
}
sayHi() {
console.log(`Hello, my name is ${this.name}, I am ${this.age} years old`);
}
}
const dog = new Dog("wangcai", 1);
dog.sayHi(); // Hello, my name is wangcai, I am 1 years old
dog.eat(); // wangcai is eating
分析一下,age
与name
都在dog对象的实例属性上,而sayHi()
函数在dog对象的原型上,即 Dog.prototype
。再往上一层,Dog.prototype
这个对象的原型是 Animal.prototype
,Animal.prototype
上具有eat()
方法。那再往上一层呢?Animal.prototype这个对象的原型是Object,再往上就到原型链顶端null了。
接下来相信大家也有概念怎么手动实现继承了。下面是一个示例:
js
function Animal(name) {
this.name = name;
}
Animal.prototype.eat = function () {
console.log(`${this.name} is eating`);
};
function Dog(name, age) {
this.age = age;
Animal.call(this, name); // 将Animal的实例属性放到Dog对象上
Object.setPrototypeOf(Dog.prototype, Animal.prototype); // 设置原型链
}
Dog.prototype.sayHi = function () {
console.log(`Hello, my name is ${this.name}, I am ${this.age} years old`);
};
const dog = new Dog("wangcai", 1);
dog.sayHi();
dog.eat();
4. 通过 apply()
或 call()
方法进行调用
js
const dog = {
name: "wangcai",
age: 1,
sayHi: function () {
console.log("Hello,my name is ", this.name);
},
};
const sayHi = dog.sayHi;
sayHi();
再回顾前面的例子,揭晓答案,结果是Hello,my name is undifined
,显然不是本狗了。
为什么会这样呢?因为JS中函数的"this"是由调用点决定的(在运行时确定) ,而不是在函数声明处决定。这就让人很困扰了,本狗的名字都对不上了,这样真的好吗?其实这样是为了提供更大的灵活性------支持动态上下文。
js
function sayHi() {
console.log("Hello,my name is ", this.name);
}
const dog = {
name: "wangcai",
sayHi,
};
const cat = {
name: "miaomiao",
sayHi,
};
dog.sayHi(); // Hello,my name is wangcai
cat.sayHi(); // Hello,my name is miaomiao
通过允许this
由调用点动态确定,可以让同一个函数在不同的对象上使用。另外,JS也提供了显式指定函数执行时的this为某个对象的方法,即apply()
或call()
。
js
const dog = {
name: "wangcai",
age: 1,
sayHi: function () {
console.log("Hello,my name is ", this.name);
},
};
const sayHi = dog.sayHi;
sayHi.apply(dog); // Hello,my name is wangcai
sayHi.call(dog); // Hello,my name is wangcai
很开心,本狗又回来了。apply()
与call()
只有函数传参上的差异,在使用时看哪个方便用哪个就行。
总结
通过体验函数这四种不同的调用方式,我们逐渐接触了JS中一些底层的知识点:作为函数调用时的闭包 ,作为方法调用时的this 指向,作为构造器调用时的原型。JS中的函数非常灵活,也很强大,并且与对象有着密切的联系。
参考:
- 《JavaScript忍者秘籍》
- 《You Dont Know JS》
- MDN 继承与原型链