JavaScript:类和对象

面向对象的 JavaScript 是编写会自动地分类对象的代码的一种方式,JavaScript 中的对象可以表示现实物体。也就是说,对象可以具有表示特性或_特点的属性,以及表示可以执行的操作的方法。使用口头语言的类比,你可以把对象看作名词,比如「狗」或「汽车」。属性的值是形容词,比如「蓝色」。而方法则是动词,比如「叫」或「开」。

从构造函数开始深入研究面向对象的 JavaScript,如果你有使用其他语言(如 Ruby、Python 等)进行面向对象编程的经验,你可能会惊讶地发现,JavaScript 没有传统的类系统(编译器在其中进行大量看不见的组织操作)。相反,它的类系统直接使用函数和对象来构建。因此在 JavaScript 中,我们并不使用特殊的类语法,而是定义构造函数。

构造函数

之前使用对象字面量表示法创建了对象。以及可以编写能够返回对象的函数。此外,还有另外一种方式可以创建对象,它是面向对象的 JavaScript 的基础:构造函数。

要实例化(即创建)一个新的对象,我们可以使用 new 运算符来调用这个函数:

new SoftwareDeveloper();

首先要注意的是 new 关键字的使用。其次,要注意构造函数的名称 SoftwareDeveloper() 的第一个字母应该大写,以便直观地区别于常规函数。请记住,虽然该函数的名称以大写字母开头,但这并不会自动地使其成为构造函数(即,虽然开发者按照惯例采用驼峰式大小写来命名构造函数,但 JavaScript 语言并不强制要求这样做)。

真正使得 SoftwareDeveloper() 成为构造函数的是:

  • 使用 new 运算符来调用该函数
  • 该函数内部是如何编码的(我们现在就来看看!)

构造函数:结构和语法

构造函数的内部结构如下所示:

function SoftwareDeveloper() {
  this.favoriteLanguage = 'JavaScript';
}

首先,构造函数并不声明局部变量,而是使用 this 关键字来保存数据。以上函数将为所创建的任何对象添加一个 favoriteLanguage 属性,并为其分配一个默认值 'JavaScript'。现在不用太在意构造函数中的 this;只要知道 this 是指在构造函数前面使用 new 关键字创建的新对象即可。我们很快就会详细介绍这个 this

最后一点比较特别的是,这个函数似乎不会返回任何东西!JavaScript 中的构造函数不应该有一个显式的返回值(即使用 return 语句)。

太棒了!既然我们已经看过了结构和语法,那么如何使用它来创建对象呢?

创建一个新的对象

正如我们在上面看到的,让我们使用 new 运算符来创建一个新的对象:

let developer = new SoftwareDeveloper();

创建多个对象

更重要的是:我们还可以使用相同的构造函数来创建尽可能多的对象!

let engineer = new SoftwareDeveloper();
let programmer = new SoftwareDeveloper();

console.log(engineer);// SoftwareDeveloper { favoriteLanguage: 'JavaScript' }

console.log(programmer);// SoftwareDeveloper { favoriteLanguage: 'JavaScript' }

构造函数可以有参数

与常规函数一样,使用构造函数的一个好处是它们也可以接受参数。让我们更新以上构造函数来接受一个参数,并为其分配 name 属性:

function SoftwareDeveloper(name) {
  this.favoriteLanguage = 'JavaScript';
  this.name = name;
}

let instructor = new SoftwareDeveloper('Andrew');
console.log(instructor);// SoftwareDeveloper { favoriteLanguage: 'JavaScript', name: 'Andrew' }

let teacher = new SoftwareDeveloper('Richard');
console.log(teacher);// SoftwareDeveloper { favoriteLanguage: 'JavaScript', name: 'Richard' }

在更新的 SoftwareDeveloper() 函数中,无论传入函数的值是什么,它都将是对象的 name 属性的值。

如果没有「new」

function SoftwareDeveloper(name) {
   this.favoriteLanguage = 'JavaScript';
   this.name = name;
}

let coder = SoftwareDeveloper('David');

console.log(coder);// undefined

如果不使用 new 运算符,则不会创建对象。该函数会像任何其他常规函数一样被调用。由于该函数不会返回任何东西(除了所有函数都会默认返回的 undefined 之外),因此 coder 变量最终会被分配给 undefined

还有一点需要注意:由于这个函数作为一个常规函数被调用,因此 this 的值也会截然不同。

查看对象的构造函数 (instanceOf)

