读书笔记:《你不知道的JavaScript(上卷)》 - [美]Kyle Simpson 著 / 赵望野 梁杰 译

this 部分讲的很全面!

出版时间:2015 年 8 月

章节:1.1 编译原理

在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。

  1. 分词/词法分析(Tokenizing / Lexing)
  2. 解析/语法分析(Parsing)
  3. 代码生成

章节:1.2 理解作用域

RHS 查询与简单地查找某个变量的值别无二致,而 LHS 查询则是试图找到变量的容器本身,从而可以对其赋值。从这个角度说,RHS 并不是真正意义上的“赋值操作的右侧”,更准确地说是“非左侧”。

LHS 和 RHS 的含义是“赋值操作的左侧或右侧”并不一定意味着就是 = 赋值操作符的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头(RHS)”。

章节:1.4 异常

如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError 异常。值得注意的是,ReferenceError 是非常重要的异常类型。

相较之下,当引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非“严格模式”下。

严格模式禁止自动或隐式地创建全局变量。因此,在严格模式中 LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出同 RHS 查询失败时类似的 ReferenceError 异常。

如果 RHS 查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作,比如试图对一个非函数类型的值进行函数调用,或着引用 nullundefined 类型的值中的属性,那么引擎会抛出另外一种类型的异常,叫作 TypeError

ReferenceError 同作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对结果的操作是非法或不合理的。

章节:2.1 词法阶段

大部分标准语言编译器的第一个工作阶段叫作词法化(也叫单词化)。回忆一下,词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。

章节:2.2 欺骗词法

JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。

章节:3.2 隐藏内部实现

在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计。

章节:3.3 函数作用域

区分函数声明和表达式最简单的方法是看 function 关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。如果 function 是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。

章节:3.4 块作用域

使用 let 进行的声明不会在块作用域中进行提升。声明的代码被运行之前,声明并不“存在”。

for (let i=0; i<10; i++) {
    console.log( i );
}

console.log( i ); // ReferenceError

for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。

章节:4.2 编译器再度来袭

包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。

当你看到 var a = 2; 时,可能会认为这是一个声明。但 JavaScript 实际上会将其看成两个声明:var a;a = 2;。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。

章节:4.3 函数优先

函数声明和变量声明都会被提升。

函数会首先被提升,然后才是变量。

function foo() {
    console.log( 1 );
}

foo(); // 1

foo = function() {
    console.log( 2 );
};

var foo 尽管出现在 function foo()... 的声明之前,但它是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前。尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。

foo(); // 3

function foo() {
    console.log( 1 );
}

var foo = function() {
    console.log( 2 );
};

function foo() {
    console.log( 3 );
}

章节:4.4 小结

所有的声明(变量和函数)都会被“移动”到各自作用域的最顶端,这个过程被称为提升。

章节:5.2 实质问题

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

function foo() {
    var a = 2;

    function bar() { 
        console.log( a );
    }

    return bar;
}

var baz = foo();

baz(); // 2 ———— 朋友,这就是闭包的效果。

foo() 执行后,通常会期待 foo() 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去 foo() 的内容不会再被使用,所以很自然地会考虑对其进行回收。而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收。谁在使用这个内部作用域?原来是 bar() 本身在使用。拜 bar() 所声明的位置所赐,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一直存活,以供 bar() 在之后任何时间进行引用。bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

章节:5.3 现在我懂了

在定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!

章节:5.4 循环和闭包

for (var i=1; i<=5; i++) {
    setTimeout( function timer() {
        console.log( i );
    }, i*1000 );
}

根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i

将一个块转换成一个可以被关闭的作用域。

for (var i=1; i<=5; i++) {
    let j = i; // 是的,闭包的块作用域!
    setTimeout( function timer() {
        console.log( j );
    }, j*1000 );
}

for 循环头部的 let 声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

for (let i=1; i<=5; i++) {
    setTimeout( function timer() {
        console.log( i );
    }, i*1000 );
}

