Better

Ethan的博客,欢迎访问交流

你不知道的 JS 上卷读书笔记2/2

你不知道的 JS 上卷对 JS 中几个特别基础且重要的点进行了讲解,这是一篇读书笔记。

关于 this

this 是一个很特别的关键字,被自动定义在所有函数的作用域中。

为什么要用 this 呢?

this 提供了一种更优雅的方式来隐式传递一个对象引用,因此可以将 API 设计的更加简洁并且易于复用。

随着你的使用模式越来越复杂,显示传递上下文对象会让代码变得越来越混乱,使用 this 则不会这样。尤其是面对对象和原型时,你就会明白函数可以自动引用合适的上下文有多重要。

首先需要明确的是,this 既不指向函数自身也不指向函数的词法作用域。this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时用的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

当一个函数被调用时,会创建一个活动记录(也被称作上下文),这个记录会包含函数在哪里被调用(调用栈),函数的调用方式,传入的参数等信息。this 就是这个记录的一个属性,会在函数执行过程中用到。

this 全面解析

通过上一节我们知道,要想知道 this 指向哪里,首先需要寻找函数的调用位置。但是做起来并不简单,因为某些编程模式可能会隐藏真正的调用位置。

最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数),我们关心的调用位置就在当前正在执行的函数前一个调用中。也就是调用栈的第二个元素就是真正的调用位置。

接下来我们看看调用规则,也就是调用位置如何决定 this 的绑定对象。

  • 默认绑定,独立函数调用。可以这条规则看做是无法应用其他规则时的默认规则。this 在非严格模式下指向全局对象,严格模式为 undefined
  • 隐式绑定:调用位置是否有上下文对象,或者说是否被某对象拥有或包含。当函数拥有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。
    • 这种模式需要注意一个隐式丢失问题,也就是说隐式绑定的函数会丢失绑定对象,此时会应用默认绑定。容易出现的用法有:函数别名、回调函数(参数传递其实就是一种隐式赋值,函数也会被隐式赋值)
  • 显式绑定:call & apply
    • 如果你传入了一个原始值(字符串、布尔、数字),则这个原始值会被转换成它的对象形式。这通常被称为装箱
    • 然后显示绑定无法解决绑定丢失问题,此时我们需要硬绑定
  • new 绑定
    • JS 中构造函数只是一些使用 new 操作符时被调用的函数,因此实际上并不存在所谓的构造函数,只有对于函数的构造调用

硬绑定其实就是在 call 或 apply 外部在包裹一个函数,这样一来函数被调用时,this 总是会被绑定到指定的上下文。典型的应用场景就是创建一个包裹函数,负责接受参数并返回值。我们可以创建一个可以重复使用的赋值函数,代码如下:

function bind(fn, obj) {
    return function() {
        return fn.apply(obj, arguments)
    }
}

由于硬绑定是一种非常常见的模式,所以 ES5 提供了内置的方法 Function.prototype.bind

使用 new 调用函数,会自动执行下面的操作

  1. 创建一个全新的对象
  2. 这个对象会被执行[[Prototype]]连接
  3. 这个新对象会被绑定到函数调用的 this
  4. 如果函数没有返回其他对象,那么 new 表达式中的函数会自动返回这个新对象。

优先级:默认绑定 < 隐式绑定 < 显式绑定 < new 绑定

这里主要是显示绑定和 new 绑定谁优先级高需要探讨一下。bind 硬绑定处理后的函数,对于 new 调用和普通调用,返回结果是不一样的。下面看 polyfill 代码

if(!Function.prototype.bind) {
    Function.prototype.bind = function(oThis) {
        if(typeof this !== 'function') {
            throw new Error('……to be bound is not callable')
        }
        var aArgs = Array.prototype.slice.call(arguments, 1)
            fToBind = this,
            fNOP = function() {},
            fBound = function() {
                // 判断硬绑定函数是都是被 new 调用,如果是的话就会使用新创建的this来替换硬绑定的this
                return fToBind.apply( this instanceOf fNOP && oThis ? this : oThis, aArgs.concat(Array.prototype.slice.call(arguments)))
            }
            fNOP.prototype = this.prototype
            fBound.prototype = new fNOP()
            return fBound
    }
}

你可能会思考?什么情况下需要在 new 中使用硬绑定函数呢?为啥不直接使用普通函数呢?这就设计到 bind 的厉害之处了,主要目的是可以预先设置函数的一些参数,这样在使用 new 进行初始化时就可以只传入其余的参数、

bind 的功能之一就是可以把除了第一个参数之外的其他参数都传给下层的函数,这种技术叫做部分应用,是柯里化的一种。