function Developer(name){
  this.name = name;
}
const dev = new Developer('Veronika');
typeof dev; // "object"
dev instanceof Developer; // true

在以上示例中,instanceOf 确认一个特定的构造函数确实创建了一个特定的对象。我们知道这一点,是因为我们在调用 Developer() 构造函数之后直接实例化了 dev 对象。

然而,很多时候并没有这么简单:instanceOf 运算符实际上会测试构造函数是否出现在某个对象的原型链中。这意味着,虽然我们不是总能检查到底是哪个构造函数创建了该对象,但是它使我们能够洞察某个对象可能访问哪些其他的属性和方法。

小结

JavaScript 的类系统是直接使用函数和对象来构建的。使用 new 运算符来调用构造函数可以实例化一个新的对象。相同的构造函数可以用于创建不同的对象。

我们在整个课程中广泛讨论了函数、对象和 this。事实证明,这三者是密切相关的! 我们将在下一部分深入探讨 this 关键字,并仔细分析这三者之间的关系。

延伸

关键字「this」

构造函数中的 this

在上一部分,我们使用了方法中的 this 来访问该方法所属的对象。先看一个例子:

function Cat(name) {
 this.name = name;
 this.lives = 9;
 this.sayName = function () {
   console.log( 'Meow! My name is ' + this.name);
 };
}

const bailey = new Cat();

在上面的 Cat() 构造函数中,sayName 所指向的函数引用了 this.name。之前,我们已经看到过在方法中使用 this,但在这里,this 是指什么呢?

事实证明,当使用 new 运算符来调用构造函数时,this 会被设置为新创建的对象!让我们来看看新的 bailey 对象是什么样的:

{
  name: Bailey,
  sayName: function () {
    console.log('Meow! My name is ' + this.name);
  }
}

在以上代码段中,请注意 this 位于构造函数外部。当你说 this 时,你其实是在说「这个对象」或「当前对象」。因此,sayName() 方法可以使用 this 来访问该对象的 name 属性!这使得以下方法调用成为可能:

bailey.sayName();// Meow! My name is Bailey

this 什么时候会被赋值?

一个常见的误解是,this 指向定义它的对象。事实并非如此!直到某个对象调用使用 this 的方法,this 才会被赋值。换句话说,赋给 this 的值取决于_调用定义 this 的方法的对象。让我们来看一个例子:

const dog = {
  bark: function () {
    console.log('Woof!');
  },
  barkTwice: function () {
    this.bark();
    this.bark();
  }
};
dog.bark();// Woof!

dog.barkTwice();// Woof! Woof!

当我们调用 dog.bark()dog.barkTwice() 时,变量 this 将被设置。由于 this 可以访问调用它的对象,因此 barkTwice 可以使用 this 来访问包含 bark 方法的 dog 对象。

但是,如果我们在 barkTwice 中使用 bark(),而不是 this.bark(),将会怎样?该函数会先在 barkTwice 的作用域内查找一个名为 bark 的局部变量。如果没有找到 bark,则会沿着作用域链继续查找。

综合来看:this.bark() 会告诉 barkTwice 查看 dog(调用该方法的对象)以查找 bark

this 会被设置为什么?

到目前为止,我们已经在许多不同的上下文中看到了 this,比如在方法中,或被构造函数引用。现在,让我们把它们放在一起来看一下!

有四种方式可以调用函数,而每种方式都会不同地设置 this

首先,使用 new 关键字来调用构造函数会将 this 设置为一个新创建的对象。还记得吗,在我们之前创建 Cat() 的实例时,this 被设置为新的 bailey 对象。

另一方面,调用属于一个对象的函数(即方法)会将 this 设置为该对象本身。回想一下前面的示例,dog 对象的 barkTwice 方法能够访问 dog 本身的属性。

第三,单独调用一个函数(即简单地调用一个常规函数)将把 this 设置为 window。如果主机环境是浏览器,则它将是全局对象。

function funFunction() {
  return this;
}

funFunction(); // (返回全局对象, `window`)

第四种调用函数的方式可以让我们自己设置 this!现在不用在意这一点,我们将在下一部分进行深入探讨。

小结

函数、对象和 this 彼此相互关联。当使用 new 运算符来调用构造函数时,this 变量会被设置为新创建的对象。当在对象上调用方法时,this 会被设置为该对象本身。当在浏览器环境中调用函数时,this 会被设置为 window,也被称为全局对象。

