你不知道的javascript---对象(万字详解)

254 阅读17分钟

本文是我看《你不知道的javascript》一书的总结与心得,像这种字多,繁琐,深奥的文章,每两天一更吧,后续也会更新有关JavaScript设计原理,Vue,React,Angular设计原理。如有错误请指出。🙏

1.对象语法

对象定义可以通过两种形式定义:

  • 声明(文字)形式
  • 构造形式

声明文字形式

let obj = {
 key:value
}

构造形式

let MyObj = new Object();
MyObj.key = value;

二者的作用与生成的对象是一样的,区别是文字声明可以添加多个属性(键值对),但是构造形式必须逐个添加。

在实际开发中,多数采用声明形式,构造形式定义对象少见。

类型

javascript一共有六种主要类型(语言类型) string,number,boolean,nuill,undefined,object

注意⚠️,它们不是对象。但null有时候会被当作对象类型。

    console.log(typeof null) //object

原因: 简单来说,typeof null的结果为Object的原因是一个bug。在 javascript 的最初版本中,使用的 32位系统,js为了性能优化,使用低位来存储变量的类型信息,不同对象在底层都表示二进制。

数据类型机器码标识
对象(Object)000
整数1
浮点数010
字符串100
布尔110
undefined-2^31(即全为1)
null全为0

在判断数据类型时,是根据机器码低位标识来判断的,而null的机器码标识为全0,而对象的机器码低位标识为000。所以typeof null的结果被误判为Object

内置对象

网上有一种常见的说法是“JavaScript中万物都是对象”🚀,这种说法有一定的合理性,但是它是错误的(基本数据类型本身不是对象)。

JavaScript中有许多特殊的对象子类型,也就是复杂基本类型。

比如函数就是对象的一个子类型,从技术上讲来说是“可调用对象”,因为他本质上和普通对象一样(只是可以被调用),所以可以像操作对象一样操作函数。

JavaScript中一些对象子类型通常被称为内置对象,有String,Number,Boolean,Object,Function,Array,Date,RegExp,Error。它们的名字看起来和简单基础类型很像。但是它们的关系更加复杂。

它们的表现形式很像Java中的类,比如Java中的String类,但是它们只是JavaScript中的内置函数而已,并且可以当做构造函数使用(new方式调用),构造一个对应子类型的新对象,

 let FakeString = "i am fake string";
    console.log(typeof FakeString) //string
    console.log(FakeString instanceof String) //false
    

    let TrueString = new String('i am true string');
    console.log(typeof TrueString) //object
    console.log(TrueString instanceof String) //true

    // 检查sub-type对象
    console.log(Object.prototype.toString.call(TrueString)) //"[object String]"

从这里我们可以看到,"i am fake string"不是一个对象,只是一个类型为string的字面量,这也验证了“JavaScript万物都是对象”这句话是错误的。果我们要对其使用一些操作,比如获取它的长度和某个字符,我们需要将其转换为String对象,(NumberBoolea也是如此步骤)

但是在JavaScript中不需要手动转换,在必要的时候会自动将其转化为String对象

let FakeString = "i am fake string";
    console.log(FakeString.length); //16
    console.log(FakeString.charAt(3)); //m
    console.log(typeof FakeString) //string

当你使用FakeString.length或者FakeString.charAt(3)这样的语法操作字符串字面量时候,JavaScript 会自动将基本字符串类型的值转换为临时String对象(这个过程是隐式的),然后调用这个临时对象的方法,最后销毁这个临时对象。

这是一种语法糖,让开发者可以方便地操作字符串,就好像字符串是对象一样,但本质上它仍然是基本数据类型。

同理,数值字面量也会发生,如33.333.toFixed(2)33会被临时转化为new Number(33),布尔字面量也是如此。

注意,null和undefined没有对应的构造形式,他们只有文字形式。相反的Date只有构造形式,没有文字形式