现在我们可以总结写判断 this 的规则了

  1. 函数是否在 new 中调用。如果是的话 this 绑定到新创建的对象
  2. 判断函数是否通过 call 或 apply 调用或者硬绑定,如果是的话 this 绑定的是指定的对象
  3. 函数是否在某个上下文对象中调用(隐式绑定),如果是的话,this 绑定的是那个上下文对象
  4. 如果都不是的话,使用默认绑定,严格模式绑定到 undefined,否则绑定到全局对象

然而规则总有意外,在某些场景下 this 的绑定行为会出乎意料,你认为应该应用其他绑定规则时,实际上应用的是尽可能是默认绑定规则。

如果你把 null 或 undefined 作为 this 的绑定对象传入 call、apply 或 bind,这些值在调用时会被忽略,实际应用的是默认绑定规则。

如果函数并不关心 this 的话,你仍然需要传入一个占位值,这时 null 可能是一个不错的选择。

在 ES6 中,可以用 ... 操作符代替 apply 来展开数组,这样可以避免不必要的 this 绑定,可惜,在 ES6 中没有柯里化相关语法,因此还是需要使用 bind。

然后总是使用 null 来忽略 this 绑定可能产生一些副作用,如果某个函数确实使用了 this,那默认绑定规则就会把 this 绑定到全局对象,这可能导致不可预计的后果。如何更安全的忽略 this 呢?

我们可以传入一个特殊的对象,把 this 绑定到这个对象不会对你的程序产生副作用。我们可以创建一个 DMZ (非军事区)对象--他就是一个空的非委托的对象。在 JS 中创建一个空对象最简单的方法都是 Object.create(null), Object.create(null) 和 {} 很像,最关键的区别在于不会创建 Object.prototype 这个委托,所以它比 {} 更空。

另一个需要注意的是,你可能(有意或者无意的)创建一个函数的间接引用,在这种情况下,调用这个函数会应用默认绑定规则,间接引用最容易在赋值时发生。比如

(p.foo = o.foo)() // 返回值是目标函数的引用,因此调用位置是 foo,而不是 p.foo 或 o.foo

注意:对于默认绑定而言,决定 this 绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式。如果函数体处于严格模式,this 会被绑定到 undefined,否则 this 会被绑定到全局对象

软绑定

硬绑定这种方式可以 this 强制绑定到指定的对象,防止函数调用应用默认绑定规则,问题在于,硬绑定会大大降低函数的灵活性,使用硬绑定后就无法使用隐式绑定或者显式绑定来修改 this。我们可以给默认绑定指定一个全局对象和 undefined 以外的值,来实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定来修改 this 的能力

if(!Function.prototype.softBind) {
    Function.prototype.softBind = function(obj) {
        var fn = this
        var curried = [].slice.call(arguments, 1)
        var bound = function() {
            // 如果 this 绑定到全局对象或者 undefined,那就把指定的默认对象绑定到 this,否则不会修改 this
            return fn.apply((!this || this === (window || global)) ? obj : this, curried.concat.apply(curried, arguments))
        }
        bound.prototype = Object.create(fn.prototype)
        return bound
    }
}

this 词法

上面介绍的四条规则已经可以包含所有正常的函数,但 ES6 提供了一种无法使用这些规则的特殊函数类型:箭头函数

简单函数并不是使用 function 关键字定义的,而是使用被称为胖箭头的操作符定义的,箭头函数不使用 this 的四种标准准则,而是根据外层作用域来决定 this。

箭头函数最常用于回调函数中,例如事件处理器或者定时器,箭头函数可以像 bind 一样确保函数的 this 被绑定到指定对象,此外,其重要性还体现在它用更常见的词法作用域取代了 this 机制。

对象

对象可以通过两种形式定义:声明(文字)形式和构造形式

用构造形式来创建对象是非常少见的,一般来说你会使用文字语法,绝大多数内置对象也是这么做的。

对象是 JS 的基础,在 JS 中一共有六种主要类型:string、number、boolean、null、undefined、object。简单基本类型本身并不是对象,null 有时会被当做一种对象类型,但这其实只是语言本身的一个bug,null 本身是基本类型。

原理是这样的,不同的对象在底层都表示为二进制,在 JS 中二进制的前三位都为 0 的话会被判断为 object 类型,null 的二进制表示全为 0,自然前三位也是 0,所以执行 typeof 时会返回 object。