除此之外,还有一组方式可以调用函数:使用 apply() 和使用 call()。这两种方式有许多相似之处,每一种方式都允许我们指定如何设置 this。接下来,我们会对它们进行详细分析!

延伸

设置 this

调用函数的更多方式

我们已经看到了调用函数的各种方式,每种方式都有关于 this 值的独特设定。此外,还有两种调用函数的方式:使用 call()apply() 方法。

每个方法都可以直接调用到一个函数本身(毕竟,JavaScript 函数是一级函数,可以有属性和方法)。因此,接收函数本身将被调用,并有一个指定的 this 值,以及所传入的任何参数。

让我们来仔细看看这些方法,从 call() 开始!

call()

call() 是一个直接调用到函数上的方法。我们传递给它一个单一的值,以设置为 this 的值,然后逐个传入该函数的任何参数,用逗号分隔。

请考虑以下函数 multiply(),它只会返回其两个参数的乘积

function multiply(n1, n2) {
  return n1 * n2;
}

multiply(3, 4); // 12
multiply.call(window, 3, 4); // 12

我们得到了相同的结果!这是怎么发生的?我们首先直接调用 call() 方法到 multiply() 函数上。请注意,.call(window, 3, 4) 之前的 multiply 后面没有任何括号! call() 将会处理该调用 multiply() 函数的参数本身!

这一步完成后,我们传递将设置为 this 的值作为 call() 的第一个参数:window。最后,我们逐个传递 multiply() 函数的参数,并用逗号分隔。

multiply.call(window, 3, 4); 执行后,该函数将以 this 的给定值被调用,我们所看到的结果就是 12。在严格模式之外,调用 multiply() 的这两种方式是等价的。

除了调用常规函数之外,我们如何调用附加到对象上的函数(即方法)呢?这是 call() 真正发挥强大功能的地方。使用 call() 来调用方法允许我们从对象中「借用」方法,然后将其用于另一个对象!请看以下对象 mockingbird

const mockingbird = {
  title: 'To Kill a Mockingbird',
  describe: function () {
    console.log(`${this.title} is a classic novel`);
  }
};

mockingbird.describe(); // 'To Kill a Mockingbird is a classic novel'

const pride = {
  title: 'Pride and Prejudice'
};

mockingbird.describe.call(pride); // 'Pride and Prejudice is a classic novel'

让我们来分析一下当 mockingbird.describe.call(pride); 被执行时,究竟发生了什么!首先,call()方法被调用到 mockingbird.describe(它指向一个函数)上。然后,this 的值被传递给 call() 方法:pride

由于 mockingbirddescribe() 方法引用了 this.title,我们需要访问 this 所指向的对象的 title 属性。但是,由于我们已经设置了自己的 this 的值,this.title 的值将会从 pride 对象中被访问!结果,mockingbird.describe.call(pride); 被执行,我们在控制台中看到 'Pride and Prejudice is a classic novel'

如果你打算在传递给它的第一个参数的作用域内调用一个函数,那么 call() 是非常有效的。同样,我们可以利用 apply() 方法达到相同的目的,尽管在参数传入的方式上有所不同。让我们来仔细看看!

apply()

就像 call() 一样,apply() 在一个函数上被调用,不仅可以调用该函数,而且还可以为它关联一个特定的 this 值。但是,apply() 并不是逐个传递参数并用逗号分隔,而是将函数的参数放在一个数组中。请回想一下之前的 multiply() 函数:

function multiply(n1, n2) {
  return n1 * n2;
}

multiply.call(window, 3, 4); // 12
multiply.apply(window, [3, 4]); // 12

使用了 call(),并逐个传递参数。而使用 apply(),则是将 multiply() 函数的所有参数收集到一个数组中,然后再将这个数组传递给 apply()

那么,使用 apply() 来调用对象的方法,又会怎样呢?

const mockingbird = {
  title: 'To Kill a Mockingbird',
  describe: function () {
    console.log(`${this.title} is a classic novel`);
  }
};

const pride = {
  title: 'Pride and Prejudice'
};

mockingbird.describe.call(pride); // 'Pride and Prejudice is a classic novel'
mockingbird.describe.apply(pride); // 'Pride and Prejudice is a classic novel'

请注意,传递给 call()apply() 的第一个参数是相同的(即绑定 this 值的对象)。由于 describe() 方法不接受任何参数,因此 mockingbird.describe.call(pride);mockingbird.describe.apply(pride); 唯一的区别就是方法!这两种方法都会产生相同的结果。

偏向选择其中一种方法