章节:5.6 小结

函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。

模块有两个主要特征:(1)为创建内部作用域而调用了一个包装函数;(2)包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。

章节:附录A 动态作用域

词法作用域最重要的特征是它的定义过程发生在代码的书写阶段(假设你没有使用 eval()with)。

动态作用域(MAZEY!在函数调用的时候才决定的)并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。

章节:附录B 块作用域的替代方案

如果我们想在 ES6 之前的环境中使用块作用域呢?考虑下面的代码:

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

console.log( a ); // ReferenceError

这段代码在 ES6 环境中可以正常工作。但是在 ES6 之前的环境中如何才能实现这个效果?答案是使用 catch

try {
    throw 2;
} catch(a) {
    console.log( a ); // 2
}

console.log( a ); // ReferenceError

章节:附录C this 词法

箭头函数在涉及 this 绑定时的行为和普通函数的行为完全不一致。它放弃了所有普通 this 绑定的规则,取而代之的是用当前的词法作用域覆盖了 this 本来的值。

在我看来,解决这个“问题”的另一个更合适的办法是正确使用和包含 this 机制。

章节:1.2 误解

this 在任何情况下都不指向函数的词法作用域。在 JavaScript 内部,作用域确实和对象类似,可见的标识符都是它的属性。但是作用域“对象”无法通过 JavaScript 代码访问,它存在于 JavaScript 引擎内部。

每当你想要把 this 和词法作用域的查找混合使用时,一定要提醒自己,这是无法实现的。

章节:1.3 this 到底是什么

this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式

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

章节:1.4 小结

学习 this 的第一步是明白 this 既不指向函数自身也不指向函数的词法作用域,你也许被这样的解释误导过,但其实它们都是错误的。

this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。

章节:第 2 章 this 全面解析

每个函数的 this 是在调用时被绑定的,完全取决于函数的调用位置(也就是函数的调用方法)。

章节:2.1 调用位置

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

章节:2.2 绑定规则

你必须找到调用位置,然后判断需要应用下面四条规则中的哪一条。我们首先会分别解释这四条规则,然后解释多条规则都可用时它们的优先级如何排列。

  1. 默认绑定(MAZEY!无任何前置调用的场景,严格模式下 this 指向 undefined)
  2. 隐式绑定
  3. 显式绑定
  4. new 绑定

调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含,不过这种说法可能会造成一些误导。思考下面的代码:

function foo() { 
    console.log( this.a );
}

var obj = { 
    a: 2,
    foo: foo 
};

obj.foo(); // 2

无论你如何称呼这个模式,当 foo() 被调用时,它的落脚点确实指向 obj 对象。当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。因为调用 foo()this 被绑定到 obj,因此 this.aobj.a 是一样的。对象属性引用链中只有最顶层或者说最后一层会影响调用位置。

function foo() { 
    console.log( this.a );
}

var obj2 = { 
    a: 42,
    foo: foo 
};

var obj1 = { 
    a: 2,
    obj2: obj2 
};

obj1.obj2.foo(); // 42

因为你可以直接指定 this 的绑定对象,因此我们称之为显式绑定。

function foo() { 
    console.log( this.a );
}

var obj = { 
    a:2
};

foo.call( obj ); // 2

如果你传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作 this 的绑定对象,这个原始值会被转换成它的对象形式(也就是 new String(..)new Boolean(..) 或者 new Number(..))。这通常被称为“装箱”。

function foo(something) { 
    console.log( this.a, something ); 
    return this.a + something;
}

var obj = { 
    a:2
};

var bar = foo.bind( obj );

var b = bar( 3 ); // 2 3 
console.log( b ); // 5

bind(..) 会返回一个硬编码的新函数,它会把参数设置为 this 的上下文并调用原始函数(MAZEY!属于硬绑定,也就是显示绑定)。

构造函数只是一些使用 new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被 new 操作符调用的普通函数而已。

