理解对象

第五讲里面讲了对象声明的两种方式:

  • 使用Object构造函数
  • 对象字面量

属性类型

ES5描述对象属性(property)的特征, 称为特性(attribute), 定义特性是为了实现js引擎用的, 所以在JS中不能直接访问它们(特性). 为了表示特性是内部值, 规范把它们放在两个方括号之中.

ES5有两种属性:

1. 数据属性:

数据属性包含一个数据值的位置。在这个位置可以读取和写入值。数据属性有 4 个描述其行为的特性。

  • [[Configurable]]: 表示能否通过 delete 删除属性从而重新定义属性,能否修改属性的特性,或者能否把属性修改为访问器属性。默认值: true
  • [[Enumerable]]: 可枚举, 即能否for-in循环出这个属性, 默认值: true
  • [[Writable]]: 是否能改属性的值, 默认值:true
  • [[Value]]: 默认值: undefined

为了帮助理解, 我们来看一个例子:

  1. var person = {};
  2. Object.defineProperty(person, "name", {
  3. writable: false,
  4. value: "Nicholas",
  5. configurable: false, //禁止删除
  6. });
  7. // 注意这个配置只能用一次,否则会报错
  8. Object.defineProperty(person, "name", {
  9. writable: true, // Cannot redefine property:
  10. })
  11. alert(person.name); //"Nicholas"
  12. person.name = "Greg"; //非严格模式下赋值被忽略, 严格模式下报错
  13. alert(person.name); //"Nicholas"
  14. delete person.name; //严格模式下报错
  15. alert(person.name); //"Nicholas"

如果运行Object.defineProperty方法时, 不明确指定的话, configurable, enumerable, writable的值都会变成false(要记住原本他们都是true).

注意: 笔者写本文的时候(2019年), 亲自写示例测试, 书上的这部分话已经不可信.

2. 访问器属性

访问器属性不包含数据值;它们包含一对儿 getter 和 setter 函数(不过,这两个函数都不是必需的)。在读取访问器属性时,会调用 getter 函数,这个函数负责返回有效的值;在写入访问器属性时,会调用
setter 函数并传入新值,这个函数负责决定如何处理数据。访问器属性有如下 4 个特性:

  • [[Configurable]]:同数据属性
  • [[Enumerable]]: 同数据属性
  • [[Get]]: 读取属性时候调用的函数, 默认undefined
  • [[Set]]: 写入属性时候调用的函数, 默认undefined

要修改访问器属性的特性, 同样是用Object.defineProperty方法

我们看这个例子:

  1. var demo = {};
  2. Object.defineProperty(demo, 'name', {
  3. configurable:true,
  4. enumerable:true,
  5. get:function(){
  6. console.log('我读取了属性')
  7. },
  8. set:function(){
  9. console.log('我设置了属性')
  10. }
  11. })

定义多个属性

定义多个属性可以用Object.defineProperties方法, 用法和Object.defineProperty类似, 只不过第二个参数为复合对象

读取属性的特性

用Object.getOwnPropertyDescriptor读取属性的特性

  1. var book = {};
  2. Object.defineProperties(book, {
  3. _year: {
  4. value: 2004
  5. },
  6. edition: {
  7. value: 1
  8. },
  9. year: {
  10. get: function(){ return this._year;
  11. },
  12. set: function(newValue){
  13. if (newValue > 2004) {
  14. this._year = newValue;
  15. this.edition += newValue - 2004;
  16. }
  17. }
  18. }
  19. });
  20. var descriptor = Object.getOwnPropertyDescriptor(book, "_year");
  21. alert(descriptor.value); //2004
  22. alert(descriptor.configurable); //false
  23. alert(typeof descriptor.get); //"undefined"
  24. var descriptor = Object.getOwnPropertyDescriptor(book, "year");
  25. alert(descriptor.value); //undefined
  26. alert(descriptor.enumerable); //false
  27. alert(typeof descriptor.get); //"function"

创建对象

为了解决多次生成对象的问题, 形成了以下常见的接种封装模式