call()apply() 都会在其传入的第一个参数(即作为 this 值的对象)的作用域内调用一个函数。那么什么时候会偏向于选择 call(),或偏向于选择 apply() 呢?

如果你事先并不知道函数所需要的参数个数,那么 call() 的使用可能会受到限制。在这种情况下,apply() 是一个更好的选择,因为它只接受一个参数数组,然后将其解包并传递给函数。请记住,解包可能会略微影响性能,但这种影响并不显著。

const cat = {
  name: 'Bailey'
}
function sayHello(message){
  console.log(`${message},${this.name}`);
}
sayHello.call(cat,'hello'); // "hello,Bailey"
sayHello.apply(cat,['hi']); // "hi,Bailey"

回调和 this

当涉及到回调函数时,this 的值有一些潜在的作用域问题,事情会变得比较棘手。

function invokeTwice(cb) {
  cb();
  cb();
}
const dog = {
  age: 5,
  growOneYear: function() {
    this.age += 1;
  }
};

dog.growOneYear();
dog.age; // 6
invokeTwice(dog.growOneYear);
dog.age; // 6

为何 invokeTwice 不可行呢?因为 invokeTwice 调用 dog.growOneYear,但是当做函数来调用它因此 this 设成了全局对象,而不是 dog 对象,因为使用回调是很常见的现象,因此需要一种保存 this 引用的方式。

使用匿名闭包来保存 this

上述例子中的

invokeTwice(dog.growOneYear);

返回:undefined

这是什么原因呢?事实证明,invokeTwice() 确实会调用 growOneYear,但它是被调用为一个函数而不是一个方法

解决这个问题的一种方式就是使用一个匿名闭包来遮蔽 dog 对象:

invokeTwice(function () {
  dog.growOneYear();
});

dog.age // 7

使用这种方式,调用 invokeTwice() 仍然会将 this 的值设置为 window。但是这对闭包没有影响;在匿名函数中,growOneYear() 方法仍然会被直接调用到 dog 对象上。因此,dogage 属性的值会从 5 更新为 7

由于这是一种十分常见的模式,因此 JavaScript 提供了另一种比较简洁的方式:bind() 方法。

使用 bind() 来保存 this

call()apply() 类似,bind() 方法也允许用户直接为 this 定义一个值。bind() 也是一个在函数上调用的方法,但不同于 call()apply(),它们都会立即调用函数——bind() 会返回一个新的函数。当被调用时,该函数会将 this 设置为我们赋给它的值。

function invokeTwice(cb) {
  cb();
  cb();
}
const dog = {
  age: 5,
  growOneYear: function() {
    this.age += 1;
  }
};

invokeTwice(dog.growOneYear); // undefined

// bind() 会返回一个新的函数并且正确地绑定 this 的值,向 bind 中传入一个参数,在 bind 函数被调用时作为目标函数的 this 参数,这里的目标函数是 growOneYear
const myGrow = dog.growOneYear.bind(dog);
invokeTwice(myGrow);
dog.age// 7
  • bind() 是一个在函数上被调用的方法
  • bind() 会返回一个新的函数,当被调用时该函数会将 this 设置为所提供的对象

小结

JavaScript 提供了三种方法,让我们可以为一个给定的函数设置 this 的值:

  • call() 会调用该函数,逐个传入参数,并用逗号分隔。
  • apply()call() 类似;它会照样调用该函数,但它会将参数作为一个数组传入。
  • bind() 会返回一个新的函数,并将 this 绑定到一个特定对象,让我们可以按照函数的样式来调用它。

如需进一步研究建议查看 Kyle Simpson 有关 this 的《你不知道的 JavaScript》系统,链接已在下方提供。

到目前为止,你已经看到函数、对象和 this 关键字是如何相互关联的。你也看到了 JavaScript 中的几乎所有东西都是一个对象!你知道吗,你甚至可以将对象建立在其他对象上!这是原型继承背后的主要思想。通过实现它,对象可以接受其他对象的属性。接下来,我们将探索所有这一切,以及更多知识!

延伸

原型继承

在 JavaScript 中继承是指一个对象基于另一个对象,称之为继承或扩展。假设有一个新建 car 对象的构造函数,每个 car 对象都具有不同的颜色属性值,例如红色、蓝色和绿色,对象不仅具有自己的属性,而且与通用对象秘密关联这种通用对象称之为原型(prototype),我们可以向原型中添加所有新的 car 对象都能共享的方法,因此在每次新建一个 car 时 它们可以共享相同的 drive 方法,而不是创建新的 drive 方法。和其他语言不同 JavaScript 利用原型来管理继承。