JS 内置对象:String、Number、Boolean、Object、Function、Array、Date、RegExp、Error。其中 null 和 undefined 没有对应的构造形式,只有问题形式,相反 Date 只有构造,没有文字形式。对于 object、Array、Function 和 RegExp 而言,无论使用文字形式还是构造形式,他们都是对象,不是字面量。在某些情况下,相比文字形式创建对象,构造形式可以提供一些额外选项,由于这两种形式都可以创建对象,所以我们首选更简单的文字形式。建议只在需要哪些额外选项时使用构造形式。Error 对象很少在代码中显式创建,一般在抛出异常时自动被创建。

内容

当我们说内容时,似乎在暗示这些值实际上被存储在对象内部,但这只是它的表现形式。再引擎内部, 这些值存储方式是多种多样的,一般并不会存在对象容器内部,存储在对象容器内部的是这些属性的名称,它们就像指针(从技术角度来说就是引用)一样,指向这些值真正的存储位置,

访问对象属性值,我们一般通过.操作符或者[]操作符。前者被称为属性访问,后者被称为键访问,主要区别在于属性访问要求属性名必须满足标识符命名规范,而键访问可以接受任意UTF-8/Unicode字符串作为属性名。此外由于键访问使用字符串来访问属性,所以可以在程序中构造这个字符串。

在对象中,属性名永远是字符串,如果你使用 string 意外的其他值作为属性名,那么他首先会被转换为一个字符串。

ES6 中新增了可计算属性名,可以在文字形式中使用[]包裹一个表达式来当做属性名。可计算属性名最常用的场景可能是 ES6 中的符号(Symbol)。Symbol 是一种新的基本数据类型,包含一个不透明且无法预测的值。一般而言你不会用到符号的实际值,你通常接触到的是符号的名称。

数组

数组有一套更加结构化的值存储机制,数组期望的是数值下标,也就是说值存储的位置(通常被称为索引)是非负整数。数组也是对象,所以虽然每个下标都是整数,你仍然可以给数组添加属性。

你完全可以把数组当做一个普通的键/值对象来使用,并且不添加任何数值索引,但这并不是一个好主意。数组和普通的对象都根据其对应的行为和用途进行了优化,所以最好只用对象存储键/值对,只用数组来存储数据下标/值对

如果你试图想数组添加一个属性,但是属性名看起来像一个数字,那它会变成一个数值下标(因此会修改数组的内容而不是添加一个属性)

例子如下

var myArray = ['foo', 42, 'bar']
myArray.baz = 'baz'
myArray.length // 3
myArray.baz // baz

var myArray = ['foo', 42, 'bar']
myArray['3'] = 'baz'
myArray.length // 4
myArray[3] // baz

复制对象

如何复制一个对象,看起来应该有一个内置的 copy 方法。但实际上事情比你想象的更复杂,因为我们无法选择一个默认的复制算法。

首先我们需要判断是浅复制还是深复制,浅复制对于对象中的引用,新旧对象中引入的对象是一样的,对于深复制来说,对深复制而言,除了复制对象本身外,还需要赋值引用的对象,如果存在互相引用,就会存在死循环了。

我们是否应该检测循环引用并终止循环(不复制深层元素)?还是应当直接报错或者是选择其他方法呢?

除此之外,我们还不确定复制一个函数意味着什么?有人会通过 toString 来序列化一个函数的源代码(但结果取决于 JS 的具体实现,不同的引擎对于不同类型的函数处理方式并不完全相同)

对于 JSON 安全(也就是说可以被序列化为一个 JSON 字符串并且可以根据这个字符串解析出一个结构和值完全一样的对象)的对象来说,有一种巧妙的复制方法

var newObj = JSON.parse(JSON.stringify(obj))

相比深复制,浅赋值要易懂且问题要少得多,所以 ES6 定义了 Object.assign 方法来实现浅复制,Object.assign 的方法的第一个参数是目标对象,之后还可以跟一个或多个源对象,他会遍历一个或多个源对象的可枚举的自有键并把他们复制到目标对象,最后返回目标对象。

Object.assign 就是使用 = 操作符来赋值,所以源对象属性的一些特性(比如 writable)不会被复制到目标对象

属性描述符

在 ES5 之前,JS 语言本身并没有提供可以直接检测属性特性的方法,比如判断是否是只读,但从 ES5 开始,所有的属性都具备了属性描述符。

我们可以通过 Object.getOwnPropertyDescriptor(object, attr),通过返回值我们可以看到普通对象对应的属性描述符不仅仅有 value,还有另外三个特性,writable(可写)、enumerable(可枚举)、configurable(可配置)、get/set。在创建普通属性时属性描述符会使用默认值。也可以通过 Object.defineProperty 来添加一个新属性或者修改一个已有的属性(如果是configurable)并对特性进行配置。

