JavaScript中的对象

编辑推荐: 掘金是一个高质量的技术社区,从 CSS 到 Vue.js,性能优化到开源类库,让你不错过前端开发的每一个技术干货。 点击链接查看最新前端内容,或到各大应用市场搜索「 掘金」下载APP,技术干货尽在掌握中。

JavaScript中的一个对象就是一系列属性的集合,一个属性包含一个属性名和一个属性值(key/value)。一个属性的值可以是函数(这个时候也被称为方法)。除了内置的对象之外,还可以自定义对象。接下来学习在JavaScript中怎么使用对象、属性、函数和方法以及怎么自定义创建对象。

对象概述

JavaScript中的对象和其他编程语言中的对象一样,它也像我们生活中的物体,通过生活中的物体,我们能更好的理解JavaScript对象。在JavaScript中,一个对象可以是一个单独的拥有属性和类型的实体。好比我们生活中的一辆车。这辆车就是一个对象(物体),它拥有属于自己的一些属性(车的特征之类)。比如车子的型号、颜色、哪生生产的、开了多少公司等等。同样JavaScript对象也有属性来定义它的特征。

对象属性

一个JavaScript对象有很多属性。一个对象的属性可以被解释成一个附加到对象上的变量。对象的属性和普能的JavaScript变量没什么区别,仅仅是属性属于某个对象。属性定义了对象的特征。例如,我们创建了一个car的对象,然后这个对象有三个属性,makemodelyear

var car = new Object();
car.make = 'Ford';
car.model = 'Mustang';
car.year = 1969;

对象属性的创建方式

JavaScript中的对象的属性有两种创建方式。其中一种通过.的方式创建,另外一种通过[]的方式来创建。

var person = new Object();

// 通过.创建属对象属性
person.name = 'w3cplus';

// 通过[]创建对象属性
person['age'] = 7;

console.log(person);

注意:JavaScript对象中未赋值的属性的值为undefined,而不是null。比如:

person.profession; // => undefined

但是,如果对象不存在,那么试图查询这个不存在的对象的属性就会报错。nullundefined值都没有属性,因此查询这些值的属性会报错,例如:

var person = {}
person.wife.name;

除非确定personperson.wife都是对象,否则不能这样写表达式person.wife.name,因为会报未捕获的错误类型,提面提供两种避免出错的方法:

// 冗余碍易懂的写法
var name;
if (person) {
    if (person.wife) {
        name = person.wife.name;
    }
}

// 简练又常用的写法
var name = person && person.wife && person.wife.name

对象属性的访问方式

JavaScript中的对象属性的访问方式同样可以通过.[]来访问。比如:

person.name;   // => "w3cplus"
person["age"]; // => 7

一个对象的属性名可以是任何有效的 JavaScript 字符串,或者可以被转换为字符串的任何类型,包括空字符串。然而,一个属性的名称如果不是一个有效的 JavaScript 标识符(例如,一个由空格或连字符,或者以数字开头的属性名),就只能通过方括号标记访问。这个标记法在属性名称是动态判定(属性名只有到运行时才能判定)时非常有用。例如:

// 同时创建四个变量,用逗号分隔
var myObj = new Object(),
    str = "myString",
    rand = Math.random(),
    obj = new Object();

myObj.type              = "Dot syntax";
myObj["date created"]   = "String with space";
myObj[str]              = "String value";
myObj[rand]             = "Random Number";
myObj[obj]              = "Object";
myObj[""]               = "Even an empty string";

console.log(myObj);

请注意,方括号中的所有键都将转换为字符串类型,因为JavaScript中的对象只能使用String类型作为键类型。 例如,在上面的代码中,当将键obj添加到myObj时,JavaScript将调用obj.toString()方法,并将此结果字符串用作新键。

删除对象属性

delete运算符用来删除对象属性,事实上delete只是断开属性和宿主对象的联系,并没有真正的删除它。delete运算符只能删除自有属性,不能删除继承属性。要删除继承属性必须从定义这个属性的原型对象上删除它,而且这会影响到所有继承自这个原型的对象。

delete运算符用来删除对象属性,如果删除成功或所删除的项目不存在,delete将返回true。然而,并不是所有的属性都可以删除,一些内置核心和客户端属性是不能删除的,通过var语句声明的变量不能删除,通过function语句定义的函数也是不能删除的。例如:

var o = { x: 1, y: 2};          // 定义一个对象
console.log(delete o.x);        // true,删除一个属性
console.log(delete o.x);        // true,什么都没做,x 在已上一步被删除
console.log("x" in o);          // false,这个属性在对象中不再存在
console.log(delete o.toString); // true,什么也没做,toString是继承来的
console.log(delete 1);          // true,无意义

检测对象属性

JavaScript对象可以看做属性的集合,我们经常会检测集合中成员的所属关系(判断某个属性是否存在于某个对象中)。可以通过in运算符、hasOwnPreperty()propertyIsEnumerable()来完成这个工作,甚至仅通过属性查询也可以做到这一点。

