重学js之JavaScript 面向对象的程序设计(创建对象)

1. 什么是面向对象

面向对象的语言有一个标志,那就是它们都有类的概念,而通过类可以创建任意多个具有相同属性和方法的对象。但是,再前面提到过。ES中没有类的概念,因此它的对象也与基于类的语言中的对象有所不同。

对象的定义:‘无序属性的集合,其属性可以包含基本值、对象或者函数。’ 严格来讲,这就相当于说对象是一组没有特定顺序的值。对象的每个属性或方法都有一个名字,而每个名字都映射到一个值。所以我们可以把 ES 的对象想象成散列表:无非就是一组名值对,其中值可以是数据或函数。

每个对象都是基于一个引用类型创建的,这个引用类型可以是上一章讨论的原生类型,也可以是自定义类型。

2. 创建对象

最简单的方式就是创建一个Object的实例,然后再为它添加属性和方法。

let p = new Object()
p.name = 'js'
p.age = 20
p.job = 'jizhe'

p.sayName = function() {
    alert(this.name)
}

p.sayName()  // js

3. 工厂模式

工厂模式:抽象了创建具体对象的过程。考虑到ES中无法创建类,于是就用一种特定的函数来封装以特定接口创建对象的细节。

function p(name, age, job) {
    let o = new Object()
    o.name = name
    o.age = age
    o.job = job
    o.sayName = function() {
        alert(this.name)
    }
    return o
}

let p1 = p('tc', 30, '老宋')
let p2 = p('bd', 22, '百度')

p1.sayName  // tc
p2.sayName  // bd 

函数 p() 能够根据接受的参数来构建一个包含所有必要信息的 Person 对象。可以无数次的调用这个函数,而每次它都会返回一个包含三个属性一个方法的对象。工厂模式虽然解决了创建多个相似对象的问题,但却没有解决对象识别的问题(即怎么样找到一个对象的类型)

4. 构造函数模式

在前面几章介绍过,ES的构造函数可以用来创建特定类型的对象。像Object 和 Array这样原生构造函数,在运行时会自动出现在执行环境中。此外,也可以创建自定义的构造函数,从而定义自定义对象类型的属性和方法。如下:

function p(name, age, job) {
    this.name = name
    this.age = age
    this.job = job
    this.sayName = function (){
        alert(this.name)
    }
}

let p1 = new P('tc', 33, 'haha')
let p2 = new P('gg', 32, '小夭同学')

p1.sayName() // haha
p2.sayName() // 小夭同学

在上面的例子中,p()函数取代了上一小段的函数。除了内容代码相同,还有以下区别:

  • 没有显式的创建对象
  • 直接将属性和方法赋给了this对象
  • 没有 retrun 语句
  • 另外函数 p 是大写。构造函数始终都以第一个字母大写开头。非构造函数时小写开头。

另外如果要创建P实例,必须使用 new 操作符,以这种方式调用构造函数实际上会经历以下4个步骤:

  • 创建一个新对象
  • 将构造函数的作用域赋给新对象(因此 this 指向了这个新对象)
  • 执行构造函数中的代码(为这个对象添加属性)
  • 返回新对象

对象的 constructor 属性,最初是用来标识对象类型的。但是,提到检测对象类型,还是 instanceof 操作符更可靠。

instanceof 判断某个对象是否属于另外一个对象的实例

优点: 相比于工厂模式,构造函数模式可以将它的实例标识为一种特定的类型。

注意: 如果以这种方式定义的构造函数是定义在 Global对象中的,因此除非另有说明,instaceof 操作符 和 construcotr 属性始终会假设是在全局作用域中查询构造函数。

4.1 将构造函数当作函数

构造函数与其他函数的唯一区别,就是在于调用它们的方式不同。不过,构造函数也是函数,不存在定义构造函数的特殊语法。任何函数,只要通过new操作符来调用,那它就可以作为构造函数;而任何函数,如果不通过new 操作符来调用,那它和普通函数也没有射门两样。

// 构造函数调用
let p = new Person('tc', 22, '哈哈哈')
p.sayName   // tc

// 普通函数调用
P('gc', 23, 'oo')  // 添加到 window
windwo.sayName  // gc

// 在另外一个对象的作用域中调用
let o = new Object()
P.call(o, 'new', 33, 'suzhou')
o.sayName   // new

4.2 构造函数的问题

构造函数虽然好用,但也有缺点。使用构造函数的主要问题就是每个方法都要在每个实例上重新创建一遍。

5. 原型模式

我们每次创建一个函数的时候都有 一个 prototype 属性,这个属性是一个指针,指向一个对象。而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法 。如果按照字面意思,那么 prototype 就是通过调用构造函数而创建的那个对象实例的原型对象。使用原型对象的好处是可以让所有对象实例共享它所包含的属性和方法。换句话说,不必再构造函数中定义对象实例的信息,而是可以将这些信息直接添加到原型对象中。

5.1 理解原型对象

无论什么时候,只要创建了一个新韩淑,就会根据一组特定的规则为该函数创建一个 prototype 属性, 这个属性指向函数的原型对象。在默认情况下,所有原型对象都会自动获得一个 constructor 构造函数属性,这个属性包含了一个指向 prototype 属性所在函数的指针。通过这个构造函数,我们还可以继续为原型对象添加其他属性和方法。

创建了自定义的构造函数之后,其原型对象默认只会 取得 constructor 属性; 至于其他方法,则都是从 Object 继承而来的。当调用构造函数创建一个新实例后,该实例的内部将包含一个指针(内部属性),指向构造函数的原型对象。在很多实现中,这个内部属性的名字是 proto ,而且通过脚本可以访问到;而在其他实现中,这个属性对脚本则是完全不可见的。不过,要明确的真正重要的一点,就是这个连接存在于实例于构造函数的原型对象之间,而不是存在于实例于构造函数之间