对于 writable 为 false 时,属性值的修改静默失败,如果在严格模式下,这种方法会出错。

如果将 configurable 置为 false 时,想再次 defineProperty 时会产生 TypeError 错误,不管是否处于严格模式,尝试修改一个不可配置的属性描述符都会出错。因此:把 configurable 修改成 false 是一个反向操作。除了无法修改,还会禁止删除这个属性。也就是说 delete 语句静默失败。

有个小例外,即便 configurable:false,我们还是可以将 writable 的状态由 true 改为 false,但是无法由 false 改为 true。

最后看看 enumerable,这个描述符控制的是属性是否会出现在对象的属性枚举中。比如 for..in 循环。用户定义的所有普通属性默认都是 enumerable:true。

不变形

有时候你会希望属性或对象是不可改变的,在 ES5 中可以通过很多方法来实现。

  • 对象常量:结合 writable:false 和 configurable:false 就可以创建一个真正的常见属性
  • 禁止扩展:禁止一个对象添加新属性并且保留已有属性,使用 Object.preventExtensions()
  • 密封:Object.seal 创建一个密封对象,实际上会在一个现有对象上调用 Object.preventExtensions 并把所有属性标记为 configurable:false
  • 冻结:Object.freeze 创建一个冻结对象,实际上会在一个现有对象上调用 Object.seal 并把所有的数据访问属性标记为 writable:false,这样就无法修改他们的值

首先需要注意的是,所有方法创建的哦度是浅不变形,也就是说只会影响目标对象和它的直接属性。如果目标对象引用了其他对象(数组、对象、函数等),其他会向的内容不受影响,仍然是可变得。

[[Get]] 与 [[Put]]

访问 obj.a 是一次属性访问,但是这条语句并不仅仅是在 obj 中查找名字为 a 的属性,虽然看起来是这样。在语言规范中,obj.a 实际上是实现了 [[Get]] 操作,对象默认的内置 [[Get]] 操作首先在对象中查找是否有名称相同的属性,如果找到会就返回这个属性的值。然而如果没有找到名称相同的属性,按照[[Get]]算法会执行另外一种非常重要的行为,遍历可能存在的[[prototype]]链。如果无论如何都没有找到名称相同的属性,则返回 undefined。

你可能会认为给对象的属性赋值会触发[[Put]]来设置或者创建这个属性,但实际情况并不完全是这样,实际的行为取决于很多因素,包括对象中是否已经存在这个属性,如果已经存在这个属性,则会检查下面这些内容

  1. 属性是否是访问描述符,如果是并且存在 setter 就调用 setter
  2. 属性的数据描述符中 writable 是否是 false,如果是,非严格模式下静默失败,在严格模式下抛出 TypeError 异常
  3. 如果都不是,将改值设置为属性的值

如果不存在这个谁能给,[[Put]]操作会更加复杂,在后面进行讨论

Getter 与 Setter

对象默认的[[Get]]和[[Put]]操作分别可以控制属性值的设置和获取。在 ES5 中可以使用 getter 和 setter 部分改写默认操作,但是只能应用在单个属性上,无法应用在整个对象上。

当你给一个属性定义 getter、setter 或者两者都有时,这个属性会被定义为"访问描述符"(和"数据描述符"相对)。对于访问描述符来说,JS 会忽略它们的 value 和 writable 特性,取而代之的是关心 set 和 get(还有 configurable 和 enumerable)特性

不管是对象文字语言中的 get a() {..} 还是 defineProperty(..) 中的显示定义,二者都会在对象中创建一个不包含值的属性,对于这个属性的访问会自动调用一个隐藏哈数,它的返回值会被当做属性访问的返回值。

属性不一定包含值,他可能是具备 getter/setter 的访问描述符。同时这里需要注意的是:getter/setter 的执行不是异步的

存在性

如 obj.a 属性返回值为 undefined,这个值可能是属性中存储的 undefined,也可能是因为属性不存在所以返回 undefined,那么如何区分呢。我们需要在不放问属性值的情况下判断这个对象是否存在这个属性

  • in 操作符:检查属性是否存在对象及其原型链中
  • hasOwnProperty:只会检查属性是否在对象中,不检查原型链

这里提及一下 hasOwnProperty 的使用,所有的普通对象都可以通过对于 object.prototype 的委托来访问 hasOwnProperty,但是有的对象可能没有连接到 Object.prototype,比如通过 Object.create(null) 创建,在这种情况下通过对象使用 obj.hasOwnProperty 就会失败,这时可以使用一种更加强硬的方法来判断:Object.prototype.hasOwnProperty.call(obj, 'a'),借用基础的 hasOwnProperty 方法并把它显式绑定到 obj 上。

