14.JS高级-对象方法的补充以及创建对象方案

506 阅读23分钟

该系列文章连载于公众号coderwhy和掘金XiaoYu2002中

  • 对该系列知识感兴趣和想要一起交流的可以添加wx:XiaoYu2002-AI,拉你进群参与共学计划,一起成长进步
  • 课程对照进度:JavaScript高级系列60-64集(coderwhy)
  • 后续JavaScript高级知识技术会持续更新,如果喜欢我们的文章,欢迎关注、点赞、转发、评论,大家的支持是我们最大的动力

脉络探索

  • 在本章节中,我们会进一步的使用属性描述符
    • 属性描述符是定义在属性身上的,进一步使用则就需要同时操作同对象的多个属性
  • 除了属性描述符,我们就会在操作一个对象多个属性的基础上,去同时操作多个对象
    • 可能会疑惑,我操作多个对象做啥?
    • 比如多个对象,都是一样的属性,如何避免每次都需要写同样的属性导致代码重复率太高
    • 我们会主要学习两种方式:工厂模式与构造函数
  • 还有两种方式是在我们主要学习的两种方式的进阶之上:ES6中的Class类构造函数结合原型。但本质上的原理是一样,在后续我们学到对应的知识点后,会再来进行学习

一、对象上同时定义多个属性

  • 我们上一章节中,说明了如何在对象上定义属性描述符
    • 但此时的定义,只能修改定义对象上其中一个属性是什么,但我们知道,一个对象上,往往是不止一个属性的,如果就一个属性,也没必要去用对象了。这同时意味着,我们要修改的属性描述符不止一个属性
    • 如果我重新调用一个defineProperty方法,其实也不是不行,但代码量就会重复不少。代码量重复的话,我可以通过遍历来规避,这是一个很好的主意,我们能不能实现一下呢?
  • 那首先我们肯定还是需要有点基础的数据的
    • defineProperty原本的第二参数prop原本只能一个属性,现在我们使用对象来进行包裹多个属性,命名为props
let obj = {
    JS: 1
};

let props = {
    name: {
        value: 'XiaoYu',
        writable: true,
        enumerable: true,//记得设置为true,b
        configurable: true
    },
    age: {
        value: 18,
        writable: false,
        enumerable: true,
        configurable: true
    }
};
  • 通过for-in遍历的形式来实现我们的想法
    • hasOwnProperty后续我们会讲,这是一个用来判断内容是否为自有属性
    • 一个对象的自有属性(也称为自身属性)是直接在该对象上定义的属性,而不是从其原型链上继承的属性。自有属性定义了对象特有的特征,区别于那些通过对象的原型继承来的属性
    • 而之所以说后面再讲解,原因就是:原型链我们还没开始讲
function defineProperties(obj, properties) {
  for (let prop in properties) {
      if (properties.hasOwnProperty(prop)) {
          Object.defineProperty(obj, prop, properties[prop]);
      }
  }
  return obj;
}

// 使用定义好的函数
defineProperties(obj, props);

console.log(obj.name);  // 输出: XiaoYu
console.log(obj.age);  // 输出: 18
  • 通过这种方式,我们就能够实现对象上同时定义多个属性的操作了
    • 不过JS的原生也是有实现这种方式的,让我们来看下吧!

1.1. Object.defineProperties

  • defineProperties就是专门定义同一对象中 多个 新的属性或修改现有属性,并且返回该对象
    • Properties翻译下来就是属性的意思,还是很好记忆的
  • 那如何使用呢?
    • 还记得我们前面利用defineProperty刚实现的效果吗?使用方式是一样的,这里的props需要传入什么内容,参考我们前面基础数据中的props
