JavaScript -- 面向对象

1. 创建对象

创建定义对象的最简单方式就是创建一个 Object 实例,然后为其添加属性和方法。

        var person = new Object();
        person.name = "Hwaphon";
        person.age = 19;
        person.sayName = function() {
            console.log(this.name);
        }

当然,我们还可以通过一个对象字面量创建对象,事实上这种方式也是使用最多的。

        var person = {
            name: "Hwaphon",
            age: 19,
            sayName: function() {
                console.log(this.name);
            }
        };

虽然 Object 构造函数或对象字面量都可以创建单个对象,但是这两种方式存在明显的缺点: 使用同一接口创建很多对象,会产生大量重复的代码。为了解决这个问题,人们开始用工厂模式的一种变体。下面就用类工厂模式解决这种问题。

        function createPerson(name, age) {
            var o = new Object();
            o.name = name;
            o.age = age;
            o.sayName = function() {
                console.log(this.name);
            };

            return o;
        }

可以频繁的调用上面这个函数以创造不同的对象,但是这种方法仍然存在一个问题,就是对象识别问题,即我们无法判断创建出来的对象是 Person, 我们能判断的就是它属于 Object 类型而已。所以这个时候又出现了一种创建对象的方式,就是构造函数模式。

        function Person(name, age) {
            this.name = name;
            this.age = age;
            this.sayName = function() {
                console.log(this.name);
            }
        }

        var person = new Person("Hwaphon", 21);
        person.sayName();

这个时候我们就可以利用 instanceof 运算符检测我们创建的对象类型。

        console.log(person instanceof Object);  // true
        console.log(person instanceof Person);  // true

我们通过 new 操作符去创建一个对象,这也就意味着每个对象是相互独立的,当然也包括 sayName 方法, 但是这个方法在每个实例中实现的功能是相同的,实在没必要每创建一个对象实例就创建一个 Function 实例。我们通过以下代码可以看出这种问题。

        var person1 = new Person("Hwaphon", 21);
        var person2 = new Person("Hello", 22);

        console.log(person1.sayName == person2.sayName);    // false

如果是这样的话,我们只能将函数放置到构造函数外去解决这个问题。

        function Person(name, age) {
            this.name = name;
            this.age = age;
            this.sayName = sayName;
        }

        function sayName() {
            console.log(this.name);
        }

        var person1 = new Person("Hwaphon", 21);
        person1.sayName();

虽说这种方法能解决上述问题,但是又引入了新的问题,因为这种方式引入了全局的 sayName 函数。更让人无法接受的是,如果对象需要定义很多方法,那么就要定义多个全局函数,这样我们自定义的引用类型就丝毫没有封装性可言了。所以不得不引入原型模式来解决这个问题。

2. 原型模式

我们创建的每个函数都有一个 prototype 属性,这个属性是一个指针,指向一个对象,而这个对象的用途是包含可以由特定类型的所有实例共享的属性和方法。使用原型的好处是可以让所有的对象实例共享它保函的属性和方法。所以为了解决上面的问题,我们可以像下面这样定义。

        function Person(name, age) {
            this.name = name;
            this.age = age;
        }

        Person.prototype.sayName = function() {
            console.log(this.name);
        }

        console.log(person1.sayName == person2.sayName);    // true

通过 hasOwnProperty() 方法可以检测实例属性,而非原型属性,通过 in 不仅可以检测实例属性也可以检测原型属性。

        function Person(name, age) {
            this.name = name;
            this.age = age;
        }

        Person.prototype.sayName = function() {
            console.log(this.name);
        }

        var person = new Person("Hwaphon", 21);
        if (person.hasOwnProperty("sayName")) {
            console.log("true");
        }

        if ("sayName" in person) {
            console.log("true");
        }

当然,如果你想在原型添加多个函数,那么可以将这些方法组成一个对象字面量。

        function Person(name, age) {
            this.name = name;
            this.age = age;
        }

        Person.prototype = {
            sayName: function() {
                console.log(this.name);
            },
            sayAge: function() {
                console.log(this.age);
            }
        };

