《你不知道的JavaScript(上卷)》阅读笔记

194 阅读18分钟

本文是《你不知道的JavaScript(上卷)》的阅读笔记。 供自己以后查漏补缺,也欢迎同道朋友交流学习。

上卷主要介绍作用域、this解析、“类”、对象、原型链等相关知识点。

作用域是什么

JS编译原理

js的编译流程和传统编译非常相似,程序中的一段源代码在执行之前会经历三个步骤:分词/词法分析(Tokeninzing/Lexing)解析/语法分析(parsing)代码生成。对于JS来说,大部分编译发生在代码执行前的几微秒。

分词/词法分析(Tokeninzing/Lexing)

这个过程会将由字符组成的字符串分解为有意义的代码块,这些代码块被称为词法单元(token)。

解析/语法分析(parsing)

这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为”抽象语法树“(AST:Abstract Syntax Tree)。

代码生成

将AST转换为可执行代码的过程称被称为代码生成。简单的说就是有某种方法可以将var a = 2;的AST转化为一组机器指令,用来创建一个叫作a的变量(包括分配内存等),并将一个值储存在a中。

理解作用域

想要完全理解作用域,需要先理解引擎。

模块

  • 引擎:从头到尾负责整个JS程序的编译及执行过程。
  • 编译器:引擎的好朋友之一,负责语法分析及代码生成等脏活累活
  • 作用域:引擎的另一位好朋友,负责收集并维护由所有生命的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对标识符的访问权限。

声明

例如var a = 2;这段看起来是一个声明,在JS里其实是俩个完全不同的声明,一个又编译器在编译时处理,另一个则由引擎在运行时处理。下面我们看看引擎是如何和编译器、作用域协同工作的。 编译器首先会将这段程序分解成词法单元,然后将词法单元解析为一个树结构。但是当编译器开始执行代码生成的时候,对这段代码的处理方式和预期有所不一样。 我们的预期是:”为一个变量分配内存,将其命名为a,然后将值2保存进这个变量。“ 但事实上编译器会进行如下处理:

  1. 遇到var a,编译器会先询问作用域是否已经有这个变量存在,没有则会在作用域集合声明一个新的变量a;如果已经有了,则忽略该声明继续编译。
  2. 接下来为引擎生成运行时所需的代码,这些代码被用来处理a = 2这个赋值操作。引擎运行时会首先询问作用域,再当前的作用域集合里是否有变量a,如果有变量a,则直接使用;如果没有则向上继续查找。最终如果找到了a,则进行赋值操作,没有则进行异常抛错。

