文章简介
JavaScript 中一切皆对象!
面向对象编程和异步操作可以说是 JavaScript 语言中最重要的两大知识点,本篇文章作为【JavaScript 漫游】专栏的第 016 篇文章,对 JavaScript 语言中面向对象编程的基础知识、this 关键字、对象的继承等重要知识点进行了记录。
- 面向对象编程
this
关键字- 对象的继承
Object
对象的相关方法
文章目录
面向对象编程
概述
面向对象编程(Object Oriented Programming
,缩写为 OOP)是目前主流的编程范式。它将真实世界各种复杂的关系,抽象为一个个对象,然后由对象之间的分工与合作,完成对真实世界的模拟。
每一个对象都是功能中心,是对单个事物的抽象,也是一个封装了属性(property
)和方法(method
)的容器,具有明确分工,可以完成接受信息、处理数据、发出信息等任务。
对象可以复用,通过继承机制还可以定制。因此,面向对象编程具有灵活、代码可复用、高度模块化等特点,易于维护和开发,比起由一系列函数或指令组成的传统的过程式编程(procedural programming),更适合多人合作的大型软件项目。
构造函数
典型的面向对象编程语言,比如 C++ 和 Java,都有类(class)的概念。类是对象的模板,对象是类的实例。
JavaScript 语言的对象体系,不是基于类的,而是基于构造函数(constructor
)和原型链(prototype
)的。
所谓构造函数,就是专门用来生成实例对象的函数。它就是一个普通的函数,但是有自己的特征和用法。
var Person = function () {
this.age = 18;
};
构造函数的特点有两个
- 函数体内部使用了
this
关键字,代表了所要生成的对象实例 - 生成对象的时候,必须使用
new
关键字
new 命令
基本用法
new
命令的作用,就是执行构造函数,返回一个实例对象。
var Person = function () {
this.age = 18;
};
var person = new Person();
person.age; // 18
使用 new
命令时,根据需要,构造函数可以接受参数。
var Person = function (num) {
this.age = num;
};
var person = new Person(35);
person.age = 35;
原理
使用new
关键字时,它后面的函数依次执行下面的步骤。
- 创建一个空对象,作为将要返回的对象实例
- 将这个空对象的原型,指向构造函数的
prototype
属性 - 将这个空对象赋值给函数内部的
this
关键字 - 开始执行构造函数内部的代码
构造函数内部,this
指的是一个新生成的空对象,所有针对this
的操作,都会发生在这个空对象上。构造函数之所以叫构造函数,就是说这个函数的目的,就是操作一个空对象(即 this
对象),将其构造为需要的样子。
如果构造函数内部有 return
语句,而且 return
后面跟着一个对象,new
命令会返回 return
语句指定的对象;否则,就会不管 return
语句,返回 this
对象。
var Person = function (num) {
this.age = num;
return 80;
};
(new Person(80)) === 80; // false
var Person = function (num) {
this.age = num;
return { age: 80 };
};
(new Person(35)).age; // 80
如果对普通函数(内部没有this
关键字的函数)使用new
命令,则会返回一个空对象。
function getMessage(){
return 'this is a message';
};
var msg = new getMessage();
msg // {}
typeof msg; // "object"
new
命令简化的内部流程,可以用下面的代码表示。
function _new(/* 构造函数 */ constructor, /* 构造函数参数 */ params) {
// 将 arguments 对象转为数组
var args = [].slice.call(arguments);
// 取出构造函数
var constructor = args.shift();
// 创建一个空对象,继承构造函数的 prototype 属性
var context = Object.create(constructor.prototype);
// 执行构造函数
var result = contructor.apply(context, args);
// 如果返回结果是对象,就直接返回,否则返回 context 对象
return (typeof result === 'object' && result != null) ? result : context;
};
// 实例
var person = _new(Person, 18);
new.target
函数内部可以使用 new.target
属性。如果当前函数是 new
命令调用,new.target
指向当前函数,否则为 undefined
function f(){
console.log(new.target === f);
};
f(); // false
new f(); // true
this 关键字
涵义
this
关键字是一个非常重要的语法点。不理解它的含义,大部分开发任务都无法完成。
this
就是属性或方法当前所在的对象。
var person = {
name: '张三',
describe: function (){
return '姓名:' + this.name;
};
};
person.describe(); // "姓名:张三"
由于对象的属性可以赋给另一个对象,所以属性所在的对象是可以变的,即 this
的指向是可变的。
var A = {
name: '张三',
describe: function () {
return '姓名:' + this.name;
};
};
var B = {
name: '李四',
};
B.describe = A.describe;
B.describe(); // '姓名:李四'
只要 describe
函数被赋给另一个对象,this
的指向就会变。
JS 语言之中,一切皆对象,运行环境也是对象,所以函数都是在某个对象之中运行,this
就是函数运行时所在的对象(环境)。这本来并不会让用户糊涂,但是 JS 支持运行环境动态切换,也就是说,this
的指向是动态的,没有办法事先确定到底指向哪个对象,这才是最让初学者感到困惑的地方。
实质
JavaScript 语言之所以有 this
的设计,跟内存里面的数据结构有关系。
var obj = { foo: 5 };
上面的代码将一个对象赋值给变量 obj
。JS 引擎会先在内存里面,生成一个对象 { foo: 5 }
,然后把这个对象的内存地址赋值给变量 obj
。也就是说,变量 obj
是一个地址(reference)。后面如果要读取 obj.foo
,引擎先从 obj
拿到内存地址,然后再从该地址读出原始的对象,返回它的 foo
属性。
原始的对象以字典结构保存,每一个属性名都对应一个属性描述对象。举例来说,上面例子的 foo
属性,实际上是以下面的形式保存的。
{
foo: {
[[value]]: 5,
[[writable]]: true,
[[enumerable]]: true,
[[configurable]]: true
}
}
注意,foo
属性的值保存在属性描述对象的value
属性里面。
这样的结构是很清晰的,问题在于属性的值可能是一个函数。
var obj = { foo: function () {}};
这时,引擎会将函数单独保存在内存中,然后再将函数的地址赋值给 foo
属性的 value
属性
{
foo: {
[[value]]: 函数的地址
...
}
}
由于函数是一个单独的值,所以它可以在不同的环境(上下文)执行。
var f = function () {};
var obj = { f: f };
// 单独执行
f();
// obj 环境执行
f();
JavaScript 运行在函数体内部,引用当前环境的其他变量。
var f = function (){
console.log(x);
};
上面代码中,函数体里面使用了变量x
。该变量由运行环境提供。
现在问题就来了,由于函数可以在不同的运行环境执行,所以需要有一种机制,能够在函数体内部获得当前的运行环境(context)。所以,this
就出现了,它的设计目的就是在函数体内部,指代函数当前的运行环境。
var f = function (){
console.log(this.x);
};
var x = 1;
var obj = {
f: f,
x: 2,
};
// 单独执行
f(); // 1
// obj 环境执行
obj.f); // 2
使用场合
全局环境
全局环境下使用 this
,它指的就是顶层对象 window
。
this === window; // true
function f() {
console.log(this === window);
}
f(); // true
构造函数
构造函数中的 this
,指的是实例对象。
var Obj = function (p) {
this.p = p;
};
var o = new Obj('Hello World!');
o.p; // 'Hello World!'
对象的方法
如果对象的方法里面包含 this
,this
的指向就是方法运行时所在的对象。该方法赋值给另一个对象,就会改变 this
的指向。
var obj = {
foo: function () {
console.log(this);
}
};
obj.foo(); // obj
(obj.foo = obj.foo)() // window
(false || obj.foo)() // window
(1, obj.foo)() // window
上面代码中,obj.foo
就是一个值。这个值真正调用的时候,运行环境已经不是 obj
了,而是全局环境,所以 this
不再指向 obj
。
可以这样理解,JS 引擎内部,obj
和 obj.foo
储存在两个内存地址,称为地址一和地址二。obj.foo()
这样调用时,是从地址一和地址二,因此地址二的运行环境是地址一,this
指向 obj
。但是,上面三种情况,都是直接取出地址二进行调用,这样的话,运行环境都是全局环境,因此 this
指向全局环境。
使用注意点
避免多层 this
由于 this
的指向是不确定的,所以切勿在函数中包含多层的 this
var o = {
f1: function () {
console.log(this);
var f2 = function () {
console.log(this);
}();
};
};
o.f1();
// Object
// Window
实际执行的是下面的代码
var temp = function () {
console.log(this);
};
var o = {
f1: function () {
console.log(this);
var f2 = temp();
};
};
一个解决方法是在第二层改用一个指向外层 this
的变量。
var o = {
f1: function (){
console.log(this);
var that = this;
var f2 = function () {
console.log(that);
}();
}
};
o.f1();
// Object
// Object
使用一个变量固定
this
的值,然后内层函数调用这个变量,是非常常见的做法,务必要掌握
避免数组处理方法中的 this
数组的 map
和 foreach
方法,允许提供一个函数作为参数。这个函数内部不应该使用 this
。
var o = {
v: 'hello',
p: ['a1', 'a2'],
f: function f() {
this.p.forEach(function (item) {
console.log(this.v + ' ' + item);
});
};
};
o.f();
// undefined a1
// undefined a2
foreach
方法的回调函数中的 this
,其实是指向 window
对象,因此取不到 o.v
的值。原因就是内层的 this
不指向外部,而指向顶层对象。
解决这个问题的一种方法,使用中间变量固定 this
。
var o = {
v: 'hello',
p: [ 'a1', 'a2' ],
f: function f() {
var that = this;
this.p.forEach(function (item) {
console.log(that.v+' '+item);
});
}
}
o.f()
// hello a1
// hello a2
另一种方法是将 this
当作 foreach
方法的第二个参数,固定它的运行环境。
var o = {
v: 'hello',
p: [ 'a1', 'a2' ],
f: function f() {
this.p.forEach(function (item) {
console.log(this.v + ' ' + item);
}, this);
}
}
o.f()
// hello a1
// hello a2
避免回调函数中的 this
回调函数中的 this
往往会改变指向,最好避免使用。
var o = new Object();
o.f = function (){
console.log(this === o);
};
// jQuery 的写法
$('#button').on('click', o.f);
点击按钮以后,控制台会显示 false
。原因是此时 this
不再指向 o
对象,而是指向按钮的 DOM 对象,因为 f
方法是在按钮对象的环境中被调用的。这种细微的差别,很容易在编程中忽视,导致难以察觉的错误。
绑定 this 的方法
this
的动态切换,固然为 JS 创造了巨大的灵活性,但也使得编程变得困难和模糊。有时,需要把 this
固定下来,避免出现意想不到的情况。JS 提供了 call
、apply
、bind
这三个方法,来切换/固定 this
的指向。
Function.prototype.call()
函数实例的 call
方法,可以指定函数内部的 this
的指向(即函数执行时所在的作用域),然后在所指定的作用域中,调用该函数。
var obj = {};
var f = function () {
return this;
};
f() === window // true
f.call(obj) === obj // true
call
方法的参数,应该是一个对象。如果参数为空,null
和 undefined
,则默认传入全局对象。
如果call
方法的参数是一个原始值,那么这个原始值会自动转成对应的包装对象,然后传入 call
方法。
var f = function (){
return this;
};
f.call(s);
// Number {[[PrimitiveValue]]: 5}
call
方法还可以接受多个参数。
func.call(thisValue, arg1, arg2, ...);
call
的第一个参数就是 this
所要指向的那个对象,后面的参数则是函数调用时所需的参数。
function add(a, b) {
return a + b;
}
add.call(this, 1, 2) // 3
call
方法的一个应用是调用对象的原生方法。
var obj = {};
obj.hasOwnProperty('toString') // false
// 覆盖掉继承的 hasOwnProperty 方法
obj.hasOwnProperty = function () {
return true;
};
obj.hasOwnProperty('toString') // true
Object.prototype.hasOwnProperty.call(obj, 'toString') // false
Function.prototype.apply()
apply
方法的作用与 call
方法类似,也是改变 this
指向,然后再调用该函数。唯一的区别就是,它接收一个数组作为函数执行时的参数,使用格式如下。
func.apply(thisValue, [arg1, arg2, ...]);
apply
方法的第一个参数也是 this
所要指向的那个对象,如果设为 null
或 undefined
,则等同于指定全局对象。第二个参数则是一个数组,该数组的所有成员依次作为参数,传入原函数。原函数的参数,在 call
方法中必须一个个添加,但是在 apply
方法中,必须以数组形式添加。
function f(x, y) {
console.log(x + y);
};
f.call(null, 1, 1); // 2
f.apply(null, [1, 1]); // 2
Function.prototype.bind()
bind()
方法用于函数体内的 this
绑定到某个对象,然后返回一个新函数。
var d = new Date();
d.getTime(); // 1707614036126
var print = d.getTime;
print(); // Uncaught TypeError: this is not a Date object.
getTime()
方法内部的 this
,绑定 Date
对象的实例,赋给变量 print
以后,内部的 this
已经不指向 Date
对象的实例了。
bind()
方法可以解决这个问题。
var counter = {
count: 0,
inc: function () {
this.count++;
};
};
var func = counter.inc.bind(counter);
func();
counter.count; // 1
this
绑定到其他对象也是可以的。
var counter - {
count: 0,
inc: function () {
this.count++;
};
};
var obj = {
count: 100
};
var func = counter.inc.bind(obj);
func();
obj.count; // 101
bind()
可以接受更多的参数,将这些参数绑定原函数的参数。
var add = function(x, y) {
return x * this.m + y * this.n;
};
var obj = {
m: 2,
n: 2
};
var newAdd = add.bind(obj, 5);
newAdd(5); // 20
如果 bind()
方法的第一个参数是 null
和 undefined
,等于将 this
绑定到全局对象,函数运行时 this
指向顶层对象(浏览器为 window
)。
function add(x, y) {
return x + y;
};
var plus5 = add.bind(null, 5);
plus5(10); // 15
对象的继承
JavaScript 语言通过原型对象(prototype)实现对象的继承。
原型对象概述
构造函数的缺点
JavaScript 通过构造函数生成新对象,因此构造函数可以视为对象的模板。实例对象的属性和方法,可以定义在构造函数内部。
function Cat(name, color) {
this.name = name;
this.color = color;
};
var cat1 = new Cat('大毛', '白色');
cat1.name; // '大毛'
cat1.color; // '白色'
通过构造函数为实例对象定义属性,虽然很方便,但是有一个缺点。同一个构造函数的多个实例之间,无法共享属性,从而造成对系统资源的浪费。
function Cat(name, color) {
this.name = name;
this.color = color;
this.meow = function (){
console.log('喵喵喵~');
};
}
var cat1 = new Cat('大毛', '白色');
var cat2 = new Cat('二毛', '黑色');
cat1.meow == cat2.meow; // false
cat1
和 cat2
是同一个构造函数的两个实例,它们都具有 meow
方法。由于 meow
方法是生成在每个实例对象上面,所以两个实例就生成了两次。也就是说,每新建一个实例,就会新建一个 meow
方法。这既没有必要,又浪费系统资源,因为所有 meow
方法都是同样的行为,完全应该共享。
这个问题的解决方法,就是 JS 的原型对象(prototype)。
prototype
属性的作用
JavaScript 继承机制的设计思想就是,原型对象的所有属性和方法,都能被实例对象共享。也就是说,如果属性和方法定义在原型上,那么所有实例对象都能共享,不仅节省了内存,还体现了实例对象之间的联系。
JavaScript 规定,每个函数都有一个 prototype
属性,指向一个对象。
function f() {};
typeof f.prototype // "object"
对于普通函数来说,该属性基本无用。但是,对于构造函数来说,生成实例的时候,该属性会自动成为实例对象的原型。
function Animal (name) {
this.name = name;
};
Animal.prototype.color = 'white';
var cat1 = new Animal('大毛');
var cat2 = new Animal('二毛');
cat1.color; // 'white'
cat2.color; // 'white'
function Animal(name) {
this.name = name;
};
Animal.prototype.color = 'white';
var cat1 = new Animal('大毛');
var cat2 = new Animal('二毛');
cat1.color; // "white"
cat2.color; // "white"
构造函数 Animal
的 prototype
属性,就是实例对象 cat1
和 cat2
的原型对象。原型对象上添加一个 color
属性,实例对象就都共享了该属性。
原型对象的属性不是实例对象自身的属性。只要修改原型对象,变动就立刻会体现在所有实例对象上。
Animal.prototype.color = 'yellow';
cat1.color; // "yellow"
cat2.color; // "yellow"
当实例对象本身没有某个属性或方法时,它会到原型对象去寻找该属性或方法。这就是原型对象的特殊之处。
如果实例对象自身就有某个属性或方法,它就不会再去原型对象寻找这个属性或方法。
cat1.color = 'black';
cat1.color; // 'black'
cat2.color; // 'yellow'
Animal.prototype.color; // 'yellow'
总结来说,原型对象的作用,就是定义所有实例对象共享的属性和方法。
原型链
JavaScript 规定,所有对象都有自己的原型对象(prototype)。一方面,任何一个对象,都可以充当其他对象的原型;另一方面,由于原型对象也是对象,所以它也有自己的原型。因此,就会形成一个原型链(prototype chain):对象到原型,再到原型的原型。
如果一层层地上溯,所有对象的原型最终都可以上溯到 Object.prototype
,即 Object
构造函数的 prototype
属性。也就是说,所有对象都继承了 Object.prototype
的属性。这就是所有对象都有 valueOf
和 toString
方法的原因,因为这是从 Object.prototype
继承的。
Object.prototype
的原型是 null
。null
没有任何属性和方法,也没有自己的原型。因此,原型链的尽头就是 null
。
Object.getPrototypeOf(Object.prototype);
// null
读取对象的某个属性时,JS 引擎先寻找对象本身的属性,如果找不到,就到它的原型去找,如果还是找不到,就到原型的原型去找。如果直到最顶层的 Object.prototype
还是找不到,则返回 undefined
。如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性,这叫做覆盖(overriding)。
constructor 属性
prototype
对象有一个 constructor
属性,默认指向 prototype
对象所在的构造函数。
function P (){};
P.prototype.constructor === P; // true
由于constructor
属性定义在 prototype
对象上面,意味着可以被所有实例对象继承。
function P (){};
var p = new P();
p.constructor === P; // true
p.constructor === p.prototype.constructor; // true
p.hasOwnProperty('constructor'); // false
constructor
属性的作用是,可以得知某个实例对象,到底是哪一个构造函数产生的。
function F(){};
var f = new F();
f.constructor === F; // true
f.constructor === RegExp; // false
另一方面,有了 constructor
属性,就可以从一个实例对象新建另一个实例。
function Constr(){};
var x = new Constr();
var y = new x.constructor();
y instanceof Constr; // true
constructor
属性表示原型对象与构造函数之间的关系。如果修改了原型对象,一般会同时修改 constructor
属性,防止引用的时候出错。
function Person(name) {
this.name = name;
}
Person.prototype.constructor === Person; // true
Person.prototype = {
method: function(){};
};
Person.prototype.constructor === Person; // false
Person.prototype.constructor === Object; // true
所以,修改原型对象时,一般要同时修改 constructor
属性的指向。
// 坏的写法
C.prototype = {
method1: function (...){...};
// ...
};
// 好的写法
C.prototype = {
constructor: C,
method1: function(...){...},
// ...
};
// 更好的写法
C.prototype.method1 = function (...){...};
如果不能确定 constructor
属性是什么函数,还有一个办法:通过 name
属性,从实例得到构造函数的名称。
function Foo(){};
var f = new Foo();
f.constructor.name; // "Foo"
instanceof 运算符
instanceof
运算符返回一个布尔值,表示对象是否为某个构造函数的实例。
var v = new Vehicle();
v instanceof Vehicle(); // true
instanceof
运算符的左边是实例对象,右边是构造函数。它会检查右键构造函数的原型对象(prototype
),是否在左边对象的原型链上。
由于 instanceof
会检查整个原型链,因此同一个实例对象,可能会对多个构造函数都返回 true
。
var d = new Date();
d instanceof Date; // true
d instanceof Object; // true
instanceof
运算符的一个用处,是判断值的类型。
var x = [1, 2, 3];
var y = {};
x instanceof Array; // true
y instanceof Object; // true
注意,instanceof
运算符只能用于对象,不适用原始类型的值。对于 undefined
和 null
,instanceof
运算符总是返回 false
。
构造函数的继承
让一个构造函数继承另一个构造函数,是非常常见的需求。这可以分成两步实现。第一步是在子类的构造函数中,调用父类的构造函数。
function Sub(value) {
Super.call(this);
this.prop = value;
};
第二步,是让子类的原型指向父类的原型,这样子类就可以继承父类原型。
Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;
Sub.prototype.method = '...';
Sub.prototype
是子类的原型,要将它赋值为 Object.create(Super.prototype)
,而不是直接等于Super.prototype
。否则后面两行对Sub.prototype
的操作,会连父类的原型Super.prototype
一起修改掉。
另外一种写法是 Sub.prototype
等于一个父类实例。
Sub.prototype = new Super();
上面这种写法也有继承的效果,但是子类会具有父类实例的方法。有时,这可能不是我们需要的,所以不推荐使用这种写法。
function Shape(){
this.x = 0;
this.y = 0;
};
Shape.prototype.name = function (x, y) {
this.x += x;
this.y += y;
console.log('Shape moved.');
};
// 第一步,子类继承父类的实例
function Rectangle() {
Shape.call(this); // 调用父类构造函数
};
// 另一种写法
function Rectangle() {
this.base = Shape;
this.base();
};
// 第二步,子类继承父类的原型
Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle;
采用这样的写法以后,instanceof
运算符会对子类和父类的构造函数,都返回 true
。
var rect = new Rectangle();
rect instanceof Rectangle; // true
rect instanceof Shape; // true
多重继承
JS 不提供多重继承功能,即不允许一个对象同时继承多个对象。但是,可以通过变通方法,实现这个功能。
function M1(){
this.hello = 'hello';
};
function M2(){
this.world = 'world';
};
function S(){
M1.call(this);
M2.call(this);
}
// 继承 M1
S.prototype = Object.create(M1.prototype);
// 继承链上加入 M2
Object.assign(S.prototype, M2.prototype);
// 指定构造函数
S.prototype.constructor = S;
var s = new S();
s.hello // 'hello'
s.world // 'world'
Object 对象的相关方法
Object.getProtypeOf()
返回参数对象的原型。这是获取原型对象的标准方法。
var F = function () {};
var f = new F();
Object.getPrototypeOf(f) === F.prototype; // true
下面是几种特殊对象的原型。
// 空对象的原型是 Object.prototype
Object.getPrototypeOf({}) === Object.prototype; // true
// Object.prototype 的原型是 null
Object.getPrototype(Object.prototype) === null; // true
// 函数的原型是 Function.prototype
function f() {};
Object.getPrototypeOf(f) === Function.prototype; // true
Object.setPrototypeOf()
为参数设置原型对象,返回该参数对象。它接受两个参数,第一个是现有对象,第二个是原型对象。
var a = {};
var b = { x: 1 };
Object.setPrototypeOf(a, b);
Object.getPrototypeOf(a) === b; // true
a.x; // 1
Object.create()
生成实例对象的常用方法是,使用 new
命令让构造函数返回一个实例。但是很多时候,只能拿到一个实例对象,它可能根本不是由构建函数生成的,那么能不能从一个实例对象,生成另一个实例对象呢?
JS 提供了 Object.create
方法,用来满足这种需求。该方法接受一个对象作为参数,然后以它为原型,返回一个实例对象。该实例完全继承原型对象的属性。
// 原型对象
var A = {
print: function () {
console.log('hello');
};
};
// 实例对象
var B = Object.create(A);
Object.getPrototypeOf(B) === A; // true
B.print(); // hello
B.print === A.print; // true
Object.prototype.isPrototypeOf()
实例对象的 isPrototypeOf
方法,用来判断对象是否为参数对象的原型。
var o1 = {};
var o2 = Object.create(o1);
var o3 = Object.create(o2);
o2.isPrototypeOf(o3) // true
o1.isPrototypeOf(o3) // true
Object.prototype.__proto__
实例对象的__proto__
属性,返回该对象的原型,该属性可读写。
var obj = {};
var p = {};
obj.__proto__ = p;
Object.getPrototypeOf(obj) === p // true
根据语言标准,__proto__
属性只有浏览器才需要部署,其他环境可以没有这个属性。它前后的两根下划线,表名它本质是一个内部属性,不应该对使用者暴露。因此,应该尽量少用这个属性,而是用 Object.getPrototypeOf()
和 Object.setPrototypeOf()
,进行原型对象的读写操作。