Object.defineProperties(obj, props)
  • 不过JS官方实现的效果肯定是比我们自己实现更好的,因为处理了更多的边界情况。我们要是设置为不可枚举,for-in就歇菜的
    • 在我们下面实现的这个案例中,是很有意思的
    • 我们之前有说过_age中的_表示私有属性,那既然都不让访问,为什么要有私有属性?
    • 主要就在于不希望外界能够直接把我的内容修改了,我们同时有_ageage两个属性,其中age是暴露在外的,内容都是一样的,但重点在于age指向于_age
    • 这么做的目的在于,外界要是修改age不会真的影响到我们,age相当于一个马甲,_age才是本体。_age承载数据,而age作为一个控制层或“马甲”,提供对数据的安全访问和操作,确保了对象状态的正确性和方法的可用性
  • 这种思想方式直到现在依然不会过时,哪怕是ES6语法之后,#写法也只是进一步强化私有属性,而该写法的作用依旧是一样的
var obj = {
    //私有属性(js中没有严格意义上的私有属性,依旧可以通过obj._age访问到,但是外人是不知道这个隐藏起来的属性,只知道他的替代obj.age)
    _age:20
}
Object.defineProperties(obj,{
    name:{
        //是否可配置
        configurable:true,
        //是否枚举
        enumerable:true,
        //添加新值
        value:"小余",
        //是否可写入
        writable:true
    },
    age:{
        configurable:false,
        enumerable:false,
        get:function(){
            return this._age
        },
        set:function(value){
            this._age = value
        }
    }
})

console.log(obj.age)//20
console.log(obj,"这是为了看age的不可枚举是否生效");//{ _age: 20, name: '小余' } 这是为了看age的不可枚举是否生效
obj.age = 18
console.log(obj.age);//18
  • 而在开发中,如果我们想对某一个属性定义对应的get和set,我们也可以这么做:
    • 直接在对象中加上set get
    • get 语法将对象属性绑定到查询该属性时将被调用的函数
var obj = {
    _age:20,
    set age(value){
        this._age = value
    },
    get age(){
        return this._age
    }
}
//以上的age写法就替代了我们在Object.defineProperties的写法
// age:{
//     configurable:false,
//     enumerable:false,
//     get:function(){
//         return this._age
//     },
//     set:function(value){
//         this._age = value
//     }
// }

//差异对比:控制台打印obj
//直接在对象中写法打印效果:
{ _age: 20, age: [Getter/Setter], name: '小余' }//表示的age属性有get和set
//在Object.defineProperties中的get、set打印效果:
{ _age: 20, name: '小余' }
  • 这种写法有一点差异,但是性能是差不多的

    • 但如果我们想要更加精准的控制的话,还是采用在defineProperties写的方式,而不是直接在对象中自己设置get与set,因为这样子才能配置configurable这类的东西
  • 差异在于我们控制台打印出来的是能够看到age的,而在Object.defineProperties中是看不到的,如图14-1

stter与getter在终端的表达形式

图14-1 stter与getter在终端的表达形式

二、对象方法补充

  • 获取对象的属性描述符:
    • getOwnPropertyDescriptor
    • getOwnPropertyDescriptors
    • 两种的区别就在于最后一个s,意味着一个是获取单个,一个是获取所有,如图14-2中获取obj的多个属性
    • 在之前我们说过,[[]]所标记的属性,是没办法正常获取到的,需要通过特定的API进行获取,在这里进行一个补充
//返回指定对象上一个自有属性对应的属性描述符
Object.getOwnPropertyDescriptor(obj, prop)
//返回一个对象,该对象包含了目标对象所有自有属性的属性描述符
Object.getOwnPropertyDescriptors(obj)
var obj = {
  names: "小余",
  age: 18
}
console.log(Object.getOwnPropertyDescriptor(obj, 'names'));
console.log(Object.getOwnPropertyDescriptors(obj));

obj对象的属性描述符详情

图14-2 obj对象的属性描述符详情

2.1. Object的方法对 对象的限制

  • 禁止对象扩展新属性:preventExtensions
    • 给一个对象添加新的属性会失败(在严格模式下会报错)
  • 密封对象,不允许配置和删除属性:seal
    • 实际是调用preventExtensions
    • 并且将现有属性的configurable:false
  • 冻结对象,不允许修改现有属性:freeze
    • 实际上是调用seal
    • 并且将现有属性的writable:false
    • 在以后使用Vue框架的时候,可以进行一个性能优化的操作。例如我有几十万条数据进行输出,正常响应式展示就会很卡,因为Vue中响应式就是通过操控get与set来进行的。如果我们确定这些数据不需要动态的去变化,就可以使用freeze来进行冻结响应式,就可以极大提升性能(数据量越大,提升幅度越明显)
