对象一直都是 JavaScript 里重要的概念,如函数、正则等全部都是对象,本文将带大家深入的了解对象相关知识。
数据属性、访问器属性
对象是 JavaScript 中的一种数据类型,对象由属性和属性值组成(通常也叫 key 和 value),属性的数据类型只能是字符串或 Symbol,每个属性都有 4 个描述符,根据描述符的不同,属性又分为两种类型,数据属性和访问器属性。
数据属性的属性描述符:
- [[Configurable]]:属性是否可以通过 delete 删除,是否可以修改它的描述符,以及是否可以在数据属性与访问器属性之间切换。
- [[Enumerable]]:是否 for in 循环、Object.keys() 可访问。
- [[Value]]:属性值
- [[Writable]]:是否可以修改 [[Value]]
访问器属性的属性描述符:
- [[Configurable]]:同上
- [[Enumerable]]:同上
- [[Get]]:函数,在读取属性时调用
- [[Set]]:函数,在写入属性时调用
疑问:[[Get]] 这种双中括号代表什么?
答:[[Get]] 这种双中括号的表示一般在规范文档中,代表是引擎的内部属性,除非语法上支持,否则 JS 代码是不能访问引擎的内部属性的。类似的还有
Date.prototype[@@toPrimitive]
这种 @@ 表示的,代表 Symbol.toPrimitive。
如果你用过 vue 2,肯定 console.log 过 vue 的响应式变量,它就是通过访问器属性实现的。访问器属性的属性值在控制台出现后有 3 个点,点击 3 个点会调用[[Get]]函数。
下面我们实现一个访问器属性:
const obj = {};
Object.defineProperty(obj, 'a', {
configurable: true,
enumerable: true,
set: undefined,
get() {
console.log('call');
return 123;
},
});
console.log(obj);
点击 3 个点之后会调用 get 函数,并获得返回值
从下面输出的 call 可以看出调用了 get 函数
Object.defineProperty
可以给对象的属性配置描述符,它同时包含了新建和修改的功能,如果对象没有此属性就会新建,如果对象以及有了属性,那么再定义就会修改。
其实我们平常用字面量创建的属性都是数据属性:
// 以下两种表示,是一样的
const obj = {
a: 1,
};
const obj2 = {};
Object.defineProperty(obj2, 'a', {
configurable: true,
enumerable: true,
writable: true,
value: 1,
});
// 可用 getOwnPropertyDescriptor 获取到属性的描述符
console.log(Object.getOwnPropertyDescriptor(obj, 'a'));
调用的 api,也只是属性
在编写代码的过程中,免不了要调用数组、字符串等等的 api,他们不是什么神奇的东西,也只是普通属性而已。以常用的Array.prototype.map()
api 为例:
const arr = [];
console.log(Object.getOwnPropertyDescriptor(arr, 'map'));
// undefined
咦,你在骗我呀,我获取到的怎么是 undefined 呢?别急,因为 getOwnPropertyDescriptor 只能获取到当前对象的属性,但 map 属性是在当前对象的原型上的(因为是 Array.prototype 上的属性),所以应该去原型上获取:
const arr = [];
console.log(Object.getOwnPropertyDescriptor(Object.getPrototypeOf(arr), 'map'));
// {writable: true, enumerable: false, configurable: true, value: ƒ}
可以看到 map api 是数据属性,并且不可枚举,但是可配置,我们可以试一下把它配置成可枚举的,这样 for in 或 Object.keys 就能访问到了:
Object.defineProperty(Object.getPrototypeOf(arr), 'map', {
enumerable: true,
});
for (var i in arr) {
console.log(i);
}
// map
但 length 属性是不可配置的,即 configurable 为 false。
所以我们可以通过 JS 的内置对象拿到所有规定的全局变量,之后再寻找内置对象的原型并输出他的属性,就可以通过代码获取到 JS 所有的 api。
创建对象
字面量
使用字面量直接定义属性和方法
let person = {
name: 'ming',
age: 25,
introduce() {
console.log('I am ' + this.name);
},
};
person.introduce();
工厂模式
当你要定义多个对象的时候,直接复制多个还是有些麻烦,所以可以写一个工厂函数
function createPerson(name, age) {
return {
name,
age,
introduce() {
console.log('I am ' + this.name);
},
};
}
const p1 = createPerson('ming', 25);
const p2 = createPerson('foo', 20);
p2.introduce();
此模式的问题是无法使用 instanceof 等判断对象的类型
构造函数
构造函数就是一个普通函数,只不过一般约定构造函数的首字母是大写的,以及它只会和 new 搭配使用
function Person(name, age) {
this.name = name;
this.age = age;
this.introduce = function () {
console.log('I am ' + this.name);
};
}
const p1 = new Person('ming', 25);
p1.introduce();
它模仿了 Java 的写法,这里和 createPerson 的区别是:
- 没有 return。
- 属性和方法直接赋值给了 this。
- 没有显式地创建对象。
如果想要继续深入继承部分的内容,那么这里就要深入理解下:
这应该是个很明显的约定,构造函数是不应该 return 的,如果 return 了那不就回到了工厂模式嘛
this 是很微妙的存在,如果没有 this 会怎样?
jslet person = { name: 'ming', age: 25, introduce() { // 如果没有 this,这里只能使用 person console.log('I am ' + person.name); }, }; let animal = { name: 'bar', introduce() { // 这里不能复用,只能是一个新的函数 console.log('I am ' + animal.name); }, };
没有显式地创建对象,那对象哪里来的?是 new “惹的祸”,new 干了什么?想要知道这个,你就得先了解原型链
原型链机制
如下代码输出什么?
const obj = {
__proto__: {
x: 1,
},
};
console.log(obj.x);
输出是 1,没有在对象 obj 上定义 x 属性,但为什么能获取到呢?因为**对象在获取属性时,如果当前的对象没有,那么就会向当前对象的原型对象去寻找,如果原型对象还没有就继续向上寻找,直到没有原型对象为止。**这就是原型链机制,根据原型为链条寻找属性的机制。代码中的 __proto__
属性就是这里说的原型。
__proto__
vs [[Prototype]]
vs prototype
你肯定见过这三兄弟,他们都和原型有关,他们的区别一定要搞清楚:
__proto__
属性:每个对象都有此属性,代表原型,是非标准的历史遗留属性,虽然可用,但现在并不推荐使用。[[Prototype]]
内部属性:每个对象都有此属性,代表原型,是 ES 规范中的描述,和__proto__
属性完全一样,更推荐现代语法使用,但内部属性是不能直接在 JavaScript 语言层面访问的,还好语言标准提供了 Object.getPrototypeOf() 和 Object.setPrototypeOf() 获取和设置原型。prototype
属性:每个函数都有prototype
属性,他只有在此函数作为构造函数时才有意义。不要忘记:函数也是对象,所以可以在对象上添加任意属性,prototype
属性就只是函数上的一个普通属性而已,只不过此属性在继承时有特殊的约定含义。
可通过控制台看到如下代码原型链图示:
const foo = {
x: 1,
};
const bar = {
y: 2,
};
Object.setPrototypeOf(foo, bar);
console.log(bar, foo);
把 foo 的原型指向了 bar,控制台中发现 foo 的原型多了一层。(注意:这里使用了 setPrototypeOf,仅用于演示,如果你看过 setPrototypeOf 的 mdn 就能发现,它的性能并不好,推荐使用 Object.create() 代替)
实现 new
知道了原型链机制,那么现在就可以分析 new 干了什么
- 创建一个新对象 foo
- 将新对象 foo 的 [[Prototype]] 内部属性赋值为构造函数的 prototype 属性
- 使用新对象 foo 的上下文执行构造函数(即构造函数中的 this 会指向新对象,并为这个新对象添加属性)
- 如果构造函数返回非空对象,则返回构造函数返回的对象;否则,返回新对象 foo
构造函数的 prototype 属性就特殊在这里,新对象 foo 的原型会指向构造函数的 prototype 属性。prototype 属性值是一个对象,对象中有一个 constructor 属性,属性值是构造函数,这个 constructor 属性到底有什么用呢?🧐
我在看了半天 mdn 后得出结论:它没什么用 😅。它有一个比较常见的用法是用于判断对象的类型,但它很容易被赋值或删除,相比于 instanceof 和 Symbol.toStringTag 来说并不可靠,其实这两个也并非绝对可靠。(这里说的 Symbol.toStringTag 指 Object.prototype.toString())
知道了原理,我们实现一个自己的 new:
function myNew(fun, ...args) {
const foo = Object.create(fun.prototype);
const res = fun.apply(foo, args);
if (typeof res === 'object' && res !== null) {
return res;
}
return foo;
}
测试一下:
function Person(name, age) {
this.name = name;
this.age = age;
this.introduce = function () {
console.log('I am ' + this.name);
};
}
const p1 = new Person('ming', 25);
p1.introduce();
console.log(p1);
function myNew(fun, ...args) {
const foo = Object.create(fun.prototype);
const res = fun.apply(foo, args);
if (typeof res === 'object' && res !== null) {
return res;
}
return foo;
}
const p2 = myNew(Person, 'ming2', 25);
p2.introduce();
console.log(p2);
new 的实现很重要,一定要理解好。
原型模式
构造函数模式创建对象的问题是函数难以复用,使用原型模式创建对象就可以解决这个问题:
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.introduce = function () {
console.log('I am ' + this.name);
};
const p1 = new Person('ming', 25);
const p3 = new Person('ming3', 25);
console.log(p1.introduce === p3.introduce);
这样的代码你是不是在哪里见过?(Vue 2 的插件就是这样加上的)
当然我们写的对象字面量、数组字面量实例都是用这种方法创建出来的,不信你可以试试如下代码:
Object.prototype.test = 'test';
// {} 字面量就相当于 new Object()
console.log({}.test);
// test
Array.prototype.testFn = function () {
console.log('nb');
console.log(this);
};
[1, 5].testFn();
// nb
// [1, 5]