前言
HTML
万物皆标签。
CSS
万物皆盒。
JavaScript
万物皆对象。
对象
JavaScript
对象的本质是数据和功能的集合,语法上表现为键值对的集合。
键
对象的键可以理解为变量名。
值
对象的值的类型可以是任意数据类型。
键值对
键和值之间用:
相连。
多组键值对之间用,
分割。
let profile = {
name: '吴彦祖',
age: 48,
charmingThenMe: false,
works: ['特警新人类', '新警察故事', '门徒', '除暴'],
bio: function () {
alert('你很能打吗?你会打有个屁用啊。')
},
hi() {
this.bio()
alert('出来混要有势力,要有背景,你哪个道上的?')
}
}
按照值是否为函数这一标准,进一步将键值对分为属性(property)和方法(method)。
对象为数据和功能的集合,数据对应属性,功能对应方法。
以
profile
为例,前四个为属性,后俩为方法。
hi() {
...
}
// 等价于
hi: function() {
...
}
// 上面的写法是方法的短语法。
访问
有两种方式访问对象的键值对,分别为点式访问和括号式访问。
// 点式访问
profile.name // '吴彦祖'
// 括号式访问
profile['age'] // 48
当你发现在一些特殊场景下使用点式访问无法实现时,记得尝试括号式访问。
这些特殊场景大多出现于键产生于运行时。
比如:
当你在遍历中需要从实参中获取键。
或者你需要同时定义对象的键和值。
构造函数
实际开发中,若按照上面的方式使用对象,意味着每需要一个profile
都需要手动写出一个拥有相同键的对象,这会带来灾难性的后果:
- 巨量的重复代码
- 一旦需要更新属性或方法,则必须遍历每一个对象
我们需要抽象。
具体来说,我们需要一个函数,可以自动创建具有相同键的对象,而不是每次使用时,手动重写一遍键。
// 键只需要在定义createProfile()时写一次
function createProfile(name, age, charmingThenMe, works, bio, hi) {
let o = {}
o.name = name
o.age = age
o.charmingThenMe = charmingThenMe
o.works = works
o.bio = function () {
alert(bio)
}
o.hi = function () {
o.bio()
alert(hi)
}
return o
}
// 后续生成对象时,只需要写值,键会自动填充
let edisonChen = createProfile(
'陈冠希',
42,
false,
['无间道', '头文字D', '神枪手'],
'在吗拓海',
'微信转账三百块'
)
edisonChen.name
edisonChen.hi()
抽象实现。
但createProfile()
似乎有点冗余,我们分析一下createProfile()
内部都干了些什么:
let o = {}
创建一个空对象o.foo = bar
为对象添加属性和方法return o
返回新创建的对象
这不就是new的作用吗!
当使用new
调用一个函数(此处称之为f()
)时,具体会有以下过程:
- 创建一个空对象
o
- 将
o
的原型指向f()
的prototype属性 - 将
this
绑定到o
,并执行f()
- 返回
o
这意味着,如果我们使用new
来调用生成对象的函数,我们只需要关注核心的业务逻辑即可,诸如生成空对象、绑定this
、返回对象这种杂活儿直接委托出去。
function Profile(name, age, charmingThenMe, works, bio, hi) {
this.name = name
this.age = age
this.charmingThenMe = charmingThenMe
this.works = works
this.bio = function () {
alert(bio)
}
this.hi = function () {
this.bio()
alert(hi)
}
}
Profile
函数体内只有新对象所需的数据和方法,我们真正关注的也只是这一部分。
至于函数名为什么从createProfile
变成了Profile
,这完全是依照惯例的约定俗成:
使用对象名并以大写开头作为该对象构造函数的名称。
let j = new Profile('周杰伦', 43, false, ['夜曲', '最伟大的作品'], '喔唷', '不错哦')
j.works
j.hi()
这就是JavaScript
的构造函数。
原型
JavaScript
中的每个对象都有一个叫做原型的内建属性。
原型也是一个对象,原型也有原型,逐级溯源,形成原型链。
当一个对象的原型为null
时,原型链结束。
一个对象,不仅能访问自己独有的属性和方法,还可以访问整个原型链上所有对象的属性和方法。
这解释了,为什么你只是声明了一个字符串,就可以调用一批字符串的内建方法。
// 在控制台中执行以下代码:
let o = {
name: 'a',
hi() {
console.log(`hello world`);
}
}
o;
点开控制台返回的对象,你会发现,除了刚刚自定义的属性name
和方法hi()
,还有一个长得很奇怪的 [[Prototype]]
,点开它你会发现另一个奇怪的键————__proto__
。
这就是对象o
的原型,它不仅长得奇怪,甚至连名字都没有。
是的,ECMAScript
认为对象原型“不配拥有姓名”,尽管你可以通过o.__proto__
访问到它,但o.__proto__
是不受标准认可的属性,它只是各大浏览器内部的实现,并且已经被官方废弃。
获取原型
不要通过__proto__
属性去获取对象的原型。
使用Object.getPrototypeOf()
获取。
let n = 123
do {
n = Object.getPrototypeOf(n)
console.log(n)
} while (n)
// Number
// Object
// null
设置原型
JavaScript
中一般使用Object.create()
或者constructors
构造函数设置原型。
Object.create()
使用实参作为原型生成一个新对象。
let a = {
hi() {
console.log('hello world')
}
}
let b = Object.create(a)
b.hi() // hello world
constructor
JavaScript
中,所有函数都有一个叫prototype
的属性,当使用new
关键字来调用一个构造函数来生成新对象时,构造函数的这个prototype
属性被设置为新生成对象的原型。
这个机制能够保证:只要指定了构造函数的prototype
属性,所有由构造函数生成的新对象的原型都能保持一致。
// 声明并初始化一个fruits对象,作为原型对象供构造函数使用
let fruits = {
hi() {
console.log(`吃个${this.name}${this.name}`)
}
}
// 声明一个Peach构造函数
function Peach(name) {
this.name = name
}
// 设置构造函数的prototype属性
Peach.prototype.hi = fruits.hi
// 或者
// Object.assign(Peach.prototype, fruits)
let p = new Peach('桃') // 生成新对象p
p.hi() // '吃个桃桃'
console.log(Peach.prototype === Object.getPrototypeOf(p)) // true
// 均为 fruits
- 使用字面量方式创建
fruits
对象,对象中定义了hi
方法 - 声明构造函数
Apple
,通过this
将name
属性和值添加到执行时产生的新对象上 - 将
fruits
的hi
方法添加到构造函数函数的prototype
上(实践中原型往往具有多个属性,此时使用Object.assign()
方法一次性添加会更高效) - 使用
new
关键字生成新对象p
- 调用
p
的hi
方法(p
本身并没有hi
方法,而是继承自fruits
) - 验证构造函数的
prototype
与新对象p
的原型的一致性
自有属性
可以看到,上面的示例中,由构造函数Peach
生成的对象p
具有两个键:
- 一个是属性
name
,定义在构造函数中 - 一个是方法
hi
,定义在原型中
那些直接定义在对象上,而非通过继承获得的属性,属于自有属性。
通过Object.hasOwn()
判断属性是否为自有属性:
console.log(Object.hasOwn(p, 'name')) // ture
console.log(Object.hasOwn(p, 'hi')) // false
console.log(Object.hasOwn(fruits, 'hi')) // true
严格来说,自有属性应该被称之为自有键,如果你一定要使用属性和方法来区分键的话。
但属性在很多语境下是不区分狭义的属性和方法的,后者在标准中也未被定义。
原型小结
回顾上面的fruits
示例,思考下面这个问题:
为什么要将方法定义在原型中,而将属性定义构造函数中呢?
因为这种行为与数据分离的机制恰好契合了类和实例。
对象间因具有相同的行为而被抽象为类,行为(方法)被 类(原型)定义。
对象间因数据的差异而成为一个又一个的实例,数据(属性)被构造函数(返回实例)定义。
原型是JavaScript
强大而灵活的特性之一,它使得代码复用和对象组合成为可能。
类
JavaScript
提供了一种更加开发者友好的方式来实现类和实例 —— class
。
以fruits
为例:
class Fruits {
name
constructor(name) {
this.name = name
}
hi() {
console.log(`吃个${this.name}${this.name}`)
}
}
let p = new Fruits('🍑')
p.hi() // 吃个🍑🍑
可以看出,class
通过封装:
- 声明并初始化原型对象
- 声明构造函数
- 初始化构造函数的
prototype
属性
等步骤,将基于原型链生成对象的语法,相较于纯构造函数而言,进一步简化。
省略属性
你甚至可以省略属性的声明。
class Fruits {
constructor(name) {
this.name = name
}
hi() {
console.log(`吃个${this.name}${this.name}`)
}
}
let p = new Fruits('🍑')
p.hi() // 吃个🍑🍑
⚠️注意:实践中不要省略,因为这会降低代码的可读性。
属性的默认值
属性在初始化的时候,可以指定默认值。
class Fruits {
name
constructor(name) {
this.name = name || '🍉'
}
hi() {
console.log(`吃个${this.name}${this.name}`)
}
}
let w = new Fruits()
w.hi() // 吃个🍉🍉
省略构造函数
如果没有初始化的需求,则可以省略构造函数。
class Fruits {
hi() {
console.log(`吃个屁`)
}
}
let p = new Fruits()
p.hi() // 吃个屁
继承
原型有原型链,类有继承。
以汽车举例,先定义汽车父类:
class Vehicle {
brand // 所有的车都有品牌
constructor(b) {
this.brand = b
}
// 所有的品牌都有标语
slogan() {
console.log(`This is ${this.brand}`)
}
}
通过继承汽车,定义电动汽车:
class EV extends Vehicle{
batteryType // 电池类型
remaining // 剩余电量
constructor(b, bt, r) {
super(b)
this.batteryType = bt
this.remaining = r
}
charge() {
let t = 0
switch (this.batteryType) {
case '三元锂':
t = (1-this.remaining) / 2
break
case '磷酸铁锂':
t = 1- this.remaining
break
default:
t = Math.random()
}
console.log(
`尊贵的${this.brand}车主:` +
`您的${this.batteryType || ''}电池` +
`只需${Math.ceil(t*60)}分钟即可充满!`
)
}
}
使用extends
从父类继承属性和方法,使用super()
调用父类的方法。
let t = new EV('Tesla', '三元锂', 0.3)
t.charge() // 尊贵的Tesla车主:您的三元锂电池只需21分钟即可充满!
let w = new EV('WuLing', '磷酸铁锂', 0.5)
w.charge() // 尊贵的WuLing车主:您的磷酸铁锂电池只需30分钟即可充满!
通过继承,子类可以使用父类的属性和方法。
也可以定义子类自己的属性和方法。
甚至,父类中已有的属性和方法,也支持在子类中重新定义。
结语
虽然class
看起来是个新东西,但本质上它还是原型链,或者说,它是原型链的语法糖。
JavaScript
本质上不是传统意义上面向对象的编程语言,JavaScript
是基于原型的编程语言。