工厂模式

  1. function createPerson(name, age, job){
  2. var o = new Object();
  3. o.name = name;
  4. o.age = age;
  5. o.job = job;
  6. o.sayName = function(){
  7. alert(this.name);
  8. };
  9. return o;
  10. }
  11. var person1 = createPerson("Nicholas", 29, "Software Engineer");
  12. var person2 = createPerson("Greg", 27, "Doctor");

工厂模式解决了生成多个类似对象的问题, 但是还没有解决对象属于什么”类”的问题

构造函数模式

  1. function Person(name, age, job){
  2. this.name = name;
  3. this.age = age;
  4. this.job = job;
  5. this.sayName = function(){
  6. alert(this.name);
  7. };
  8. }
  9. var person1 = new Person("Nicholas", 29, "Software Engineer");
  10. var person2 = new Person("Greg", 27, "Doctor");

我们通常把构造函数的函数名首字母大写, 这样它看起来想一个”类”, 区别于工厂模式, 构造函数模式有三个不同点:

  • 没有显示地创建对象
  • 直接将属性和方法赋给this
  • 没有return

创建一个实例的方式变成了 new Person, 背后产生了以下几个步骤:

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

构造函数的优点:

构造函数的好处在于, 它可以作为一种自定义的”类”, 也容易识别实例是否为某一种类型:

  1. alert(person1.constructor == Person); //true
  2. alert(person2.constructor == Person); //true

注意, 创建Person的实例的时候必须用new关键字, 否则创建的示例会挂载到window上.


构造函数的缺点:

还是用上一个例子, 但是我们稍作修改:

  1. function Person(name, age, job){
  2. this.name = name;
  3. this.age = age;
  4. this.job = job;
  5. this.sayName = new Function('alert(this.name)'); //为了方便理解我们使用new Function
  6. //这样每次创建Person实例的时候, sayName都是重复创建具有一样功能的方法(即资源浪费)
  7. }
  8. var p1 = new Person('k',18,'fe');
  9. var p2 = new Person('x',19,'bd');
  10. p1.sayName == p2.sayName; //false

前面我知道了函数名其实就是一个指针, 那么我们可以稍微改造一下:

  1. function Person(name, age, job){
  2. this.name = name;
  3. this.age = age;
  4. this.job = job;
  5. this.sayName = sayName;
  6. }
  7. function sayName(){
  8. alert(this.name)
  9. }
  10. var p1 = new Person('k',18,'fe');
  11. var p2 = new Person('x',19,'bd');
  12. p1.sayName == p2.sayName; //true

这样, 看起来也没什么问题了. 不过sayName作为一个全局函数, 只能给Person的示例调用, 好像又对不起它作为全局函数的称号, 如果要定义很多个方法, 那就需要很多个全局函数, 这样看起来又不像”封装好”的样子.

原型模式

为了解决构造函数模式的问题, 诞生了原型模式, 第五章我们提到了Function的每个实例都有两个属性, 一个是length, 另一个是prototype, 我们现在来着重讲这个prototype. prototype其实是一个指针, 指向一个对象, 这个对象用于存储所有实例共享的属性和方法.

我们先看一张图:

06-面向对象 - 图1

可以看到我随便创造的一个空函数demo, 它的prototype指向了一个对象, 这个对象包含了constructor__proto__两个属性, 而
constructor又指回了demo本身, __proto__指向的是Object, 实际上这个Object, 就是我们常常看到的 new Object里面的那个Object构造函数, 这侧面反映了几个事实:

  • 所有对象都是Object的实例(这个讲原型链的时候会继续深入讲解)
  • 一个对象可以通过__proto__访问生成这个对象的构造函数原型
  • 原型(prototype)的constructor属性,指向的是构造函数本身

我们再看这个例子:

  1. function Person(){ }
  2. Person.prototype.name = "Nicholas";
  3. Person.prototype.age = 29;
  4. Person.prototype.job = "Software Engineer";
  5. Person.prototype.sayName = function(){
  6. alert(this.name);
  7. };
  8. var person1 = new Person();
  9. person1.sayName(); //"Nicholas"
  10. var person2 = new Person();
  11. person2.sayName(); //"Nicholas"
  12. alert(person1.sayName == person2.sayName); //true

