讲清楚之 javascript 对象(一)

有了前面几节的知识,这一节我们理解起来就要轻松很多。在 javascript 里函数也是对象,浏览器的全局上下文也是对象, key - value 的身影在代码里比较常见,合理的使用对象多维度、可扩展的特性可以为开发中带来很多乐趣。

如果知识存在盲区,则实际开发中就会就会应为评估不足,模型设计不合理出现各种问题, 小则打打补丁、模块API重新设计,做兼容处理。 大则是关键数据维度无法满足应用场景, 就需要费事费力的进行架构调整或者重构了。

下面我们来梳理一下 javascript 对象的表现方式和特点,过于细节的知识就不梳理了。

JavaScript 的设计是一个简单的基于对象的范式。一个对象就是一系列属性的集合,一个属性包含一个属性名和一个属性值。一个属性的值可以是函数,这种情况下属性也被称为方法。除了浏览器里面预定义的那些对象之外,我们也可以定义自己的对象。熟悉 javascript 的语法特性,合理的设计数据模型,创建灵活、不含糊的自定义对象能够提高 javascript 的运行效率。

字面量对象

使用字面量方式创建对象占据了大多数开发场景,字面量对象示例:

let foo = {
    a: 1,
    b: '1234',
    c: function () {
        console.log(this.a + this.b)
    }
}
let foo1 = {
    a: 666,
    b: 'hi',
    c: function () {
        console.log(`${this.b}, ${this.a}`)
    }
}
foo.c() // '11234'
foo1.c() // 'hi, 666'

对象字面量的特点主要是直观、简单灵活,每一个key、value在编码阶段就是确定的。

使用对象字面量的方式来创建对象的缺点是,当我们需要创建多个相同对象时必须为每个对象在源代码中编写变量和方法。当这样的相同内容的对象很多时就是一场灾难。于是我们发明了很多其他创建对象的方式,下面进一步探讨。

工厂模式

工厂模式创建对象示例:

let createFoo = function (a, b, c) {
    let o = new Object()
    o.a = a
    o.b = b
    o.c = c
    return o
}
let foo = createFoo(1, '1234', function(){
    console.log(this.a + this.b)
})
let foo1 = createFoo(666, 'hi', function(){
    console.log(`${this.b}, ${this.a}`)
})

foo.c() // '11234'
foo1.c() // 'hi, 666'

所谓工厂模式就是对象的创建就像'商品'通过工厂按照标准化的流程被加工出来。

上面就是一个工厂函数的栗子,执行 createFoo 函数时先创建一个对象 o,然后把传递进来的实参添加到 o 上面,最后返回对象 o。这样每次执行 createFoo 函数都会返回一个新的对象,当我们需要1000个相似对象时 createFoo 就为我们在内部生成了1000个独立的对象 o。通过对这个栗子的分析会发现: 工厂函数在进行大批量对象创建时对资源的消耗比较大,同时由于每次都返回的是一个新对象,我们就没办法判断对象的类型。

工厂函数与字面量方式创建对象相比,优势就是不用在编码阶段创建大批量相似结构的对象,而这一系列的创建工作都是在运行阶段创建的。每次创建实例时都要创建实例对应的所有属性和方法,所以工厂函数同样存在创建N个实例需要创建N个属性、方法的问题。

工厂函数创建实例同时也面临实例类型的问题:

foo instanceof createFoo // false
foo1 instanceof createFoo // false

// 返回的对象是构造函数 Object 的实例
foo instanceof Object // true
foo1 instanceof Object // true
为什么实例函数不相等呢?
在 JavaScript 中 objects 是一种引用类型。两个独立声明的对象永远也不会相等(因为变量 foo 和 foo1 指向的堆地址不同),即使他们有相同的属性,只有在比较一个对象和这个对象的引用时,才会返回true.
let too = {
    a: 1
}
let too1 = {
    a: 1
}
let too2 = too1

too == too1 // false
too === too1 // false

too1 == too2 // true
too1 ===too2 // true

构造函数

构造函数方式创建自定义对象,就是利用函数中构造函数原形实例对象之间的关系来封装私有属性、公有属性:

function Foo (a, b, c) {
    this.a = a
    this.b = b
    this.c = c
}
let foo1 = new Foo(1, '1234', function(){
    console.log(this.a + this.b)
})
let foo2 = new Foo(666, 'hi', function(){
    console.log(`${this.b}, ${this.a}`)
})

// foo1、foo2 是 Foo 的实例
foo1 instanceof Foo // true
foo2 instanceof Foo // true

构造函数的实现看着要简单很多,也能通过实例判断出类型。

构造函数的执行逻辑:

