网络日志

JavaScript的对象、原型、类和继承

前言

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()内部都干了些什么:

  1. let o = {} 创建一个空对象
  2. o.foo = bar 为对象添加属性和方法
  3. return o 返回新创建的对象

这不就是new的作用吗!

当使用new调用一个函数(此处称之为f())时,具体会有以下过程:

  1. 创建一个空对象o
  2. o的原型指向f()的prototype属性
  3. this绑定到o,并执行f()
  4. 返回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属性,所有由构造函数生成的新对象的原型都能保持一致。

// 声明并初始化一个fruit对象,作为原型对象供构造函数使用
let fruit = {
    hi() {
        console.log(`吃个${this.name}${this.name}`)
    }
}

// 声明Fruit(构造)函数
function Fruit(name) {
    this.name = name
}

// 设置构造函数的prototype属性
Fruit.prototype.hi = fruit.hi
// 或者
// Fruit.prototype = fruit

let p = new Fruit('桃') // 生成新对象p

p.hi() // '吃个桃桃'

console.log(Fruit.prototype === Object.getPrototypeOf(p)) // true
// 均为 fruit
  1. 使用字面量方式创建fruit对象,对象中定义了hi方法
  2. 声明构造函数Fruit,通过thisname属性和值添加到执行时产生的新对象上
  3. fruithi方法添加到构造函数函数的prototype上(实践中原型往往具有多个属性,此时使用直接赋值一次性添加)
  4. 使用new关键字生成新对象p
  5. 调用phi方法(p本身并没有hi方法,而是继承自fruits
  6. 验证构造函数的prototype与新对象p的原型的一致性

自有属性

可以看到,上面的示例中,由构造函数Peach生成的对象p具有两个键:

  1. 一个是属性name,定义在构造函数中
  2. 一个是方法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是基于原型的编程语言。