Object
我们先来思考一个问题: Q:对象是什么?JavaScript中对对象的定义是什么? ES5中对象的定义是:一个属性的集合(数据属性、访问器属性、内部属性)。
个人对对象的定义:无序的键值对组成的数据结构
- 数据属性 由一个名字与一个 ECMAScript语言类型 值和一个 Boolean 属性集合组成。
- 访问器属性 由一个名字与一个或两个访问器函数,和一个 Boolean 属性集合组成。访问器函数用于存取一个与该属性相关联的 ECMAScript语言类型 值。
- 内部属性 没有名字,且不能直接通过 ECMAScript 语言操作。内部属性的存在纯粹为了规范的目的(如:[[Prototype]])。
有两种带名字的访问器属性(非内部属性):get 和 put,分别对应取值和赋值。
属性的特性(描述符)
从ES5开始,对象的每个属性都具备几个特性,用于定义和解释属性的状态。
名称 | 取值范围 | 描述 |
---|---|---|
[[Value]] | 所有类型 | 该属性对应的值 |
[[Writable]] | Boolean | 属性的值是否可以被修改 |
[[Enumerable]] | Boolean | 属性是否可被枚举(决定for..in等操作是否能取到改属性) |
[[Configurable]] | Boolean | 决定属性是否可被配置 |
[[get]] | Function或undefined | 返回该属性值的方法 |
[[set]] | Function或undefined | 设置值的方法 |
查看特性 使用Object.getOwnPropertyDescriptors()
或Object.getOwnPropertyDescriptor()
来查看执行对象的多个或某个属性的特性。
js
var obj = {a: 1, b: 2};
// 接收一个对象,返回对象中所有属性的特性
Object.getOwnPropertyDescriptors(obj);
/*
{
a: {
configurable: true,
enumerable: true,
value: 1,
writable: true
},
b: {
configurable: true,
enumerable: true,
value: 2,
writable: true
}
}
*/
// 接收两个参数,一个对象和一个指定的属性名,返回指定属性名的特性
Object.getOwnPropertyDescriptor(obj, 'a');
/*
{
configurable: true,
enumerable: true,
value: 1,
writable: true
}
*/
修改特性 使用Object.defineProperties()
或Object.defineProperty()
来批量或单个修改属性的特性。
js
var obj = {a: 1, b: 2};
// 接收两个参数:对象和要修改的属性和特性的集合
Object.defineProperties(obj, {
a: {
value: 2,
writable: false
},
b: {
value: 5
}
});
Object.defineProperty(
obj,
'b',
{
value: 7,
writable: false
}
);
注意: 如果 [[Configurable]] 被设置为false的话,是无法使用上面两个方法来修改特性的。(value除外)
[[Writable]] 控制属性的可写性。
js
var obj = {a: 1};
Object.defineProperty(
obj,
'a',
{
writable: false
}
);
obj.a = 55; // 这个操作在严谨模式下会报TypeError 因为修改了不可修改的属性
console.log(obj.a); // 1
[[Enumerable]] 可枚举性控制
js
var obj = {a: 1, b: 2, c: 3};
Object.defineProperty(
obj,
'a',
{
enumerable: false
}
);
for(let i in obj) {
console.log(i); // b, c
}
Object.keys(obj); // [b, c]
Object.values(obj); // [2, 3]
[[Configurable]] 控制属性的可配置性,如果设置为false,改属性的特性无法被修改,而且该属性不能被删除。 也就是说将Configurable置为false是一个不可逆的操作。 不过value这个特性是例外的,即使属性是不可配置的,但只要是可写的,就能修改value。
js
var obj = {a: 1};
Object.defineProperty(
obj,
'a',
{
configurable: false
}
);
delete obj.a;
console.log(obj.a);
Object.defineProperty(
obj,
'a',
{
enumerable: false
}
); // TypeError: Cannot redefine property: a
Object.defineProperty(
obj,
'a',
{
value: 8
}
);
console.log(obj.a); // 8
如果想要冻结一个属性,只要把它的configurable和writable置为false就行了。这个属性将无法被删除和修改。
注意 copy动作是不会复制属性特性的。
js
var obj1 = {a: 1};
Object.defineProperty(
obj1,
'a',
{
writable: false
}
);
var obj2 = {};
obj2.a = obj1.a;
obj2.a = 5;
console.log(obj2.a); // 5
var obj3 = Object.assign({}, obj1);
obj3.a = 8;
console.log(obj3.a); // 8
get和set大家都比较熟,在这里就不详细说明了。
键
对象的键值是一个最特殊的点,它必须是字符串 即使传入时不是字符串,引擎也会自动的将其转成字符串,不管是基础类型还是复杂类型都会被转成字符串,同时在属性或者键访问的时候也会先将传入的key转成字符串
js
let obj = {
1: '233'
};
obj[{}] = '123';
obj[true] = true;
obj[undefined] = 11;
let keys = Object.keys(obj);
console.log(typeof keys[0]); // string
console.log(obj['[object Object]']); // '123'
obj[{a:22}] = 456;
console.log(obj['[object Object]']); // 456
console.log(obj[{n:3}]); // 456
这里有个比较坑的点:空字符串也可以作为键
js
let obj = {
'': 1
};
console.log(obj['']); // 1
PS:ES6新增的MAP数据结构可以支持不同类型的键值。
对象的内部
对象内容就是一堆键值对的集合,那么内存是怎么存储对象的呢?
对象的内容并不是全部都存在对象的容器里面的,引擎的存储方式是多种多样,对象的容器里面是key、value的组合,value指向对应的属性的实际值。 所以可以通过:obj.a(属性访问)和obj['a'](键值访问)的方式来读取。
js
var obj = {
name: 'shark',
age: 30,
call: function() {
console.log(this.name);
},
child: {name: 'xiaopp'}
};
对象在内存中的大致布局:
属性和方法
有些人会把面向对象的一些东西套到js的对象上,最常见就是把对象中的函数属性称为"方法",确实在面向对象的语言中,属于类的函数被称为方法。
看看c++中是怎么区分函数和方法:
-
函数: 通过函数名来调用,可以接收数据,可以返回指定的结果。接收的数据都是显示传递的,函数和类没有关联
-
方法: 实际上和函数差不多,但是方法是绑在类上的,方法接收的数据可以是隐式的,可以操作类中的数据使用类中的其他成员。在c++中方法又被称为"成员函数"
而在js中这种说法容易让人误会,从技术的角度来看,对象的函数并不属于对象,只是对象中的一个属性指向了这个函数,这个函数并不独属于这个对象。
js
function demo() {
console.log(123);
}
var obj1 = {
foo: demo
};
var obj2 = {
foo: demo
}
obj1.foo(); // 123
obj2.foo(); // 123
虽然我们可以通过this来使用对象内部的属性,但是这个函数其实也并不是对象独有的。
js
function call() {
console.log(this.name);
}
var obj1 = {
name: 'obj1',
call: call
};
var obj2 = {
name: 'obj2',
call: call
};
obj1.call(); // obj1
obj2.call(); // obj2
如果函数是在对象声明的呢?这个也不能说这个函数就和对象绑定了,依然是对象的一个属性指向这个函数,我们有办法让它不只属于这个对象。
js
var obj1 = {
foo: function() {
console.log(123);
}
};
var foo = obj1.foo;
foo(); // 123
那怎么区分方法和函数呢? 只能说js中的函数和方法是可以互相转换的。通过属性访问或者键访问来使用对象中的函数时我们将其称为对象的方法。
JSON和对象
js对象的结构和json很像,特别是把键用""来包裹的时候,所以这两个经常被混淆,而且有人还把json串当成对象,称之为json对象 但是很多人都忘记一点:json的定义是一个由键值对组成的字符串。跟对象一点关系都没有。 真正的json对象是关键字:JSON,目前支持两个方法:parse和stringify
js
JSON.parse('{"name":"shark"}');
JSON.stringify({name: 'shark'});
内部属性[[class]]
每个对象内部都有一个[[class]]属性,这是一个内部属性,无法被直接访问,但是可以通过Object.prototype.toString()
来查看:
js
Object.prototype.toString.call([]); // "[object Array]"
Object.prototype.toString.call(/\d/g); // "[object RegExp]"
可以发现这个属性返回的是对应的对象的类型。它与创建这个类型的内置构造函数是一一对应的。
内置类型函数
- String()
- Number()
- Boolean()
- Array()
- Object()
- Function()
- RegExp()
- Date()
- Error()
- Symbol()
但是并不是所有情况都是这样的:
js
Object.prototype.toString.call(null); // "[object Null]"
Object.prototype.toString.call(undefined); // "[object Undefined]"
null和undefined并没有对应的内置构造函数,但是也可以获取到对应类型的返回值。
一切(万物)皆对象
这个是很有趣的说法,在JavaScript中,对象是一切的基石,数组、函数都是在对象的基础上延伸出来的。这个很好理解。
怎么理解基础类型也是对象? 除null和undefined之外的基础数据类型都可以直接使用对应的类型的属性和方法。
js
let s = 'this is a string';
s.length; // 16
s.replace(' ', '|'); // 'this|is|a|string'
像上面的例子一样,我们可以像操作对象一样操作一个字符串。
为什么可以这么操作呢,这些属性和方法哪里来的呢? 这其实是JavaScript的一个装包和拆包的过程。
装包和拆包 这个说法来自Java。 当我们执行s.length
时,JavaScript发现了.操作符,被操作的是字符串s
,那么JavaScript会对s
进行装包操作。语句执行完成后,进行拆包操作。 大致的流程如下:
- 创建一个临时对象:
new String('this is a string')
- 让
s
执行这个临时对象,相当于:s = new String('this is a string')
- 执行
s.length
并返回值 - 将
s
修改会原来的值,相当于:s = 'this is a string'
- 删除临时对象
注意
- 虽然JavaScript会支持这种基础类型返回属性和方法的方式,但是如果需要大量的调用的话,从一开始就定一个类型对象会更好,避免JavaScript频繁的装拆包。
- 使用内置类型构造函数创建出来的是一个对象,并不是对应的类型:
js
let a = 'demo';
let b = new String('demo');
typeof a; // string
typeof b; // object
Object.prototype.toString.call(a); // [object String]
Object.prototype.toString.call(b); // [object String]
所以千万别写这样的代码:
js
let n = new String('');
if (!n) { // false
console.log('n is empty');
}
let b = new Boolean(false);
if (!b) { // false
// ...do something
}
关于typeof null == object
这是个世纪大bug,很多年了,不修改是因为怕兼容问题(有提案将它修改成:typeof null === NULL)
导致这个bug的原因是从娘胎里面带出来的,js的值是存在一个32位的单元里面的,前3位为TYPE TAG(类型标签),其他位表示真实的值。对象类型的前三位都是0,而js中的null指向的是NULL指针(机器码里面的空指针0x00),空指针全是0,所以导致这个bug
思考一个问题
对象为什么只支持字符串的键值?
主要是JavaSript的对象存储的时候使用了哈希表(hashtable)的结构,通过键值对来存储和访问数据。哈希表将键哈希后映射到数组的索引位置,然后在该索引位置存储对应的值。为了实现高效的哈希,键必须是可哈希的,而字符串可以直接转换为唯一的哈希值。其他类型的键,如数字或对象,需要进行额外的处理才能进行哈希。 另外,字符串的键值更具备可读性和可理解性。
Array
数组,最常用的类型之一。不过JavaScript中的 "数组" 与其他语言的数组并不一样。
数组通常是指一段线性分配的内存,通过整数计算偏移并访问其中的元素,是一种性能出众的数据结构。
但是JavaScript中提供的数组不是这样的,它是对象的一个衍生类型,一种"类数组"的结构。它是松散的接口,在内存里面可以是不连续的,而且每个元素的值也是不限制类型的。那么说它是个特殊的对象也不为过。
看看下面的代码:
c++
// c++中定义数组必须声明类型和长度
int arr = [100];
js
// js的数组没有这种限制
var arr = [1,2,4]
Object.keys(arr); // ["0", "1", "2"]
[...arr.keys()]; // [0, 1, 2]
Object.getOwnPropertyDescriptors(arr);
/*
{
0: {value: 1, writable: true, enumerable: true, configurable: true}
1: {value: 2, writable: true, enumerable: true, configurable: true}
2: {value: 4, writable: true, enumerable: true, configurable: true}
length: {value: 3, writable: true, enumerable: false, configurable: false}
}
*/
Q1:JavaScript数组的下标是数字还是字符串 如果是按照正统的数组来看,数组的下标一定是正整数。但是JavaScript中的数组不是正统的,它是一个Array Object,那按照对象的原则,所有的属性名最终都会变成字符串。所以数组也是不例外的,只不过他的属性名比较特殊,是正整数的数字。
可能你会有疑问:数组实际上是一个对象,那么为什么我们不能通过:arr.0
或arr.'0'
来访问呢?
js
var arr = [1];
arr.0; // Uncaught SyntaxError: Unexpected number
arr.'0'; // Uncaught SyntaxError: Unexpected string
var obj = {'0': 2};
obj.0; // Uncaught SyntaxError: Unexpected number
obj.'0'; // Uncaught SyntaxError: Unexpected string
不是数组才具有这种特殊性,而是JavaScript规定以数字开头的属性都不能使用.
来访问,只能使用[]
来访问。
思考一下: 为什么要这么做?
ES规范中dot的说明:
The dot notation is explained by the following syntactic conversion:
MemberExpression . IdentifierName
注意这里不是property name而是IdentifierName,也就是.
后面应该是一个标识符名称,那么这个名称应该符合标识符的规则:字母、下划线、 <math xmlns="http://www.w3.org/1998/Math/MathML"> 开头,后续为字母、下划线、数字、 开头,后续为字母、下划线、数字、 </math>开头,后续为字母、下划线、数字、。 数字开头不符合标识符的规则。另外,js会把数字开头的当成数字来处理。
Q1.1:如果JavaScript数组的下标是字符串,那是不是可以随便设置 答案当然是否定的啦,怎么可能给你随便设置。 ES规定下标的取值范围是0 ~ 2^32 - 1
Q2: JS是怎么区分数组的属性和下标的? 区分的方法很简单: 设置数组的属性P,当且仅当 ToString(ToUnit32(P)) 等于P,并且 ToUnit32(P) 不等于2^32 - 1时,P为下标(以string的形式),否则P为属性名。
就是说将P转换成无符号的32位整数后,与它原值对比,如果是一样的,证明P是一个正整数,然后判断P是否超过了下标的最大值,没超过则认为P是一个合法的下标。
js
var arr = [];
// 2^32
arr[4294967296] = 1;
/*
{
4294967296: 1,
length: 0
}
*/
// 2^32 - 1
arr[4294967295] = 2;
/*
{
4294967295: 2,
4294967296: 1,
length: 0
}
*/
// 2^32 - 2
arr[4294967294] = 3;
/*
{
4294967294: 3
4294967295: 2,
4294967296: 1,
length: 4294967295
}
*/
arr[-1] = 4;
/*
{
4294967294: 3
4294967295: 2,
4294967296: 1,
-1: 4,
length: 4294967295
}
*/
长度length 跟其他语言不一样的地方,数组的length属性是可以编辑的,可以通过修改length实现数组的伸缩
js
var arr = [1,2,3,4,5];
arr.length; // 5
arr.length = 100;
console.log(arr);
/*
{
0:1,
1:2,
2:3,
3:4,
4:5,
length: 100
}
*/
这个确实很坑,我们把数据扩展到100,但是js并不会自动填充,实际能枚举的属性还是5个。
这个行为是可以理解的,js中数组也是被当做一个对象来处理的(Array算是Object的一个子类),整个数据结构相对离散,注定它不会像其他语言的数组那样在内存中存储(一个连续的内存),它不需要关注连续性,而且元素的类型也是不定,不确定性其实是很大的,所以不自动填充更好,免得引起其他误会。 在这种设置下,只要保证length属性与数组的大小不会出现越界的情况就行了:
- 当length被修改时,如果新值nl小于旧值ol,对下标大于新值nl - 1的节点进行删除操作:
js
var arr = [1,2,3,4,5];
arr.length = 2;
console.log(arr);
/*
{
0:1,
1:2,
length: 2
}
*/
- 当通过下标的形式来设置值时,会比较length和下标P,如果length大于P,不操作,如果小于,会自动将length的值置为P + 1
js
var arr = [1];
arr[5] = 5;
console.log(arr.length); // 6
注意:length的最大值是:4294967295(2^32 - 1),最小是0
坑
看下这段代码
js
var arr = [];
typeof arr; // object
类型判断typeof对于数组只会返回object(这个大家都清楚),如果要判断是否为数组,可以使用Array.isArray
或者Object.prototype.toString.call(arr)
来实现。
总结
JavaScript由于历史原因吧,确实有很多地方不够严谨,可能本文的内容实际用处并不是很大,但是我觉得了解下语义设计的逻辑,可以让我们更好的学习这门语言,更好的掌控它,有时候也可以据此做出更优雅的处理。