var obj = {
  names: "小余",
  age: 18
};

// 禁止对象扩展新属性
Object.preventExtensions(obj);
obj.newProperty = 'new'; // 尝试添加新属性,这将失败
console.log(obj.newProperty); // 输出:undefined,在严格模式下这会抛出错误

// 封闭对象,不允许配置和删除属性
Object.seal(obj);
delete obj.age; // 尝试删除属性,这将失败
console.log(obj.age); // 输出:18
obj.names = "JS高级"; // 可以修改已有属性的值
console.log(obj.names); // 输出:JS高级

// 冻结对象,不允许修改现有属性
Object.freeze(obj);
obj.names = "coderwhy"; // 尝试修改属性值,这将失败
console.log(obj.names); // 输出:JS高级,因为freeze后属性值不可更改

// 输出对象的属性以验证它们是否还存在
console.log(obj); // 输出:{ names: "JS高级", age: 18 }

三、创建多个对象方案

  • 如果我们现在希望创建一系列的对象:比如Person对象

    • 包括张三、李四、王五、李雷等等,他们的信息各不相同
    • 那么采用什么方式来创建比较好呢?
  • 目前我们已经学习了两种方式:

    • new Object方式
    • 字面量创建的方式
  • 如果说,我们想要创建两个对象或者更多,这些对象里面的属性是一样的,只有value不同,我们难道需要像这样重复创建吗?感觉重复代码不少呢

    • 在我们前面讲解Object.defineProperties()方法的时候,就是为了解决defineProperty只能定义单个属性的问题。而现在,我们遇到的这个问题跟之前是一个性质的
    • 通过以前的使用经验,遍历也许可以解决的。当然,这种情况,大概率也有对应的原生API可以解决
var p1 = {
    name:"小余",
    age:20,
    sex:"男",
    address:"福建",
    eating:function(){
        console.log(this.name+"在吃烧烤");
    },
    running:function(){
        console.log(this.name+"在跑步做运动");
    }
}

var p2 = {
    name:"coderwhy",
    age:35,
    sex:"男",
    address:"广州",
    learn:function(){
        console.log(this.name+"在学编程");
    },
    running:function(){
        console.log(this.name+"在跑步做运动");
    }
}
//以上的方式过于相似,存在过于重复的代码,我们能不能进行优化呢,用另一种方式创建?
  • 上面这种方式有一个很大的弊端:创建同样的对象时,需要编写重复的代码

  • 那这次,JS官方有没有推出API来解决这个问题呢?

  • 很可惜,这次并没有直接的API帮我们解决这个问题。那我们就直接继续采用遍历数据的方式吗?

    • 可以是可以,但我们需要注意需求。在使用defineProperty之所以可以使用遍历,是因为一个对象内的属性本身较为紧密,都属于同一个对象。
    • 但对象本身是一个独立的个体,我们如果遍历的话,是比较难以满足我们单独使用一个对象的需求的
  • 创建多个对象的方案其实一共有四种:

    1. 工厂模式

    2. 构造函数

    3. 类class

    4. 对象原型和Object.create()

    • 目前来说,我们只讲解前两种方式
    • 因为类是ES6之后的语法,我们会放在ES6-ES14系列中来进行学习。而原型链,我们也还没进行学习,此时对我们来说会有点难度,可以等后续文章学习到了原型以及原型链之后再回头来进行思考

3.1. 创建对象方案-工厂模式

  • 工厂函数在JS中是一种常用的设计模式,主要用于创建和返回复杂对象的实例。它们不像构造函数那样依赖于new关键字,而是普通函数,但被设计来构造并返回新的对象实例
  • 可以接受参数,根据这些参数定制创建的对象,每个对象实例可以根据不同的需求进行个性化配置。而这也是我们所需要的部分