使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作:

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

章节:2.3 优先级

之所以要在 new 中使用硬绑定函数,主要目的是预先设置函数的一些参数,这样在使用 new 进行初始化时就可以只传入其余的参数。bind(..) 的功能之一就是可以把除了第一个参数(第一个参数用于绑定 this)之外的其他参数都传给下层的函数(这种技术称为“部分应用”,是“柯里化”的一种)。

现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的顺序来进行判断:

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

章节:2.4 绑定例外

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

function foo() { 
    console.log( this.a );
}

var a = 2;

foo.call( null ); // 2

总是使用 null 来忽略 this 绑定可能产生一些副作用。如果某个函数确实使用了 this(比如第三方库中的一个函数),那默认绑定规则会把 this 绑定到全局对象(在浏览器中这个对象是 window),这将导致不可预计的后果(比如修改全局对象)。

一种“更安全”的做法是传入一个特殊的对象,把 this 绑定到这个对象不会对你的程序产生任何副作用。就像网络(以及军队)一样,我们可以创建一个“DMZ”(demilitarized zone,非军事区)对象——它就是一个空的非委托的对象。

function foo(a,b) {
    console.log( "a:" + a + ", b:" + b );
}

// 我们的DMZ空对象
var ø = Object.create( null );

// 把数组展开成参数
foo.apply( ø, [2, 3] ); // a:2, b:3

// 使用bind(..)进行柯里化
var bar = foo.bind( ø, 2 ); // 第一个参数强制设为 2
bar( 3 ); // 因为硬绑定了第一个参数,输入的参数从第二个参数开始 a:2, b:3

如果可以给默认绑定指定一个全局对象和 undefined 以外的值,那就可以实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改 this 的能力。可以通过一种被称为软绑定(MAZEY! this 在默认情况下能指向某个对象,又具备可以修改 this 指向的能力)的方法来实现我们想要的效果。

if (!Function.prototype.softBind) { 
    Function.prototype.softBind = function(obj) {
        var fn = this;
        // 捕获所有 curried 参数
        var curried = [].slice.call( arguments, 1 ); 
        var bound = function() {
            return fn.apply(
                (!this || this === (window || global)) ?
                    obj : this
                curried.concat.apply( curried, arguments )
            ); 
        };
        bound.prototype = Object.create( fn.prototype );
        return bound; 
    };
}

章节:2.5  this 词法

箭头函数不使用 this 的四种标准规则,而是根据外层(函数或者全局)作用域来决定 this

箭头函数的绑定无法被修改。(new 也不行!)

章节:3.2 类型

有一种常见的错误说法是“JavaScript 中万物皆是对象”,这显然是错误的。

原始值"I am a string"并不是一个对象,它只是一个字面量,并且是一个不可变的值。如果要在这个字面量上执行一些操作,比如获取长度、访问其中某个字符等,那需要将其转换为 String 对象。

章节:3.3 内容

如果你使用 string(字面量)以外的其他值作为属性名,那它首先会被转换为一个字符串。

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

configurable 修改成 false 是单向操作,无法撤销!即便属性是 configurable: false,我们还是可以把 writable 的状态由 true 改为 false,但是无法由 false 改为 true

结合 writable: falseconfigurable: false 就可以创建一个真正的常量属性(不可修改、重定义或者删除)。

禁止一个对象添加新属性并且保留已有属性,可以使用 Object.preventExtensions(..)

var myObject = { 
    a:2
};

Object.preventExtensions( myObject );

myObject.b = 3; 
myObject.b; // undefined

Object.seal(..) 会创建一个“密封”的对象,这个方法实际上会在一个现有对象上调用 Object.preventExtensions(..) 并把所有现有属性标记为 configurable: false

Object.freeze(..) 会创建一个冻结对象,这个方法实际上会在一个现有对象上调用 Object.seal(..) 并把所有“数据访问”属性标记为 writable: false,这样就无法修改它们的值。