添加方法到原型

对象包含数据(即属性)和操纵数据的手段(即方法)。在此之前,我们都是将方法直接添加到构造函数本身:

function Cat() {
 this.lives = 9;

 this.sayName = function () {
   console.log(`Meow! My name is ${this.name}`);
 };
}

这样,通过将一个函数保存到新创建的 Cat 对象的 sayName 属性中,可以将 sayName() 方法添加到所有 Cat 对象上。这样做没问题,但是如果我们想用这个构造函数实例化更多的 Cat 对象呢?你每一次都要为这个 Cat 对象的 sayName 创建一个新的函数!更重要的是:如果你想对方法进行更改,则必须逐个更新所有对象。在这种情况下,最好是让同一个 Cat 构造函数所创建的所有对象共享一个 sayName 方法。

为了节省内存并保持简洁,我们可以在构造函数的 prototype 属性中添加方法。原型只是一个对象,构造函数所创建的所有对象都会保持对原型的引用。这些对象甚至可以将 prototype 的属性当作自身属性来使用!

JavaScript 利用对象与其原型之间的这个秘密链接来实现继承。请考虑以下原型链:

Cat() 构造函数是使用 new 运算符来调用的,该运算符创建了 bailey 实例(对象)。请注意,meow() 方法是在 bailey 对象的构造函数的原型中定义的。原型只是一个对象,该构造函数所创建的所有对象均被秘密链接到该原型。因此,我们可以将 bailey.meow() 当作 bailey 自身的方法来执行!

每个函数都有一个 prototype 属性,它其实只是一个对象。当使用 new 运算符将该函数作为构造函数来调用时,它会创建并返回一个新的对象。该对象被秘密地链接到其构造函数的 prototype,而这个秘密链接让该对象可以访问 prototype 的属性和方法,就像它自己的一样!

由于我们知道 prototype 属性仅仅指向一个普通对象,因此这个对象本身也有一个秘密链接指向它的原型。而且,这个原型对象也有引用指向它自己的原型,以此类推。原型链就是这样形成的。

无论你是访问属性(例如 bailey.lives;)还是调用方法(即 bailey.meow();),JavaScript 解释器都会按照特定的顺序在原型链中查找它们:

  1. 首先,JavaScript 引擎将查看对象自身的属性。这意味着,直接在该对象中定义的任何属性和方法将优先于其他位置的任何同名属性和方法(类似于作用域链中的变量阴影)。
  2. 如果找不到目标属性,它将搜索对象的构造函数的原型,以寻找匹配。
  3. 如果原型中不存在该属性,则 JavaScript 引擎将沿着该链继续查找。
  4. 该链的最顶端是 Object() 对象,也就是顶级父对象。如果仍然找不到该属性,则该属性为未定义。
// 构造函数
// 可以调用它来创建一个具有以下三种属性的对象
function Dog(age, weight, name){
  this.age = age;
  this.weight = weight;
  this.name = name;
}
// 还可以在构造器中定义 bark 方法
Dog.prototype.bark = function() {
  console.log(`${this.name} says woof!`)
}
// 调用构造函数创建一个新对象
dog = new Dog(2, 60, 'Java');

dog.bark(); // 'Java says woof!'

当在 Dog 中调用 bark 方法时,JavaScript 引擎会查看自己的属性,尝试找到与 bark 方法相匹配的名称,由于 bark 没有直接定义在这个 dog 上,它会看看 bark 方法的原型。不需要调用 dog.prototype.bark 只需要调用 dog.bark 起作用了,因为这个 dog 对象已经通过它的原型与 bark 方法联系起来了。

另外如果要创建多个实例对象,以下两种方式哪个好?

// (A)
function Dalmatian (name) {
  this.name = name;

  this.bark = function() {
    console.log(`${this.name} barks!`);
  };
}

// (B)
function Dalmatian (name) {
  this.name = name;
}

Dalmatian.prototype.bark = function() {
  console.log(`${this.name} barks!`);
};

(B) 是最佳选择,因为每次创建 Dalmatian 的实例时,将不需要重新创建 bark 所指向的函数。

替换 prototype 对象

如果完全替换某个函数的 prototype 对象,结果会怎样?这将如何影响该函数所创建的对象?:

function Hamster() { this.hasFur = true; }
let waffle = new Hamster();
let pancake = new Hamster();