in运算符的左侧是属性名(字符串),右侧是对象。如果对象的自有属笥或继承属性中包含这个属性则返回true。例如:

var person = {
    name: 'w3cplus',
    age: 7
};
console.log('w3cplus' in person);  // => false, 'w3cplus'不是 person的属性
console.log('age' in person);      // => true, ‘age’是person的属性
console.log('toString' in person); // => true, 'toString'是person的继承属性

对象的hasOwnProperty()方法用来检测给定的名字是否是对象的自有属性。对于继承属性它将返回false。例如:

var person = {
    name: 'w3cplus',
    age: 7
}
console.log(person.hasOwnProperty('name'));    // => true, 'name'是person的自有属性
console.log(person.hasOwnProperty('age'));     // => true, 'age'是person的自有属性
console.log(person.hasOwnProperty('toString'));// => false, 'toString'是person的继承属性

propertyIsEnumerable()hasOwnProperty()的增强版,只有检测到是自有属性且这个属性的可枚举性(Enumerable Attribute)为true时它才返回true。某些内置属性是不可枚举的。通常由JavaScript代码创建的属性都是可枚举的,除非在ECMAScript 5中使用一个特殊的方法来改变属性的可枚举性。

var person = inherit({
    name: 'w3cplus',
    age: 7
});
person.occupation = 'student';
person.propertyIsEnumerable('occupation');        // => true, occupation是person的自有属性,可枚举 
person.propertyIsEnumerable('age');               // => false, age是person的继承属性, 不可枚举
Object.prototype.propertyIsEnumerable('toString');// => false, toString是person的继承属性,不可枚举

除了使用in运算符之外,另一种更简便的方法是使用!==来判断一个属性是否是undefined。例如:

var person = {
    name: 'w3cplus',
    age: 7
}
console.log(person.name !== undefined);    // => true, name是person的属性
console.log(person.w3cplus !== undefined); // => false, w3cplus不是person的属性
console.log(person.toString !== undefined);// => true, toString是person的继承属性

然而有一种场景只能使用in运算符,而不能使用上述属性访问的方式。in可以区分不存在的属性和存在但值为undefined的属性。例如:

var obj = {
    x: undefined
}
console.log(obj.x !== undefined);   // => false,x属性存在,但值为undefined
console.log(obj.y !== undefined);   // => false,y属性不存在
console.log('x' in obj);            // => true, x属性存在
console.log('y' in obj);            // => false, y属性不存在
console.log(delete obj.x);          // => true,删除了obj对象里的x属性
console.log(obj.x !== undefined);   // => false,x属性不存在
console.log('x' in obj);            // => false, x属性不存在

另外一种用到null(以及undefined)的场景是当检测一个属性是否在对象中存在时,比如:

// 不好的写法:检测假值
if (object[propertyName]) {
    // 一些代码
}
// 不好的写法:和null相比较
if (object[propertyName] != null) {
    // 一些代码
}
// 不好的写法:和undefined相比较
if (object[propertyName] != undefined) {
    // 一些代码
}

上面这段代码里的每个判断,实际上是通过给定的名字来检查属性的值,而并非判断给定的名字所指的属性是否存在。在第一个判断中,当属性值为假值时结果会出错,比如:0" "(空字符串)、falsenullundefined,毕竟这些都是属性的合法值。

不管你什么时候需要检测属性的存在性,请使用in运算符或者hasOwnProperty()。这样做可以避免很多问题

枚举对象属性

除了检测对象的属性是否存在,我们还会经常遍历对象的属性。通常使用for-in来遍历。

for-in可以在循环体中遍历对象中所有可枚举的属性,包括对象自有的属性和继承的属性,把属性名称赋值给循环变量。对象继承的内置方法是不可枚举的,但在代码中给对象添加的属性都是可枚举的。

var person = {
    name: 'w3cplus',
    age: 7,
    occupation: 'student'
}

person.propertyIsEnumerable('toString'); // => false,不可枚举

for (prop in person) {
    console.log(prop); // => name, age, occupation
}

有许多实用工具库给 Object.prototype 添加了新的方法或属性,这些方法和属性可以被所有对象继承并使用。然而在ECMAScript 5标准之前,这些新添加的方法是不能定义为不可枚举的,因此它们都可以在 for-in 循环中枚举出来。为了避免这种情况,需要过滤 for-in 循环返回的属性,下面两种方式是最常见的:

for(prop in person) {
if (!person.hasOwnProperty(prop)) continue;          // 跳过继承的属性
if (typeof person[prop] === "function") continue;    // 跳过方法
}