枚举:对于不可枚举的属性,不会出现在 for..in 循环中(尽管可以通过 in 操作符来判断是否存在)

在数组上应用 for..in 循环有时会产生出人意料的结果,因为这种枚举不仅会包含所有数值索引,还会包含所有可枚举属性。最好只在对象上应用for..in循环,如果要遍历数组就使用传统的for循环来遍历数值索引。

也可以通过 obj.propertyIsEnumerable(attrname) 判断是否可枚举,该函数会检查属性名是否直接存在于对象中(而不是原型链)且满足 enumerable:true。

Object.keys() 会返回一个数组,包含所有可枚举属性,Object.getOwnPropertyNames()会返回一个数组,包含所有属性,无论他们是否可以枚举

in 和 hasOwnProperty 的区别在于是否查找原型链,然而,Object.keys 和 Object.getOwnPropertyNames 都只会查找对象直接包含的属性。

目前并没有内置的方法可以获取 in 操作符使用的属性列表。

遍历

for..in 循环可以用来遍历对象的可枚举属性列表(包括原型链),但是如果遍历属性的值?ES5 中增加了一些辅助迭代器,包括 forEach、every、some。每种辅助迭代器都可以接受一个回调函数并把它应用到数组的每个元素上,唯一的区别就是他们对于回调函数返回值的处理方式不同。

  • forEach 会遍历数组中的所有值,并忽略回调函数的返回值
  • every 会一直运行直到回调函数返回值为 false
  • some 会一直运行知道回调函数返回 ture

遍历数组下标时才用的是数字顺序,但是遍历对象属性时的顺序是不确定的,在不同的 JS 引擎中可能不一样,因此,在不同的环境中需要保持一致性时,一定不要相信任何观察到的顺序,它们是不可靠的。

那么如何直接遍历值而不是数组下标(或对象属性)呢?在ES6 中增加了一种用来遍历数组的 for..of 循环语法(如果对象本身定义的迭代器的话也可以遍历对象)

for..of 循环首先会向被访问对象请求一个迭代器对象,然后通过调用迭代器对象的next方法来遍历所有返回值。数组有内置的@@iterator,因此 for..of 可以直接应用在数组上。我们可以使用 Symbol 来得到迭代器对象,然后手动调用

var myArray = [1,2,3]
var it = myArray[Symbol.iterator]()
it.next()

这里使用 Symbol.iterator 来获取对象的 @@iterator 内部属性,引用类似 iterator 的特殊属性时要使用符号名,而不是符号包含的值。

普通对象没有内置的@@iterator,所以无法自动完成 for..of 遍历,之所以这样做,有很多复杂的原因。当然你可以给任何想遍历的对象手动定义@@iterator

Object.defineProperty(object, Symbol.iterator, {
    enumerable: false,
    writable: false,
    configurable: true,
    value: function() {
        var o = this
        var idx = 0
        var ks = Object.keys(o)
        return {
            next: function() {
                return {
                    value: o[ks[idx++]],
                    done: (idex > ks.length)
                }
            }
        }
    }
})

混合对象类

面向类的设计模式:实例化、继承和多态,这些概念其实无法直接对应到 JS 的对象机制。

类理论基础:类/继承描述了一种代码的组织结构形式,面向对象编程强调的是数据和操作数据的行为本质上是互相关联的,因此好的设计就是把数据以及它相关的行为打包起来,这在正式的计算机科学中称为数据结构。

类理论强烈建议父类和子类使用相同的方法名来表示特定的行为,从而让子类重写父类,但在 JS 中,这样做会降低代码的可读性和健壮性。

在相当长的一段时间里,JS 只有一些近似类的语法元素(比如 new 和 instanceof),不过后来在 ES6 中新增了一些元素,比如 class 关键字。但这并不意味着 JS 中实际上就有类了。由于类是一种设计模式,所以你可以用一些方法近似实现类的功能。为了满足对于类设计模式最普遍需求,JS 提供了一些近似类的语法。虽然有近似类的语法,但 JS 的机制似乎一直在阻止你使用类设计模式。

在软件设计中类是一种可选的模式,你需要自己决定是否在 JS 中使用它。

类和实例的概念来源于房屋建造。一个类就是一张蓝图,为了获得真正可以交互的对象,我们必须按照类来建造(实例化)一个东西,这个东西通常称为实例。