首先要注意的是,在创建新的对象 wafflepancake 之后,我们仍然可以为 Hamster 的原型添加属性,而且它仍然可以访问这些新的属性。

Hamster.prototype.eat = function () { console.log('Chomp chomp chomp!'); };
waffle.eat(); // 'Chomp chomp chomp!'
pancake.eat(); // 'Chomp chomp chomp!'

现在将 Hamsterprototype 对象完全替换为其他内容:

Hamster.prototype = {
  isHungry: false,
  color: 'brown'
};

先前的对象无法访问更新后的原型的属性;它们只会保留与旧原型的秘密链接:

console.log(waffle.color); // undefined
waffle.eat(); // 'Chomp chomp chomp!'
console.log(pancake.isHungry); // undefined

事实证明,此后创建的任何新的 Hamster 对象都会使用更新后的原型:

const muffin = new Hamster();
muffin.eat(); // TypeError: muffin.eat is not a function
console.log(muffin.isHungry); // false
console.log(muffin.color); // 'brown'

__proto__ 可访问原型


const myArray = [1, 2, 3]; myArray.join(''); console.dir(myArray); // 查看这个展开里的 __proto__

这个数组 myArray 不能直接访问 join 方法,它转到原型链并直接从数组中获取方法,所以实际上,我们一直都在使用原型方法。

检查对象的属性

如果一个对象本身没有某个特定属性,它可以访问原型链中某个这样的属性(当然,假设它是存在的)。由于选择很多,有时可能会不好判断某个特定的属性究竟来自哪里!这里有一些有用的方法可以帮助你进行判断。

hasOwnProperty()

hasOwnProperty() 可以帮助你找到某个特定属性的来源。在向其传入你要查找的属性名称的字符串后,该方法会返回一个布尔值,指示该属性是否属于该对象本身(即该属性不是被继承的)。请考虑在函数中直接定义一个属性的 Phone 构造函数,以及它的 prototype 对象的另一个属性:

function Phone() {
   this.operatingSystem = 'Android';
}
Phone.prototype.screenSize = 6;

// 现在创建一个新的对象 `myPhone`,并检查 `operatingSystem` 是否为其本身的属性,也就是说,它不是从该对象的原型(或原型链上的其他地方)继承来的:
const myPhone = new Phone();
const own = myPhone.hasOwnProperty('operatingSystem');
console.log(own);//true

// 那么,Phone 对象的 prototype 上的 screenSize 属性又如何呢?
const inherited = myPhone.hasOwnProperty('screenSize');
console.log(inherited);//false

使用 hasOwnProperty(),我们可以洞察某个属性的来源。

isPrototypeOf()

对象还可以访问 isPrototypeOf() 方法,该方法可以检查某个对象是否存在于另一个对象的原型链中。 使用这种方法,你可以确认某个特定的对象是否是另一个对象的原型。 请看以下 rodent 对象:

const rodent = {
   favoriteFood: 'cheese',
   hasTail: true
};
// 构建一个 Mouse() 构造函数,并将它的 prototype 赋给 rodent:
function Mouse() {
   this.favoriteFood = 'cheese';
}
Mouse.prototype = rodent;
// 创建一个新的 Mouse 对象,它的原型应该是 rodent 对象。让我们来确认一下:
const ralph = new Mouse();
const result = rodent.isPrototypeOf(ralph)
console.log(result);//true

isPrototypeOf() 是确认某个对象是否存在于另一个对象的原型链中的好办法。

Object.getPrototypeOf()

isPrototypeOf() 很有用处,但要想使用它,必须首先掌握原型对象!如果你不确定某个对象的原型是什么呢?Object.getPrototypeOf() 可以帮助你解决这个问题。

使用前面的例子,让我们将 Object.getPrototypeOf() 的返回值存储在变量 myPrototype 中,然后检查它是什么:

const myPrototype = Object.getPrototypeOf(ralph);

console.log(myPrototype);//{ favoriteFood: "cheese", hasTail: true }

ralph 的原型与结果具有相同的属性,因为它们就是同一个对象。Object.getPrototypeOf() 很适合检索给定对象的原型。

constructor 属性

每次创建一个对象时,都会有一个特殊的属性被暗中分配给它:constructor。访问一个对象的 constructor 属性会返回一个对创建该对象的构造函数的引用!以下是一个简单的 Longboard 构造函数。我们还会继续创建一个新的对象,然后将其保存到一个 board 变量中:

function Longboard() {
   this.material = 'bamboo';
}
const board = new Longboard();

// 如果访问 board 的 constructor 属性,我们应该会看到原来的构造函数本身:
console.log(board.constructor); // function Longboard() {this.material = 'bamboo';}

// 好极了!请记住,如果某个对象是使用字面量表示法创建的,那么它的构造函数就是内置的 Object() 构造函数!
const rodent = {
   teeth: 'incisors',
   hasTail: true
};

console.log(rodent.constructor);//function Object() { [native code] }

小结

JavaScript 中的继承是指一个对象基于另一个对象。继承让我们可以重用现有的代码,使对象具有其他对象的属性。

当使用 new 运算符将一个函数作为构造函数来调用时,该函数会创建并返回一个新的对象。这个对象会被秘密链接到其构造函数的 prototype,而它只是另一个对象。使用这个秘密链接,可以让一个对象访问原型的属性和方法,就像是它自己的一样。如果 JavaScript 没有在某个对象中找到一个特定属性,它将在原型链上继续查找。如有必要,它会一路查找到 Object()(顶级父对象)。

此外还介绍了几个方法和属性,可以用于检查对象及其原型的来源和引用,即:

  • hasOwnProperty()
  • isPrototypeOf()
  • Object.getPrototypeOf()
  • .constructor

延伸

原型继承:子类

子类

实现继承的好处之一就是它允许你重用现有代码。通过建立继承,我们可以子类化,也就是让一个「子」对象接受「父」对象的大部分或全部属性和方法,同时保留它自己独特的方法。

假设我们有一个父 Animal 对象(即构造函数),其中包含诸如 ageweight 等属性。同一个 Animal 对象还可以访问 eatsleep 等方法。

现在,再假设我们要创建一个 Cat 子对象。与描述其他动物一样,你也可以通过 ageweight 来描述一只猫,而且你也可以确定猫会 eatsleep。因此,在创建这个 Cat 对象的时候,我们可以简单地重写和重新实现 Animal 中的所有方法和属性——或者,我们也可以让 CatAnimal 继承 这些现有的属性和方法,从而节省时间并防止重复代码!

我们不仅可以让 Cat 接受 Animal 的属性和方法,还可以赋予 Cat 独特的属性和方法!也许一只 Cat 具有独特的 lives 属性为 9,或有一个专门的 meow() 方法,是其他 Animal 所没有的。使用原型继承,Cat 只需要实现 Cat 的独特功能,并重用 Animal 的现有功能即可。

通过原型继承

当在任何对象上调用任何属性时,JavaScript 引擎将首先在该对象中查找该属性(即该对象自己的、非继承的属性)。如果没有找到该属性,JavaScript 将查看该对象的原型。如果在对象原型中仍然找不到该属性,则 JavaScript 将在原型链上继续搜索。

JavaScript 中的继承重点就是建立原型链。

秘密链接

在尝试搜索一个不存在于某个对象中的属性时,该对象的构造函数的原型是被首先搜索的。请考虑以下具有两个属性 clawsdietbear 对象:

const bear = {
   claws: true,
   diet: 'carnivore'
};

将以下 PolarBear() 构造函数的 prototype 属性赋为 bear

function PolarBear() {
   //...
}

PolarBear.prototype = bear;

调用 PolarBear() 构造函数来创建一个新的对象,然后给它两个属性:

const snowball = new PolarBear();

snowball.color = 'white';
snowball.favoriteDrink = 'cola';

snowball 对象目前看起来像这样:

{
   color: 'white',
   favoriteDrink: 'cola'
}

请注意,snowball 只有两个自己的属性:colorfavoriteDrink。但是,snowball 也可以访问自身并不具有的属性:clawsdiet

console.log(snowball.claws);//true
console.log(snowball.diet);//'carnivore'

由于 claw 和 diet 都作为 prototype 对象中的属性存在,因此它们会被查找,因为对象被秘密链接到其构造函数的 prototype 属性。

太棒了!但你可能会想:这个通向 prototype 对象的秘密链接到底是什么呢?当从 PolarBear() 构造函数构造对象之后(如 snowball),这些对象可以立即访问 PolarBear() 的原型中的属性。这究竟是怎么做到的呢?

事实证明,这个秘密链接是 snowball 的 proto 属性(注意每一端有两个下划线)。proto 是构造函数所创建的所有对象(即实例)的一个属性,并直接指向该构造函数的 prototype 对象。让我们来看看它是什么样的!