这个时候存在一个问题,因为这个时候 constructor 不再指向 Person 了,所以我们可以手动设置 constructor

        Person.prototype = {
            constructor: Person,
            
            sayName: function() {
                console.log(this.name);
            },
            sayAge: function() {
                console.log(this.age);
            }
        };

上面我们将原型和构造函数分开了,这可能会让人感到困惑,所以可以使用动态原型模式,将所有信息封装在构造函数中。

    function Person(name, age) {
        this.name = name;
        this.age = age;

        if (typeof this.sayName != "function") {
            Person.prototype.sayName = function() {
                console.log(this.name);
            };
        }
    }

    var person = new Person("Hwaphon", 21);
    person.sayName();

只有在 sayName() 方法不存在的情况下才会将它添加进原型中,所以 if 判断语句只有在初次调用构造函数时才会执行。

除了上面的方法以外,还有一种寄生构造函数模式,这种模式的基本思想是创建一个函数,该函数的作用仅仅是封装创建对象的过程,然后将新创建的对象返回。比如,这种模式创建一个对象的过程可能如下所示:

        function Person(name, age) {
            var o = new Object();
            o.name = name;
            o.age = age;
            o.sayName = function() {
                console.log(this.name);
            };

            return o;
        }

        var person = new Person("hwaphon", 21);
        person.sayName();

那么这种方式的优点在哪里呢?正如它名称中的 “寄生” 所说的,这种模式可以寄生在一个指定的对象中,也就是说它可以为一个已经存在的对象增加功能。比如说,对于自带的 Array 对象,如果不是迫不得已,一般是不应该直接使用 Array.prototype 扩充原生数组对象的,所以这时候寄生构造模式可以发挥它的作用。

        function SpecialArray() {
            var array = new Array();

            array.push.apply(array, arguments);
            array.formatArray = function() {
                return this.join("->");
            }

            return array;
        }

        var array = new SpecialArray(1,2,3,4),
            result = array.formatArray();

        console.log(result);

可见,当你想要扩充一个对象的功能而又不想直接修改原本的对象时,可以使用寄生构造函数模式,这和设计模式中的装饰者模式倒是很像。

还有一个称为稳妥构造方式的创建方法,当你不想共享任何变量而且环境不允许使用 this, new 的情况下,可以选择这种方式。

        function Person(name, age) {
            var o = new Object();

            o.sayName = function() {
                console.log(name);
            };

            o.sayAge = function() {
                console.log(age);
            };

            return o;
        }

        var person = Person("Hwaphon", 33);
        person.sayName();
        person.sayAge();

值得注意的是,这个时候只有通过 sayName() 和 sayAge() 才可以访问到 nameage 属性,而之前介绍的方法都在要返回的对象中设置了属性,而这种方式依赖于直接传入的参数,所以返回的对象中是不包含 nameage 属性的,这倒是满足了封装的特性。


3. 复制对象

还有一个常见的问题就是如何复制一个对象,看下面这个例子:

        function anotherFunction() {}

        var anotherObject = {
            c: true
        };

        var anotherArray = [];

        var myObject = {
            a: 2,
            b: anotherObject,
            c: anotherFunction,
            d: anotherArray
        };

如果我现在想复制 myObject 对象,可以看出只有 a 是一个基础类型的值,其它 b, c, d 全是对象类型的值,根据从其它高级语言中的到的经验,对于基础类型的值默认是复制其值,而对于对象类型,则复制的是引用。

JavaScript 中自然也是这样的,涉及浅复制和深复制。首先我们先介绍浅复制,因为 ES6 中定义了一个 Object.assign() 方法,我们可以通过这个方法轻松地实现浅复制。

Object.assign() 第一个参数是目标对象,之后可以跟多个源对象,它会遍历一个或者多个源对象所有可枚举的自有键并把他们复制到目标对象,最后返回目标对象。

        var newObj = Object.assign({}, myObject);
        console.log(newObj.a);
        console.log(newObj.b === myObject.b);   // true
        console.log(newObj.c === myObject.c);   // true
        console.log(newObj.d === myObject.d);   // true

可见,newObjmyObj 的对象比较都返回了 true, 这说明其实二者都是一个引用,指向同一个函数对象。