总结:变量的赋值操作会执行俩个动作,首先去在当前作用域中声明一个变量(如果之前没声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就进行赋值。

引用执行

编译器在编译的过程的第二步中生成了代码,引擎执行他时,会通过查找变量a来判断它是否已声明过。查找的过程由作用域进行协助,当变量出现在赋值操作的左侧时进行LHS查询,出现在右侧进行RHS查询。 RHS可以理解为retrieve his source value(取到它的源值)。 例如:console.log(a); 其中对a的引用是一个RHS引用,这里没有赋予任何值。 相比下a=2;是LHS引用,赋值操作找到一个目标。

作用域嵌套

作用域是根据名称查找变量的一套规则。实际情况中,通常要同时查找好几个作用域。当一个块或函数嵌套在另一个块或者函数中时,就发生了作用域的嵌套。在当前作用域无法找到某个变量时,引擎就会在外层嵌套的作用域中进行继续查找,直到查到该变量或者最外层的全局作用域为止。

function foo(a) {
    // 其中b进行的RHS引用无法再函数foo内部完成,但可以在上一级作用域中完成
    console.log(a + b);
}

var b = 2;

foo(3); // 5

引用异常

在变量还没有声明的情况下,LHS和RHS的行为是不一样的。 如果RHS查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出ReferenceError的异常类型。 如果RHS找到了一个变量,但你尝试对这个变量的值进行不合理的操作,比如试图对非函数类型的值进行调用,或者引用null或undefined的值中的属性会报TypeError。

function foo(a) {
    // 对b进行RHS查询时是无法找到该变量的
    console.log(a + b);
    b = a;
}

foo(2); // b is not defined

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

function foo(a) {
    // 对b进行LHS查询时,没找到会创建一个全局变量
    b = a;
    console.log(a + b);
}
foo(2); // 4

小结

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。赋值操作符会导致LHS查询。=操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作。

词法作用域

作用域有俩种主要的工作模型。第一种是最为普遍的词法作用域,另一种是动态作用域。JS采用的是词法作用域。

词法阶段

编译器第一个工作阶段叫作词法化,词法化的过程会对源代码中的字符进行检查 ,如果有状态的解析过程,还会赋予单词语义。无论函数在哪里被调用或如何调用,词法作用域只由函数被声明时所处的位置决定。作用域查找会在第一个匹配的标识符时停止,在多层的嵌套作用域中可以定义同名的标识符,这叫作”遮蔽效应“。

欺骗词法

词法作用域完全由些代码期间函数所声明的位置来定义,怎样才能在运行时来”修改“(欺骗)词法作用域呢?JS中有俩种机制来实现这个目的,使用这俩种机制并不是好主意,而且欺骗词法作用域会导致性能下降。

eval

eval(...)函数可以接受一个字符串为参数,并运行这个字符串来实现修改词法作用域环境。

function foo(str, a) {
    eval( str ); // 欺骗!
    console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3

在严格模式下,eval在运行时有其自己语法作用域,意味着其中的声明无法修改所在的作用域。

function foo(str) {
    "use strict";
    eval( str );
    console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2"); 

JS中还有一些其他功能效果和eval相似的方法,例如setTimeout(...)和setInterval(...)的第一个参数可以是字符串,可以被解析为一段动态生成的函数代码。这些功能已经过时且并不被提倡。不要使用他们!

with

JS中另一个难以掌握的用来欺骗词法作用域的功能是with关键字。with通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。

var obj = {
    a: 1,
    b: 2,
    c: 3
};
// 单调乏味的重复”obj“
obj.a = 2;
obj.b = 3;
obj.c = 4;
// 简单的快捷方式
with (obj) {
    a = 3;
    b = 4;
    c = 5;
}

但这样的使用方式会有奇怪的副作用,例如:

function foo(obj) {
    with (obj) {
        a = 2;
    }
}

var o1 = { a: 3 };
var o2 = { b: 3 };
foo(o1);
console.log(o1.a); // 2

foo(o2);
console.log(o2.a); // undefined
console.log(a); // 2 --a被泄露到全局作用域上了

当我们将o2作为作用域时,其中并没有a标识符,因此进行了正常的LHS标识符查找,在o2、foo、全局作用域都没有找到时,在非严格模式下自动创建了一个全局变量。

性能

JS引擎会在编译阶段进行数项性能优化。其中有些优化依赖于代码词法的静态分析,并预先确定所以变量和函数的定义位置,才能在执行过程中快速找到标识符。如果代码中使用了eval或with,它只能简单地假设关于标识符位置的判断都是无效的,因此代码中的优化就没有了意义。如果大量使用eval和with,运行起来会非常慢。

小结

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。 JavaScript 中有两个机制可以“欺骗”词法作用域:eval(..) 和 with。前者可以对一段包含一个或多个声明的“代码”字符串进行演算,并借此来修改已经存在的词法作用域(在运行时)。后者本质上是通过将一个对象的引用当作作用域来处理,将对象的属性当作作用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时)。 这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们。

函数作用域和块作用域

函数中的作用域

函数作用域是指属于这个函数的全部变量都可以在整个函数的范围内使用及复用。这种设计方案是非常有用的,能充分利用JS变量根据需要改变值类型的”动态“特性。

隐藏内部实现

把变量和函数包裹在一个函数的作用域中,然后用这个作用域来”隐藏“它们。

function doSomething(a) {
    function doSomeThingElse(a) {
        return a - 1;
    }
    var b;
    b = a + doSomeThingElse(a*2);
    console.log(b*3);
}
doSomething(2); // 15
// b 和doSomeThingElse都无法从外部被访问,只能被doSomething所控制。从设计角度上,内容私有化了

”隐藏“作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,俩个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。

函数作用域

匿名和具名

setTimeout(function () {
    console.log('I waited 1 second!');
}, 1000);

因为function没有名称标识符,这就叫作匿名函数表达式。函数表达式可以匿名,但函数声明则不能省略函数名。

立即执行函数表达式

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

console.log(a); // 2

函数被包含在一对( )括号内部,因此成为一个表达式,通过在末尾加上另外一个( )可以立即执行这个函数,专业术语叫做IIFE(Immediately Invoked Function Expression)。 IIFE的另一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去。例如:

var a = 2;
(function IIFE(global) {
    var a = 3;
    console.log(a); // 3
    console.log(global.a); // 2
})(global);

console.log(a); // 2

IIFE还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在IIFE执行之后当作参数传递进去。这种模式在UMD项目中被广泛应用。

var a = 2;
(function IIFE(def) {
    def(window)
})(function def(global) {
    var a = 3;
    console.log(a); // 3
    console.log(global.a); // 2
})

块作用域

块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息扩展为在块中隐藏信息。

with

它不仅是一个难以理解的结构,也是块作用域的一个例子,用with从对象中创建出的作用域仅在with声明中而非外部作用域中有效。

try/catch

非常少有人注意到try/catch分句会创建一个块作用域,其中声明的变量仅在catch内部有效。

try {
    undefined(); // 执行一个异常
} catch (err) {
    console.log(err); // 能够执行
}
console.log(err); // ReferenceError: err not found

let

let关键字可以将变量绑定到所在的任意作用域中(通常是{...}内部)。

var foo = true;
if (foo) {
    let bar = 2;
}

console.log(bar); // ReferenceError

const

ES6还引入了const,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误。

if (true) {
    var a = 2;
    const b = 3; // 包含在if中的块作用域常量
    a = 3; // 正常
    b = 4; // 错误
}

console.log(a); // 3
console.log(b); // ReferenceError

小结

函数是 JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会 在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。 但函数不是唯一的作用域单元。块作用域指的是变量和函数不仅可以属于所处的作用域, 也可以属于某个代码块(通常指 { .. } 内部)。 从 ES3 开始,try/catch 结构在 catch 分句中具有块作用域。 在 ES6 中引入了 let 关键字(var 关键字的表亲),用来在任意代码块中声明变量。if (..) { let a = 2; } 会声明一个劫持了 if 的 { .. } 块的变量,并且将变量添加到这个块 中。

作用域提升

先声明还是先赋值

一般来说JS代码是在从上到下一行一行执行的,但实际上并不完全正确,存在作用域提升的特殊情况,例如:

a = 2;
var a;
console.log(a); // 2 不是undefined
console.log(a); // undefined
var a = 2;

编译器的作用

引擎会在执行JS代码前进行编译,编译阶段的一部分工作就是找到所有的声明,并用合适的作用域关联起来。所以正确的思路是,包括变量和函数在内的所有声明都是在代码执行前先处理。定义声明在编译阶段进行,赋值声明在原地等待执行阶段。上面的例子可以被解析为:

var a;
a = 2;
console.log(a); // 2

第二个解析为:

var a;
console.log(a); // undefined
a = 2;

这个过程就好像变量和函数声明从他们的代码中出现的位置被“移动”到了最上面。这个过程就叫作提升。

函数优先

函数声明和变量声明都会被提升,但函数会优先被提升,然后才是变量。

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

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

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

尽量避免在块内部声明函数,这个行为并不可靠。

小结

无论作用域中的声明在什么地方,都将在代码本身被执行前先进行处理,这一过程被称为提升。同时需要注意避免重复声明,特别是当普通的var声明和函数声明混合在一起的时候,会引起很多危险的问题!

作用域闭包

闭包无处不在

闭包无处不在,你只需要能够识别并拥抱它。

闭包的实质

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会有对原始定义作用域的引用,这就叫作闭包。闭包的神奇之处在于阻止外层作用域被引擎的垃圾回收器回收。 例如: 内部函数调用外部引用

function foo() {
    var a = 2;
    function bar() {
        console.log(a); // 2
    }
    bar();
}
foo(); // 这是闭包

把函数作为返回值,保持对外部的引用

function foo() {
    var a = 2;
    function bar() {
        console.log(a);
    }
    return bar;
}
var baz = foo();
baz(); // 2 这就是闭包的效果

对函数类型的值进行传递,在别处调用

function foo() {
    var a = 2;
    function baz() {
        console.log(a) // 2
    }
    bar(baz);
}

function bar(fn) {
    fn(); // 这也是闭包
}

间接传递函数

var fn;
function foo() {
    var a = 2;
    function baz() {
        console.log(a) // 2
    }
    fn = baz; // 将baz分配
}

function bar() {
    fn(); // 这也是闭包
}
foo();
bar(); // 2

闭包的使用

闭包的使用其实非常广泛,在我们无意中写的代码里有很多闭包。在定时器、事件监听、ajax处理、通信、异步或同步任务中,只要使用了回调函数,实际上就是在使用闭包! 定时器的使用:

function wait(msg) {
    setTimeout(function timer() {
        console.log(msg)
    }, 1000);
}

wait('hell0'); // wait执行1000毫秒后,它的内部作用域并不会消失,timer函数依然保有wait的作用域的闭包

事件监听:

function setupBot(name, selector) {
    $(selector).click(function (){
        console.log('Activating: ' + name);
    })
}

setupBot('name1', '#id1');

循环和闭包

要说明闭包,for循环是最常见的例子。

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

正常情况下,我们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个。 但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。

因为延迟函数的回调会在循环结束时才执行。事实上,当定时器运行时即使每个迭代中执行的是 setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个 6 出来。

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

缺陷是什么?我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。

IIFE 会通过声明并立即执行一个函数来创建作用域。

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

对这段代码进行改进,传入参数

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

当然现在可以使用ES6的let声明,来劫持块作用域:

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

模块

还有其他的代码模式利用闭包的强大威力,但从表面上看,它们似乎与回调无关。下面一起来研究其中最强大的一个:模块。

正如下面代码中所看到的,这里并没有明显的闭包,只有两个私有数据变量 something和 another,以及 doSomething() 和 doAnother() 两个内部函数,它们的词法作用域(而这就是闭包)也就是 foo() 的内部作用域。

function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];
    function doSomething() {
        console.log( something );
    }
    function doAnother() {
        console.log( another.join("!") );
    }
    
    return {
        doSomething: doSomething,
        doAnother: doAnother,
    }
}

var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1!2!3