除了 for-in 循环之外,ECMAScript 5定义了两个用以枚举属性名称的函数。

  • Object.keys(),它返回一个数组,这个数组由对象中可枚举的自有属性的名称组成
  • Object.getOwnPropertyNames(),它和 Ojbect.keys() 类似,只是它返回对象的所有自有属性的名称,而不仅仅是可枚举的属性

在 ECMAScript 5 之前,没有原生的方法枚举一个对象的所有属性。然而,可以通过以下函数完成:

function listAllProperties(o){     
    var objectToInspect;     
    var result = [];

    for(objectToInspect = o; objectToInspect !== null; objectToInspect = Object.getPrototypeOf(objectToInspect)){  
        result = result.concat(Object.getOwnPropertyNames(objectToInspect));  
    }

    return result; 
}

这在展示 “隐藏”(在原型中的不能通过对象访问的属性,因为另一个同名的属性存在于原型链的早期)的属性时很有用。可以通过在数组中去除同名元素即可轻松地列出访问的属性。

有关于对象属性的枚举更详细的介绍,可以阅读前面整理过的一篇读书笔记

属性的 getter 和 setter

我们知道,对象属性是由名字、值和一组特性(Attribute)构成的。在ECMAScript 5中,属性值可以用一个或两个方法替代,这两个方法就是 gettersetter。由 gettersetter 定义的属性称做存取器属性(Accessor Property),它不同于数据属性(Data Property),数据属性只有一个简单的值。

当程序查询存取器属性的值时,JavaScript 调用 getter 方法。这个方法的返回值就是属性存取表达式的值。当程序设置一个存取器属性的值时,JavaScript 调用 setter 方法,将赋值表达式右侧的值当做参数传入 setter。从某种意义上讲,这个方法负责设置属性值。可以忽略 setter 方法的返回值。

和数据属性不同,存取器属性不具有可写性(Writable Attribute)。如果属性同时具有 gettersetter 方法,那么它是一个读/写属性。如果它只有 getter 方法,那么它是一个只读属性。如果它只有 setter 方法,那么它是一个只写属性,读取只写属性总是返回 undefined。定义存取器属性最简单的方法是使用对象直接量语法的一种扩展写法。

下面例子描述了getterssetters 是如何为用户定义的对象 o 工作的。

var o = {
    a: 7,
    get b() { 
        return this.a + 1;
    },
    set c(x) {
        this.a = x / 2
    }
};

console.log(o.a); // => 7
console.log(o.b); // => 8
o.c = 50;
console.log(o.a); // => 25

o 对象的属性如下:

  • o.a — 数字
  • o.b — 返回 o.a + 1getter
  • o.c — 由 o.c 的值所设置 o.a 值的 setter

请注意在一个对象字面量语法中定义gettersetter使用"[gs]et property()"的方式(相比较于__define[GS]etter__)时,并不是获取和设置某个属性自身,容易让人误以为是"[gs]et propertyName(){ }"这样错误的使用方法。定义一个gettersetter函数使用语法"[gs]et property()",定义一个已经声明的函数作为的gettersetter方法,使用Object.defineProperty(或者 Object.prototype.__defineGetter__ 旧语法回退)。

原则上,gettersetter 既可以:

  • 使用 object initializers 定义
  • 也可以之后随时使用 gettersetter 添加方法添加到任何对象

当使用object initializers的方式定义gettersetter时,只需要在getter方法前加get,在setter方法前加set,当然,getter方法必须是无参数的,setter方法只接受一个参数(设置为新值),例如:

var o = {
    a: 7,
    get b() {
        return this.a + 1;
    },
    set c(x) {
        this.a = x / 2;
    }
};

使用Object.defineProperties的方法,同样也可以对一个已创建的对象在任何时候为其添加gettersetter方法。这个方法的第一个参数是你想定义gettersetter方法的对象,第二个参数是一个对象,这个对象的属性名用作gettersetter的名字,属性名对应的属性值用作定义gettersetter方法的函数,下面是一个例子定义了和前面例子一样的gettersetter方法:

var o = {
    a:0
}

Object.defineProperties(o, {
    "b": { 
        get: function () {
            return this.a + 1;
        }
    },
    "c": {
        set: function (x) {
            this.a = x / 2;
        }
    }
});

o.c = 10;         // => 执行10 / 2 = 5
console.log(o.b); // => 执行 5 + 1 = 6

这两种定义方式的选择取决于你的编程风格和手头的工作量。当你定义一个原型准备进行初始化时,可以选择第一种方式,这种方式更简洁和自然。但是,当你需要添加gettersetter方法 —— 因为并没有编写原型或者特定的对象 ——使用第二种方式更好。第二种方式可能更能表现JavaScript语法的动态特性——但也会使代码变得难以阅读和理解。

对象属性的拷贝

Object对象提供了一个复制对象属性的方法:Object.assign(),在我们需要将一个或多个对象复制到目标对象时,可以使用这个方法。Object.assign()会把一个或多个源对象的可枚举(可访问)属性复制给目标对象。并且返回目标对象。