实际上它的原理如下:

06-面向对象 - 图2

图中实例的[[prototype]], 其实就是__proto__;

除了用这个属性确定对象实例和原型的关系, 还可以通过下列方式查看:

  1. Person.prototype.isPrototypeOf(person1) //true
  2. Object.getPrototypeOf(person1) == Person.prototype //true

前面给出的例子, 构造函数都是一个空函数, 不存在任何的属性和方法. 基于上一个代码示例, 我们尝试通过实例重写原型的属性.

  1. alert(person1.name); // "Nicholas"
  2. person1.name = "kkk"; //注意我这里只是改了实例的属性, 并没有改构造函数
  3. alert(person1.name); //"kkk"
  4. alert(person2.name); // "Nicholas"

可以看出, 通过实例去改变原型, 是没办法在原型中改变对应的属性或者方法.

从上面的代码能反映出, 对象实例查找某一个属性(或者方法), 是先查找构造函数中的同名属性 , 如果找到则停止, 否则继续在原型中查找同名属性.

解释: 上面的代码是显式地person1.name = “kkk”. 假如构造函数中存在this.name=”kkk”, 那么person1.name的值毫无疑问就是”kkk”;

由于构造函数和原型的这种特性, 我们要查找一个对象实例的属性究竟来自自身还是来自原型, 需要用hasOwnProperty方法:

  1. alert(person1.hasOwnProperty('name')); //true

in:
通常我们会在for-in(当然ES5可以用Object.keys)中看到in操作符, 用于遍历一个对象的所有课枚举属性, 然而单独使用in操作符的时候, 是用于检测某个对象的某个属性(或方法)是否存在于原型链中. 只要原型中存在需要查找的属性, 假设这个属性为name, 那么不管构造函数是否存在name, in操作符依然能查找出来.

为了查找只存在原型上的属性(或方法), 我们可以将in和hasOwnProperty写成一个函数用于检测:

  1. function hasPrototypeProperty(object, name){
  2. return !object.hasOwnProperty(name) && (name in object);
  3. }

简写原型:

前面的代码可以看到, 每添加一个属性, 就要多书写Person.prototype一次, 实际上们可以这样:

  1. function Person(){}
  2. Person.prototype = {
  3. constructor:Person, //注意这里要手动绑定构造函数,因为此时的原型相当于一个新领养的小孩, 要重新让他认爹. 虽然我们大多数情况下用不到constructor属性, 但是建议养成这个习惯
  4. }

原型的动态:

由于在原型中查找值的过程是一次搜索,因此我们对原型对象所做的任何修改都能够立即从实例上反映出来 即使是先创建了实例后修改原型也照样如此。

请看例子:

  1. var friend = new Person();
  2. Person.prototype.sayHi = function(){
  3. alert("hi");
  4. };
  5. friend.sayHi(); //"hi"(没有问题!)

如果生成实例之后用字面量的形式修改了原型对象, 那么就会报错:

  1. function Person(){
  2. }
  3. var friend = new Person();
  4. Person.prototype = {
  5. constructor: Person,
  6. name : "Nicholas",
  7. age : 29,
  8. job : "Software Engineer",
  9. sayName : function () {
  10. alert(this.name);
  11. }
  12. };
  13. friend.sayName(); //error. 因为friend是原本那个原型衍生而来的

原生对象的原型: Array, Object, Function同样可以通过上述方式修改原型对象, 不过除非必要, 不建议修改.


原型模式的缺点:
所有属性和方法都写在原型, 看上去实现了共享. 但是如果原型对象中的属性是引用类型的话, 实例对改属性的修改, 也会立刻反映到所有实例上.

看例子:

  1. function Person(){ }
  2. Person.prototype = {
  3. constructor: Person,
  4. name : "Nicholas",
  5. age : 29,
  6. job : "Software Engineer",
  7. friends : ["Shelby","Court"],
  8. sayName : function () {
  9. alert(this.name);
  10. }
  11. };
  12. var person1 = new Person();
  13. var person2 = new Person();
  14. person1.friends.push("Van");
  15. alert(person1.friends); //"Shelby,Court,Van"
  16. alert(person2.friends); //"Shelby,Court,Van"
  17. alert(person1.friends === person2.friends); //true