模块模式需要具备两个必要条件。

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

上面的示例是个独立的模块创建器,可以被调用任意多次,每次调用都会创建一个新的模块实例。当只需要一个实例时,可以对这个模式进行简单的改进来实现单例模式:

var foo = (function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];
    function doSomething() {
        console.log( something );
    }
    function doAnother() {
        console.log( another.join("!") );
    }
    
    return {
        doSomething: doSomething,
        doAnother: doAnother,
    }
})();

foo.doSomething(); // cool
foo.doAnother(); // 1!2!3

我们将模块函数转成了IIFE,立即调用这个函数并将返回值直接赋值给单例的模块实例标识符foo。

现代的模块机制

大多数模块依赖加载器/管理器本质上都是将这种模块定义封装进一个友好的API。

var MyModules = (function Manager() {
    var moduless = {};
    
    function define(name, deps, impl) {
        for (var i = 0; i < deps.length; i++) {
            deps[i] = modules[deps[i]];
        }
        modules[name] = impl.apply(impl, deps);
    } 
    
    function get (name) {
        return modules[name];
    }
    
    return {
        define: define,
        get: get
    }
})();

这段代码的核心是modules[name] = impl.apply(impl, deps)。为了模块的定义引入了包装函数(可以传入任何依赖),并且将返回值,也就是模块的API,存储在一个根据名字来管理的模块列表中。

// 下面展示了如何使用它来定义模块
MyModules.define("bar", [], function() {
    function hello(who) {
        return "Let me introduce: " + who;
    }
    
    return {
        hello: hello
    }
});

MyModules.define("foo", ["bar"], function(bar) {
    var hungry = "hippo";
    function awesome() {
        console.log(bar.hello(hungry).toUpperCase());
    }
    
    return {
        awesome: awesome
    }
});

var bar = MyModules.get("bar");
var foo = MyModules.get("foo");

console.log(bar.hello("hippo")); // Let me introduce: hippo
foo.awesome(); // LET ME INTRODUCE: HIPPO

foo和bar模块都是通过一个返回公共的API的函数来定义的。foo还接受bar作为依赖参数,并能相应的使用它。

未来的模块机制

ES6中为模块增加了一级语法支持。通过模块系统进行加载时,ES6会将文件当作独立的模块来处理。每个模块可以导入其他模块或特定的API,同样也可以导出自己的API成员。

相比之下,ES6的模块API更加稳定(API不会在运行时改变)。

// bar.js
function hello(who) {
    return "Let me introduce: " + who;
}
export hello;

// foo.js
// 仅从 "bar" 模块导入 hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {
    console.log(hello( hungry ).toUpperCase());
}
export awesome;

// baz.js
// 导入完整的 "foo" 和 "bar" 模块
module foo from "foo";
module bar from "bar";
console.log(bar.hello( "rhino" )); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO

import 可以将一个模块中的一个或多个 API 导入到当前作用域中,并分别绑定在一个变量上(在我们的例子里是 hello)。module 会将整个模块的 API 导入并绑定到一个变量上(在我们的例子里是 foo 和 bar)。export 会将当前模块的一个标识符(变量、函数)导出为公共 API。这些操作可以在模块定义中根据需要使用任意多次。

小结

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

闭包也是一个非常强大的工具,可以哟个多种形式来实现模块等模式。

动态作用域

JS并不具有动态作用域,它只有词法作用域,但this机制在某种程度上很像动态作用域。 主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。

this词法

var obj = {
    id: "awesome",
    cool: function coolFn() {
        console.log( this.id );
    }
};
var id = "not awesome"
obj.cool(); // awesome
setTimeout( obj.cool, 100 ); // not awesome

this和对象原型

关于this

this关键字是JS中最复杂的机制之一,被自动定义在所有函数的作用域中。

this的误解

为什么要用this

this 提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将 API 设计得更加简洁并且易于复用。随着你的使用模式越来越复杂,显式传递上下文对象会让代码变得越来越混乱,使用 this则不会这样。

它的作用域

this在任何情况下都不指向函数的词法作用域。

function foo() {
    var a = 2;
    this.bar();
}

function bar() {
    console.log(this.a);
}
foo(); // RefrenceError: a is not defined

this到底是什么

this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。 当一个函数被调用时,会创建一个活动记录(也被称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this就是记录的其中一个属性,会在函数执行的过程中用到。

小结

this既不指向函数自身也不指向函数的词法作用域,你也许被这样的解释误导过,但其实它们都是错误的。this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。

this全面解析

调用位置

在理解this的绑定过程之前,首先要理解调用位置:调用位置就是函数在代码中被调用的位置(而不是声明的位置)。只有仔细分析调用位置才能回答这个问题:这个this到底引用的是什么?

绑定规则

默认绑定

独立函数调用:函数调用时应用了this的默认绑定,因此this指向了全局对象。

fucntion foo () {
    console.log(this.a);
}
var a = 2;
foo(); // 2

如果使用严格模式,全局对象讲无法使用默认绑定,因此this会绑定到undefined。

隐式绑定

这条规则需要考虑调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含。

function foo () {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
}
obj.foo(); // 2

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

function foo() {
    console.log( this.a );
}
var obj2 = {
    a: 42,
    foo: foo
};
var obj1 = {
    a: 2,
    obj2: obj2
};
obj1.obj2.foo(); // 42

隐式丢失: 一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把 this 绑定到全局对象或者 undefined 上,取决于是否是严格模式。

function foo() {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
};
var bar = obj.foo; // 函数别名
var a = "oops,global"; // a是全局对象的属性
bar(); // "oops,global"

虽然bar是obj.foo的一个引用,但是实际上,它引用的是foo函数本身,因此此时的bar() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定。 一种更微妙、更常见并且更出乎意料的情况发生在传入回调函数时:

function foo () {
    console.log(this.a);
}
function doFoo(fn) {
    // fn引用的是foo
    fn(); // 调用位置!
}
var obj = {
    a: 2,
    foo: foo
}
var a = "oops, global"; // a是全局对象的属性
doFoo(obj.foo); // "oops,global"

参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值。

显示绑定

JS提供call和apply俩个函数去做this的显示绑定,第一个参数是一个对象,会把这个对象绑定到this。

function foo () {
    console.log(this.a);
}
var obj = {
    a: 2
}
foo.call(obj); // 2

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

简单的辅助绑定函数

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

new绑定

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

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

使用 new 来调用 foo(..) 时,我们会构造一个新对象并把它绑定到 foo(..) 调用中的 this上。new 是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定。

优先级

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

绑定例外

被忽略的this

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

function foo() {
    console.log( this.a );
}
var a = 2;
foo.call( null ); // 2

更安全的this 一种“更安全”的做法是传入一个特殊的对象,把 this 绑定到这个对象不会对你的程序产生任何副作用。它就是一个空的非委托的对象。 JS中创建一个空对象最简单的方法都是 Object.create(null)。Object.create(null) 和{}很像,但是并不会创建Object.prototype这个委托,所以它比{}“更空”

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);
bar(3); // a:2, b: 3

间接引用

间接引用最容易在赋值时发生:

function foo() {
    console.log(this.a);
}
var a = 2;
var o = { a: 3, foo: foo };
var p = { a:4 };
o.foo(); // 3
(p.foo = o.foo)(); // 2

赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,因此调用位置是 foo() 而不是p.foo() 或者 o.foo()。根据我们之前说过的,这里会应用默认绑定。

软绑定

硬绑定这种方式可以把 this 强制绑定到指定的对象(除了使用 new时),防止函数调用应用默认绑定规则。问题在于,硬绑定会大大降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改 this。 如果可以给默认绑定指定一个全局对象和 undefined 以外的值,那就可以实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改 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;
    };
}

它会对指定的函数进行封装,首先检查调用时的this,如果this绑定到全局对象或者undefined,那就把指定的默认对象obj绑定到this,否则不会修改 this。此外,这段代码还支持可选的柯里化。

function foo() {
    console.log("name: " + this.name);
}
var obj = { name: "obj" },
obj2 = { name: "obj2" },
obj3 = { name: "obj3" };
var fooOBJ = foo.softBind( obj );
fooOBJ(); // name: obj
obj2.foo = foo.softBind(obj);
obj2.foo(); // name: obj2
fooOBJ.call( obj3 ); // name: obj3
setTimeout( obj2.foo, 10 );
// name: obj <---- 应用了软绑定

this词法

箭头函数并不是使用 function 关键字定义的,而是使用被称为“胖箭头”的操作符 => 定义的。箭头函数不使用 this 的四种标准规则,而是根据外层(函数或者全局)作用域来决定 this。

function foo() {
    // 返回一个箭头函数
    return (a) => {
        //this 继承自 foo()
        console.log( this.a );
    };
}
var obj1 = {
    a:2
};
var obj2 = {
    a:3
};
var bar = foo.call( obj1 );
bar.call( obj2 ); // 2, 不是 3

小结

如果要判断一个运行中函数的this绑定,就需要找到这个函数的直接调用位置。找到之后就可以顺序应用下面这四条规则来判断this的绑定对象。

  1. 由 new 调用?绑定到新创建的对象。
  2. 由 call 或者 apply(或者 bind)调用?绑定到指定的对象。
  3. 由上下文对象调用?绑定到那个上下文对象。
  4. 默认:在严格模式下绑定到 undefined,否则绑定到全局对象。

对象

语法

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

var myobj = {
    key: value
}

构造形式:

var myObj = new Object();
myObj.key = value;

构造形式和文字形式生成的对象是一样的。唯一的区别是,在文字声明中你可以添加多个键 / 值对,但是在构造形式中你必须逐个添加属性。

类型

对象是JS的基础。在JS中一共有六种主要类型:

  • string
  • number
  • boolean
  • null
  • undefined
  • object 简单基本类型(string、boolean、number、null 和 undefined)本身并不是对象。null 有时会被当作一种对象类型,但是这其实只是语言本身的一个 bug,即对 null 执行typeof null 时会返回字符串 "object"。实际上,null 本身是基本类型。

内置对象 JS中还有一些对象子类型,通常被称为内置对象。有些内置对象的名字看起来和简单基础类型一样,不过实际上更复杂。

  • String
  • Number
  • Boolean
  • Object
  • Function
  • Array
  • Date
  • RegExp
  • Error

在其他语言里,这些都是类型(Type)或者类(Class),但在JS中,他们实际上只是一些内置函数。

内容

如果要访问 myObject 中 a 位置上的值,我们需要使用 . 操作符或者 [] 操作符。.a 语法通常被称为“属性访问”,["a"] 语法通常被称为“键访问”

var myObject = {
    a: 2,
}
myObject.a; // 2
myObject["a"]; // 2

可计算属性名

ES6 增加了可计算属性名,可以在文字形式中使用 [] 包裹一个表达式来当作属性名:

var prefix = "foo";
var myObject = {
    [prefix + "bar"]:"hello",
    [prefix + "baz"]: "world"
};
myObject["foobar"]; // hello
myObject["foobaz"]; // world

属性与方法

如果访问的对象属性是一个函数,有些开发者喜欢叫它方法。

数组

数组也支持[]访问形式,数组也是对象,可以给数组添加属性:

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

虽然添加了命名属性,数组的length值并未发生变化。但是如果你试图向数组添加一个属性,但属性名看起来像一个数字,那么它会变成一个数值下标:

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

复制对象

复制对象要考虑浅复制还是深复制,浅复制对于复制对象和数组等深层次属性只是引用,深复制是复制深层次的属性。 对于 JSON 安全(也就是说可以被序列化为一个 JSON 字符串并且可以根据这个字符串解析出一个结构和值完全一样的对象)的对象来说,有一种巧妙的复制方法:

// JSON处理的深拷贝
var newObj = JSON.parse( JSON.stringify( someObj ) );

当然,这种方法需要保证对象是 JSON 安全的,所以只适用于部分情况。

浅复制:ES6定义了Object.assign方法来实现浅复制。

var newObj = Object.assign({}, myObject);

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

属性描述符

从ES5开始,JS所有的属性都具备了属性描述符。

var myObject = {
    a:2
};
Object.getOwnPropertyDescriptor( myObject, "a" );
// {
// value: 2,
// writable: true,
// enumerable: true,
// configurable: true
// }

它还包含另外三个特性:writable(可写)、configurable(可配置)和 enumerable(可枚举)。

  1. writable(可写) 你可以把 writable:false 看作是属性不可改变,相当于你定义了一个空操作 setter。
var myObject = {};
Object.defineProperty(myObject, "a", {
    value: 2,
    writable: false, // 不可写!
    configurable: true,
    enumerable: true
});
myObject.a = 3;
myObject.a; // 2
  1. configurable(可配置) 只要属性是可配置的,就可以使用 defineProperty(..) 方法来修改属性描述符。要注意有一个小小的例外:即便属性是 configurable:false,我们还是可以把 writable 的状态由 true 改为false,但是无法由 false 改为 true。
var myObject = {
    a:2
};
myObject.a = 3;
myObject.a; // 3
Object.defineProperty( myObject, "a", {
    value: 4,
    writable: true,
    configurable: false, // 不可配置!
    enumerable: true
} );
myObject.a; // 4
myObject.a = 5;
myObject.a; // 5
Object.defineProperty( myObject, "a", {
    value: 6,
    writable: true,
    configurable: true,
    enumerable: true
} ); // TypeError

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

var myObject = {
    a:2
};
myObject.a; // 2
delete myObject.a;
myObject.a; // undefined

Object.defineProperty( myObject, "a", {
    value: 2,
    writable: true,
    configurable: false,
    enumerable: true
} );
myObject.a; // 2
delete myObject.a; // 无法删除
myObject.a; // 2
  1. enumerable(可枚举) 这个描述符控制的是属性是否会出现在对象的属性枚举中,比如说for..in循环。如果把 enumerable 设置成 false,这个属性就不会出现在枚举中,虽然仍然可以正常访问它。相对地,设置成 true 就会让它出现在枚举中。

不变性

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

var myObject = {};
Object.defineProperty( myObject, "FAVORITE_NUMBER", {
    value: 42,
    writable: false,
    configurable: false
} );

2. 禁止扩展 如果你想禁止一个对象添加新属性并且保留已有属性,可以使用Object.preventExtensions(..):

var myObject = {
    a:2
};
Object.preventExtensions( myObject );
myObject.b = 3;
myObject.b; // undefined

3. 密封 Object.seal(..) 会创建一个“密封”的对象,这个方法实际上会在一个现有对象上调用Object.preventExtensions(..) 并把所有现有属性标记为 configurable:false。 所以,密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(虽然可以修改属性的值)。