另外,每当代码读取某个对象的某个属性时,都会执行一次搜索,目标是具有给定名字的属性。搜索首先从对象实例本身开始。如果在实例中找到了具有给定名字的属性,则返回该属性的值;如果没有找到,则继续搜索指针指向的原型对象,在原型队形中查找具有给定名字的属性。如果在原型对象中找到了这个属性,则返回该属性的值。也就是说我们调用p.sayName()的时候,会先后执行两次搜索,首先,解析器会问:实例 p 有 sayName 属性吗,如果没有,则再问p的原型有sayName属性嘛,如果有 则读取保存在原型对象中的函数。

虽然可以通过对象实例访问保存在原型中的值,但却不能通过对象实例重写原型中的值。如果我们在实例中添加了一个属性,而该属性与实例原型中的一个属性同名,那么我们就在实例中创建该属性,该属性会屏蔽原型中的那个属性。

5.2 原型与in操作符

有两种方式使用 in 操作符:单独使用和在 for-in循环中使用。在单独使用时,in操作符会在通过对象能够访问给定属性时返回 true,无论该属性存在于实例中还是原型中。 另外由于in操作符只要通过对象能够访问到属性就返回 true ,hasOwnProperty()只在属性存在于实例中时才返回true,因此只要 in 操作符返回 true 而 hasOwnProperty()返回 false,就可以确定属性时原型中的属性。

注意:在使用 for-in 循环时,返回的是能够通过对象访问的、可枚举的属性,其中既包括存在于实例中的属性,也包括存在于原型中的属性。屏蔽了原型中不可枚举属性的实例属性也会在 for-in循环中返回。

5.3 更简单的原型语法

为了减少不必要的输入,以及从视觉上更好的封装原型的功能,常见的作法是用包含一个说有属性和方法的对象字面量来重写整个原型对象。

function P(){
    
}

P.prototype = {
    name: 'tc',
    age: 22,
    job: 'web',
    sayName: function(){
        console.log(this.name)
    }
}

5.4 原型的动态性

由于在原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都能够立即从实例上反映出来。尽管可以随时为原型添加属性和方法,并且修改能够立即在所有对象实例中反映出来,但如果是重写整个原型对象,那么情况就不同了。另外,调用构造函数时会为实例添加一个指向最初原型__proto__指针,而把原型修改为另一个对象就等于切断了构造函数和最初原型之间的联系。实例中的指针仅指向原型,而不是指向构造函数。

5.5 原生对象的原型

原型模式的重要性不仅体现在创建自定义类型方面,就连所有原生的引用类型,都是采用这种模式创建的。所有原生引用类型(Object、Array、String)都在其构造函数的原型上定义了方法。

typeof Array.prototype.sort     // function
typeof String.prototype.substring   // function

通过原生对象的原型,不仅可以取得所有默认方法的引用,也可以定义新的方法。可以像修改自定义对象的原型一样修改原生对象的原型,因此可以随时添加方法。

重要:尽管可以这样做,但是并不推荐在产品化的程序中修改原生对象的原型。如果因某个实现中缺少某个方法,就在原生对象的原型中添加这个方法,那么当在另一个支持该方法的实现中运行代码时,就可能的导致命名冲突,而且,这样做也可能会意外地重写原生方法

5.6 原型对象的问题

原型模式也有缺点,第一、它省略了为构造函数传递初始化参数这一环节,结果所有实例在默认情况下都将取得相同的属性值。虽然这个在某一程度上带来了不方便,但其最大的问题还是由其共享的本性所导致的。

在原型中,所有的属性时被很多实例共享的,这种共享对于函数非常合适。对于那些包含基本值的属性也行,但是对于引用类型值的属性来说,就有问题了。

6. 组合使用构造函数模式和原型模式

创建自定义类型的最常见方式,就是组合使用构造函数模式和原型模式。构造函数模式用于定义实例属性,而原型模式用于定义方法和共享的属性。结果每个实例都会有自己的一份实例属性的副本,但同时又共享着对方法的引用,最大限度地节省了内存。

7. 动态原型模式

把所有信息都封装了在构造函数中,而通过构造函数中初始化原型,又保持了同时使用构造函数和原型的有点。也就是说可以通过检查某一个应该存在的方法是否有效,来决定是否初始化原型。

function P(name, age, job) {
    this.name = name
    this.age = age
    this.job = job
    
    if(typeof this.sayName != 'function') {
        P.prototype.sayName = fucntion() {
            console.log(this.name)
        }
    }
}

let p = new P('tc', 23, 'web')
p.sayName() //  tc

上面的代码 只有在 sayName() 方法不存在的情况下才会将它添加到原型中。这段代码只有在初次调用函数时才会执行。过后,原型已经初始化完成,不需要再修改。这样对原型所做的修改,能够立即在所有实例中得到反映。另外if语句检查的可以是初始化之后应该存在的任何属性或方法。

**注意: ** 使用动态原型模式时,不能使用对象字面量重写原型,如果在已经创建了实例的情况下重写原型,那么就会切断现有实例与新原型之间的联系。

8. 寄生构造函数模式

创建一个函数,该函数的作用仅仅时封装创建对象的代码,然后再返回新创建的对象。

注意: 寄生构造函数模式返回的对象与构造函数或者与构造函数的原型属性之间没有关系,也就是说,构造函数返回的对象与在构造函数外部创建的对象没有什么不同。为此,不能依赖 instanceof 操作符来确定对象类型。

9. 稳妥构造函数模式

与寄生构造函数模式类似,使用稳妥构造函数模式创建的对象与构造函数之间也没有什么关系,因此 instanceof 操作符对这种对象也没有意义。

欢迎关注 公众号【小夭同学】