Object.assign()方法只会拷贝源对象自身的并且可枚举的属性,即该方法会执行源对象的访问器属性getter函数,并把复制值拷贝给目标对象,如果你想拷贝访问器属性本身,应该使用Object.getOwnPropertyDescriptor()Object.defineProperties()方法。

拷贝对象属性时可能会产生异常(如,目标对象的某个只读属性和源对象的某个属性同名时),这时候会抛出一个TypeError异常并中断拷贝过程,异常之后的属性不会再被复制,但已复制的属性不受影响。Object.assign()会跳过值为nullundefined的源对象属性。

在JavaScript中Object.assign()方法可以拷贝一个对象,也可以合并多个对象。同时也可以复制symbol类型属性。

// 拷贝一个对象
var person = {
    name: 'w3cplus',
    age: 7
};
var copyObj = Object.assign({}, person);
console.log(copyObj);

// 合并多个对象
var obj1 = {
    domain: 'w3cplus.com',
}
var obj2 = {
    name: 'w3cplus',
    age: 7
}

var obj = Object.assign(obj1, obj2);
console.log(obj);

// 也可以用于复制symbol类型属性
var obj3 = {
    name: 'w3cplus',
    age: 7
}
var obj4 = {
    [Symbol('domain')]: 'w3cplus.com'
}
var obj5 = Object.assign({}, obj3, obj4);
console.log(obj5);

使用Object.assign()方法不能用于继承和隐藏属性的复制:

var source = {
    name: {
        value: 'w3cplus'
    },
    domain: {
        value: 'w3cplus.com',
        enumerable: true 
    }
}
var obj = Object.create(
    {
        foo: 1
    },
    source
)

var copyObj = Object.assign({}, obj);
console.log(copyObj);

除此之外,Object.assign()方法还有其他的用处。

为对象添加属性

class Point {
    constructor(x, y) {
        Object.assign(this, {x, y})
    }
}

上面的方法通过Object.assign()方法,将xy属性添加到Point类的对象实例。

为对象添加方法

Object.assign(SomeClass.prototype, {
    someMethod(arg1, arg2) {
        // ...
    },
    anotherMothod() {
        // ...
    }
});

下面的写法等同于上面的写法:

SomeClass.prototype.someMethod = function (arg1, arg2) {
    // ...
};
SomeClass.prototype.anotherMethod = function(){
    // ...
};

上面代码使用了对象属性的简洁表示法,直接将两个函数放在大括号中,再使用Object.assign()方法添加到SomeClass.prototype之中。

为属性指定默认值

const DEFAULTS = {
    logLevel: 0,
    outputFormat: 'html'
};
function processContent(options) {
    let options = Object.assign({}, DEFAULTS, options);
}

上面代码中,DEFAULTS对象是默认值,options对象是用户提供的参数。Object.assign()方法将DEFAULTSoptions合并成一个新对象,如果两者有同名属性,则option的属性值会覆盖DEFAULTS的属性值。

比较对象

在JavaScript中objects是一种引用类型。两个独立声明的对象永远也不会相等,即使他们有相同的属性,只有在比较一个对象和这个对象的引用时,才会返回true

var fruit = {
    name: 'apple'
};
var fruitbear = {
    name: 'apple'
};
fruit == fruitbear;  // => false
fruit === fruitbear; // => false

在ES6中,对于对象比较提供了一个新的方法:Object.is()Object.is()用来比较两个值是否严格相等。它与严格比较运算符===的行为基本一致,不同之处只有两个:一个是+0不等于-0,二是NaN等于自身。

+0 === -0;   // => true
NaN === NaN; // => false

Object.is(+0, -0);   // =>false
Object.is(NaN, NaN); // =>true

用于对象比较:

var fruit = {
    name: 'apple'
};
var fruitbear = {
    name: 'apple'
};
Object.is(fruit, fruitbear); // => false

对象引用

JavaScript中对象是通过引用传递的,它们不会被拷贝:

var obj1 = obj2 = {};
obj1.name = 'w3cplus';
obj2.name === 'w3cplus'; // => true

通过原型继承时,原型也是作为引用进入原型链的,原型属性不会被拷贝。这个在对象属性拷贝一节也提到过:

var prot = {
    girl: {
        name: 'Alice'
    }
}

var p1 = Object.create(prot);
var p2 = Object.create(prot);
p1.girl.name = 'Fuck';
console.log(p2.girl.name); // => 'Fuck'

可见原型关系是一种动态关系。

创建对象

在JavaScript中,创建对象有8种方式。在《JavaScript高级程序设计》(第3版)一书中有详细的介绍过。在网上也有很多同学整理了相关的阅读比记:

下面简单的看看这些创建对象方式分别是怎么创建的,又有何优缺点。

创建单个对象

创建单个自定义对象最简单的方式就是通过new Object()构造函数。personconstructor值是Object。然后再为他添加属性和方法:

var person = new Object();
person.name = 'w3cplus';
person.age = 7;
person.job = 'FE';
person.sayName = function () {
    console.log(this.name);
}

上面的例子创建了一个名为person的对象,并为他添加了三个属性和一个方法。其中sayName()方法用于显示name属性,this.name将被解析为person.name,早期的开发人员经常使用这个模式来创建对象。

除了使用new Object()构造函数创建对象,还有一种更为简单明了的方式,通过对象的字面量来创建对象。

var person = {
    name: 'w3cplus',
    age: 7,
    job: 'FE,
    sayName: function () {
        console.log(this.name)
    }
}
  • 优点:简单、方便
  • 缺点:批量创建对象很麻烦。不能使用instanceof来确定对象类型

工厂模式

工厂模式是为了解决批量创建对象的问题。就是用一个函数将上述创建单个对象的方法包装起来,就可以减少代码量了。

通过一个方法来创建对象,利用arguments对象获取参数设置属性,这种方法参数不直观,容易出现问题。

function person() {
    var oTemp = new Object();
    oTemp.name = arguments[0];
    oTemp.age = arguments[1];
    oTemp.job = arguments[2];
    oTemp.sayName = function() {
        console.log(this.name)
    }
    return oTemp;
} 

person('w3cplus', 7, 'FE');
person('w3cplus', 7, 'FE').sayName();  // => w3cplus
person('Google', 10, 'Search');
person('w3cplus') instanceof Object;   // => true

在上面的基础上,给其传参数,这样更为直观明了些:

function person(name, age, job) {
    var oTemp = new Object();
    oTemp.name = name;
    oTemp.age = age;
    oTemp.job = job;
    oTemp.sayName = function () {
        console.log(this.name)
    }
    return oTemp;
}

person('w3cplus', 7, 'FE');

函数person()能够根据接受的参数来创建一个包含所有必要信息的person对象。可以多次调用这个函数,每次都会返回一个包含三个属性nameagejob和一个方法sayName()的对象。

  • 优点:以最少的代码量解决创建多个相似对象
  • 缺点:没法识别对象,即怎么样知道这是哪个对象类型

构造函数

构造函数模式解决了对象识别问题,是基于工厂模式的改进。

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

new Person('w3cplus', 7, 'FE');
new Person('Angel', 20, 'Artist');
new Person('w3cplus', 7, 'FE').sayName();
new Person('Angel', 20, 'Artist').sayName();

在这个例子中,Person()函数取代了工厂模式中的person()函数。他们两者之间不同之处:

  • 没有显式的创建对象
  • 直接将属性和方法赋值给this对象
  • 没有return语句

此外,还应该注意到函数名Person使用的是大写字母P。按照惯例,构造函数始终都应该以一个大写字母开头,而非构造的函数则应该以一个小写字母开头。

在这里,构造函数模式创建对象,利用了new作用域转移的特性。在使用Person()创建对象必须使用new操作符来创建Person实例。以这种方式调用构造函数实际上会经历以下几个过程:

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

有关于 JavaScript中的new关键词更多的介绍,建议阅读前面整理的读书笔记《JavaScript中的new关键词》。

使用Person()可以同时创建多个不同的实例,比如:

var person1 = new Person('w3cplus', 7, 'FE');
var person2 = new Person('Angel', 20, 'Artist');

这两个对象都有一个constructor(构造函数)属性,该属性都是指向Person

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

对象的constructor属性最初是用来标识对象类型的。但是,检测对象类型,还是instanceof操作符更可靠一些。在我们这个例子中创建的对象都是Object对象的实例,也是Person对象的实例,这一点通过instanceof操作符可以验证。

console.log(person1 instanceof Object); // => true
console.log(person1 instanceof Person); // => true

创建自定义的构造函数意味着将来可以将他的实例标识为一种特定的类型;而这正是构造函数模式胜过工厂模式的地方。在这个例子中,person1person2之所以同是Object的实例,是因为所有的对象都继承自Object

构造函数的主要问题,就是每个方法都要在实例上重新创建一遍,造成内存浪费。在前面的例子中,person1person2都有一个名为sayName()的方法,但是两个方法不是同一Function的实例。不要忘了ECMAScript中的函数也是对象,因此每定义一个函数,也就是实例化了一个对象,从逻辑角度讲,此时的构造函数可以这样定义:

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

从这个角度来看构造函数,更容易看明白每个Person实例都会包含一个不同的Function实例的本质。说明白些,会导致不同的作用域链和标识符解析,但是创建Function新实例的机制仍然是相同的。因此,不同实例上的同名函数是不相等的,以下代码可以证实这一点。

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

然而,创建两个完成同样任务的Function实例的确没有必要;况且有this对象在,根本不用在执行代码前就把函数绑定到特定的对象上。因此,可以像下面这样,通过把函数定义转移到构造函数外部来解决这个问题。

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