4. 冻结 Object.freeze(..) 会创建一个冻结对象,这个方法实际上会在一个现有对象上调用Object.seal(..) 并把所有“数据访问”属性标记为 writable:false,这样就无法修改它们的值。 这个方法是你可以应用在对象上的级别最高的不可变性,它会禁止对于对象本身及其任意直接属性的修改

[[Get]]

var myObject = {
    a: 2
};
myObject.a; // 2

myObject.a在myObject上实际上是实现了[[Get]]操作,对象默认的内置 [[Get]] 操作首先在对象中查找是否有名称相同的属性,如果找到就会返回这个属性的值,找不到则返回undefined。

[[Put]]

[[Put]] 被触发时,实际的行为取决于许多因素,包括对象中是否已经存在这个属性(这是最重要的因素)。 如果已经存在这个属性,[[Put]] 算法大致会检查下面这些内容。

  1. 属性是否是访问描述符(参见 3.3.9 节)?如果是并且存在 setter 就调用 setter。
  2. 属性的数据描述符中 writable 是否是 false ?如果是,在非严格模式下静默失败,在严格模式下抛出 TypeError 异常。
  3. 如果都不是,将该值设置为属性的值。 如果对象中不存在这个属性,[[Put]] 操作会更加复杂。

Getter和Setter

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

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

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

存在性

如 myObject.a 的属性访问返回值可能是 undefined,但是这个值有可能是属性中存储的 undefined,也可能是因为属性不存在所以返回 undefined。那么如何区分这两种情况呢?

var myObject = {
    a:2
};
("a" in myObject); // true
("b" in myObject); // false
myObject.hasOwnProperty( "a" ); // true
myObject.hasOwnProperty( "b" ); // false

in操作符会检查属性是否在对象及其[[Prototype]]原型链中。 hasOwnProperty只会检查属性是否在myObject对象中,不会检查[[Prototype]]链。

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

1. 枚举

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循环中。

也可以通过propertyIsEnumerable和Object.keys来区分是否可枚举

var myObject = { };
Object.defineProperty(
    myObject,
    "a",
    // 让 a 像普通属性一样可以枚举
    { enumerable: true, value: 2 }
);
Object.defineProperty(
    myObject,
    "b",
    // 让 b 不可枚举
    { enumerable: false, value: 3 }
);
myObject.propertyIsEnumerable( "a" ); // true
myObject.propertyIsEnumerable( "b" ); // false
Object.keys( myObject ); // ["a"]
Object.getOwnPropertyNames( myObject ); // ["a", "b"]

propertyIsEnumerable会检查给定的属性名是否直接存在于对象中(而不是在原型链上)并且满足enumerable:true。 Object.keys会返回一个数组,包含所有可枚举属性,Obejct.getOwnPropertyNames会返回一个数组,包含所有属性,无论他们是否可枚举。 in和hasOwnProperty的区别在于是否查找[[Prototype]]链,然而,Object.keys和Obejct.getOwnPropertyNames都只会查找对象直接包含的属性。

遍历

for..in 循环可以用来遍历对象的可枚举属性列表(包括 [[Prototype]] 链)。但是如何遍历属性的值呢? ES5中增加了一些数组的辅助迭代器,包括 forEach(..)、every(..) 和 some(..)。每种辅助迭代器都可以接受一个回调函数并把它应用到数组的每个元素上,唯一的区别就是它们对于回调函数返回值的处理方式不同。 forEach(..) 会遍历数组中的所有值并忽略回调函数的返回值。every(..) 会一直运行直到回调函数返回 false(或者“假”值),some(..) 会一直运行直到回调函数返回true(或者“真”值)。 ES6增加了一种用来遍历数组的 for..of 循环语法:

var myArray = [ 1, 2, 3 ];
for (var v of myArray) {
console.log( v );
}
// 1
// 2
// 3

小结

JS中对象有字面形式和构造形式,一般字面形式更常用。

混合对象”类“

这章主要介绍和类相关的面向对象编程。还要了解面向类的设计模式:实例化、继承和多态。

类理论

类/继承描述了一种代码的组织结构形式,一种在软件中对真实世界中问题领域的建模方法。

"类"设计模式

JS是在(低级)面向类的基础上实现了所有(高级)设计模式,比如迭代器模式、观察者模式、工厂模式、单例模式,等等。

JS中的"类"

JS很长一段时间里,JS只有一些近似类的语法元素(比如new和instanceof), 不过在后来的ES6中,新增了class关键字。但这不意味着JS中实际上是有类的,只是有近似类的语法。

类的机制

在许多面向类的语言中,“标准库”会提供Stack类,它是一种“栈”数据结构(支持压入、弹出,等等)。Stack类内部会有一些变量来存储数据,同时会提供一些公有的可访问行为(“方法”),从而让你的代码可以和(隐藏的)数据进行交互(比如添加、删除数据)。

建造

把类和实例对象之间的关系看作是直接关系而不是间接关系更有助于理解。类通过复制操作被实例化为对象形式。

构造函数

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

类的继承

在面向类的语言中,你可以先定义一个类,然后定义一个继承前者的类。后者通常被称为“子类”,前者通常被称为“父类”。

多态

Car 重写了继承自父类的 drive() 方法,但是之后 Car 调用了 inherited:drive() 方法,这表明 Car 可以引用继承来的原始 drive() 方法。快艇的 pilot() 方法同样引用了原始drive() 方法。这个技术被称为多态或者虚拟多态。 在子类(而不是它们创建的实例对象!)中也可以相对引用它继承的父类,这种相对引用通常被称为 super。

多重继承

JS本身不提供“多重继承”功能。

混入

在继承或者实例化时,JS 的对象机制并不会自动执行复制行为。简单来说,JS中只有对象,并不存在可以被实例化的“类”。一个对象并不会被复制到其他对象,它们会被关联起来。 由于在其他语言中类表现出来的都是复制行为,因此JS开发者也想出了一个方法来模拟类的复制行为,这个方法就是混入。接下来我们会看到两种类型的混入:显式和隐式。

显式混入

由于JS不会自动实现 Vehicle到 Car 的复制行为,所以我们需要手动实现复制功能。这个功能在许多库和框架中被称为extend,但是为了方便理解我们称之为mixin。

// 非常简单的 mixin(..) 例子 :
function mixin( sourceObj, targetObj ) {
    for (var key in sourceObj) {
        // 只会在不存在的情况下复制
        if (!(key in targetObj)) {
            targetObj[key] = sourceObj[key];
        }
    }
    return targetObj;
}

var Vehicle = {
    engines: 1,
    ignition: function() {
        console.log( "Turning on my engine." );
    },
    drive: function() {
        this.ignition();
        console.log( "Steering and moving forward!" );
    }
};
var Car = mixin( Vehicle, {
    wheels: 4,
    drive: function() {
        Vehicle.drive.call( this );
        console.log(
            "Rolling on all " + this.wheels + " wheels!"
        );
    }
});

1. 再说多态 Vehicle.drive.call(this)这就是显式多态,JS中没有相对多态。使用伪多态通常会导致代码变得更加复杂、难以阅读并且难以维护,因此应当尽量避免使用显式伪多态,因为这样做往往得不偿失。

2. 混合复制