function createPerson(){

}
//我们虽然里面属性大多数都是相同的,但是数据是不一样的,比如p1的name是小余,p2的是coderwhy,这个时候我们就可以向调用的里面传入参数来实现我们不同数据的传输
var p1 = createPerson()
var p2 = createPerson()
  • 我们抽离出共性的属性,然后进行传递参数,最后返回一个对象进行使用
    • 从使用角度来说是较为方便的,也是一种抽取方式
    • 而这个过程中,没有使用到陌生的API。这种运用方式之所以被称为工厂模式,则是因为该流程是固定的,就像是工厂中的流水线一样
    • 其结构相同,只有数据不同,如图14-3
//上面那个空模板的改善方式,传入参数
function createPerson(name,age,sex,occupation,address){
	var p = new Object()
    p.name = name
    p.age = age
    p.sex = sex
    p.occupation = occupation
    p.address = address
    p.eating = function(){
        console.log(this.name + "在吃满汉全席")
    }
    return p
}

var p1 = createPerson("小余",20,"男","大三学生","福建",)
var p2 = createPerson("coderwhy",35,"男","全栈工程师兼教师","广州")

console.log(p1,p2);
//打印效果如下:
// {
//   name: '小余',
//   age: 20,
//   sex: '男',
//   occupation: '大三学生',
//   address: '福建',
//   eating: [Function (anonymous)]
// } {
//   name: 'coderwhy',
//   age: 35,
//   sex: '男',
//   occupation: '全栈工程师兼教师',
//   address: '广州',
//   eating: [Function (anonymous)]
// }

new调用所产生的结构共性

图14-3 new调用所产生的结构共性

3.1.1. 工厂函数的缺点

  1. 通过上述console.log(p1,p2)打印出来的是我们的字面量。也说明了通过工厂函数创建的对象默认都是普通的Object类型。这在需要实现基于类型的继承或者当我们需要在代码中检查一个对象是否为某个类的实例时,会是一个限制
  2. 无法使用原型优势:工厂函数返回的每个对象都会有其自己的一份实例方法,而不是共享原型链上的方法。这意味着每创建一个新对象,就会在内存中创建一份方法的拷贝。相比之下,使用构造函数或类创建的对象可以共享其原型链上的方法,这有助于减少内存的使用
  3. 调试难度:由于工厂函数返回的对象通常都是匿名的,这可能会在调试过程中带来一定的困难。特别是在堆栈跟踪中,可能很难确定对象的具体创建源
  • 我认为这是分类过于模糊,我们通过工厂函数创建出来了一个"人",他身上的属性有姓名,身高,职业,性别等等,我们通过工厂函数传入参数也只是对数据进行改变,他本质上的属性是一样的,脱离不了人本身,最多一个叫小余,另一个叫coderwhy,两个不一样的人,但都是人。这个时候我们希望调用工厂函数这个函数的时候,告诉我们类型是人,具体一些,而不是"宇宙中存在的东西",那太过于宽广,如同搂不住的烟雾,难以定位
  • 所以会给我们调试的时候带来一定的难度,但使用会简单,只需要最普通的对象就能够实现

3.2. 认识构造函数

  • 工厂方法创建对象有一个比较大的问题:我们在打印对象时,对象的类型都是Object类型
    • 但是从某些角度来说,这些对象应该有一个他们共同的类型;
    • 下面我们来看一下另外一种模式:构造函数的方式;
  • 我们先理解什么是构造函数?
    • 构造函数也称之为构造器(constructor)(很重要的词,提前记一下),通常是我们在创建对象时会调用的函数
    • 在其他面向的编程语言里面,构造函数是存在于类中的一个方法,称之为构造方法
    • 但是JavaScript中的构造函数有点不太一样
  • JavaScript中的构造函数是怎么样的?
    • 构造函数也是一个普通的函数,从表现形式来说,和千千万万个普通的函数没有任何区别
    • 那么如果这么一个普通的函数被使用new操作符来调用了,那么这个函数就称之为是一个构造函数
    • 那么被new调用有什么特殊的呢?