构造函数+原型模式

仅仅只用构造函数, 那么在生成对象方法的时候会造成资源浪费. 如果只是用原型模式的话, 那么会产生属性为引用类型时候的弊端. 所以我们可以把这两张方式结合起来:

  1. function Person(name,age, job){
  2. this.name = name;
  3. this.age = age;
  4. this.job = job;
  5. this.friends = ["Shelby","Court"],
  6. }
  7. Person.prototype = {
  8. constructor: Person,
  9. sayName : function () {
  10. alert(this.name);
  11. }
  12. };
  13. var person1 = new Person("Nicholas", 29, "Software Engineer"); var person2 = new Person("Greg", 27, "Doctor");
  14. person1.friends.push("Van");
  15. alert(person1.friends); //"Shelby,Count,Van"
  16. alert(person2.friends); //"Shelby,Count"

这样, 组合模式基本解决所有的需求.

动态原型模式

组合模式的优化:

function Person(name,age, job){
    this.name = name;
    this.age = age;
    this.job = job;
    this.friends = ["Shelby","Court"];
    // 看起来更像是一个'类'
    if (typeof this.sayName != "function"){
      Person.prototype.sayName = function(){ 
        alert(this.name);
      };
    }
  }

寄生构造函数

假如你有这么一个这样的需求: 批量生产某一种数据类型, 比如数组实例, 但是这个数组实例又有一个toPipedString方法, 那你可以考虑用寄生构造函数.

它和工厂模式长得很像(实际上就是一样的, 该有的缺点都有):

function _Array(){
      var values = new Array();
      values.push.apply(values, arguments);
      values.toPipedString = function(){
          return this.join("|");
      }
      return values;
  }
  var a = new _Array(2,6,8,9,4);
  a.toPipedString();

  var b = _Array(2,6,8,9,4);// 这里没有用new , 返回的结果依旧一样(就是工厂模式)
  b.toPipedString();

在上面的_Array构造函数, 并没有使用到this, 而且它还有显式的return(引用类型), 也就是说new了也是白new. 虽然书上给出了这个寄生构造函数模式, 但笔者认为实在没有必要使用这种方式.

稳妥构造函数

就是寄生构造函数模式下, 只暴露方法, 不允许通过对象实例直接访问对象的属性值

继承

JS只有实现继承, 并且主要依靠原型链实现的.

原型链

前面我们讲了原型, 至于原型链. 实际就是用父类的实例, 作为子类的原型对象(即用父类实例重写子类原型对象), 这样父类拥有的属性和方法, 子类实例自然也能访问到.

原型链需要注意:

function SuperType(){ 
    this.property = true;
  }

  SuperType.prototype.getSuperValue = function(){ 
    return this.property;
  };

  function SubType(){ 
    this.subproperty = false;
  }

  //继承了 SuperType
  SubType.prototype = new SuperType();

  //注意以下操作一点要在继承完之后才能执行, 并且不能用字面量添加方法, 否则会导致继承失效.

  //添加新方法
  SubType.prototype.getSubValue = function (){ 
    return this.subproperty;
  };
  //重写超类型中的方法
  SubType.prototype.getSuperValue = function (){
    return false;
  };

  var instance = new SubType(); 
  alert(instance.getSuperValue());  //false

除了以上要注意的地方, 原型链继承还有两个问题:

  • 第一个问题就是, 当父类的实例包含了引用类型属性时, 子类的原型对象就同样包含了这个引用类型的属性.
    我们来看示例:
function SuperType(){
    this.colors = ["red", "blue", "green"];
  }
  function SubType(){ }
  SubType.prototype = new SuperType();

  var instance1 = new SubType(); 
  instance1.colors.push("black"); 
  alert(instance1.colors);  //"red,blue,green,black"

  var instance2 = new SubType(); 
  alert(instance2.colors); //"red,blue,green,black", 已经发生修改
  • 第二个问题: 不能向父类传参.

