ECMA-262 将对象定义为一组属性的无序集合,意味着对象就是一组没有特定顺序的值,其中内容就是键值对的组合,其中键可以是数据或者函数
思维导图
属性的类型
内部特性用来描述属性的特征,开发者不能直接访问这些特性,规范会用两个中括号把特性的名字括起来 [[ 特性的名字 ]]
属性分两种: 数据属性和访问器属性
数据属性
数据属性包含一个保存数值的位置。值会从这个位置进行读取和写入;数据属性有 4 个特性描述它们的行为
- [[ Configurable ]] :是否可配置, 表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,默认直接定义在对象上的属性这个可配置属性都为 true
- [[ Enumerable ]]: 是否可枚举,表示属性是否可以通过 for-in 循环返回,默认直接定义在这个对象的属性的特性都是 true
- [[ Writable ]]: 是否可写,表示属性的值是否可以被修改,默认直接定义在这个对象的属性的特性都是 true
- [[ Value ]]: 属性的实际值,这就是读取和写入属性值的位置,默认为 undefined。
Object.defineProperty 修改属性的默认特性
ts 声明:defineProperty(o: T, p: PropertyKey, attributes: PropertyDescriptor & ThisType): T;
参数说明: o 表示需要修改的对象; p 表示要修改的对象的属性名字; attributes 表示描述符对象(数据属性的特心描述,值查看描述符对象配置的 ts 声明 ⬇️)
/// 描述符对象的配置 ts 声明
// interface PropertyDescriptor {
// configurable?: boolean; // 是否可配置,即是否可删除, 为 true 时意味定义的属性不可被删除
// enumerable?: boolean; // 是否可以 for-in 枚举出来,为 false 时意味定义的属性 for-in 不可迭代
// value?: any; // 定义属性时的值
// writable?: boolean; // 是否可被修改,为 false 则属性的值不可被修改
// get?(): any; // getter,读取拦截
// set?(v: any): void; // setter,设置拦截
// }
/**
* person 的 name 属性的 [[Value]] 特性会被设置为 defaultVal
* 之后对这个值的任何修改都会保存到 [[ Value ]] 这个位置
*/
const person = {
name: "defaultVal",
};
Object.defineProperty(person, "name", {
writable: false, // 表示 name 的值不可以被修改
enumerable: true, // 表示 for-in 可迭代出 name 属性
configurable: false, // 不可删除,表示 name 不能被 delete
value: "NameVal", // 这里修改属性的 [[ Value ]] 值为 NameVal 了, 之前的 defaultVal 将失效
});
delete person.name; // 不管用,因为 configurable 为 false,name 不可被删除
person.name = "newNameVal"; // 不管用了,因为 writable 为 false 了,即不可写入,不可修改
log(person); // log: { name: 'NameVal' }
访问器属性
访问器属性中 [[ Get ]] 特性描述符对应 描述符中的 get() 函数,在读取对象属性时调用; [[Set]] 特性描述符对应 set(value) 函数
const person = {
_name: "mock_private_name", // 模拟这是一个私有属性
age: 23,
};
// 定义 person 下的 name 属性的访问器描述符行为
Object.defineProperty(person, "name", {
get() {
// 获取拦截,返回拦截出了后的值
return this._name + "_";
},
set(newVal) {
// 这里可以做一些拦截
if (typeof newVal === "string") {
// 如果 newVal 是 string 类型,才设置 name 的值
this._name = newVal;
} else {
this._name = String(newVal);
}
},
});
log(person.name); // log: mock_private_name_ , 实际上 name 的值来自于 person._name
person.name = 33;
log(person.name); // log: 33_
定义多个属性描述符 & 获取属性描述符配置
定义多个属性描述符,可以通过 Object 调用
defineProperties(o: T, properties: PropertyDescriptorMap & ThisType): T
// 为 obj 定义 多个属性和属性对应的描述符
const obj = {};
const resultObj = Object.defineProperties(obj, {
name: {
value: "create_name",
configurable: false,
enumerable: true,
},
age: {
value: 23,
configurable: false,
enumerable: false,
writable: false,
},
});
log(obj); // log: { name: 'create_name' }
log(resultObj); // log: { name: 'create_name' }
log(obj === resultObj); // log: true, 说明 defineProperties 返回的对象 和 定义的 对象指向同一片内存地址
const descriptionConfig = Object.getOwnPropertyDescriptors(obj);
log(descriptionConfig);
/**
log
{
name: {
value: 'create_name',
writable: false,
enumerable: true,
configurable: false
},
age: {
value: 23,
writable: false,
enumerable: false,
configurable: false
}
}
*/
log(Object.getOwnPropertyDescriptor(obj, "name")); // 获取 obj 的 name 描述符
/**
log: {
value: 'create_name',
writable: false,
enumerable: true,
configurable: false
}
*/
对象的其他 API
Object.assign 合并对象
Object.assign(target,...sources: any[]) 接受一个目标对象和一个或多个源对象作为参数,将每个源对象中可枚举( Object.propertyIsEnumerable() 返回 true) 和自有( Object.hasOwnProperty() 返回 true ) 属性复制(浅复制)到目标对象,以这个方法会使用源对象上的 [[Get]] 取得属性的值,然后使用目标对象上的 [[ Set]] 设置属性的值。
如果合并期间出错,则操作会终止并退出,但是没有回滚的概念,即这不是一个原子操作,它是一个尽力而为、可能只会完成复制的方法
<body>
<script>
const { log } = console;
const target = {
name: "name_value",
// 按照 target 定义中的 setter 来设置属性的值
set sex(val) {
log(`call-target-sex-setter`);
this._sex = val; // 注意,这里不能使用 this.sex = val(因为这样会导致 一直触发 sex 导致栈溢出)
},
set fn(val) {
return (this._fn = val);
},
};
const source1 = {
age: 23,
};
const source2 = {
email: "xxx.email",
};
const source3 = {
// 按照 source 定义中的 getter 来获取相应属性的值
get sex() {
log("call-source3-sex-getter");
return "man";
},
get fn() {
return () => "fnLog";
},
};
// assign(target: object, ...sources: any[]): any
Object.assign(target, source1, source2, source3);
// 在合并 source3 到 target 的时候,会先执行 source 3 的 getter 打印 call-source3-sex-getter,然后会执行 target 的 setter 打印 call-target-sex-setter
log(target); // log: {name: 'name_value', age: 23, email: 'xxx.email', _sex: 'man'}; 看将 source1/2/3 的可枚举和自由属性复制到了目标对象上
log(target._fn()); // log: fnLog
</script>
</body>
Object.is 通过递归判断多个值是否相等
// 版本 1
const isEqualForManyV1 = (...rest) => {
const [first, second, ...other] = rest;
if (rest.length === 1) return false;
if (rest.length === 2) return Object.is(first, second);
return Object.is(first, second) && isEqualForManyV1(second, ...other);
};
const arr = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1];
log(isEqualForManyV1(...arr));
// 版本 2
const isEqualForManyV2 = (x, ...rest) => {
return (
Object.is(x, rest[0]) && (rest.length < 2 || isEqualForManyV2(...rest))
);
};
log(isEqualForManyV2(...[1, 1]));
增强对象语法
- 属性简写
- 可计算属性
- 简写方法名
- 对象解构
- 嵌套解构
- 参数解构重命名
const nickname = "15";
const variableName = "age";
const obj = {
// 属性简写
nickname, // 是 nickname: nickname 的简写
// 可计算属性, [] 里边的变量会被解析为对应的值来作为 key
[variableName]: 23, // 相当于 age: 23
// 简写方法名
fn() {
// 相当于 fn: function(){ log("fn log") } or fn: () => log("fn log")
log("fn log");
},
innerObj: {
name: "inner_name",
age: 23,
},
};
// 嵌套解构,下边的解构等于 const { innerObj } = ob; const { name, age} = innerObj;
const {
innerObj: { name, age },
} = obj;
// 参数解构重命名
const person = {
name: "zhangsan",
age: 15,
};
// 这里 {name: pn, age: pa} 就是从传递过来的参数中 解构出 name 重命名为 pn, age 解构重命名为 pa
const printPerson = ({ name: pn, age: pa }) => {
log(`${pn}_${pa}`);
};
printPerson(person); // log: zhangsan_15
创建对象的方式
工厂模式
工厂设计模式用于抽象创建特定对象的过程
const createPerson = (name, age, job) => {
return {
name,
age,
job,
sayName() {
log(`hi, my name: ${this.name}`);
},
};
};
const p = createPerson("jakequc", 15, "fe");
p.sayName(); // log: hi, my name: jakequc
构造函数模式
ECMAScript 中的构造函数用于创建特定类型对象,像 Object 和 Array 样的原生构造函数,运行时可以直接在执行环境中使用,当然也可以自定义构造函数,一函数的形式为对象类型定义属性和方法
// ES6 之前的构造函数模式
// 因为使用了 this,所以不能是箭头函数; 按照惯例,构造函数的首字母要大写
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
this.sayName = () => {
log(this.name);
};
}
const p2 = new Person("16name", 16, "fe");
p2.sayName(); // log: 16name
⚠️: Person 构造函数 和 createPerson 有三点明显的不同
-
- 没有显示创建对象
- 属性和方法直接复制给了 this
- 没有 return
new 执行的过程
创建实例对象,需要使用 new 操作符,执行 new 操作符会调用构造函数并执行如下操作
- 在内存中创建一个新对象
- 这个新对象内部的 [[Prototype]] 指向构造函数的 prototype 属性
- 改变构造函数的上下文(this)给行对象添加属性(执行构造函数内部的代码)
- 如果构造函数返回非空对象,则返回该对象,否则,返回刚创建的新对象。
const _new = (ConstructFn, ...args) => {
// 1. 创建一个空对象
const obj = {};
// 2. 空对象的原型指向 构造函数 ConstructFn 的原型
Object.setPrototypeOf(obj, ConstructFn.prototype); // 或者 obj.__proto__ = ConstructFn.prototype;
// 3. 执行并改变构造函数的上下文
const res = ConstructFn.apply(obj, args);
// 4. 如果构造函数函数返回的是对象,则直接返回,否则返回创建的对象
return res instanceof ConstructFn ? res : obj;
};
构造函数
构造函数也是函数
两者的区别在于调用方式的不同,任何函数只要使用 new 操作符调用就是构造函数,不实用 new 操作符调用就是普通函数
构造函数的问题
构造函数的主要问题在于,其定义的方法都会在每个实例上都创建一遍。
原型模式
每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含由特定引用类型的实例共享的属性和方法,即共享属性行为是放在 prototype 上的。原来在构造函数中直接赋值给对象实例的值,可以直接赋值给他们的原型,实现对象实例共享行为
function Constructor(name, age) {
this.name = name;
this.age = age;
}
// 挂在到函数(构造函数)原型上的属性或方法是实例共享的
Constructor.prototype.sayName = function () {
log(this.name);
};
const p3 = new Constructor("15name", 15);
const p4 = new Constructor("16name", 16);
p3.sayName(); // log: 15name
p4.sayName(); // log: 16name
理解原型
只要创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象),所有原型对象的 constructor 属性会指向与之关联的构造函数 实例与构造函数之间有直接联系(实例的内部 __proto__(不是所有浏览器都有这个属性) 指向构造函数的原型对象),但实例与构造函数之间没有
function Person() {}
log(typeof Person.prototype); // log: object
// 构造函数有一个 prototype 指向 其原型对象,而这个原型对象也有一个 constructor 指向这个构造函数
log(Person.prototype.constructor === Person); // log: true
log(Person.prototype.__proto__ == Object.prototype); // true;
log(Person.prototype.__proto__.constructor === Object); // true;
// 正常的原型链都会终止于 Object 的原型对象, Object 原型的原型是 null
log(Person.prototype.__proto__.__proto__ === null); // true;
// 构造函数、原型对象、实例 是 3 个完全不同的对象
// 实例通过 __proto__ 找到原型对象, 构造函数通过 prototype 找到原型对象,因此 实例的 __proto__ 和 构造函数的 prototype 指向同一个地址
const instance = new Person();
log(instance.__proto__ === Person.prototype); // true
// Object.getPrototypeOf(obj) 获取 obj 的原型
log(Object.getPrototypeOf(instance) === Person.prototype); // true
// Constructor.prototype.isPrototypeOf(instance) instance 的 __proto__ 指向调用它的对象时返回 true
log(Person.prototype.isPrototypeOf(instance)); // true
const newObj = Object.create(instance);
log(Object.getPrototypeOf(newObj) === instance); // true
注意: Object.setPrototypeOf(instance1, instance2) 可以重写一个对象的原型继承关系,但是可能会严重影响代码性能;但是可以使用 Object.create(instance) 来创建一个新对象,同时为新对象指定原型
重写原型可能造成的问题
如果要给原型上加很多的共享实例或方法,或许会直接使用一个对象来重写原型(但是 constructor 指向可能会因为重写而丢失),看如下例子
导致 constructor 指向问题
function Person() {}
// 重写了 prototype,影响了 Person.prototype.constructor 的指向问题,
// 可以在对象中指定 constructor 的值(但将 constructor 变成可枚举的了 )
// 通过 Object.defineProperty 定义 constructor 指定 value 和 enumerable: false 可以恢复原来的指向
Person.prototype = {
pub1() {
log("pub1");
},
}; // 这种方式的 Person.prototype.constructor 等于 [Function: Object]
log(Person.prototype.constructor); // log: [Function: Object]
// ----- split -----
function Person2() {}
// 不影响 Person2.prototype.constructor 的指向问题
Person2.prototype.pub1 = function () {
log("pub1");
}; // 这种方式的 Person.prototype.constructor 等于 [Function: Person]
log(Person2.prototype.constructor); // log: [Function: Person2]
导致原型链的动态性丢失问题
因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对象所做的修改也会在实例上反映出来
// 动态搜索【没有重写原型链
function Person() {}
// friend 在 原型链添加 sayHi 之前创建
const friend = new Person();
// 此处 Person.prototype 已经挂在了 sayHi 方法
Person.prototype.sayHi = function () {
log("hi");
};
// 因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对象所做的修改也会在实例上反映出来
friend.sayHi(); // log: hi
重写整个原型会切断最初原型与构造函数的联系,实例只有指向原型的指针,没有指向构造函数的指针
function Person() {}
const friend = new Person();
Person.prototype = {
constructor: Person,
sayHi() {
log("hi2");
},
};
// 因为重写原型切断了最初原型与构造函数的联系
friend.sayHi(); // TypeError: friend.sayHi is not a function
原型层级
访问对象属性时,会按照这个属性的名称开始搜索,搜索开始于对象实例本身,如果在这个实例上发现了给定的键名,则返回该键对应的值,如果没有找到这个属性,则搜索会沿着指针进入原型对象,然后在原型对象上找到属性后,在返回对应的值; 因此如果原型上和实例上存在相同的属性或方法时,实例上的优先级高(覆盖原则,使用 delete 删除之后就不会出现覆盖情况)
比如: p.sayName() 时,首先 js 引擎会问 "p 实例有 sayName 属性吗?" 没有则去 p 的原型上找 sayName, 如果有责使用 sayName();
instance.hasOwnProperty(keyName) 会检查这个键是否在实例上还是在原型对象上,在自己身上则返回 true,反之 false
原型的问题
它弱化了像构造函数传递出时化参数的能力,会导致所有实例某人都取得相同的属性值,这带来了不便,但原型链最大的问题源自它的共享特性。不如原型上有个数组属性,实例更改了这个数组属性值后,其他实例也会被更改了这个值(可能其他实例根本不需要这样做)
in 操作符
in 操作符 只要 对应的实例 可以访问到 属性则返回 true ,不论他是咋原型链上 还是在 实例上
instance.hasOwnProperty(keyName) 只会检测 keyName 这个属性在 instance 上是否存在,不会检测原型链上的属性
有两种方式使用 in 操作符:
- 单独使用 key in obj ,返回 boolean 类型
- for-in 循环中使用
单独 in 操作符 和 hasOwnProperty 方法
function Person(age) {
// age 才是挂在到实例上的
this.age = age;
}
// name 和 sayName 是挂载 到 prototype 上的
Person.prototype.name = "pName";
Person.prototype.sayName = function () {
log(this.name);
};
const p1 = new Person();
log(p1.hasOwnProperty("name")); // log: false;
log(p1.name); // log: pName, p1 可以访问到 name 属性,因此 in 操作符 返回 true
log("name" in p1); // log: true;
// 检测 keyName 是否 不在实例中定义而仅仅是在 原型上定义
function hasJustPrototypeProperty(keyName) {
// 只要通过对象可以访问,in操作符就返回true,而hasOwnProperty()只有属性存在于实例上时才返回true。
// 因此,只要in操作符返回true且hasOwnProperty()返回false,就说明该属性是一个原型属性
return keyName in this && !this.hasOwnProperty(keyName);
}
log(hasJustPrototypeProperty.call(p1, "name")); // log: true
for-in 循环
for-in 循环中使用 in 操作符,对象可访问且可枚举的键名都会返回(包含实例键名和原型键名)
Object.keys(obj) 可以获取对象上所有可枚举的实例键名(不包含原型上的键名)
function Person(age) {
// age 才是挂在到实例上的
this.age = age;
}
// name 和 sayName 是挂载 到 prototype 上的
Person.prototype.name = "pName";
Person.prototype.sayName = function () {
log(this.name);
};
const p1 = new Person();
const forinKeys = [];
for (key in p1) {
forinKeys.push(key);
}
log(forinKeys); // log: [ 'age', 'name', 'sayName' ]
// 只有 实例上的可枚举键名才会被 Object.keys(obj) 返回
log(Object.keys(p1)); // log: [ 'age' ]
// Object.getOwnPropertyNames(obj) 会返回所有键名, 不论该 键名 是否可枚举
log(Object.getOwnPropertyNames(Person.prototype)); // log: [ 'constructor', 'name', 'sayName' ], constructor 是不可枚举的
const symbolK1 = Symbol("k1");
const symbolK2 = Symbol("k2");
const obj = {
[symbolK1]: "v1",
[symbolK2]: "v2",
k3: "v3",
};
// Object.getOwnPropertySymbols(obj) 会返回 obj 的 Symbol 类型键名 数组
log(Object.getOwnPropertySymbols(obj)); // log: [ Symbol(k1), Symbol(k2) ]
键名枚举顺序
for-in循环和Object.keys()的枚举顺序是不确定的,取决于JavaScript引擎,可能因浏览器而异。
Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()和Object.assign()的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。在对象字面量中定义的键以它们逗号分隔的顺序插入
const symbolK1 = Symbol("k1");
const symbolK2 = Symbol("k2");
const obj = {
[symbolK1]: "v1",
[symbolK2]: "v2",
k3: "v3",
1: 1,
2: 2,
3: 3,
k4: "v4",
};
log(Object.getOwnPropertyNames(obj)); // [ '1', '2', '3', 'k3', 'k4' ] (数值键升序,其他以插入顺序为准)
log(Object.getOwnPropertySymbols(obj)); // [ Symbol(k1), Symbol(k2) ]
log(Object.keys(obj)); // [ '1', '2', '3', 'k3', 'k4' ] (可能因浏览器而已)
原生对象原型
可以通过原生对象的原型取得所有默认方法的引用,也可以给原生类型的实例定义新的方法(因为实例最终可以通过原型链查找到新的方法),比如给 String 原始值包装类型的实例添加一个 _startsWith(str) 方法
String.prototype._startsWith = function (str) {
return this.indexOf(str) === 0;
};
log("1516"._startsWith("15")); // log: true
⚠️: 当需要扩展原生对象时、尽管可以在原型上添加新的方法,但是在实际开发中修改原生对象原型,这样可能会造成误会,而且可能引发命名冲突,推荐做法是创建一个自定义类,即成原生类型