// new null; //Uncaught TypeError: null is not a constructor
    // let myNullValue = null;
    // console.log(myNullValue); // 输出:null

    // let myVariable;
    // console.log(myVariable); // 输出:undefined
    // new undefined; // Uncaught TypeError: undefined is not a constructor
    
    // 创建表示当前时间的Date对象
    // let now = new Date();
    // console.log(now); // 输出类似:Wed Dec 11 2024 17:36:27 GMT+0800 (中国标准时间)

    // 创建指定日期的Date对象,比如2024年1月1日
    // let specificDate = new Date(2024, 0, 1); // 月份从0开始,0表示1月
    // console.log(specificDate); 

对于Object,Function,Array,RegExp(正则表达式),无论是文字形式还是构造形式,它们都是对象不是字面量。

    // 对象
    // 文字形式
    let obj1 = {};
    console.log(typeof obj1); //object
    // 构造形式
    let obj2 = new Object();
    console.log(typeof obj2); //object

    // 函数
    // 文字形式
    function fun1() {};
    console.log(typeof fun1); //function

    // 构造形式
    let fun2 = new Function();
    console.log(typeof fun2); //function

    // 数组
    // 文字形式
    let arr1 = [];
    console.log(typeof arr1); //object
    // 构造形式
    let arr2 = new Array();
    console.log(typeof arr2); //object

    // 布尔值
    // 文字形式
    let bool1 = true;
    console.log(typeof bool1); //boolean
    // 构造形式
    let bool2 = new Boolean(true);
    console.log(typeof bool2); //object

    // RegExp
    // 文字形式
    let reg1 = /abc/;
    console.log(typeof reg1); //object
    // 构造形式
    let reg2 = new RegExp('abc');
    console.log(typeof reg2); //object

函数这里有点特殊,打印出来的是function,不要困惑😄。在JavaScriptfunction也是对象的子类型,typeof操作符对函数有特殊的处理。typeof操作符用于返回一个值的类型,当应用于函数时,它返回function。这样的设计可以让开发者在编写代码时,快速地知道一个值是函数还是其他普通对象,从而更好地理解代码的行为

而像String,Number,Boolean就不同了,文字形式是基本类型,构造形式是对象。

    // Number
    // 文字形式
    let num1 = 1;
    console.log(typeof num1); //number
    // 构造形式
    let num2 = new Number(1);
    console.log(typeof num2); //object

    // String
    // 文字形式
    let str1 = 'abc';
    console.log(typeof str1); //string
    // 构造形式
    let str2 = new String('abc');
    console.log(typeof str2); //object

    // bool
    // 文字形式
    let bol1 = false;
    console.log(typeof bol1); //boolean
    // 构造形式
    let bol2 = new Boolean(false);
    console.log(typeof bol2); //object

Error对象很少在代码中显示创建,比如new Error(...),一般都是爆出异常时候自动创建,这种写法一般用不着。

内容

对象的内容是由一些储存在特定命名位置的值组成的,我们称之为属性(其实就是你平常用文字声明的对象内部的键值对👀)。

 let MyObject = {
      a:2
    }
    console.log(MyObject.a); //2
    console.log(MyObject['a']); //2

访问对象内部的值有两种方式。

  • 使用.操作符,通常被称为“属性访问”
  • 使用[]操作符,通常被称为“键访问” 这两种方法访问的是同一个位置,作用效果是一样的,二者可以互换。

主要区别在于,.操作符要求属性名满足标识符命名规范。[]操作符可以接受任意UTF-8/Unicode**字符串**作为属性名(注意接受的是字符串哦😯~,如果你写成MyObject(a)是不行的哦~)

  let MyObject = {
      'Super-Fun!' : 2
    }
    console.log(MyObject.Super-Fun!);//Uncaught SyntaxError: missing ) after argument list
    console.log(MyObject['Super-Fun!']); // 2