构造函数:类实例是有一个特殊的类方法构造的,这个方法名通常和类名相同,被称为构造函数,这个方法的任务就是初始化实例需要的所有信息。类构造函数属于类,而且通常和类同名,此外,构造函数大多需要用 new 来调,这样语言引擎才知道你想要构造一个新的类实例。

类的继承:子类会包含父类行为的原始副本,但是也可以重写所有继承的行为甚至定义新行为。

多重继承:这个看上去似乎是一个非常有用的功能,可以把许多功能组合在一起。然而,这个机制同时也会带来很多复杂的问题。如果两个父类中都定义了相同的方法,子类引用的是哪一个呢?难道每次都需要手动指定具体父类吗?这样多态继承的很多优点就不存在了。

JS 本身并不提供多重继承,然而这无法阻止开发者们的热情,他们会尝试各种各样的办法来实现多重继承,比如混入。

显式混入

function mixin(sourceObj, targetObj) {
    for(var key in sourceObj) {
        if(!(key in targetObj)) {
            targetObj[key] = sourceObj[key]
        }
    }
    return targetObj
}

JS 中的函数无法真正的复制,所以你只能复制对共享函数对象的引用

原型

JS 中的对象有一个特殊的 [[Prototype]] 内置属性,其实就是对于其他对象的引用。几乎所有的对象在创建时 [[Prototype]] 属性都会被赋予一个非空的值。

使用 for..in 遍历对象时原理和查找 [[Prototype]] 链类似,任何可以通过原型链访问到且 enumerable 的属性都会被枚举。使用 in 操作符来检查属性在对象中是否存在时,同样会查找对象的整条原型链(无论是否可枚举)。

所有普通的 [[Prototype]] 链最终都会指向内置的 Object.prototype,由于所有的普通对象都源于 Object.prototype,因此他包含 JS 许多通用的功能。比如 toString,valueOf,hasOwnProperty,isPrototypeOf 等。

foo 不直接存在于 myObject 中而是存在于原型链上层时, myObject.foo = 'bar' 会出现三种情况

  • 如果原型链上层存在名为 foo 的普通数据访问属性并且没有被标记为只读,那就会直接在 myObject 中添加一个名为 foo 的新属性,它是屏蔽属性
  • 如果原型链上层存在 foo,但是它被标记为只读,那么无法修改已有属性或者在 myObject 上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误,否则,这条赋值语句会被忽略,总之,不会发生屏蔽
  • 如果原型链上层存在 foo,并且它是一个 setter,那就一定会调用这个 setter,foo 不会被添加到 myObject,也不会重新定义 foo 这个 setter。

如果你希望第二种和第三种情况下也屏蔽 foo,那就不能使用 = 操作符来赋值,而是使用 Object.defineProperty 来向 myObject 添加 foo。

为什么一个对象需要关联到另一个对象?JS 和面向类的语言不同,它并没有类来作为对象的抽象模式,JS 中只有对象。从这个角度而言,JS 才是真正的面向对象的语言,因为它是少有的可以不通过类,直接创建对象的语言。

JS 中有一种奇怪的行为一直被无耻的滥用,那就是模仿类。这种奇怪的类似类的行为利用了函数的一种特殊特性:所有的函数默认都会拥有一个名为 prototype 的公有且不可枚举的属性,他会指向另一个对象。

在 JS 中,并没有类似的复制机制,你不能创建一个类的多个实例,只能创建多个对象,它们[[prototype]]关联的是同一个对象。但是默认情况下并不会进行复制,因此这些对象之间并不会完全失去联系,它们是互相关联的。

继承意味着复制操作,JS 默认并不会复制对象属性,相反,JS 会在两个对象之间出创建一个关联,这样一个对象就可以通过委托访问另一个对象的属性和函数,委托可以更加准确的描述 JS 中对象的关联机制。

new 会劫持所有普通函数并用构造函数的形式来调用它,因此并不存在构造函数,而是 new 发挥为例。因此 JS 中对于构造函数最准确的解释是,所有带 new 的函数调用。函数不是构造函数,但是当且仅当使用 new 时,函数调用会变成构造函数调用。

关于 constructor 属性:Foo.constructor 属性只是 Foo 函数在声明时的默认属性。如果你创建了一个新对象并替换了函数默认的 prototype 对象引用,那么新对象并不会自动获得 constructor 属性。

继承

这一块我不知道看了多少次了。通常我们实现继承原型链关联的代码如下

Bar.prototype = Object.create(Foo.prototype)

我们更多的是需要知道另外两种错误的写法

Bar.prototype = Foo.prototype
Bar.prototype = new Foo()