var myObject = { 
    a: undefined
};

myObject.a; // undefined 

myObject.b; // undefined

从返回值的角度来说,这两个引用没有区别——它们都返回了 undefined。然而,尽管乍看之下没什么区别,实际上底层的 [[Get]] 操作对 myObject.b 进行了更复杂的处理。

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

Object.defineProperty(
    myObject,  // 目标对象
    "b",         // 属性名
    {           // 描述符
        // 给b设置一个getter
        get: function(){ return this.a * 2 },

        // 确保b会出现在对象的属性列表中
        enumerable: true 
    }
);

myObject.a; // 2

myObject.b; // 4

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

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

myObject.a = 3;

myObject.a; // 2

所有的普通对象都可以通过对于 Object.prototype 的委托来访问 hasOwnProperty(..),但是有的对象可能没有连接到 Object.prototype(通过 Object.create(null) 来创建)。在这种情况下,形如 myObejct.hasOwnProperty(..) 就会失败。

这时可以使用一种更加强硬的方法来进行判断:Object.prototype.hasOwnProperty.call(myObject,"a"),它借用基础的 hasOwnProperty(..) 方法并把它显式绑定到 myObject 上。

var myObject = { };

Object.defineProperty(
    myObject,
    "a",
    // 让a像普通属性一样可以枚举
    { enumerable: true, value: 2 }
);

Object.defineProperty(
    myObject,
    "b",
    // 让b不可枚举
    { enumerable: false, value: 3 }
);

myObject.b; // 3
("b" in myObject); // true 
myObject.hasOwnProperty( "b" ); // true

// .......

for (var k in myObject) { 
    console.log( k, myObject[k] );
}
// "a" 2

myObject.b 确实存在并且有访问值,但是却不会出现在 for..in 循环中(尽管可以通过 in 操作符来判断是否存在)(MAZEY!可枚举的定义:enumerable: true,除非该属性名是一个 Symbol)。原因是“可枚举”就相当于“可以出现在对象属性的遍历中”。

可枚举列表

章节:3.4 遍历

for..in 循环可以用来遍历对象的可枚举属性列表(包括 [[Prototype]] 链)。

当然,你可以给任何想遍历的对象定义 @@iterator,举例来说:

var myObject = { 
    a: 2,
    b: 3 
};

Object.defineProperty( myObject, 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: (idx > ks.length)
                }; 
            }
        }; 
    }
} );

// 手动遍历myObject
var it = myObject[Symbol.iterator](); 
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { value:undefined, done:true }

// 用for..of遍历myObject
for (var v of myObject) { 
    console.log( v );
}
// 2
// 3

(MAZEY!迭代器以 { value:undefined, done:true } 结束)

// MAZEY! 给对象添加迭代器
const targetObject = {
  a: 1,
  b: 2,
  c: 3,
  [Symbol.iterator]() {
    const that = this
    const keys = Object.keys(that)
    let index = 0
    return {
      next () {
        let ret = {
          value: undefined,
          done: true
        }
        if (index < keys.length) {
          ret = {
            value: that[keys[index]],
            done: false
          }
          index++
        }
        return ret
      }
    }
  }
}
// 遍历值
for (let item  of targetObject) {
  console.log(item) // 1 2 3
}

章节:4.1 类理论

面向对象编程强调的是数据和操作数据的行为本质上是互相关联的(当然,不同的数据有不同的行为),因此好的设计就是把数据以及和它相关的行为打包(或者说封装)起来。这在正式的计算机科学中有时被称为数据结构。

多态,这个概念是说父类的通用行为可以被子类用更特殊的行为重写。

章节:4.2 类的机制

类构造函数属于类,而且通常和类同名。此外,构造函数大多需要用 new 来调,这样语言引擎才知道你想要构造一个新的类实例。

章节:4.4 混入

