JS - 对象创建 & 以及常见的方法

229 阅读13分钟

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 操作符会调用构造函数并执行如下操作

  1. 在内存中创建一个新对象
  2. 这个新对象内部的 [[Prototype]] 指向构造函数的 prototype 属性
  3. 改变构造函数的上下文(this)给行对象添加属性(执行构造函数内部的代码)
  4. 如果构造函数返回非空对象,则返回该对象,否则,返回刚创建的新对象。
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

⚠️: 当需要扩展原生对象时、尽管可以在原型上添加新的方法,但是在实际开发中修改原生对象原型,这样可能会造成误会,而且可能引发命名冲突,推荐做法是创建一个自定义类,即成原生类型