console.log(snowball.__proto__); //{ claws: true, diet: 'carnivore' }

由于 proto 属性所指向的对象与 PolarBear 的原型 bear 相同,因此将它们进行比较会返回 true:

console.log(snowball.__proto__ === bear);//true

强烈推荐不要重新分配 __proto__ 属性,甚至不要在你编写的任何代码中使用它。首先,会有跨浏览器的兼容性问题。更重要的是:由于 JavaScript 引擎会在原型链上搜索和访问属性,因此更改对象的原型可能会导致性能问题。有关 proto 的 MDN 文章甚至警告,不要在页面顶部的红色文本中使用此属性!

我们有必要知道这个秘密链接,以了解函数和对象是如何相互关联的,但你不应该使用 __proto__ 来管理继承。如果你只是需要查看对象的原型,则仍然可以使用 Object.getPrototypeOf() 来达到目的。

如果只继承原型呢?

假设我们希望一个 Child 对象从一个 Parent 对象继承。为什么不应该只设置 Child.prototype = Parent.prototype 呢?

首先,还记得吗,对象是通过引用来传递的。这意味着,由于 Child.prototype 对象和 Parent.prototype 对象引用的是同一个对象,因此你对 Child 的原型所作的任何更改也会被应用于 Parent 的原型!我们可不希望子对象能够修改其父对象的属性!

最重要的是,这样做不会创建原型链。如果我们想让一个对象从我们想要的任何对象进行继承,而不仅仅是它的原型呢?

我们仍然需要一种方式来有效地管理继承,同时又完全不会改变原型。

Object.create()

到目前为止,我们在继承方面遇到了一些问题。首先,虽然 proto 可以访问被调用的对象的原型,但是在你编写的代码中使用它并不是好习惯。

另一方面,我们也不应该继承原型;这样做不会创建原型链,而且我们对子对象所作的任何更改也会反映在父对象中。

那么,我们应该如何继续往前呢?

实际上,我们可以借助一种方式来自己设置对象的原型:使用 Object.create()。而且最棒的是,这种方式既可以让我们管理继承,同时又不会改变原型!

Object.create() 会接受一个对象作为参数,并返回一个新的对象,其 proto 属性会被设置为传递给它的参数。然后,你只需要将所返回的对象设置为子对象构造函数的原型即可。让我们来看一个例子!

首先,假设我们有一个 mammal 对象,它有两个属性:vertebrate 和 earBones:

const mammal = {
   vertebrate: true,
   earBones: 3
};

还记得吗,Object.create() 会接受一个对象作为参数,并返回一个新的对象。这个新对象的 proto 属性会被设置为最初传递给 Object.create() 的参数。让我们把这个返回值保存到变量 rabbit 中:

const rabbit = Object.create(mammal);

我们预期这个新的 rabbit 对象是空白的,没有自己的属性:

console.log(rabbit); //{}

但是,rabbit 现在应该已被秘密链接到 mammal。也就是说,它的 proto 属性应该指向 mammal:

console.log(rabbit.__proto__ === mammal); //true

太棒了!这意味着,现在 rabbit 扩展了 mammal(即 rabbit 继承自 mammal),而且 rabbit 可以将 mammal 的属性当作自己的属性一样进行访问!

console.log(rabbit.vertebrate);//true

console.log(rabbit.earBones);//3

Object.create() 给了我们一个在 JavaScript 中建立原型继承的简洁方法。我们可以通过这种方式轻松扩展原型链,而且可以让对象从我们想要的任何对象进行继承!下面让我们来看一个更复杂的例子:

小结

JavaScript 中的继承重点就是建立原型链。这让我们可以子类化,也就是创建一个“子”对象,让它继承“父”对象的大部分或全部属性和方法。然后,我们可以分别实现任何子对象的独特属性和方法,同时仍然保留其父对象的数据和功能。

对象(实例)通过该实例的 proto 属性被秘密链接到其构造函数的原型对象。 你不应该在你编写的任何代码中使用 proto。在任何代码中使用 proto,或者只继承原型,将会直接导致某些不必要的问题。要在 JavaScript 中高效地管理继承,一个有效的方式就是避免完全改变原型。

Object.create() 可以帮助我们做到这一点,它可以接受一个父对象,返回一个新的对象,并将其 proto 属性设置为该父对象。

延伸

Conners Hua

这个家伙很懒,什么都没有留下。

您可能还喜欢...

发表评论

电子邮件地址不会被公开。 必填项已用*标注