借用构造函数

为了解决原型链继承问题, 我们可以借用父类构造函数, 而不是将父类实力重写子类原型对象 , 通常这种方式叫做经典继承或者伪造继承.

function SuperType(name){
    this.name = name;
    this.colors = ["red", "blue", "green"];
  }

  function SubType(name){
    var name = name || 'testdog'
    SuperType.call(this,name);  //想起call和apply的作用吗?
  }

  var instance1 = new SubType(); 
  instance1.colors.push("black"); 
  alert(instance1.colors); //"red,blue,green,black"

  var instance2 = new SubType(); 
  alert(instance2.colors); //"red,blue,green"

  var instance = new subType('kkk')
  instance.name;  //'kkk'

不过这样的话, 就子类实例无法用instanceof来检查是否是也是父类的实例了.

当然, 不可能把所有属性和方法都放在父类构造函数中, 肯定还有写在父类原型对象的情况. 所以只用call/apply借用父类构造函数实现继承, 也是不够的.

组合继承

顾名思义, 原型链+构造函数组合而成.

function SuperType(name){ 
    this.name = name;
    this.colors = ["red", "blue", "green"];
  }

  SuperType.prototype.sayName = function(){ 
    alert(this.name);
  };

  function SubType(name, age){
    //继承属性
    SuperType.call(this, name);
    this.age = age;
  }
  //继承方法
  SubType.prototype = new SuperType(); 
  SubType.prototype.constructor = SubType; 
  SubType.prototype.sayAge = function(){
    alert(this.age);
  };

  var instance1 = new SubType("Nicholas", 29); 
  instance1.colors.push("black"); 
  alert(instance1.colors); //"red,blue,green,black" 
  instance1.sayName(); //"Nicholas";
  instance1.sayAge(); //29

  var instance2 = new SubType("Greg", 27); 
  alert(instance2.colors); //"red,blue,green" 
  instance2.sayName(); //"Greg";
  instance2.sayAge(); //27

注意: 创建一个子类实例, SuperType其实是运行了两次的 , 一次在于原型链继承, 另一次在于借用以覆盖引用类型属性共享的问题.

原型式继承

如果只是基于现有的对象实现继承, 那可不比兴师动众写那么多函数:

var k = {
    name:'k',
    age:18,
    friends:['a','b','c'],
  };  
  var object = function(o){
    function F(){};
    F.prototype = o;  //浅复制, 
    return new F();
  };
  var obj = object(k);
  obj.friends.push('d');

  var obj2 = object(k);
  obj2.friends; //['a','b','c','d'];

ES5规范化了这种继承方式, 于是有了Object.create方法, 用法同上, 但是它可以多一个参数.这个参数的格式和Object.defineProperties的第二个参数格式一致.

var person = {
    name: "Nicholas",
    friends: ["Shelby", "Court", "Van"]
  };

  var anotherPerson = Object.create(person, { 
    name: {
      value: "Greg"
    }
  });

  alert(anotherPerson.name); //"Greg"

寄生式继承

其实是原型式继承的基础上再包装一层, 用于添加需要的方法, 类似于前面讲的寄生构造函数模式或者工厂模式.

寄生组合继承

前面讲了组合继承, 它还存在一个两次调用父类的问题. 既然组合类型是通过借用父类函数实现属性继承, 通过原型链实现方法的继承. 那么我们可以在原型链这里动一次手脚.

function inheritPrototype(subType, superType){
    var prototype = Object.create(superType.prototype);
    prototype.constructor = subType; 
    subType.prototype = prototype; 
  }

  function SuperType(name){ 
    this.name = name;
    this.colors = ["red", "blue", "green"];
  }

  SuperType.prototype.sayName = function(){ 
    alert(this.name);
  };

  function SubType(name, age){ 
    SuperType.call(this, name);
    this.age = age;
  }

  inheritPrototype(SubType, SuperType);
  SubType.prototype.sayAge = function(){ 
    alert(this.age);
  };

本章完