因为[]操作符使用字符串来访问属性,所以我们可以对它使用骚操作。

    let MyObject = {
      a : 2
    }
    let wantA = true;
    let idx;
    if(wantA){
      idx = 'a';
    }else{
      idx = 'b';
    }
    console.log(MyObject[idx]); //2

注意,在对象中属性名永远是字符串,如果你使用string类型以外的值作为属性名,比如number,boolean,它们会被转化为一个字符串。(注意,数组虽然也是对象子类型,但是我们访问数组是通过下标访问,使用的是数字,不要搞混了哦~😯)

    let MyObject = {};

    MyObject[true] = "change boolean to string";
    MyObject[1] = "change number to string";
    MyObject[MyObject] = "change object to string";


    console.log(MyObject["true"]); //change boolean to string
    console.log(MyObject[1]); //change number to string
    // console.log(MyObject[MyObject]); //change object to string
    console.log(MyObject["[object Object]"]); //change object to string

可以看出它们都被转化为了字符串。(上面有任何看不懂的代码,问ai即可,这里就不浪费篇章解释了)

可计算属性名

如果要用到可计算属性名,[]操作符就派上用场了,如MyObject[prefix+name].但是文字形式这样做是不行的。

let prefix = "foo"

    let MyObject = {
      [prefix + "bar"] : "hello world"
    }
    console.log(MyObject["foobar"]); //hello world

对于可计算属性最常用的场景可能是Symbol.

let obj = {
    [Symbol.Something] : "hello world"
}

属性描述符

在ES5之前(远古时代),JavaScript本身没有提供可以直接检测属性特性的方法。但从ES5开始,所有属性都有了属性描述符。属性描述符分为数据属性描述符访问器属性描述符

数据属性描述符


    let obj = {
      a:2
    };

    console.log(Object.getOwnPropertyDescriptor(obj,'a'));

image.png

我们可以看到一个这个简单的属性对应的属性描述符,不仅仅只有一个2,还有writeable(可写),enumerable(可枚举),configurable(可配置),[[Prototype]](原型)<-这个这里不做介绍,我的博客中写了有关原型的介绍,有兴趣可以去看看。

我们可以对普通属性使用Object,defineProperty(...)添加属性或者修改已有属性(前提它是可修改的)。

let obj = {
      a:2
    };

    Object.defineProperty(obj,'a',{
      value:3,
      writable:true,
      configurable:true,
      enumerable:false
    })
    console.log(Object.getOwnPropertyDescriptor(obj,'a'));

image.png 可以看见我们给一个普通对象属性修改了它的一些特性和内容。

Value

这个不多讲解,大家也能看出就是这个属性的值

Writable

Writable决定是否可以修改属性的值

let obj = {
      a:2
    };

    Object.defineProperty(obj,'a',{
      value:2,
      writable:false,
      configurable:true,
      enumerable:false
    })
    obj.a = 3;
    console.log(obj.a); //2

可以看见,我们对于属性值的修改静默失败了(指代码在执行一个操作时,没有抛出任何错误(例如没有显示报错信息),但是这个操作实际上没有按照预期完成。)

但在严格模式下,会直接报错。

    "use strict"
    let obj = {
      a:2
    };

    Object.defineProperty(obj,'a',{
      value:2,
      writable:false,
      configurable:true,
      enumerable:false
    })
    obj.a = 3;
    console.log(obj.a); //Uncaught TypeError: Cannot assign to read only property 'a' of object '#<Object>'

Condigurable

我们可以使用defineProperty(...)来修改属性描述符,前提是可配置的。

let obj = {
      a:2
    };
    
      Object.defineProperty(obj,'a',{
        value:2,
        writable:true,
        configurable:false,
        enumerable:true
      })

      Object.defineProperty(obj,'a',{
        value:6,
        writable:true,
        configurable:true,
        enumerable:true
      }) //Uncaught TypeError: Cannot redefine property: a
    at Function.defineProperty (<anonymous>)