JavaScript 中只有对象,并不存在可以被实例化的“类”。一个对象并不会被复制到其他对象,它们会被关联起来。

章节:5.1 [[Prototype]]

使用 in 操作符来检查属性在对象中是否存在时,同样会查找对象的整条原型链(无论属性是否可枚举)。

myObject.foo = "bar";

如果 foo 存在于原型链上层,赋值语句 myObject.foo = "bar" 的行为就会有些不同(而且可能很出人意料)。

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

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

章节:5.2 “类”

函数的一种特殊特性:所有的函数默认都会拥有一个名为 prototype 的公有并且不可枚举的属性,它会指向另一个对象。

new Foo() 这个函数调用实际上并没有直接创建关联,这个关联只是一个意外的副作用。new Foo() 只是间接完成了我们的目标:一个关联到其他对象的新对象。

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

没有定义的东西都变成了“洞”。而这些洞(或者说缺少定义的空白处)最终会被委托行为“填满”。

如果你用 new 来调用小写方法或者不用 new 调用首字母大写的函数,许多 JavaScript 开发者都会责怪你。这很令人吃惊,我们竟然会如此努力地维护 JavaScript 中(假)“面向类”的权力,尽管对于 JavaScript 引擎来说首字母大写没有任何意义。

function NothingSpecial() { 
    console.log( "Don't mind me!" );
}

var a = new NothingSpecial();
// "Don't mind me!" 

a; // {}

NothingSpecial 只是一个普通的函数,但是使用 new 调用时,它就会构造一个对象并赋值给 a,这看起来像是 new 的一个副作用(无论如何都会构造一个对象)。这个调用是一个构造函数调用,但是 NothingSpecial 本身并不是一个构造函数。换句话说,在 JavaScript 中对于“构造函数”最准确的解释是,所有带 new 的函数调用。

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

function Foo() { /* .. */ }

Foo.prototype = { /* .. */ }; // 创建一个新原型对象

var a1 = new Foo();
a1.constructor === Foo; // false!
a1.constructor === Object; // true!

当然,你可以给 Foo.prototype 添加一个 .constructor 属性,不过这需要手动添加一个符合正常行为的不可枚举属性。举例来说:

function Foo() { /* .. */ }

Foo.prototype = { /* .. */ }; // 创建一个新原型对象

// 需要在 Foo.prototype 上“修复”丢失的 .constructor 属性
// 新对象属性起到 Foo.prototype 的作用
// 关于 defineProperty(..),参见第3章
Object.defineProperty( Foo.prototype, "constructor" , {
    enumerable: false,
    writable: true,
    configurable: true,
    value: Foo // 让.constructor指向Foo
} );

结论?一些随意的对象属性引用,比如 a1.constructor,实际上是不被信任的,它们不一定会指向默认的函数引用。此外,很快我们就会看到,稍不留神 a1.constructor 就可能会指向你意想不到的地方。a1.constructor 是一个非常不可靠并且不安全的引用。通常来说要尽量避免使用这些引用。

章节:5.3 (原型)继承

典型的“原型风格”:

function Foo(name) { 
    this.name = name;
}

Foo.prototype.myName = function() { 
    return this.name;
};

function Bar(name,label) { 
    Foo.call( this, name ); 
    this.label = label;
}

// 我们创建了一个新的 Bar.prototype 对象并关联到 Foo.prototype
Bar.prototype = Object.create( Foo.prototype );

// 注意!现在没有 Bar.prototype.constructor 了
// 如果你需要这个属性的话可能需要手动修复一下它
Bar.prototype.myLabel = function() { 
    return this.label;
};

var a = new Bar( "a", "obj a" );

a.myName(); // "a" 
a.myLabel(); // "obj a"

对比一下两种把 Bar.prototype 关联到 Foo.prototype 的方法。

// ES6 之前需要抛弃默认的 Bar.prototype
Bar.ptototype = Object.create( Foo.prototype );