// 另一种混入函数,可能有重写风险
function mixin( sourceObj, targetObj ) {
    for (var key in sourceObj) { 
        targetObj[key] = sourceObj[key]; 
    }
    return targetObj; 
}
var Vehicle = { 
    // ... 
};
// 首先创建一个空对象并把 Vehicle 的内容复制进去
var Car = mixin(Vehicle, {}); 
// 然后把新内容复制到 Car 中 
mixin({ 
    wheels: 4, 
    drive: function() { 
    // ... 
    }
}, Car);

JS中的函数无法真正的复制,只能复制对共享函数对象的引用。如果你修改了共享的函数对象,比如添加一个属性,那Vehicle和Car都会收到影响。

3. 寄生继承 显式混入模式的一种变体被称为“寄生继承”,它既是显式的又是隐式的。

//“传统的 JavaScript 类”Vehicle
function Vehicle() {
    this.engines = 1;
}
Vehicle.prototype.ignition = function() {
    console.log( "Turning on my engine." );
};
Vehicle.prototype.drive = function() {
    this.ignition();
    console.log( "Steering and moving forward!" );
};
//“寄生类”Car
function Car() {
    // 首先,car 是一个 Vehicle
    var car = new Vehicle();
    // 接着我们对 car 进行定制
    car.wheels = 4;
    // 保存到 Vehicle::drive() 的特殊引用
    var vehDrive = car.drive;
    // 重写 Vehicle::drive()
    car.drive = function() {
        vehDrive.call( this );
        console.log("Rolling on all " + this.wheels + " wheels!");
    }
    return car;
}
var myCar = new Car();
myCar.drive();
// Turning on my engine.
// Steering and moving forward!
// Rolling on all 4 wheels!

首先我们复制了一份Vehicle父类(对象)的定义,然后混入子类(对象)的定义,然后用这个复合对象构建实例。

隐式混入

var Something = {
    cool: function() {
        this.greeting = "Hello World";
        this.count = this.count ? this.count + 1 : 1;
    }
};

Something.cool();
Something.greeting; // "Hello World"
Something.count; // 1

var Another = {
    cool: function() {
        // 隐式把 Something 混入 Another
        Something.cool.call( this );
    }
};
Another.cool();
Another.greeting; // "Hello World"
Another.count; // 1(count 不是共享状态)

通过在构造函数调用或者方法调用中使用Something.cool.call(this),实际上际上“借用”了函数 Something.cool() 并在 Another 的上下文中调用了它。最终的结果是 Something.cool() 中的赋值操作都会应用在 Another 对象上而不是Something 对象上。

小结

类是一种设计模式。JS也有类似语法,但和其他语言中的类完全不同。类意味着复制。多态看起来是从子类引用父类,但本质是引用复制的结果。 慎用混入模式,避免代码更加难懂并且难以维护,还容易埋下更多的隐患。

原型

[[Prototype]]

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

var myObject = {
    a:2
};
myObject.a; // 2

当你试图引用对象的属性时会触发[[Get]]操作,比如 myObject.a。对于默认的 [[Get]] 操作来说,第一步是检查对象本身是否有这个属性,如果有的话就使用它。

var anotherObject = {
    a:2
};
// 创建一个关联到 anotherObject 的对象
var myObject = Object.create( anotherObject );
myObject.a; // 2

现在 myObject 对象的 [[Prototype]] 关联到了 anotherObject。显然 myObject.a 并不存在,但是尽管如此,属性访问仍然成功地(在 anotherObject 中)找到了值2。 但是,如果anotherObject中也找不到a并且 [[Prototype]] 链不为空的话,就会继续查找下去。 这个过程会持续到找到匹配的属性名或者查找完整条 [[Prototype]] 链。如果是后者的话,[[Get]] 操作的返回值是 undefined。

Object.prototype

所有普通的 [[Prototype]] 链最终都会指向内置的 Object.prototype。

属性设置和屏蔽

myObject.foo = "bar";

如果 myObject 对象中包含名为 foo 的普通数据访问属性,这条赋值语句只会修改已有的属性值。

如果 foo 不是直接存在于 myObject 中,[[Prototype]] 链就会被遍历,类似 [[Get]] 操作。如果原型链上找不到 foo,foo 就会被直接添加到 myObject 上。

如果属性名 foo 既出现在 myObject 中也出现在 myObject 的 [[Prototype]] 链上层,那么就会发生屏蔽。myObject 中包含的foo 属性会屏蔽原型链上层的所有 foo 属性,因为myObject.foo 总是会选择原型链中最底层的 foo 属性。

在于原型链上层时 myObject.foo = "bar" 会出现的三种情况。

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

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

有些情况下会隐式产生屏蔽:

var anotherObject = { a:2 };
var myObject = Object.create( anotherObject );
anotherObject.a; // 2
myObject.a; // 2

anotherObject.hasOwnProperty( "a" ); // true
myObject.hasOwnProperty( "a" ); // false

myObject.a++; // 隐式屏蔽!
anotherObject.a; // 2
myObject.a; // 3

myObject.hasOwnProperty( "a" ); // true

“类”

JS和面向类的语言不同,它并没有类来作为对象的抽象模式或者蓝图,JS中只有对象。

“类函数”

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

function Foo() {
    // ...
}
Foo.prototype; // { }

这个对象通常被称为Foo的原型。这个对象是在调用new Foo时创建的,最后会被关联到这个“FooD点prototype”对象上。

function Foo() {
    // ...
}
var a = new Foo();
Object.getPrototypeOf( a ) === Foo.prototype; // true

在JS中,我们并不会将一个对象象(“类”)复制到另一个对象(“实例”),只是将它们关联起来。这一机制被称为原型继承。

“构造函数”

function Foo() {
    // ...
}
Foo.prototype.constructor === Foo; // true
var a = new Foo();
a.constructor === Foo; // true

Foo.prototype 默认有一个公有并且不可枚举的属性 .constructor,这个属性引用的是对象关联的函数(本例中是 Foo)。此外,我们可以看到通过“构造函数”调用 new Foo() 创建的对象也有一个 .constructor 属性,指向“创建这个对象的函数”。 实际上 a 本身并没有 .constructor 属性。而且,虽然 a.constructor 确实指向 Foo 函数,但是这个属性并不是表示 a 由 Foo“构造”。

构造函数还是调用 上一段代码很容易让人认为 Foo 是一个构造函数,因为我们使用 new 来调用它并且看到它“构造”了一个对象。实际上,new 会劫持所有普通函数并用构造对象的形式来调用它。 使用 new 调用时,它就会构造一个对象并赋值给 a。

技术

JS开发者绞尽脑汁想要模仿类的行为:

function Foo(name) {
    this.name = name;
}
Foo.prototype.myName = function() {
    return this.name;
};
var a = new Foo( "a" );
var b = new Foo( "b" );
a.myName(); // "a"
b.myName(); // "b"

这段代码展示了另外两种“面向类”的技巧:

  1. this.name = name 给每个对象(也就是 a 和 b)都添加了 .name 属性,有点像类实例封装的数据值。
  2. Foo.prototype.myName = ... 可能个更有趣的技巧,它会给 Foo.prototype 对象添加一个属性(函数)。

(原型)继承

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 = Object.create( Foo.prototype )。调用Object.create(..) 会凭空创建一个“新”对象并把新对象内部的 [[Prototype]] 关联到你指定的对象(本例中是 Foo.prototype)。 注意,下面这两种方式是常见的错误做法,实际上它们都存在一些问题:

// 和你想要的机制不一样!
Bar.prototype = Foo.prototype;
// 基本上满足你的需求,但是可能会产生一些副作用 :(
Bar.prototype = new Foo();

Bar.prototype = Foo.prototype 并不会创建一个关联到 Bar.prototype 的新对象,它只是让 Bar.prototype 直接引用 Foo.prototype 对象。 Bar.prototype = new Foo() 的确会创建一个关联到 Bar.prototype 的新对象。但是它使用了 Foo(..) 的“构造函数调用”,如果函数 Foo 有一些副作用(比如写日志、修改状态、注册到其他对象、给 this 添加数据属性,等等)的话,就会影响到 Bar() 的“后代”,后果不堪设想。 因此,要创建一个合适的关联对象,我们必须使用 Object.create。同时ES6添加了辅助函数 Object.setPrototypeOf(..),可以用标准并且可靠的方法来修改关联。

// ES6 之前需要抛弃默认的 Bar.prototype
Bar.ptototype = Object.create( Foo.prototype );
// ES6 开始可以直接修改现有的 Bar.prototype
Object.setPrototypeOf( Bar.prototype, Foo.prototype );

检查“类”的关系 第一种方法是站在“类”的角度来判断:

function Foo() {
    // ...
}
var a = new Foo();

a instanceof Foo; // true

但这个方法只能处理对象a和函数之间的关系,无法判断俩个对象(比如a和b)之间是否通过[[Prototype]]链关联。

第二种是通过辅助函数来处理:

// 用来判断 o1 是否关联到(委托)o2 的辅助函数
function isRelatedTo(o1, o2) {
    function F(){}
    F.prototype = o2;
    return o1 instanceof F;
}
var a = {};
var b = Object.create( a );
isRelatedTo( b, a ); // true

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

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

我们也可以直接获取一个对象的 [[Prototype]] 链。在 ES5 中,标准的方法是:

Object.getPrototypeOf( a );

可以验证一下,这个对象引用是否和我们想的一样:

Object.getPrototypeOf( a ) === Foo.prototype; // true

绝大多数浏览器也支持一种非标准的方法来访问内部[[Prototype]]属性

a.__proto__ === Foo.prototype; // true

这个奇怪的.proto 属性”神奇地“引用了内部的[[Prototype]]对象,如果你想直接查找原型链的话,这个方法非常有用。 .proto 的实现大致上是这样的:

Object.defineProperty(Object.prototype, "__proto__", {
    get: function() {
        return Object.getPrototypeOf(this);
    },
    set: function(o) {
        // ES6 中的 setPrototypeOf(..)
        Object.setPrototypeOf(this, o);
        return o;
    }
});

对象关联

如果在对象上没有找到需要的属性或者方法引用,引擎就会继续在 [[Prototype]] 关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的 [[Prototype]],以此类推。这一系列对象的链接被称为“原型链”。

创建关联

var foo = {
    something: function() {
        console.log( "Tell me something good..." );
    }
};
var bar = Object.create( foo );
bar.something(); // Tell me something good...

Object.create(..) 会创建一个新对象(bar)并把它关联到我们指定的对象(foo),这样我们就可以充分发挥 [[Prototype]] 机制的威力(委托)并且避免不必要的麻烦(比如使用 new 的构造函数调用会生成 .prototype 和 .constructor 引用)。

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

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

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

关联关系是备用

这种直接委托由于存在 [[Prototype]] 机制,这段代码可以正常工作,但你的软件就会变得很难理解和维护。

var anotherObject = {
    cool: function() {
        console.log( "cool!" );
    }
};
var myObject = Object.create( anotherObject );
myObject.cool(); // "cool!"

这里我们调用的 myObject.doCool() 是实际存在于myObject 中的,这可以让我们的 API 设计更加清晰。从内部来说,我们遵循的是委托设计模式,通过 [[Prototype]] 委托到anotherObject.cool。内部委托比起直接委托可以让 API 接口设计更加清晰。

var anotherObject = {
    cool: function() {
        console.log( "cool!" );
    }
};
var myObject = Object.create( anotherObject );
myObject.doCool = function() {
    this.cool(); // 内部委托!
};
myObject.doCool(); // "cool!"

小结

使用 new 调用函数时会把新对象的 .prototype 属性关联到“其他对象”。带 new 的函数调用通常被称为“构造函数调用”,尽管它们实际上和传统面向类语言中的类构造函数不一样。

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

行为委托

面向委托的设计

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

类理论

类设计模式鼓励你在继承时使用方法重写(和多态),比如说在 XYZ 任务中重写 Task 中定义的一些通用方法,甚至在添加新行为时通过 super 调用这个方法的原始版本。你会发现许多行为可以先“抽象”到父类然后再用子类进行特殊化(重写)。 下面是对应的伪代码:

class Task {
    id;
    // 构造函数 Task()
    Task(ID) { id = ID; }
    outputTask() { output( id ); }
}
class XYZ inherits Task {
    label;
    // 构造函数 XYZ()
    XYZ(ID,Label) { super( ID ); label = Label; }
    outputTask() { super(); output( label ); }
}
class ABC inherits Task {
    // ...
}

现在你可以实例化子类 XYZ 的一些副本然后使用这些实例来执行任务“XYZ”。这些实例会复制 Task 定义的通用行为以及 XYZ 定义的特殊行为。

委托理论

我们试着来使用委托行为而不是类来思考同样的问题。 首先你会定义一个名为 Task 的对象,它会包含所有任务都可以使用(写作使用,读作委托)的具体行为。接着,对于每个任务(“XYZ”、“ABC”)你都会定义一个对象来存储对应的数据和行为。你会把特定的任务对象都关联到 Task 功能对象上,让它们在需要的时候可以进行委托。

Task = {
    setID: function(ID) { this.id = ID; },
    outputID: function() { console.log( this.id ); }
};
// 让 XYZ 委托 Task
XYZ = Object.create( Task );
XYZ.prepareTask = function(ID,Label) {
    this.setID( ID );
    this.label = Label;
};
XYZ.outputTaskDetails = function() {
    this.outputID();
    console.log( this.label );
};
// ABC = Object.create( Task );
// ABC ... = ...

在这段代码中,Task和XYZ并不是类(或者函数), 它们是对象。XYZ通过Object.create创建,它的 [[Prototype]] 委托了Task对象。 这个设计模式要求尽量少使用容易被重写的通用方法名,提倡使用更有描述性的方法名,尤其是要写清相应对象行为的类型。这样做实际上可以创建出更容易理解和维护的代码。 委托行为意味着某些对象XYZ在找不到属性或者方法引用时会把这个请求委托给另一个对象Task。 这是一种极其强大的设计模式,和父类、子类、继承、多态等概念完全不同。

比较思维模型

现在你已经明白了“类”和“委托”这两种设计模式的理论区别,接下来我们看看它们在思维模型方面的区别。 下面是典型的(“原型”)面向对象风格:

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();

子类Bar继承了父类Foo,然后生成了b1和b2两个实例。b1委托了Bar.prototype,后者委托了Foo.prototype。

使用对象关联风格来编写功能完全相同的代码:

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();