function foo(){

}

//foo就是普通函数调用
foo()

//通过new关键字去调用一个函数,那这个函数就算一个构造函数
new foo()
//甚至我们不写小括号也是可以调用的,但从规范清晰的角度来说,最好还是加上
new foo

3.2.1. new操作符调用作用

  • 如果一个函数被使用new操作符调用了,那么它会执行如下操作:
    1. 在内存中创建一个新的对象(空对象)

    2. 这个对象内部的[[prototype]]属性会被赋值为该构造函数的prototype属性;(后面详细讲)

    3. 构造函数内部的this,会指向创建出来的新对象

    4. 执行函数的内部代码,即函数体代码

function foo(){
    var moni = {}
    //this = moni 也就是第一步骤的空对象,在第三步骤中被this所指向
} 
//new foo相当于会默认执行上面这两个步骤(平时是不显示的,内部自己执行的),然后进行我们的第五步返回创建出来的新对象,不需要我们手写return返回值
  1. 如果构造函数返回一个对象,则返回该对象,否则返回步骤1创建的对象
    • 而我们知道,步骤1创建的对象是一个空对象
function foo(){
    console.log("foo~");
}

var f1 = new foo//foo~
console.log(f1);//foo {} 确实创建出来的一个空对象,且类型就是foo

//很明显类型精准了很多,如果上面不够明显,我们可以再来一个案例(输出xiaoyu {},已经很能证明了吧)
function xiaoyu(){
    console.log("我是小余");
}

var f1 = new xiaoyu//我是小余
console.log(f1);//xiaoyu {},node打印结果
  • 而f1的类型确实是来自xiaoyu函数的,我们可以进行试验,这是工厂模式所做不到的事情
    • 通过从f1的原型身上可以拿到对应构造函数的名称,就是xiaoyu函数
    • 这个类型有什么作用,我们需要放到后面结合原型链再来进行专门的学习
function xiaoyu(name){
  this.name = name
  console.log("我是小余");
}

var f1 = new xiaoyu("小余")//我是小余
console.log(f1.__proto__.constructor.name);//xiaoyu

3.3. 创建对象方案-构造函数

function Person(name,age,sex,address){
    this.name = name
    this.age = age
    this.sex = sex
    this.address = address

    this.eating = function(){
        console.log(this.name + "在吃鱿鱼须");
    }
    this.runding = function(){
        console.log(this.name + "在跟坤坤打篮球");
    }
}

var f1 = new Person("小余同学",20,"男","福建")
console.log(f1);

// Person {
//     name: '小余同学',
//     age: 20,
//     sex: '男',
//     address: '福建',
//     eating: [Function (anonymous)],
//     runding: [Function (anonymous)]
//   }

//然后跟工厂函数一样的,我们可以进行重复描写
var f2 = new Person("小满zs",23,"男","北京")
var f3 = new Person("洛洛",20,"萌妹子","福建")
//很明显,在开头多了一个类型Person,是不是更加明确清晰了。想要的任意类型都能够自己更加精准的定位,写ikun都行

构造函数Person调用结果

图14-4 构造函数Person调用结果

  • 这个构造函数可以确保我们的对象是有Person的类型的,如图14-4(实际是constructor的属性,这个我们后续再探讨)

  • 但是构造函数就没有缺点了吗?

    • 构造函数也是有缺点的,它在于我们需要为每个对象的函数去创建一个函数对象实例
    • 当使用类或构造函数创建一个对象时,这个对象就被称为那个类或构造函数的一个“实例”。实例继承了类或构造函数的所有方法和属性,但每个实例都持有自己的属性值,这些值是独立于其他实例

3.3.1. 如何区分是否是构造函数