这样做解决了多个函数解决相同问题的问题,但是有产生了新的问题,在全局作用域中实际上只被某个对象调用,这让全局对象有点名不副实。更让人无法接受的是:如果对象需要定义很多方法,那么就要定义很多全局函数,于是我们这个自定义的引用类型就丝毫没有封装性可言了。

当不使用new来创建对象时,由于在全局作用域中this指向Global(浏览器中就是window对象),所以可以直接通过window对象调用sayName,不过不建议这么做,因为这样做会污染全局环境。

也可以用call()apply()来为Person指定作用域名。

这两个方法是Function类型提供的,也就是说,可以在函数上面使用。callapply方法的功能一样,就是可以扩充函数运行的作用域,区别就在于使用call的时候,传递给函数的参数必须逐个列举出来,而apply方法却不用。这样可以根据自己函数的要求来决定使用callapply

你可以这样理解,函数被包裹在一个容器(作用域)里面,在这个容器里面存在一些变量或者其他东西,当函数运行,调用这些变量等,就会在当前容器里面找这个东西。这个容器其实外面还包裹了一个更大的容器,如果当前小容器没有的话,函数会到更大的容器里面寻找,依此类推,一直找到最大的容器window对象。但是如果函数在当前小容器里运行的时候,小容器里面有对应变量等,即便是大容器里面也有,函数还是会调用自己容器里面的。

callapply方法,就是用来解决这个问题,突破容器的限制:

var person = {
    name: 'w3cplus',
    age: 7,
    job: 'FE',
    sayName: function() {
        console.log(this.name)
    }
}

person.sayName(); // => w3cplus
sayName();        // => Uncaught ReferenceError: sayName is not defined

这个时候person就是一个容器,其中创建了一个sayName()函数,执行的时候,必须在person作用域下面执行。当在最下面直接执行的时候,也就是在window的作用域下面执行会报错sayName is not defined,因为window下面没有定义sayName()函数。而里面的this指针,是一个比较特殊的东西,它指向当前作用域,this.name的意思,就是调用当前作用域下面的name值。

下面我们为window对象添加一个name属性:

window.name = 'w3cplus'

或者直接:

name = 'w3cplus'

因为window是最大的容器,所以window可以省略掉,所有定义的属性或者变量,都挂靠到window上面。

那现在我们就想在window这个大容器下面,运行person小容器里面的sayName()函数,就需要用callapply来扩充sayName()函数的作用域。执行下面语句:

person.sayName.call(window);

或者

person.sayName.call(this);

输出的结果都是一样的,你也可以换用apply看看效果:

解释一下上面代码,sayName 首先是 Function 类型的实例,也就具有了 call 方法和 apply 方法,callapply 方法既然是 Function 类型的方法,所以就需要用这种方式调用 person.sayName.call(window) 而不是什么 person.sayName().call(window) 之类的。

然后 callapply 方法的参数,就是一个作用域(对象),表示将前面的函数在传递进去的作用域下面运行。将 window 这对象传递进去之后,sayName 方法中的 this.name 指向的就是 window.name,于是就扩充了作用域。

为什么传递 windowthis 都是一样的效果?因为我们当前执行这个函数的位置是 window,前面说过 this 指针指向的是当前作用域,所以 this 指向的就是 window,所以就等于 window

  • 优点: 在工厂模式的基础上解决了对象识别问题
  • 缺点: 每个实例的方法都是独立的,多数情况下同个对象的实例方法都是一样的,于是这里就造成了冗余

原型模式

原型模式很好解决了构造函数创建对象的封装性问题。原型也是JavaScript最重要的特性之一。

原型就是为了共用而生:默认情况下,每个对象与它的所有实例都共用一个原型。对象可以通过.prototype访问原型。实列存在内部属性[[Prototype]],不能直接访问(不推荐使用__proto__)。

类通过 prototype 属性添加的属性与方法都是绑定在这个类的 prototype 域(实际为一个 Prototype 对象)中,绑定到这个域中的属性与方法只有一个版本,只会创建一次.

类的实例对象可以直接像调用自己的属性一样调用该类的 prototype 域中的属性与方法,类可以通过调用 prototype 属性来间接调用prototype 域内的属性与方法.

注意:通过类实例化出对象后对象内无 prototype 属性,但对象可直接像访问属性一样的访问类的 prototype 域的内容,实例对象有个私有属性__proto__,__proto__属性内含有类的 prototype 域内的属性与方法:

function Person() {

}
Person.prototype.name = 'w3cplus';
Person.prototype.age = 7;
Person.prototype.job = 'FE';
Person.prototype.sayName = function () {
    console.log(this.name);
}

var person1 = new Person();
person1.sayName();                   // => w3cplus

var person2 = new Person();
person2.sayName();                   // => w3cplus