第一种方式并不会创建一个关联到 Bar.prototype 的新对象,它只是让 Bar.prototype 直接引用 Foo.prototype,存在的问题有

  • 没有隔离,引用同一个对象,修改 Bar.prototype 会直接影响到 Foo.prototype
  • 构造函数无法得知,无法知道一个实例对象到底由那个函数创建,因为 constructor 属性总是相同,无论你如何修正,总有一方不同

第二种方式的确会创建一个关联到 Bar.prototype 的新对象。但是它使用了构造函数调用,存在的问题是

  • 如果函数 Foo 有一些副作用(比如写日志,修改状态,注册到其他对象,给 this 添加属性 等等),会影响 Bar 的后代

因此要创建一个合适的关联对象,必须使用 Object.create 而不是使用具有副作用的 Foo()。这样做唯一的缺点就是需要创建一个新对象然后把就对象抛弃掉,不能直接修改已有默认对象。

在 ES6 之前,我们只能通过设置__proto__属性来实现,但是这个方法并不是标准并且无法兼容所有浏览器。ES6 提供了赋值函数 Object.setPrototypeOf 可以用标准并且可靠的方法来修改关联。

Object.setPrototypeOf(Bar.prototype, Foo.prototype)

检查类关系

第一种方法是站在"类"的角度来判断

a instanceof Foo

instanceof 操作符的左操作数是一个普通的对象,右操作数是一个函数。它回答的问题是:在 a 的整条原型链中是否有 Foo.prototype 指向的对象。可惜这只能判断对象和函数之间的关联,如果想判断两个对象之间是否有关联,只用 instanceof 无法实现。

如果使用内置的 bind 函数来生成一个硬绑定函数的话,该函数使用 prototype 属性的。

第二种方法:isPrototypeOf

扩展__proto__的实现大致是这样的

Object.defineProperty( Object.prototype, '__proto__', {
    get: function() {
        return Object.getPrototypeOf(this)
    },
    set: function(o) {
        Object.setPrototypeOf(this, o)
        return o
    }
})

对象关联

[[Prototype]]机制就是存在于对象中的一个内部链接,它会引用其他对象,这链接的作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就会继续在[[Prototype]]关联的对象上进行查找。同理,如果后者也没有找到需要的引用就会继续查找它的[[Prototype]],依次类推,这一系列对象的链接被称为原型链

Object.create 是一个大英雄,会创建一个新对象并把它关联到我们指定的对象,这样就可以充分发挥[[Prototype]]机制的威力并且避免不必要的麻烦。

使用 Object.create(null) 会创建一个拥有空 [[Prototype]] 链接的对象,这个对象无法进行委托。这些特殊的空 [[Prototype]] 对象通常被称作字典,它们完全不会收到原型链的干扰,因此非常适合用来存储数据。

我们并不需要类来创建两个对象之间的关系,只需要通过委托来关联对象就足够了。

Object.create 的 polyfill 代码

if(!Object.create) {
    Object.create = function(o) {
        function F() {}
        F.prototype = o
        return new F()
    } 
}

当然以上只是简单版本,内置 create 支持第二个参数,用来指定需要添加到新对象中的属性名以及这些属性的属性描述符。因为 ES5 之前无法模拟属性操作符,所以 polyfill 代码无法实现这个附加功能。

委托相比继承而言,是一个更合适的属于,因为对象之间的关系不是复制,而是委托。

行为委托

为了更好地学习如何直观的使用[[Prototype]],必须认识到它代表的是一种不同于类的设计模式。

委托行为意味着某些对象在找不奥属性或者方法引用时会把这个请求委托给另一个对象。这是一种极其强大的设计模式,和父类、子类、继承、多态等概念完全不同。在你的脑海中对象和并不是按照父类到子类的关系垂直组织的,而是通过任意方向的委托关联并排组织的。

互相委托:你无法在两个或两个以上互相(双向)委托的对象之间创建循环委托。因为如果你引用了一个两边都不存在的属性或者方法,就会在 [[Prototype]] 上产生一个无限递归的循环。但是如果所有的引用都被严格限制的话,是可以互相委托的。互相委托理论上可以正常工作,在某些情况下非常有用。

之所以要禁止互相委托,是因为引擎的开发者们发现在设置时检查一次无限循环引用要更加高效,否则每次从对象中查找属性时都需要进行检查。

这里我们需要知道的是"类"和"委托"这两种设计模式的理论区别。

JS 中函数之所以可以访问 call、apply 和 bind,就是因为函数本身是对象。而函数对象同样有[[Prototype]]属性并且关联到 Function.prototype 对象,因此所有函数对象都可以通过委托调用这些默认方法

通过一个例子感受一下两者的区别