这段代码中我们同样利用 [[Prototype]] 把b1委托给Bar并把Bar委托给Foo,和上一段代码一模一样。我们仍然实现了三个对象之间的关联。 但这段代码简洁了许多,我们只是把对象关联起来,并不需要那些既复杂又令人困惑的模仿类的行为(构造函数、原型以及new)。

类与对象

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

更简洁的设计

对象关联除了能让代码看起来更简洁(并且更具扩展性)外还可以通过行为委托模式简化代码结构。

更好的语法

ES6的class语法可以更简洁的定义类方法:

class Foo {
    methodName() { /* .. */ }
}

在ES6中我们也可以在任意对象的字面形式中使用简洁方法声明,所以对象关联风格可以这么写:

var LoginController = {
    errors: [],
    getUser() { // 妈妈再也不用担心代码里有 function 了!
        // ...
    },
    getPassword() {
        // ...
    }
};

此外ES6中可以使用Object.setPrototypeOf来修改它的[[Prototype]]:

// 使用更好的对象字面形式语法和简洁方法
var AuthController = {
    errors: [],
    checkAuth() {
        // ...
    },
    server(url,data) {
        // ...
    }
};
// 现在把 AuthController 关联到 LoginController
Object.setPrototypeOf(AuthController, LoginController);

内省

很多程序员在写代码时都会检查实例的类型,类实例的自省主要目的是通过创建方式来判断对象的结构和功能。

// 例如:
function Foo() {
    // ...
}
Foo.prototype.something = function(){
    // ...
}
var a1 = new Foo();
if (a1 instanceof Foo) {
    a1.something();
}

instanceof语法会产生语义困惑而且非常不直观。如果你想检查对象a1和某个对象的关系,那必须使用另一个引用该对象的函数才行——你不能直接判断两个对象是否关联。

function Foo() { /* .. */ }
Foo.prototype...
function Bar() { /* .. */ }
Bar.prototype = Object.create( Foo.prototype );
var b1 = new Bar( "b1" )

// 内省
// 让 Foo 和 Bar 互相关联
Bar.prototype instanceof Foo; // true
Object.getPrototypeOf( Bar.prototype ) === Foo.prototype; // true
Foo.prototype.isPrototypeOf( Bar.prototype ); // true
// 让 b1 关联到 Foo 和 Bar
b1 instanceof Foo; // true
b1 instanceof Bar; // true
Object.getPrototypeOf( b1 ) === Bar.prototype; // true
Foo.prototype.isPrototypeOf( b1 ); // true
Bar.prototype.isPrototypeOf( b1 ); // true

显然这是一种非常糟糕的方法。举例来说,(使用类时)你最直观的想法可能是使用 Bar instanceof Foo(因为很容易把“实例”理解成“继承”),但是在JS中这是行不通的,你必须使用 Bar.prototype instanceof Foo。

还有一种常见但更加脆弱的内省模式,许多开发者认为它比instanceof更好:

if (a1.something) {
    a1.something();
}

这被称为“鸭子类型”,但这种方法有许多风险。例如ES6的Promise就是通过是否有.then方法判断是否是个Promise,那同时也意味着只要有then方法的就会被认为是个Promise,容易产生错误。如果有一个不是 Promise 但是具有 then() 方法的对象,那你千万不要把它用在 ES6 的Promise 机制中,否则会出错。

对象关联风格代码,其内省更加简洁:

var Foo = { /* .. */ };
var Bar = Object.create( Foo );
Bar...
var b1 = Object.create( Bar );

// 让 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

小结

在软件架构中你可以选择是否使用类和继承设计模式。大多数开发者理所当然地认为类是唯一(合适)的代码组织方式,但是本章中我们看到了另一种更少见但是更强大的设计模式:行为委托。

行为委托认为对象之间是兄弟关系,互相委托,而不是父类和子类的关系。JavaScript 的[[Prototype]] 机制本质上就是行为委托机制。

当你只用对象来设计代码时,不仅可以让语法更加简洁,而且可以让代码结构更加清晰。

ES6中的Class

class

class Widget {
    constructor(width,height) {
        this.width = width || 50;
        this.height = height || 50;
        this.$elem = null;
    }
    render($where){
        if (this.$elem) {
            this.$elem.css( {
                width: this.width + "px",
                height: this.height + "px"
            } ).appendTo( $where );
        }
    }
}
class Button extends Widget {
    constructor(width,height,label) {
        super( width, height );
        this.label = label || "Default";
        this.$elem = $( "<button>" ).text( this.label );
    }
    render($where) {
        super( $where );
        this.$elem.click( this.onClick.bind( this ) );
    }
    onClick(evt) {
        console.log( "Button '" + this.label + "' clicked!" );
    }
}

除了语法更好看之外,ES6 还解决了什么问题呢?

  1. 不再引用杂乱的 .prototype 了。
  2. Button声明时直接“继承 ”了Widget,不再需要通过Object.create(..)来替换.prototype对象,也不需要设置 .proto 或者 Object.setPrototypeOf(..)。
  3. 可以通过 super(..) 来实现相对多态。
  4. class 字面语法不能声明属性(只能声明方法)。
  5. 可以通过 extends 很自然地扩展对象(子)类型,甚至是内置的对象(子)类型。

class陷阱

class 语法并没有解决所有的问题,在 JavaScript 中使用“类”设计模式仍然存在许多深层问题。

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 语法无法定义类成员属性(只能定义方法),如果为了跟踪实例之间共享状态必须要这么做,那你只能使用丑陋的 .prototype 语法,像这样:

class C {
    constructor() {
        // 确保修改的是共享状态而不是在实例上创建一个屏蔽属性!
        C.prototype.count++;
        // this.count 可以通过委托实现我们想要的功能
        console.log( "Hello: " + this.count );
    }
}
// 直接向 prototype 对象上添加一个共享状态
C.prototype.count = 0;
var c1 = new C();
// Hello: 1
var c2 = new C();
// Hello: 2
c1.count === 2; // true
c1.count === c2.count; // true

这种方法最大的问题是,它违背了class语法的本意,在实现中暴露了.prototype。 如果使用 this.count++ 的话,我们会很惊讶地发现在对象 c1 和 c2 上都创建了 .count 属性,而不是更新共享状态。class 没有办法解决这个问题,并且干脆就不提供相应的语法支持,所以你根本就不应该这样做。

此外,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也存在一些非常细微的问题。出于性能考虑,super 并不是动态绑定的,它会在声明时“静态”绑定。

class P {
    foo() { console.log( "P.foo" ); }
}
class C extends P {
    foo() {
        super();
    }
}
var c1 = new C();
c1.foo(); // "P.foo"
var D = {
    foo: function() { console.log( "D.foo" ); }
};
var E = {
    foo: C.prototype.foo
};
// 把 E 委托到 D
Object.setPrototypeOf( E, D );
E.foo(); // "P.foo"

如果你认为super会动态绑定(非常合理!),那你可能期望super会自动识别出E委托了D,所以E.foo中的super应该调用D.foo。

静态大于动态吗

通过上面的这些特性可以看出,ES6 的 class 最大的问题在于,(像传统的类一样)它的语法有时会让你认为,定义了一个 class 后,它就变成了一个(未来会被实例化的)东西的静态定义。

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

小结

class 很好地伪装成 JavaScript 中类和继承设计模式的解决方案,但是它实际上起到了反作用:它隐藏了许多问题并且带来了更多更细小但是危险的问题。