person1.sayName == person2.sayName;  // => true

在此,我们将sayName()方法和所有的属性直接添加在了Personprototype属性中,构造函数变成了空函数。即便如此,我们仍然可以通过构造函数来创建新对象,而且新对象还会具有相同的属性和方法。但是与构造函数不同的是,新对象的这些属性和方法是由所有实例共享的。换句话说,person1person2访问的都是同一组属性和同一个sayName()函数。

上面我们说了原型模式的好处,接下来我们来看一下原型模式的缺点。原型模式省略了为构造函数传递参数的这一环节,结果所有实例在默认情况下都具有相同的属性值。这会在某些程度上带来一种不便,这并不是原型模式最大的问题,因为如果我们想为一个通过原型模式创建的对象添加属性时,添加的这个属性就会屏蔽原型对象的保存的同名属性。换句话说,就是添加的这个属性会阻止我们去访问原型中的属性,但并不会改变原型中的属性。

原型模式最大的问题是由其共享的本质所导致的。原型中所有的属性被很多实例共享,这种共享对函数非常合适,对包含基本值的属性也说的过去,但是对引用类型的属性值来说问题就比较突出了,下面我们来看一个例子:

function Person() {

}

Person.prototype = {
    constructor: Person,
    name: 'w3cplus',
    age: 7,
    job: 'FE',
    sayName: function() {
        console.log(this.name);
    }
}

var person1 = new Person();
var person2 = new Person();

person1.sayName();   // => w3cplus
person2.sayName();   // => w3cplus

如果我们把sayName做修改:

function Person() {

}

Person.prototype = {
    constructor: Person,
    name: 'w3cplus',
    age: 7,
    job: 'FE',
    sayName: function() {
        console.log('Hello ' + this.name);
    }
}

var person1 = new Person();
var person2 = new Person();

person1.sayName();   // => Hello w3cplus
person2.sayName();   // => Hello w3cplus

一般每个对象都是要有属于自己的属性的,所以我们很少看到有人单独使用原型模式来创建对象。

  • 优点:共用原型减少了冗余。
  • 缺点:在原型上的改变会影响到所有的实例,于是实例没有了独立性。

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

创建自定义类型最常见的方式就是组合使用构造函数模式与原型模式。构造函数模式用于定义实例属性,原型模式用于定义方法和共享的属性。结果,每个实例都会有自己的一份实例属性的副本,但同时又共享着对方法的引用,最大限度的节省了内存。另外,这种混成模式还支持向构造函数传递参数;可谓是集两种模式之长。

function Person(name, age, job){ 
    this.name = name; 
    this.age = age; 
    this.job = job; 
    this.friends = ["Shelby", "Court"]; 
} 

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

var person1 = new Person('StrayBugs', 22, 'student'); 
var person2 = new Person('Angel', 20, 'Artist'); 

person1.friends.push("Van"); 
console.log(person1.friends);                        // => "Shelby,Count,Van" 
console.log(person2.friends);                        // => "Shelby,Count" 
console.log(person1.friends === person2.friends);    // => false 
console.log(person1.sayName === person2.sayName);    // => true

  • 优点:结合了构造函数模式和原型模式的优点,并解决了其缺点。
  • 缺点:代码没有很好的封装起来。

动态原型模式

有其他OO语言经验的开发人员在看到独立的构造函数和原型时,很可能会感到非常的困惑。动态原型模式就是用来解决这个问题的一个方案,它把所有的信息都封装在了构造函数中,而通过构造函数中初始化原型(仅在必要的情况下),又保持了同时使用构造函数和原型的优点。换句话说,可以通过检查某个应该存在的方法是否有效,来决定是否要初始化原型。来看一个例子:

function Person(name, age, job){ 

    //属性 
    this.name = name; 
    this.age = age; 
    this.job = job;
    //方法 
    if (typeof this.sayName != "function"){ 
        Person.prototype.sayName = function(){ 
            console.log(this.name); 
        }; 
    } 
} 

var friend = new Person('StrayBugs', 22, 'student'); 
friend.sayName();   // => 'StrayBugs'

注意构造函数代码中的if语句,这里只在sayName()方法不存在的情况下才会将它添加到原型中。这断代码只有在第一次调用构造函数的时候才会被执行。此后,原型已经被初始化,不需要再做什么修改。不过要记住,这里所做的修改能立即在所有实例中得到反映。因此,这种方法可以说确实非常完美。其中if语句检查的是初始化之后应该存在的任何方法和属性–不必再用一大堆if来检查每个属性和方法,只检查其中一个即可。对于采用这样模式创建的对象,还可以使用instanceof操作符来确定他的类型。

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

有的时候也会使用_initialized来判断是否已给原型赋予了任何方法,保证方法永远只被创建并赋值一次。因为这里的标记是附加在类上的,故如果后期直接对其进行修改,还是有可能出现再次创建的情况:

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

    if (typeof Person._initialized == 'undefined') {
        Person.prototype.sayName = function () {
            console.log(this.name);
        }

        Person._initialized = true; // 设置一个静态属性
    }
}

