JS对象

这是一篇关于JavaScript对象与原型的综合性的学习笔记,知识点主要提炼或引用自《JavaScript高级程序设计》(第三版)(第四版)与《你不知道的JavaScript》(上卷),以及JS源头https://developer.mozilla.org/zh-CN/

什么是对象?

ECMA-262将对象定义为一组属性的无序集合

用结构一点的话讲,对象近似一张散列表。

对象的创建

一般创建对象最简单的有两种方法

  • 一是使用Object创建一个实例,再向这个实例添加子属性和方法
  • 二是直接使用对象字面量的方式

属性定义

如果对于Object.defineProperty()方法有一定的了解,或者看过《JavaScript高级程序设计》,那一定会记得里面提到过这样一段内容

ECMA-262 使用一些内部特性来描述属性的特征。这些特性是由为 JavaScript 实现引擎的规范定义 的。因此,开发者不能在 JavaScript 中直接访问这些特性。为了将某个特性标识为内部特性,规范会用两个中括号把特性的名称括起来,比如[[Enumerable]]。

这些特性对于常规的代码工作或许意义不大,但是当你想要去封装或者实现一个工具类的时候,这或许会给你意想不到的帮助。

数据属性(数据描述符)

数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有 4 个特性描述它们的行为。

  • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特 性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。非严格模式下对 这个属性调用 delete 没有效果,严格模式下会抛出错误。此外,一个属性被定义为不可配置之后,就 不能再变回可配置的了。再次调用 Object.defineProperty()并修改任何非writable 属性会导致错误
  • [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对 象上的属性的这个特性都是 true
  • [[Writable]]:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的 这个特性都是 true。非严格模式下尝试给这个属性重新赋值会被忽略。在严格模式下,尝试修改只读属性 的值会抛出错误。
  • [[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性 的默认值为 undefined

Object.defineProperty()

如果对于双向绑定中的数据劫持有一定的学习,那一定了解这个方法。

这个方法接收 3个参数: 要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包
含:configurable、enumerable、writable 和 value,跟相关特性的名称一一对应。

const obj = {};
Object.defineProperty(obj, 'a', {
    value: 1,
    writable: false
});
obj.a = 2;
console.log(obj.a);//1

而如果在严格模式下,执行到obj.a=2时就会直接抛出错误

这里还有一个细节

在调用 Object.defineProperty()时,configurable、enumerable 和 writable 的值如果不指定,则都默认为 false

const obj = {};
Object.defineProperty(obj, 'a', {
    value: 1,
});
obj.a = 2;
console.log(obj.a);//1

访问器属性(存取描述符)

访问器属性包含一个获取(getter)函数和一个设置(setter)函数,但它们并不是必须的。

在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效 的值。在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。

这一点是非常重要的,许多前端框架关于数据这一方面的基础实现,都有用到这一点。

访问器属性同样具有四个特性

  • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特 性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
  • [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对 象上的属性的这个特性都是 true。
  • [[Get]]:获取函数,在读取属性时调用。默认值为 undefined。
  • [[Set]]:设置函数,在写入属性时调用。默认值为 undefined。

获取函数和设置函数不一定都要定义。只定义获取函数意味着属性是只读的,尝试修改属性会被忽 略。在严格模式下,尝试写入只定义了获取函数的属性会抛出错误。类似地,只有一个设置函数的属性是不能读取的,非严格模式下读取会返回 undefined,严格模式下会抛出错误。

对象默认的内置 [[Get]] 操作首先在对象中查找是否有名称相同的属性, 如果找到就会返回这个属性的值,如果没有找到名称相同的属性,会遍历可能存在的 [[Prototype]] 链,也就是原型链,如果无论如何都没有找到名称相同的属性,那 [[Get]] 操作会返回值undefined。看到这里,我们就有了一个小小的发现,访问对象属性和访问一个独立的变量时有一点不同,如果引用一个词法作用域中不存在的变量,会抛出ReferenceError 异常,但是我们引用一个不存在的对象属性时却并不会抛出异常,而是返回undefined

先来看一个简单的例子

let book = {
    year_: 2017,
    edition: 1
}
Object.defineProperty(book, "year", {
    get() {
        return this.year_;
    }, set(newValue) {
        if (newValue > 2017) {
            this.year_ = newValue;
            this.edition += newValue - 2017;
        }
    }
});
book.year = 2018;
console.log(book.year_)//2018
console.log(book.edition); // 2

再来看一个稍微复杂一点的例子

function Archiver() {
    var temperature = null;
    var archive = [];

    Object.defineProperty(this, 'temperature', {
        get: function () {
            console.log('get!');
            return temperature;
        },
        set: function (value) {
            temperature = value;
            archive.push({ val: temperature });
        }
    });

    this.getArchive = function () { return archive; };
}

var arc = new Archiver();
arc.temperature; // 'get!'
arc.temperature = 11;
arc.temperature = 13;
console.log(arc.getArchive()); // [{ val: 11 }, { val: 13 }]

上面的这一个例子实现了属性赋值操作的记录自动保存功能,每一次对temperature进行赋值的时候都会保存这一次的赋值记录。

除了使用Object.defineProperty()重写get与set,我们也可以直接在对象字面量中重写

let obj = {
    get a() {
        console.log('get a ')
        return a * 2
    },
    set a(val) {
        console.log('set a')
        a = val
    }
};
obj.a = 3
//set a
console.log(obj.a)
//get a 
//6

注意:getter 和 setter 是成对出现的,缺少setter会抛出ReferenceError 异常,缺少getter,读取时会返回undefined,也就是说,单个的getter或者setter无法定义一个对象属性。

保持不变

​ 通过前面的种种特性,我们可以保持一个对象的一定的不变性。

常量

​ 结合 writable:false 和 configurable:false 就可以创建一个真正的常量属性(不可修改、 重定义或者删除)

禁止扩展

​ 禁止一个对象添加新属性并且保留已有属性,使用 Object.prevent Extensions(…)

var myObject = {
    a: 2
};
Object.preventExtensions(myObject);
myObject.b = 3;
console.log(myObject.b); // undefined

​ 在非严格模式下,创建属性 b 会静默失败。在严格模式下,将会抛出 TypeError 错误。

密封

​ Object.seal(…) 会创建一个“密封”的对象,这个方法实际上会在一个现有对象上调用 Object.preventExtensions(…) 并把所有现有属性标记为 configurable:false。所以,密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(虽然可以修改属性的值)。

冻结

​ Object.freeze(…) 会创建一个冻结对象,这个方法实际上会在一个现有对象上调用 Object.seal(…) 并把所有“数据访问”属性标记为writable:false,这样就无法修改它们的值。

​ 这个方法是可以应用在对象上的级别最高的不可变性,它会禁止对于对象本身及其任意 直接属性的修改(但是这个对象引用的其他对象是不受影响的)。
​ 因此可以“深度冻结”一个对象,具体方法为,首先在这个对象上调用 Object.freeze(…), 然后遍历它引用的所有对象并在这些对象上调用 Object.freeze(…)。但是这样做有可能会在无意中冻结其他(共享)对象。

除了上面的两种较为简单的创建对象方式,还有许多方式可以创建对象,当然,他们中的大多数都是上面两种方式的二次封装。

工厂函数模式

工厂函数可以按照特定接口创 建对象,宛如一条流水线可以不断的生成对象。

function createPerson(name, age, job) {
    let o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function () {
        console.log(this.name);
    };
    return o;
}
let person1 = createPerson("Nicholas", 29, "Software Engineer");
let person2 = createPerson("Greg", 27, "Doctor");

createPerson()接收 3个参数,根据这几个参数构建了一个包含 Person 信息的对象。 可以用不同的参数多次调用这个函数,每次都会返回包含 3个属性和 1个方法的对象。

这种工厂模式虽 然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)。也就是说,你知道person1是一个对象,知道它有3个属性一个方法,但也就仅限于此了。

构造函数模式

ECMAScript中的构造函数是用于创建特定类型对象的。像 Object 和 Array 这 样的原生构造函数,运行时可以直接在执行环境中使用。当然也可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = function () {
        console.log(this.name);
    };
}
let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");
person1.sayName(); // Nicholas
person2.sayName(); // Greg

构造函数模式和工厂函数模式区别

  • 没有显式地创建对象。
  • 属性和方法直接赋值给了 this。
  • 没有 return。
  • 首字母大写。
  • 使用new操作符创建实例

定义自定义构造函数可以确保实例被标识为特定类型。

console.log(person1 instanceof Person)//true

创建 Person 的实例,使用 new 操作符。以这种方式调用构造函数会执行这些操作。

  • 在内存中创建一个新对象。
  • 这个新对象内部的[[Prototype]]特性被赋值为构造函数的 prototype 属性
  • 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
  • 执行构造函数内部的代码(给新对象添加属性)。
  • 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象

person1 和 person2 分别保存着 Person 的不同实例。这两个对象都有一个 constructor 属性指向 Person

console.log(person1.constructor == Person); // true 
console.log(person2.constructor == Person); // true

构造函数不一定要写成函数声明的形式。赋值给变量的函数表达式也可以表示构造函数

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

问题

构造函数的主要问题在于,其定义的方法会在每个实例上 都创建一遍。对前面的例子而言,person1 和 person2 都有名为 sayName()的方法,但这两个方 法不是同一个 Function 实例。ECMAScript中的函数是对象,因此每次定义函数时,都会 初始化一个对象。

不同实例上的函数虽然同名却不相等

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

前面的Person构造函数和下面这个是等价的。

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

既然person1和person2乃至其它的Person实例的sayName函数都是实现相同的功能,那我们为什么不直接使用一个sayName实例呢?

要解决这个问题,可以把函数定义转移到构造函数外部

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = sayName;
}
function sayName() {
    console.log(this.name);
}
let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");
person1.sayName(); // Nicholas
person2.sayName(); // Greg

sayName()被定义在了构造函数外部。在构造函数内部,sayName 属性等于全局sayName() 函数。因为这一次 sayName 属性中包含的只是一个指向外部函数的指针,所以 person1 和 person2 共享了定义在全局作用域上的 sayName()函数。这样虽然解决了相同逻辑的函数重复定义的问题,但 全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法, 那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。这个新问题可以通过原型模式来解决。

原型模式

每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例 共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处 是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中直接赋给对象实例的值,可以直接赋值给它们的原型。

function Person() { }
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function () {
    console.log(this.name);
};
let person1 = new Person();
person1.sayName(); // "Nicholas"
let person2 = new Person();
person2.sayName(); // "Nicholas"
console.log(person1.sayName == person2.sayName); // true

与构造函数模式不同,使用这种原型模式定义的属性和方法是由所有实例共享的。

无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向 原型对象)。默认情况下,所有原型对象自动获得一个名为 constructor 的属性,指回与之关联的构 造函数。对前面的例子而言,Person.prototype.constructor 指向 Person。然后,因构造函数而异,可能会给原型对象添加其他属性和方法。

在自定义构造函数时,原型对象默认只会获得 constructor 属性,其他的所有方法都继承自 Object。每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构 造函数的原型对象。脚本中没有访问这个[[Prototype]]特性的标准方式,但 Firefox、Safari 和 Chrome 会在每个对象上暴露__proto__属性,通过这个属性可以访问对象的原型。在其他实现中,这个特性 完全被隐藏了。关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。