本文是JavaScript基础教程中的第七章。
在这一章中,你将学习JavaScript中的对象,对我们在 数组 等章节中提到的属性、方法等概念与用法会有更清晰的认识。
什么是对象?
在JavaScript中,对象(object)是一种复合数据类型,它可以存储多个属性(property)和方法(method)。 属性是对象的特征,比如对象的名字、颜色、大小等。方法是对象的行为,比如跑、跳、说话等。每个属性和方法都有一个名字(key)和一个值(value), 它们之间用冒号(:)分隔,不同的属性和方法之间用逗号(,)分隔。整个对象用花括号({})包裹起来。
这么说肯定不太明白,让我们看一个具体的例子吧!例如,如果我们想用对象来描述原神中的角色:可莉(klee),我们可以这样写:
// 创建一个对象
let klee = {
// 属性
name: "可莉", // 名字
element: "火", // 元素属性
weapon: "法器", // 武器类型
birthday: "7月27日", // 生日
nickname: ["火花骑士", "蒙德最强战力", "骑士团团宠"], // 多个昵称,使用数组表示
// 方法
explode: function() { // 爆炸
console.log("蹦蹦炸弹!");
},
cook: function() { // 烹饪
console.log("要和可莉一起去炸鱼吗?虽然被抓住就是一整天的禁闭,但鱼很好吃,所以值得!");
}
};
这样我们就创建了一个名为 klee
的对象,它有五个属性和两个方法,它们描述了可莉这个角色的特征和行为。
我们可以用点号(.)或者方括号([])来访问对象的属性和方法,比如:
// 访问属性
console.log(klee.name); // 输出 可莉
console.log(klee["element"]); // 输出 火
// 调用方法
klee.explode(); // 输出 蹦蹦炸弹!
klee["cook"](); // 输出 要和可莉一起去炸鱼吗?虽然被抓住就是一整天的禁闭,但鱼很好吃,所以值得!
以上这种创建对象的方式叫做字面量方式,也就是直接把对象的内容写出来。用这种方式创建对象时,你把对象的属性和方法都写在了一起,并且用花括号括起来,这样看起来就更加清晰明了。
对象的分类
在ECMAScript中,对象有多种不同的类型,但总的来说,对象分为两大类:内置对象(built-in object)和自定义对象(custom object)。
内置对象是由ECMAScript实现提供的对象,不依赖于宿主环境,比如 Array
、Date
、Math
等,这些对象我们我们在
JavaScript基础教程:函数 中使用过,希望你还记得😊。
自定义对象则是由程序员自己创建的对象,比如上面我们创建的可莉(klee)对象。
更多分类...
在ECMAScript中,对象的分类还有很多,比如:
内置对象又分为两种:本地对象(native object)和宿主对象(host object)。 本地对象是由ECMAScript规范定义的对象,比如Object、Function、String等。宿主对象是由宿主环境提供的对象,比如浏览器中的Window、Document等。
本地对象又分为两种:普通对象(ordinary object)和奇异对象(exotic object)。 普通对象是遵循ECMAScript规范中定义的默认行为的对象,比如Array.prototype、RegExp.prototype等。 奇异对象是有一些特殊行为的对象,比如Array、String、Arguments等。
在深入学习JavaScript后你会见到更多关于它们的概念的!但作为入门级教程,我们就不深究了,只需要知道对象分为内置对象和自定义对象就可以了。
对象的创建
我们已经知道了如何用字面量方式创建对象:直接把对象的属性和方法都写在花括号里。
但是,如果我们想要创建多个相似的对象,或者想要给对象添加一些特殊的功能,字面量方式就显得有些繁琐了。所以,我们还需要学习其他几种创建对象的方法:
使用工厂函数
如果我们要批量创建一些结构相似的对象,我们可以使用一种叫做工厂函数(factory function)的方法。
工厂函数其实就是一个普通的函数,它的作用是根据一些参数来生成一个对象,并返回这个对象。 我们可以把工厂函数想象成一个工厂,它可以批量生产相似的产品(对象)。
来看一个示例吧!
// 定义一个工厂函数,用来创建角色对象
function createCharacter(name, element, weapon) {
// 创建一个空对象
let character = new Object();
// 给对象添加属性
character.name = name;
character.element = element;
character.weapon = weapon;
// 给对象添加方法
character.attack = function() {
console.log(this.name + "发动了" + this.element + "攻击!");
};
// 返回对象
return character;
}
// 使用工厂函数创建角色对象
let klee = createCharacter("可莉", "火", "法器");
let xiao = createCharacter("魈", "风", "长柄武器");
从上面的代码中我们可以看出,使用工厂函数可以让我们简化创建对象的过程——只需要传入一些参数就可以得到一个完整的对象。
但是,它也有一个缺点,就是它不能识别出对象的类型,
也就是说,我们无法知道 klee
和 xiao
是属于哪个类别(category)的对象(比如到底是角色对象还是武器对象呢?)。
虽然我们可以给他们加一个诸如 type
的属性来表示它们的类型,但在JavaScript中,我们还有更好的方法来解决这个问题。
使用构造函数
在 JavaScript 中,我们可以使用一种叫做构造函数(constructor function)的方法来创建对象。构造函数其实也是一个普通的函数,但它有一些特殊的规则和用法:
构造函数的名字通常首字母大写,以表示它不是一个那么普通的函数。
构造函数内部使用
this
关键字来引用正在创建的对象,并给this
添加属性和方法。构造函数需要用
new
操作符来调用,并传入一些参数。构造函数会自动返回
this
对象,不需要显式地写return
语句。
使用构造函数创建角色对象的代码如下:
// 定义一个构造函数,用来创建角色对象
function Character(name, element, weapon) {
// 给this添加属性,注意这里的this指向正在创建的对象
this.name = name;
this.element = element;
this.weapon = weapon;
// 给this添加方法
this.attack = function() {
console.log(this.name + "发动了" + this.element + "攻击!");
};
}
// 使用new操作符和构造函数创建角色对象
let klee = new Character("可莉", "火", "法器");
let xiao = new Character("魈", "风", "长柄武器");
使用构造函数的优点是,它可以识别出对象的类型。也就是说,我们可以知道 klee
和 xiao
是属于 Character
这个类别的对象。
使用 instanceof
运算符可以判断一个对象是否属于某个类别,比如:
console.log(klee instanceof Character); // 输出 true
console.log(xiao instanceof Character); // 输出 true
但是,构造函数也有一个缺点,就是它会给每个对象都创建一份相同的方法,这样会浪费内存空间。为了解决这个问题, 我们可以使用原型(prototype)来让它们共享同一套方法 ,这部分知识我们将在稍后介绍。
使用object构造函数
另一种创建对象的方法是使用 JavaScript 提供的内置 Object
构造函数 。
Object
构造函数可以接收一个参数,根据参数的类型,返回一个与之对应的对象实例。例如:
// 使用object构造函数创建一个空对象
let obj1 = new Object();
// 等价于 let obj1 = {};
// 使用object构造函数创建一个数字对象
let obj2 = new Object(123);
// 等价于 let obj2 = new Number(123);
// 使用object构造函数创建一个布尔对象
let obj3 = new Object(true);
// 等价于 let obj3 = new Boolean(true);
使用 Object
构造函数的好处是,它可以根据参数的类型自动选择合适的内置构造函数(Number、String、Boolean等),而不需要我们手动指定。
但是,这也意味着我们不能自定义对象的类型或属性和方法。如果我们想要创建一个具有特定类型或功能的对象,就需要使用刚刚介绍的自定义构造函数。
究竟还有什么是对象!
没错,JavaScript中的数组、字符串、布尔值和数字都可以被当作对象来使用! 这是因为在JavaScript中,除了null和undefined之外,其他的基本数据类型(number、string、boolean)在访问属性或方法时, 会临时地被包装成一个对象,比如下面这个例子中:
const str = "hello";
str.length; // 5
str.toUpperCase(); // "HELLO"
我们访问了字符串的 length
属性和 toUpperCase
方法,但是字符串本身并不具有这些属性和方法,它们是从哪里来的呢?
其实,当我们访问字符串的属性和方法时,JavaScript会自动创建一个临时的String对象,并把字符串的值赋给它,然后再访问这个对象的属性和方法。 这个临时的String对象在使用完毕后就会被销毁,所以我们看不到它。
对象继承
我们已经知道了如何使用构造函数来创建对象,并且给它们设置原型。但是有时候,我们想创建一些相似但又有差异的对象,比如不同的原神角色对象。
这些对象可能有一些共同的属性和方法,比如 name
、hp
、forward
、backward
等,也可能有一些特有的属性和方法,
比如 explode
、cook
等。
如果我们为每个角色对象都定义一个构造函数,并且在构造函数中定义这些共同的属性和方法,那么我们就会写很多重复的代码, 而且如果要修改或添加一些共同的属性和方法,就要在每个构造函数中都修改或添加,这样就会增加代码的维护难度和出错概率。
而如果我们为每个角色对象都定义一个工厂函数,并且在工厂函数中返回这些共同的属性和方法,那么我们也会写很多重复的代码, 而且如果要修改或添加一些共同的属性和方法,就要在每个工厂函数中都修改或添加,这样也会增加代码的维护难度和出错概率。
为了解决这个问题,我们可以使用 继承 的思想。继承是一种面向对象编程的重要特性,它可以让一个对象拥有另一个对象的属性和方法, 并且可以根据自己的需要修改或添加一些属性和方法。这样,我们就可以复用已有的代码,避免重复造轮子,提高代码的可读性和可扩展性。
举个例子,假设我们有一个 character
对象,它有一些通用的属性和方法,比如 name
、hp
、forward
、backward
等。
然后我们想创建一个 klee
对象,它也有这些属性和方法(可莉也应该有生命值、向前、向后等属性/方法),但是还有一些特有的属性和方法,
比如 explode
、cook
等。
我们可以让 klee
对象继承自 character
对象,也就是说,让 klee
对象拥有 character
对象的所有属性和方法。
这样就不用重复定义这些通用的属性和方法,只需要添加或修改一些特有的属性和方法就可以了。
继承的简单实现
在JavaScript中,实现继承的基础是原型和原型链。
原型是一个对象,它可以作为其他对象的模板,提供一些公共的属性和方法。每个对象都有一个指向它原型的隐藏属性,
叫做 [[Prototype]]
。你可以用 Object.getPrototypeOf(obj)
来获取一个对象的原型。
原型链则是一种链接关系,它表示一个对象可以通过它的原型,以及原型的原型( Object.getPrototypeOf(Object.getPrototypeOf(obj))
)……
一直向上查找,直到找到最顶层的原型(Object.prototype
,再往上就是 null
了),来访问一些特征。
下面来看一个简单的例子,这个例子展示了两个简单对象之间的继承:
// 定义一个原型对象,包含共享的属性和方法
let character = {
attack: function() {
console.log(this.name + "进行了" + this.element + "元素攻击!");
}
};
let klee = {};
// 将 klee 的原型设置为 character
klee.__proto__ = character;
klee.name = "可莉";
klee.element = "火";
klee.attack(); // 可莉进行了火元素攻击!
note
设置对象的 [[Prototype]]
有多种方法,这里我们使用了 __proto__
属性来设置对象的原型。
在这个例子中,我们定义了一个原型对象 character
,它包含一个 attack
方法。
然后我们创建了对象 klee
,并且把她的 [[Prototype]]
设置为 character
。
这样,klee
就可以通过原型链来访问 character
对象的 attack
方法了。
可莉和原型对象之间的关系如下图所示:
下面是完整的原型链示意:
klee -> character -> Object.prototype -> null
Object.prototype
Object.prototype
是一个对象,也是所有对象的原型,它的原型是 null
。
同时,Object.prototype
包含一些通用的属性和方法,比如 toString
、valueOf
等。
通常来说,当我们访问 klee
对象的某个属性或方法时,JavaScript会沿着这条原型链向上查找。
例如,当我们调用 klee.toString()
时,JavaScript会首先在 klee
自身查找,
由于 klee
对象中没有这个方法的定义,JavaScript就会在 character
查找,
在我们对 character
的定义中也没有这个方法,于是JavaScript就在 Object.prototype
查找,而 toString()
方法恰好在这里找到了。
所以调用 klee.toString()
会执行 Object.prototype.toString()
方法。
如果 在 Object.prototype
中也没有找到这个方法,那么JavaScript就会继续在原型链上向上查找,而 Object.prototype
的原型
是 null
(即 Object.prototype.__proto__ === null
),于是 klee.toString()
会返回 undefined
。
根据上面的机制,你可以比较容易地发现:给对象设置属性会创建自有属性,它会覆盖原型链上的同名属性(也就是访问该属性不会沿原型链向上查找了)。 我们将之称为属性遮蔽(Property Shadowing)。
同理,我们也可以创建更长的原型链,并在原型链上查找一个属性或方法。
例如,我们可以让 character
的原型是另一个对象,比如 animal
,这样 klee
对象就可以继承 animal
的属性和方法。
使用构造函数实现继承
我们已经知道了如何使用 __proto__
属性或者 Object.setPrototypeOf
方法来设置对象的原型,但是这些方法都是在对象已经创建之后才能使用的,
在实际开发中,我们通常会使用构造函数来创建对象,并且在创建对象的同时就设置好对象的原型。这样可以让我们更方便地创建多个相似的对象,并且让它们共享一些公共的属性和方法。
让我们用一个例子来说明这些概念。现在,我们想创建一些原神角色对象,并且给它们设置一些通用的属性和方法,
比如 name
、hp
、forward
、backward
等。
我们可以定义一个 Character
函数,它可以用来作为构造函数,接收一些参数,并且给新创建的对象初始化一些属性:
function Character(name, hp) {
this.name = name;
this.hp = hp;
}
使用 new
关键字来调用构造函数 Character
,并传入参数,这样就会创建一个新的对象,并且把这个对象作为 this
绑定到构造函数中:
var klee = new Character('Klee', 100);
在这个例子中,我们创建了一个 klee
对象,它有自己的 name
和 hp
属性。
但是我们还想给所有的角色对象添加一些公共的方法,比如 forward()
和 backward()
,
让它们可以移动。但我们不想在每个角色对象中都定义一遍这些方法(那会让我们的代码变得臃肿),
我们想让所有的角色对象都能共享这些方法!
这时候就可以用到原型了。我们可以把这些公共的方法定义在 Character.prototype
对象上,
这个对象是 Character
函数的一个属性,也是所有由 Character
函数创建的对象的原型。
Character.prototype.forward = function() {
console.log(this.name + ' 正在向前走');
};
Character.prototype.backward = function() {
console.log(this.name + ' 正在向后走');
};
当我们使用 new Character()
创建一个新对象时,JavaScript会自动把这个新对象的 [[Prototype]]
设置为 Character.prototype
,
于是,新创建的 klee
对象就可以通过原型链访问到 Character.prototype
中的 forward()
和 backward()
方法了。
小心!
如果你按照上面的代码顺序直接复制到控制台运行,你会发现 klee
对象中并没有 forward()
和 backward()
方法!
这是因为,在你创建 klee
对象之前,你还没有给 Character.prototype
添加这些方法,
所以当你创建 klee
对象时,它的原型上也没有这些方法。你需要先给 Character.prototype
添加方法,
然后再创建新对象,或者重新设置已有对象的原型。
下面是该原型链的示意图:
klee -> Character.prototype -> Object.prototype -> null
同样地,当我们访问 klee
对象的某个属性或方法时,JavaScript会沿着这条原型链向上查找。
注意!
你应该注意到了,在最早的例子中,klee
继承的是 character
对象的属性和方法,而在现在这个例子中,klee
继承的是 Character.prototype
对象的属性和方法。
这就是使用构造函数创建对象的原型链和使用 __proto__
属性(或者其他同类型方法)设置对象原型的区别。
现在,让我们加长这条原型链:
例如,我们可以让 Character.prototype
的原型是另一个对象,比如 Animal.prototype
,
这样 klee
对象就可以继承 Animal.prototype
的属性和方法。
function Animal(type) {
this.type = type;
}
Animal.prototype.eat = function() {
console.log(this.name + ' 正在吃东西');
};
// 将 Character.prototype 的原型设置为 Animal.prototype
Object.setPrototypeOf(Character.prototype, Animal.prototype);
klee.eat(); // Klee 正在吃东西
在这个例子中,我们让 Character.prototype
的原型是 Animal.prototype
,
这样 klee
对象就可以继承 Animal.prototype
的属性和方法。
我们用了 Object.setPrototypeOf()
函数来设置原型,这是一种标准的方法。
你也可以用 __proto__
属性来设置原型,虽然这种方法被很多浏览器支持,但这是一种非标准的语法,非必要情况下不要使用。
下面是一个原型链的示意图:
klee -> Character.prototype -> Animal.prototype -> Object.prototype -> null
让我们最后巩固加强一下原型链的应用:
例如,当我们访问 klee.eat()
时,JavaScript会首先在 klee
自身查找,
由于 klee
对象中没有这个方法的定义,JavaScript就会在 Character.prototype
查找,
在我们对 Character.prototype
的定义中也没有这个方法,于是JavaScript就在 Animal.prototype
查找,而 eat()
方法恰好在这里找到了。
所以访问 klee.eat()
会调用 Animal.prototype.eat()
。
如果在 Animal.prototype
中也没有找到这个方法,那么JavaScript就会继续在原型链上向上查找,
直到到达最顶层的原型(Object.prototype
),再往上就是 null
了。最终会返回 undefined
。
上述过程在下图中有更直观的表示:
Object.create()
在学习了对象的继承后,让我们来学习一种新的创建对象的方法。
还记得我们一开始介绍对象继承的例子吗?当时,我们先定义好了两个对象,再通过修改 __proto__
属性来实现继承。
但事实上,实现这一过程有一个更简单直接的方法:Object.create()
Object.create()
函数接受一个对象作为参数,它会创建一个新的对象,这个新的对象的原型就是传入的对象。
var character = {
name: 'character',
hp: 100
};
var klee = Object.create(character);
console.log(klee.name); // character
console.log(klee.hp); // 100
在这个例子中,我们使用 Object.create()
函数创建了一个新的对象 klee
,
这个对象的原型就是 character
对象,所以 klee
对象可以继承 character
对象的属性和方法。
我们可以通过 Object.getPrototypeOf()
函数来获取一个对象的原型:
console.log(Object.getPrototypeOf(klee) === character); // true
和之前的效果一样!
one more thing...
让我们思考一个有趣的问题:当我们调用 Object.create()
或者其他 Object
的方法时,JavaScript内部是如何执行的?
我们知道,Object
首字母大写,是一个内置的构造函数,它可以创建对象。这个函数对象本身也有一些属性和方法,其中一个属性就是 Object.prototype
,它指向一个原型对象。
当我们调用 Object.create()
或者其他方法时,JavaScript会先在 Object
函数对象上寻找这个方法,如果没有找到,就会沿着原型链向上查找,
也就是在 Object.prototype
原型对象上查找这个方法。
原型链如下:
Object -> Object.prototype -> null
由于 Object
是一个内置对象,它的属性和方法都是由JavaScript引擎提供的,所以我们称之为内置属性和内置方法。
总结
本章我们学习了对象的有关知识,包括对象的创建、对象的属性和方法、对象的继承和原型链等。涉及的内容较多,我们来总结一下:
在JavaScript中,对象是一种复合数据类型,具有属性和方法来描述特征和行为。
对象分为内置对象和自定义对象。内置对象是由 ECMAScript 实现提供的,不依赖于宿主环境;自定义对象是程序员自己创建的。
创建对象的方法有字面量方式、工厂函数、构造函数和 Object 构造函数以及
Object.create()
方法。JavaScript中的数组、字符串、布尔值和数字也可以当作对象使用,在访问属性和方法时,它们会被临时包装成对象。
在JavaScript中,实现继承的基础是原型和原型链。原型是一个对象,它可以作为其他对象的模板,提供一些公共的属性和方法。 原型链表示一个对象可以通过它的原型以及原型的原型一直向上查找,直到找到最顶层的原型来访问一些属性和方法。我们用
[[Prototype]]
来表示一个对象的原型。使用
__proto__
属性或Object.setPrototypeOf()
方法可以设置对象的原型。使用构造函数和
new
关键字可以创建对象并自动设置对象的原型([[Prototype]]
)为构造函数的prototype
属性,即obj.__proto__ = Fun.prototype
。可以通过给对象设置属性来创建自有属性,这会覆盖原型链上的同名属性,称为属性遮蔽(Property Shadowing)。
小试牛刀
Hello World Ultra
var person = {
name: "Proca",
age: 18,
greet: function() {
console.log("Hello World, I'm " + this.name);
}
};
var anotherPerson = person;
anotherPerson.name = "杨夕璃";
person.greet();
anotherPerson.greet();
请问这段代码会有什么运行效果?你对 this
是如何理解的?请在评论区留下你的看法😊。
游戏初始化
使用构造函数和字面量语法,创建两个不同的角色对象,并赋值给两个变量。你可以自由选择角色的名字、元素、武器和等级,并根据角色名选择变量名。
定义的对象应该具有以下属性和方法:
name
:一个字符串,表示角色的名字,例如"琴"
。element
:一个字符串,表示角色的元素属性,例如"风"
。weapon
:一个字符串,表示角色使用的武器类型,例如"单手剑"
。level
:一个数字,表示角色的等级,例如80
。showInfo
:一个方法,它会打印出角色的所有属性,例如"琴是风属性的单手剑角色,等级为80。"
。
等级提升
编写一个函数 upgradeLevel
,它接受一个角色对象和一个数字作为参数,并将该角色的等级提升指定的数字。例如:
如果
a.level
是80,那么执行upgradeLevel(a, 10)
后,a.level
变成了90。如果提升后的等级超过了100,那么该函数应该抛出一个错误,并提示用户无法超过最大等级。例如:
如果执行了
upgradeLevel(a, 30)
,那么该函数应该抛出一个错误,并提示"无法超过最大等级100。"
编纂家谱
你现在要为一个大家族创建一个家谱。每个家庭成员都有姓名、年龄和性别属性。家庭成员之间有父子关系,需要用对象原型继承表达。
最终,你需要为这个谜题设计一个 findAncestor
函数,用户输入一个家庭成员的名字,返回与他最接近的祖先。
请根据下面的描述,完成代码:
- 创建一个
FamilyMember
构造函数,包含属性:name
(字符串)、age
(数字)和gender
(字符串,'M'或'F')。 - 定义一个原型方法
FamilyMember.prototype.introduce
,通过console.log
输出家庭成员的详细信息,格式为:“我是 name,今年 age 岁,性别 gender。”。 - 用
FamilyMember
创建以下角色:
- 爷爷:Tom(75岁,'M')
- 奶奶:Jane(72岁,'F')
- 爸爸:Michael(50岁,'M')
- 妈妈:Eva(49岁,'F')
- 儿子:David(25岁,'M')
- 女儿:Alice(18岁,'F')
- 设置家庭成员之间的原型链:祖父 -> 父亲 -> 儿子;祖母 -> 母亲 -> 女儿。
- 实现
findAncestor
函数:输入一个家庭成员的名字(字符串),返回该成员的祖先名字(字符串),如找不到返回null
。
以下代码片段可以帮助你开始这个任务:
function FamilyMember(name, age, gender) {
// 构造函数实现
}
FamilyMember.prototype.introduce = function() {
// 方法实现
}
// 创建家庭成员实例
// 链接原型链