var person1 = new Person('w3cplus', 7, 'FE');

寄生构造函数模式

通常,在上述几种模式都不适合的情况下可以使用寄生构造函数模式。这种模式的基本思想是创建一个函数,该函数的作用仅仅是封装创建对象的代码,然后再返回新创建的对象,但从表面看,这个函数又很像典型的构造函数。来看一个例子:

function Person(name, age, job){
    var o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function(){
        console.log(this.name);
    }
    return o;
}
var person = new Person('w3cplus', 7, 'FE');
person.sayName();  // => w3cplus

在这个例子中,Person函数创建了一个对象,并以相应的属性和方法初始化该对象,然后返回了这个对象。除了使用new操作符把使用的包装函数叫做构造函数之外,这个模式和工厂模式并没有多大的区别。构造函数在不返回值的情况下,会默认返回新对象的实例。而通过在构造函数的末尾添加一个return语句,可以重写调用构造函数时返回的值。

这个模式可以在特殊的情况下来为对象创建构造函数。假设我们想创建一个具有额外方法的特殊数组。由于不能直接修改Array构造函数,因此可以使用这个模式:

function SpecialArray(){ 

    //创建数组 
    var values = new Array(); 

    //添加值 
    values.push.apply(values, arguments); 

    //添加方法 
    values.toPipedString = function(){ 
        return this.join("|"); 
    }; 

    //返回数组 
    return values; 
} 

var colors = new SpecialArray("red", "blue", "green"); 
console.log(colors.toPipedString());   // => "red|blue|green"

在这个例子中,我们创建了一个名为SpecialArray的构造函数。在这个函数的内部,首先创建了一个数组,然后push()方法初始化了数组的值。随后又给数组实例添加了toPipedString()方法,用来返回以竖线分隔的数组值。最后将数组以函数的形式返回。接着,我们调用了SpecialArray构造函数,传入了初始化的值,并调用了toPipedString()方法。

关于寄生构造函数模式,有一点需要声明:首先,返回的对象与构造函数或者构造函数的原型没有任何关系;也就是说,构造函数返回的对象与在构造函数外部创建的对象没有什么不同。为此,不能依赖instanceof操作符来确定对象的类型。由于存在这一的问题,我们建议在可以使用其他模式的情况下不要使用这种模式。

稳妥构造函数模式

道格拉斯·克拉克福德发明了JavaScript中的稳妥对象这个概念。所谓稳妥对象,是指没有公共属性,而且其方法也不引用this对象。稳妥对象最适合在一些安全环境中(这些环境会禁止使用thisnew),或者在防止数据被其他应用程序改动时使用。稳妥构造函数遵循的与寄生构造函数类似的模式,但又两点不同:一是新创建对象的实例方法不引用this;二是不使用new操作符调用构造函数。按照稳妥构造函数的要求,可以将前面的Person构造函数重写如下:

function Person(name, age, job){
    //创建要返回的新对象
    var o = new Object();

    //可以在这里定义私有变量和函数

    //添加方法
    o.sayName = function(){
        alert(this.name);
    };

    //返回对象
    return o;
}

注意,在以这种模式创建的对象中,除了使用sayName()方法之外,没有其他办法访问name的值。可以像下面使用稳妥的Person构造函数:

var person =Person('w3cplus', 7, 'FE');
person.sayName();   // => w3cplus

这样,变量person中保存的是一个稳妥对象,而除了sayName()方法外,没有别的方式可以访问其他数据成员。即使有其他代码会给这个对象添加方法或数据成员,但也不可能有别的办法访问传入到构造函数中的原始数据。稳妥构造函数模式提供的这种安全性,使得他非常适合在某些安全执行环境–例如,ADsafe(ADsafe)提供的环境下使用。

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

总结

JavaScript真是一门神奇的语言,太深澳了。就一个对象都有这么多东东需要学习。而且要对对象有更深入的了解和理解,还将涉及到其他相关的东东,比如原型呀,继承呀。反正我是一团浆糊。不过值得庆幸的是,经过这一周的学习,对JavaScript里的对象比之前有了更深的理解。希望自己能一天比一天成长。读书笔记,乱得狠,有不对之处,请多多指正。

大漠

常用昵称“大漠”,W3CPlus创始人,目前就职于手淘。对HTML5、CSS3和Sass等前端脚本语言有非常深入的认识和丰富的实践经验,尤其专注对CSS3的研究,是国内最早研究和使用CSS3技术的一批人。CSS3、Sass和Drupal中国布道者。2014年出版《图解CSS3:核心技术与案例实战》。

如需转载,烦请注明出处:https://www.w3cplus.com/javascript/working-with-Objects.html

返回顶部