// ES6 开始可以直接修改现有的 Bar.prototype
Object.setPrototypeOf( Bar.prototype, Foo.prototype );

(MAZEY! setPrototypeOf 改变/替换对象的原型)

// MAZEY! setPrototypeOf 给原型设置原型(原型自身属性优先级更高)
function Foo () {
  this.name = 'foo'
}
Foo.prototype.grade = 94
Foo.prototype.age = 17
function Bar () {
  this.name = 'bar'
}
Bar.prototype.gender = 'female'
Bar.prototype.age = 18
Baz = new Bar()
// 设置原型前
console.log(Baz.gender, Baz.age, Baz.grade) // female 18 undefined
Object.setPrototypeOf(Bar.prototype, Foo.prototype)
// 设置原型后
console.log(Baz.gender, Baz.age, Baz.grade) // female 18 94

// setPrototypeOf 为对象替换原型
const Human = {
  say () {
    return 'Hello'
  }
}
const Dog = {
  say () {
    return 'WangWang'
  }
}
const Cherrie = Object.create(Human)
console.log(Cherrie.say()) // Hello
Object.setPrototypeOf(Cherrie, Dog)
console.log(Cherrie.say()) // WangWang

Bar.prototype

第二种判断 [[Prototype]] 反射的方法,它更加简洁。

Foo.prototype.isPrototypeOf( a ); // true

章节:5.4 对象关联

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

Object.create(null) 会创建一个拥有空(或者说 null[[Prototype]] 链接的对象,这个对象无法进行委托。由于这个对象没有原型链,所以 instanceof 操作符(之前解释过)无法进行判断,因此总是会返回 false。这些特殊的空 [[Prototype]] 对象通常被称作“字典”,它们完全不会受到原型链的干扰,因此非常适合用来存储数据。

Object.create(..) 是在 ES5 中新增的函数,所以在 ES5 之前的环境中(比如旧 IE)如果要支持这个功能的话就需要使用一段简单的 polyfill 代码,它部分实现了 Object.create(..) 的功能。

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

ES6 中有一个被称为“代理”(Proxy)的高端功能,它实现的就是“方法无法找到”时的行为。

章节:5.5 小结

出于各种原因,以“继承”结尾的术语(包括“原型继承”)和其他面向对象的术语都无法帮助你理解 JavaScript 的真实机制(不仅仅是限制我们的思维模式)。相比之下,“委托”是一个更合适的术语,因为对象之间的关系不是复制而是委托。

章节:6.1 面向委托的设计

你确实可以这么做,但是如果你选择对抗事实,那要达到目的就显然会更加困难。

下面是典型的(“原型”)面向对象风格:

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() + "." );
};

var b1 = new Bar( "b1" );
var b2 = new Bar( "b2" ); 

b1.speak();
b2.speak();

[推荐]下面我们看看如何使用对象关联风格来编写功能完全相同的代码:

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 b2 = Object.create( Bar ); 
b2.init( "b2" );

b1.speak();
b2.speak();

章节:6.2 类与对象

尽管语法上得到了改进,但实际上这里并没有真正的类,class 仍然是通过 [[Prototype]] 机制实现的,因此我们仍然面临思维模式不匹配问题。

对象关联可以更好地支持关注分离(separation of concerns)原则,创建和初始化并不需要合并为一个步骤。

章节:6.4 更好的语法

匿名函数没有 name 标识符,这会导致:

  1. 调试栈更难追踪;
  2. 自我引用(递归、事件(解除)绑定,等等)更难;
  3. 代码(稍微)更难理解。

简洁方法没有第 1 和第 3 个缺点。去掉语法糖的版本使用的是匿名函数表达式,通常来说并不会在追踪栈中添加 name,但是简洁方法很特殊,会给对应的函数对象设置一个内部的 name 属性,这样理论上可以用在追踪栈中。(但是追踪的具体实现是不同的,因此无法保证可以使用。)

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