不管是不是严格模式。我们尝试修改一个不可配置的属性描述符都会出错。所以,如你所见,把configgurable修改成false是单向操作(无法撤销!你没有回头路了,哈哈哈😄!)

注意:有一个意外,即使属性是configurable:false,我们仍可以将writeable状态从true修改为false,但是不能从false改为true

// 首先定义一个属性,此时configurable和writable都为true
Object.defineProperty(obj, 'prop', {
    value: 10,
    writable: true,
    configurable: true,
    enumerable: true
});

// 现在将configurable设为false,同时把writable设为false
Object.defineProperty(obj, 'prop', {
    writable: false,
    configurable: false
});

// 尝试修改属性值,由于writable已经变为false,修改不会生效
obj.prop = 20;
console.log(obj.prop); // 输出10,证明修改没有成功,符合writable为false的设定

configurable:false除了无法修改属性特性,还会禁止删除这个属性。

    let obj = {
      a:2
    };

    delete obj.a;
    console.log(obj.a); //undefined


      Object.defineProperty(obj,'a',{
        value:2,
        writable:true,
        configurable:false,
        enumerable:true
      })
      delete obj.a;
      console.log(obj.a); //2

可以看出,操作configurable:false后,删除操作静默失败了,在严格模式下,直接报错。

Enumerable

Enumerable控制属性是否会出现在对象的属性枚举中(简而言之,就是修改它能决定这个属性是否能被枚举)。比如说for..in..

let obj1 = {};
    Object.defineProperty(obj1, 'prop1', {
      value: 10,
      writable: true,
      configurable: true,
      enumerable: true
    });
    for (let key in obj1) {
      console.log(key + ' and ' + obj1[key]); //prop1 and 10
    }
    
let obj2 = {};
    Object.defineProperty(obj2, 'prop2', {
      value: 20,
      writable: true,
      configurable: true,
      enumerable: false
    });
    for (let key in obj2) {
      console.log(key + ' and ' + obj2[key]); //没有输出
    }
    console.log(obj.prop2) //20
    
    

可见Enumerable:为fasle的属性是无法被枚举的,但是仍然可以访问。

访问属性描述符

访问属性描述符为GetterSetter,但在讲解它们二者的时候,我们先讲解下[[Get]][[Put]]

[[Get]]

let obj = {
    a:2
};
console.log(obj.a) //2

这段代码很明显是去对a进行属性访问。但是不仅仅只是在查找名字为a的属性。

实际上进行了[[Get]]操作,JavaScript中对象会默认内置[[Get]],在进行属性访问的时候,会首先查找对象是否有名称相同的属性,如果有则返回这个属性的值,如果没有就回去原型链上查找,如果原型链上也没有就返回undefined。

注意:这种方法和访问变量是不一样的,如果你引用了当前词法作用域中不存在的变量,并不会像对象属性一样饭后undefined,而是会抛出一个ReferenceError的异常


    {
      let innerObject = {};
      console.log(innerObject.unknownProperty);
      // 输出:undefined,访问对象的未知属性返回 undefined
    }
    {
      console.log(unknownVariable);
      // 抛出 ReferenceError: unknownVariable is not defined
      // 引用不存在的变量抛出错误
    }

[[Put]]

又可以获取属性的[[Get]]操作,那么就会有对应的[[Put]]操作。

[[Put]]被触发的时候,并不是简单的设置或者创建属性,而是会进行以下步骤

如果已经存在这个属性:

属性是否是访问器属性(包含getset

  • 1.属性是否是访问器属性(访问描述符), 如果对象属性被定义为访问器属性(包含getter和/或setter函数),那么当[[Put]]操作被触发时,实际上会调用setter来处理赋值操作。
  • 2.检查属性的可写性(writable,如果writablefalse?如果是,非严格模式下静默失败,严格模式下抛出TypeError异常。
  • 3.检查对象的扩展性(extensible)和属性是否存在,如果对象是不可扩展的([[Extensible]]false),并且要设置的属性不存在,那么在严格模式下会抛出TypeError,在非严格模式下操作会静默失败。
  • 如果都不是,则设置属性。

Getter和Setter

  • 在ES5中,可以使用GetterSetter部分改写默认操作,但只能用于单个属性不能应用在整个对象上。
  • 二者都是隐藏属性,Getter会在获取属性值的时候调用,Setter会在设置属性值的时候调用

当给一个属性定义Setter或者Getter,亦或者二者都有,这个属性会被定义为访问描述符(访问器属性),对于访问描述符,JavaScript对于它们的访问会与普通数据属性不同,会忽略它们的valuewritable属性,而是去关心setget属性(还有configurable和enumerable)特性。

let MyObject = {
  // 给a定义一个getter
  get a () {
    return 2;
  }
}

Object.defineProperty(MyObject, 'b', {
  // 给b定义一个getter
  get:function(){
    return this.a * 2
  },
  //确保b后出现在对象属性列表中 
  enumerable: true
})
MyObject.a = 3
console.log(MyObject.a) // 输出2
console.log(MyObject.b) // 输出4

这两种定义getter的方法都会在对象中创建一个不包含值的属性,对这个属性访问会自动调用隐藏函数,而这个函数的返回值会被当作属性访问的返回值

这里有个小细节,我们可以看到即使我们给a设置为三,但是打印出来的a还是之前的2?这是为什么呢?

  • 当一个属性被定义为具有get访问器(像MyObject中的a属性)时,每次访问这个属性,实际上是在调用get函数来获取属性的值。

  • 但是我们只定义了agetter,所以对a的值进行设置时候set操作会忽略赋值操作,不会抛出错误。即使有合法的setter,由于我们自定义的getter只会返回2,所以set操作是没有意义的。 例如

let MyObject = {
         get a() {
           return 2;
         },
         set a(newValue) {
           // 这里可以添加一些逻辑来处理赋值操作
           console.log("尝试设置a的值为", newValue);
         }
       };

-尽管有了set函数可以处理赋值操作,但是get函数始终返回2。也就是我们没有改变get,这意味着无论通过set函数如何修改属性内部可能存储的值,外部通过访问a属性(MyObject.a)得到的结果始终是2

所以我们要定义能够改变gettersettersetter操作会覆盖单个属性默认的[[Put]]

let MyObject = {
  // 给a定义一个getter
  get a () {
    return 2;
  },

  set a (newValue) {
    // this默认绑定到MyObject,所以this.a指向MyObject.a,即a的getter
    this.a = newValue * 2;
  }
}

你是不是直接写成这样了?🤔,我猜有小伙伴是的😄,结果爆出了栈溢出错误Uncaught RangeError: Maximum call stack size exceeded,这里简单说下原因

问题产生的核心原因 —— 无限递归调用

当执行 this.a = newValue * 2 这条语句时(也就是在 set 函数内部进行赋值操作时),关键问题出现了。

因为 a 是访问器属性this.a 这样的写法表示要访问(或者说操作) MyObject 对象的 a 属性。根据访问器属性的机制

  • 如果是访问(像在其他代码中使用 MyObject.a 去获取值的情况),就会触发 a 属性的 get 函数。
  • 但在这里是赋值操作(this.a =...),所以会再次触发 a 属性的 set 函数。 而在 set 函数里又写了 this.a = newValue * 2,这就意味着每一次进入 set 函数去尝试更新 a 属性值的时候,又会因为这个赋值语句再次触发 set 函数,如此循环往复,形成了一个无限循环的递归调用过程。

正确写法

let MyObject = {
    _aValue: 2, // 定义一个内部变量来存储a的值
    // 给a定义一个getter
    get a () {
        return this._aValue;
    },
    set a (newValue) {
        this._aValue = newValue * 2;
    }
};

this._aValue = newValue * 2。这里_aValue是一个普通的数据属性(不是访问器属性),对它进行赋值操作不会触发a属性的getset方法。所以,当执行set a方法时,只是简单地将新值乘以 2 后赋给_aValue,不会产生无限递归的情况。

存在性

let obj = {
  a:undefined
}

console.log(obj.a)// undefined

console.log(obj.b)// undefined

我们可以看到打印出的都是undefined,那么我们如何区分这种情况呢?

1.不访问属性值的情况在判断对象是否存在这个属性。

in操作符

let obj = {
  a:undefined
}
console.log(obj.a)// undefined
console.log(obj.b)// undefined

console.log("a" in obj) // true
console.log("b" in obj) // false

hasOwnProperty(...)

let obj = {
  a:undefined
}
console.log(obj.a)// undefined
console.log(obj.b)// undefined

console.log(hasOwnProperty("a")) // true
console.log(hasOwnProperty("b")) // false
(obj,"a") // true
Object.prototype.hasOwnProperty.call(obj,"b") // false

它们都可以判断对象是否有某个属性,但是他们也是有区别的。

  • in操作符不仅会检查属性是否存在对象中,还会检查是否在其[[Prototype]]的原型链中。
  • hasOwnPropertype(...)只会检查属性是否存在对象中,不会检查[[Prototype]]原型链

Object.prototype.hasOwnProperty.call(...)

所有普通对象都可以通过Object.propotype的委托访问hasOwnProperty(...)。但是像某些对象可能没有链接到Object.propotype(比如Object.create(null)创建的对象没有原型,所以它不会自动继承Object.prototype上的方法),那么使用hasOwnProperty(...)就会失败

let nullProtoObject = Object.create(null);
 console.log(nullProtoObject.toString()); //TypeError

所以我们要通过一种更为强硬的方法进行判断

let obj = Object.create(null);
obj.a = 2;
console.log(Object.prototype.hasOwnProperty.call(obj,"a")) // true; 

即使obj对象本身没有继承Object.prototype.hasOwnProperty方法(比如通过Object.create(null)创建的对象),也可以正确地调用这个检查属性的功能。

通过枚举

之前通过enumerable介绍了什么是枚举,这里我们就利用它。

let obj = {};

Object.defineProperty(obj, 'a', {
  value: 1,
  enumerable: true,
})
Object.defineProperty(obj, 'b', {
  value: 2,
  enumerable: false,
})

for(let key in obj){
  console.log(key) //a
}

for..in...只会枚举可枚举的属性。默认情况在,属性都是可枚举的,也就说在默认情况下,我们可以将“可枚举”当作“属性在对象中”。

let obj = {
    a:1,
    b:2
};

for(let key in obj){
  console.log(key === "c") //false
}

除了``for...in..`还有其他方法判断是否可枚举


let obj = {};

Object.defineProperty(obj, 'a', {
  value: 1,
  enumerable: true,
})
Object.defineProperty(obj, 'b', {
  value: 2,
  enumerable: false,
})

console.log(obj.propertyIsEnumerable('a') ) // true
console.log(obj.propertyIsEnumerable('b')) // false
console.log(Object.keys(obj)) // ["a"]

相同点:

  • propertyIsEnumerable(...)会检查属性名是否直接存在对象中,而不是在原型链上。
  • 方法返回一个由给定对象的所有可枚举自身属性的属性名组成的数组。它不会包含从原型链继承的属性

区别:

  • Object.keys 会检查对象所有的可枚举自身属性。它一次性返回所有符合条件的属性名称的集合,而不是针对单个属性进行检查,返回一个数组,里面包含所有可枚举属性。
  • propertyIsEnumerable(...)只检查单个元素,返回布尔值。

还有知识我认为不适合写在这,单独写更好,所以这篇文章到此为止了,感谢各位的观看🙏