下面介绍的深复制只适用于 JSON 安全的对象,因为我们的深复制是借助 JSON 来实现的。

        var newObj = JSON.parse(JSON.stringify(myObject));
        console.log(newObj.a);
        console.log(newObj.b === myObject.b);   // false
        console.log(newObj.c === myObject.c);   // false
        console.log(newObj.d === myObject.d);   // false

4. 属性描述符

自从 ES5 开始,所有的属性都具备了属性描述符,下面让我们来一一介绍。

  • Writable - 决定是否可以修改属性的值。

          var myObjet = {};
    
          Object.defineProperty(myObjet, "a", {
              value: 2,
              writable: false,
              configurable: true,
              enumerable: true
          });
    
          console.log(myObjet.a); // 2
    
          myObjet.a = 4;
          console.log(myObjet.a); // 2
    

可见,如果将 writable 的值为 false 的时候,那么修改对象属性的值将会失效(在严格模式下会 TypeError)。

  • Configurable - 控制是否可以修改属性描述符

          var myObjet = {};
    
          Object.defineProperty(myObjet, "a", {
              value: 2,
              writable: true,
              configurable: false,
              enumerable: true
          });
    
          console.log(myObjet.a); // 2
    
          delete myObjet.a;
          console.log(myObjet.a); // 2
    

从上面的例子可以看出,我们对属性的删除已经失效了,另外值得注意的是,一旦将 configurable 设置为 false, 我们就不可以再利用 defineProperty() 再去修改属性的 writable, configurable, enumerable 描述符。好吧,我承认还有一个例外,这时候还是可以将 writrabletrue 设置为 false,但是不可以在改为 true

  • Enumerable - 设置属性是否支持枚举

一旦将 Enumerable 设置为 false, 虽然它还是可以正常访问,但是它将不会出现在对象的属性枚举中。更具体的说,当你使用 for...in 遍历对象的属性时,设置为 false 的属性会被直接跳过。

看到这里,你可能会问了,在对象中设置一个属性有这么麻烦吗?我平常的时候好像根本没接触过上面提到的这几个属性,那是因为它们都有一个默认的设置,怎么查看呢?看下面这个例子:

        var myObjet = {
            a: 2
        };

        var despritor = Object.getOwnPropertyDescriptor(myObjet, "a");
        console.log(despritor);

它的返回值如下所示

        configurable: true
        enumerable:true
        value:2
        writable:true

有的时候,我们希望自己定义的属性或者对象是不可改变的,那么改怎么做呢?比如说我想设置定义一个常量?

我们可以将 writableconfigurable 都设置为 false, 这样我们就可以轻松地创建一个真正意义上的常量。

        var myObjet = {};

        Object.defineProperty(myObjet, "PI", {
            value: 3.1415926,
            writable: false,
            configurable: false
        });

下面来看几个实用的函数。

  • 禁止拓展 - 如果你想禁止一个对象添加属性并且保留已有属性,可以使用 Object.preventExtensions()

          var myObject = {
              a: 2
          };
    
          Object.preventExtensions(myObject);
          console.log(myObject.a);    // 2
    
          myObject.b = 10;
          console.log(myObject.b);    // undefined
    
  • 密封 - 密封也就是说在禁止拓展的基础上, 将 configurable 也设置为 false,将对象密封的方法为 Object.seal()

  • 冻结 - 冻结就是在密封的基础上将 writable 也设置为 false, 这意味着连对象中属性的值也无法修改了,这个方法用于定义那种一旦定义了就不用在改动的对象。将对象冻结的方法为 Object.freeze()


5. 实例

构造函数,实例以及原型的关系基本如下所示:

  1. 对于构造函数而言,它内部有一个 prototype 属性,这个属性直接指向原型,而且通过此构造函数创建的实例,将默认指向该原型,这也就意味着原型中的方法在实例中也是可用的。

  2. 在实例中,我们不可以通过 prototype 访问原型,不过可以通过 __proto__ 访问该原型。

  3. 在原型中,有一个 constructor 属性,这个属性默认指向构造函数,而且原型中也有一个 __proto__ 属性,这个属性指向 Object,所以我们可以说,所有的对象都是继承自 Object 的。


你可能感兴趣的:(JavaScript -- 面向对象)