章节:6.5 内省

还有一种常见但是可能更加脆弱的内省模式,许多开发者认为它比 instanceof 更好。这种模式被称为“鸭子类型”。这个术语源自这句格言“如果看起来像鸭子,叫起来像鸭子,那就一定是鸭子。”

使用对象关联时,所有的对象都是通过 [[Prototype]] 委托互相关联,下面是内省的方法,非常简单:

// 让Foo和Bar互相关联
Foo.isPrototypeOf( Bar ); // true 
Object.getPrototypeOf( Bar ) === Foo; // true

// 让b1关联到Foo和Bar
Foo.isPrototypeOf( b1 ); // true
Bar.isPrototypeOf( b1 ); // true 
Object.getPrototypeOf( b1 ) === Bar; // true

我们没有使用 instanceof,因为它会产生一些和类有关的误解。现在我们想问的问题是“你是我的原型吗?”我们并不需要使用间接的形式,比如 Foo.prototype 或者繁琐的 Foo.prototype.isPrototypeOf(..)

章节:6.6 小结

行为委托认为对象之间是兄弟关系,互相委托,而不是父类和子类的关系。JavaScript 的 [[Prototype]] 机制本质上就是行为委托机制。也就是说,我们可以选择在 JavaScript 中努力实现类机制,也可以拥抱更自然的 [[Prototype]] 委托机制。

对象关联(对象之间互相关联)是一种编码风格,它倡导的是直接创建和关联对象,不把它们抽象成类。对象关联可以用基于 [[Prototype]] 的行为委托非常自然地实现。

章节:A.2 class 陷阱

你可能会认为 ES6 的 class 语法是向 JavaScript 中引入了一种新的“类”机制,其实不是这样。class 基本上只是现有 [[Prototype]](委托!)机制的一种语法糖。也就是说,class 并不会像传统面向类的语言一样在声明时静态复制所有行为。如果你(有意或无意)修改或者替换了父“类”中的一个方法,那子“类”和所有实例都会受到影响,因为它们在定义时并没有进行复制,只是使用基于 [[Prototype]] 的实时委托:

class C { 
    constructor() {
        this.num = Math.random(); 
    }
    rand() {
        console.log( "Random: " + this.num );
    } 
}

var c1 = new C();
c1.rand(); // "Random: 0.4324299..."

C.prototype.rand = function() {
    console.log( "Random: " + Math.round( this.num * 1000 ));
};

var c2 = new C();
c2.rand(); // "Random: 867" 

c1.rand(); // "Random: 432" ——噢!

class 语法仍然面临意外屏蔽的问题。

class C { 
    constructor(id) {
        // 噢,郁闷,我们的id属性屏蔽了id()方法
        this.id = id;
    }
    id() {
        console.log( "Id: " + id );
    }
} 

var c1 = new C( "c1" );
c1.id(); // TypeError -- c1.id现在是字符串"c1"

出于性能考虑,super (MAZEY!相当于指向对象原型的指针,约等于 Object.getPrototypeOf(this))并不像 this 一样是晚绑定(late bound, 或者说动态绑定)的,它在 [[HomeObject]].[[Prototype]] 上,[[HomeObject]] 会在创建时静态绑定。

// MAZEY! super
const Human = {
  say () {
    return 'Hello'
  }
}
const Cherrie = {
  say () {
    // super === Cherrie.prototype
    return super.say() + ' from Cherrie'
  }
}
Object.setPrototypeOf(Cherrie, Human)
console.log(Cherrie.say()) // Hello from Cherrie

章节:A.3 静态大于动态吗

class 似乎想告诉你:“动态太难实现了,所以这可能不是个好主意。这里有一种看起来像静态的语法,所以编写静态代码吧。”

对于 JavaScript 来说这是多么悲伤的评论啊:动态太难实现了,我们假装成静态吧。(但是实际上并不是!)

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

发表评论

您的电子邮箱地址不会被公开。