// 面向对象风格
function foo(who) {
    this.me = who
}
Foo.prototype.identify = function() {
    return "I am " + this.me    
}
function Bar(who) {
    Foo.call(this, who)
}
Bar.prototype = Object.create(Foo.prototype)
Bar.prototype.speak = function() {
    alert("hello, " + this.identify() + '.')
}

// 对象关联风格
Foo = {
    init: function(who) {
        this.me = who
    },
    identify: function() {
        return "I am " + this.me
    }
}
Bar = Object.create(Foo)
Bar.speak = function() {
    alert("hello, " + this.identify() + '.')
}
var b1 = Object.create(Bar)
b1.init('b1')
var b12 = Object.create(Bar)
b2.init('b2')

b1.speak()
b2.speak()

两种方案区别

  • 对象关联风格不需要那些既复杂又令人困惑的模拟类的行为(构造函数,原型以及 new)
  • 对象关联风格通常不推荐同名(多态),因此可以通过 this 避免丑陋的显示伪多态

更好的语法

ES6 中 class 语言可以简洁的定义类方法,这个特性让 class 咋看起来更有吸引力。其实在 ES6 中也可以在任意对象的字面形式中使用简洁方法声明。比如

var obj = {
    getUser() {

    },
    getName() {

    }
}

唯一的区别是对象的字面形式仍然需要使用","来分隔元素,而 class 语法不需要

简洁方法有一个非常小但是非常重要的缺点,代码如下

var Foo = {
    bar() {},
    baz: function baz() {}
}

去掉语法糖如下

var Foo = {
    bar: function() {},
    baz: function baz() {}
}

由于函数本身没有名称标识符,所以 bar 的缩写形式实际上会变成一个匿名函数表达式并赋值给 bar 属性,相比之下,具名函数表达式会额外给 .baz 属性附加一个词法名词标识符 baz。匿名函数表达式的三大主要缺点 ,匿名函数没有 name 标识符,会导致

  • 调试栈更难追踪
  • 自我引用(递归、事件解除绑定等等)更难
  • 代码更难理解

简洁方法没有第 1 和第 3 个缺点,因为简洁方法很特殊,会给对应的函数对象设置一个内部的 name 属性,理论上可以用在追踪栈中。

使用简洁方法时一定要小心这一点。如果你需要自我引用的话,那最好使用传统的具名函数表达式来定义对应的函数,不要使用简洁方法。

内省

内省就是检查实例的类型。我们通常使用的 instanceof 来判断,比如 a instanceof Foo,正确的理解应该是 a 和 Foo.prototype 是互相关联的,而不是说 a 是 Foo 的实例。

还有一种常见但是可能更加脆弱的内省模式,这种模式被称为"鸭子类型",这个属于源自一个格言"如果看起来像鸭子,叫起来像鸭子,那就一定是鸭子"

if(a.something) {
    a.something()
}

ES6 class

传统面向类的语言中,父类和子类、子类和实例之间其实是复制操作,但是在[[Prototype]]中并没有复制,相反他们之间只有委托关联。强行使用[[Prptotype]]实现类是很变扭的。那么在 ES6 class 中解决了哪些问题呢

  • 不再引用杂乱的 prototype
  • extends 关键字
  • 通过 super 实现相对多态
  • 为避免犯错,无法定义类成员属性(只能定义方法),如果为了跟踪实例之间共享状态必须要做这么做,只能使用丑陋的 prototype 语法了

class 的陷进:class 并不是引入了一种新的类机制,只是现有 [[Prototype]] 机制的一种语法糖。class 并不会像传统面向类的语言一样在声明时静态复制所有行为。如果你有意或无意修改或替换了父类中的一个方法,那子类和所有实例都会收到影响,因为他们在定义时并没有进行复制,只是基于 [[Prototype]] 的实时委托。

第二个陷进就是 super 存在的一些细微问题。你可能认为 super 的绑定方法和 this 类型,也就是说无论目前的方法在原型链中处于什么位置,super 总会绑定到链中的上一层。

然而处于性能考虑(this 绑定已经是很大的开销了),super 并不是动态绑定的,它会在声明时静态绑定。

根据应用方式的不同,super 可能不会绑定到合适的对象,所以你可能需要用 toMethod 来手动绑定 super。

静态 VS 动态

在传统的面向类的语言中,类定义之后就不会进行修改,所以类的设计模式就不支持修改。但是 JS 中最强大的特性之一就是它的动态性,任何对象的定义都可以修改(除非你设置成不可变)

ES6 的 class 想伪装成一种很好的语法问题的解决方案,但是实际上却让问题更难解决而且让 JS 更难理解。



留言