构造函数初始化阶段首先会向上下文栈中压入一个上下文,接着在变量对象创建的时候会收集实参,初始化函数内部的变量申明、确定 this 的指向、确定作用链。将实参的值分别拷贝给变量a、b、c。然后像普通函数一样进入执行阶段,执行函数内部语句.

构造函数就是函数 既然构造函数就是普通函数, 那么为什在函数前面加一个 new 就能实例化并返回一个对象呢?

我们来创建一个模拟构造函数加深理解,没错是创建一个构造函数(思路来源于网络, 无耻的偷过来了ɖී؀ීϸ)。

// 假设我们创建一个汽车对象类型, car函数
function Car(make, model, year) {
    this.make = make
    this.model = model
    this.year = year
    this.drive = function (name) {
        console.log(`${name} drives the ${this.model} ${this.make}`)
    }  
}

// 将函数以参数形式传入
function New(func) {
    // 声明一个中间对象,该对象为最终返回的实例
    let res = {}
    if (func.prototype !== null) {
        // 将实例的原型指向构造函数的原型
        res.__proto__ = func.prototype
    }
    // ret为构造函数执行的结果,这里通过apply,将构造函数内部的this指向修改为指向res,即为实例对象
    var ret = func.apply(res, Array.prototype.slice.call(arguments, 1))
    // 当我们在构造函数中明确指定了返回对象时,那么new的执行结果就是该返回对象
    if ((typeof ret === "object" || typeof ret === "function") && ret !== null) {
        return ret
    }
    // 如果没有明确指定返回对象,则默认返回res,这个res就是实例对象
    return res
}
// 通过new声明创建实例,这里的p1,实际接收的正是new中返回的res
let mycar  = New(Car, "Tesla", "Model X", 2018)
mycar.drive('小丸子')
console.log(mycar.make);

// mycar 是 Car 的实例
mycar instanceof Car // true

let mycar = new Car(...) 实例化对象的方式看作是let mycar = New(Car, "Tesla", "Model X", 2018) 的一种简单的语法糖写法。

代码 new Car(...) 执行时,会发生以下事情:

  1. 一个继承自 Car.prototype 的新对象被创建。
  2. 使用指定的参数调用构造函数 Car ,并将 this 绑定到新创建的对象。new Car 等同于 new Car(),也就是没有指定参数列表,Car 不带任何参数调用的情况。
  3. 构造函数返回的对象就是 new 表达式的结果。如果构造函数没有显式返回一个对象,则使用步骤1创建的对象。(一般情况下,构造函数不返回值,但是用户可以选择主动返回对象,来覆盖正常的对象创建步骤)

实例类型无法判断的问题, 通过构造函数的方式来创建对象完美的解决了。但是构造器函数存在和工厂函数一样的问题:每次创建一个实例对象时都会在内部新建一个中间对象,实例方法也会创建N次,这样就存在不必要的内层消耗。

原型与构造函数组合

在上面Car构造函数的栗子中,当创建100个 Car 的实例时内部复制了100次 drive 函数。 虽然每个 drive 函数的功能一样,但是由于分别属于不同的实例就每次都分配独立的内存空间。

相同的功能函数怎么忍受得了重复创建。回忆之前我们在原型一节讲到的,每个函数存在prototype 属性,通过该属性指向自己的原型对象。那我们可以在函数的原型上做文章,将实例公共的属性和方法挂载在原型上。实例通过__ptoto__属性指向了构造函数的原型,从而让构造函数的原型对象在各个实例的原型链上,于是我们通过构造函数的原型来实现公有属性和方法的封装,且只会创建一次。

还是上面 Car的栗子:

function Car(make, model, year) {
    this.make = make
    this.model = model
    this.year = year
}
Car.prototype.drive = function (name) {
    console.log(`${name} drives the ${this.model} ${this.make}`)
}

let mycar  = new Car( "Tesla", "Model X", 2018)
mycar.drive('小丸子')

上面的栗子也还可以写成这样子:

function Car(make, model, year) {
    this.make = make
    this.model = model
    this.year = year
}

Car.prototype = {
    constructor: Car,
    drive: function () {
        console.log(`${name} drives the ${this.model} ${this.make}`)
    }
}

let mycar  = new Car( "Tesla", "Model X", 2018)
mycar.drive('小丸子')

两种写法是等价的,需要注意的是后一种相当于创建一个新对象并赋值给了构造函数Car的原型,如果不将新原型的constructor重现指向构造函数,则会导致构造函数Car的实例类型判断出错(instanceof Car 为 false).

不同的实现方法都有各自的使用场景。同时对象的实现方式又与数据维度以及另外一个话题 设计模式有关。我们使用原型与构造函数组合模式就能够解决很多问题。

关于 javascript 的各种模式可以参考:

Javascript设计模式

相关推荐