一般来说,构造函数实在跟普通函数没有区别,单纯看一个函数是看不出来的,于是社区对此有了约定俗成的规范,不是必须要遵守,但拥抱规范能够让我们平时更加方便,减少大家的理解跟沟通成本

  • 在写的时候,我们都以首字母大写开头,使用大驼峰命名,这是一个社区规范
  • 而编辑器会在前置条件满足后,给于我们提示:'此构造函数可能会转换为类声明'
  • 前置条件只有一点:在函数内使用了this完成赋值操作,这意味着该函数的内容并不固定,随this变化而动态调整内容,但结构不变
  • 在this指向中,我们有说明new调用是改变this指向最优先的事项,因此在编辑器的眼中使用了this,是一个预备动作,但需要注意,只有new调用后,才真正属于构造函数,如图14-5
function XiaoYu(name){
  //对于构造函数,我们函数名首字母会是大写。如果由多个单词组成的话,则是采用大驼峰标识
  this.name = 'coderwhy'//不需要动态的形参传入,也会被编辑器默认为构造函数类型
  }

如何区分是否为构造函数(编辑器中的构造函数)

图14-5 如何区分是否为构造函数(编辑器中的构造函数)

3.3.2. 构造函数缺点

function foo(){
    function bar(){
        console.log("你猜一不一样");
    }
    return bar
}

var f1 = foo()
var f2 = foo()
console.log(f1 === f2);//false
  • 为什么是f1 === f2是false,那是因为他们创建出来的对象其实不是同一个对象,虽然打印出来都是[Function: bar],但是确实是不一样的两个对象

  • 那为什么不是同一个对象?

    • 第一次执行的时候,我们在foo函数里面定义了bar函数,执行foo函数的时候(函数调用),会在内存中创建一个bar的函数对象。然后我们用f1接收了这个函数对象之后,第二次执行会重新创建一个bar函数对象,然后放入f2中。此时是同时存在两个函数对象的
    • 我们在之前学习的时候,就有说明过,每一次的函数调用,都是一个新的函数,一次调用结束就会销毁调用的那个函数
  • 它在于我们需要为每个对象的函数去创建一个函数对象实例(也就是写在开头的那句话)

    • 这里创建的函数对象实例为什么会是缺点?
    • 因为每个对象创建一个函数实例会增加内存的开销,这可能会导致性能问题,有点浪费我们的空间,特别是当我们有大量的对象的时候。此外,如果在构造函数中改变了对象的原型,这也会导致每个对象都有不同的原型,这可能会增加代码的复杂性
//我们还可以拿刚刚的案例来试一下
function Xiaoyu(name,age,sex,address){
    this.name = name
    this.age = age
    this.sex = sex
    this.address = address

    //我们这里的创建对象,相当于在对象里再重复的创建对象其实是没有必要的,就是每个对象的函数去创建一个函数对象实例;为什么这个会是缺点看上面第三点
    this.eating = function(){
        console.log(this.name + "在吃鱿鱼须");
    }
    this.runding = function(){
        console.log(this.name + "在跟坤坤打篮球");
    }
}

var f1 = new Xiaoyu("小余同学",20,"男","福建")
var f2 = new Xiaoyu("小余同学",20,"男","福建")
//name其实都是"小余同学",他们的值是没有区别的,造成他们不相等的原因是:每个对象都有不同的原型,所以不相等
console.log(f1.eating === f2.eating);//false
console.log(f1.runding === f2.runding);//false

后续预告

  • 在下一章节中,我们就要开始认识原型,也就是Prototype了
    • 我们虽然还没学到什么是原型,但大家见到的次数是绝对不会陌生的
    • 在我们每次在控制台查看对象的时候,都会看到[[prototype]],一点开,里面密密麻麻的属性,而且好多都没有见过,这也许会让我们有一点的害怕,不想去看这些内容,如图14-6
    • 并且在很多的视频课程中,不是直接避开原型以及原型链的内容就是只有简要的讲解,那我们当然不会这样做
  • 我们在下一章节中,会认识原型,并且了解它有什么用,再对原型进行一个操作(我们之前写显式调用的三个函数方法就是放在原型身上),并且去看Person构造函数原型的一个内存图,在下一章节中,会有很多的内存指向图辅助大家理解这一知识点

对象中的原型世界

图14-6 对象中的原型世界