你不知道的JavaScript(上卷)
前言
- 掌握了这些知识之后,无论什么技术、框架和流行词语你都能轻松理解。
- 可以在这里下载本书第一部分“作用域和闭包”随附的资料(代码示例、练习题等):http://bit.ly/1c8HEWF。 可以在这里下载本书第二部分“this和对象原型”随附的资料(代码示例、练习题等):http://bit.ly/ydkjs-this-code
序
- 尽管我曾经一度非常热爱制作东西,但是现在却更渴望了解事物的运行原理。我经常寻找解决问题或修复bug的最佳方法,却很少花时间来研究我所使用的工具。
- 知其然,也要知其所以然。
第1章 作用域是什么
- 正是这种储存和访问变量的值的能力将状态带给了程序
- 这些问题说明需要一套设计良好的规则来存储变量,并且之后可以方便地找到这些变量。这套规则被称为作用域。
1.1 编译原理
- 尽管通常将JavaScript归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。
- 这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree, AST)。
1.2 理解作用域
- · 作用域引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
- 变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。
- RHS查询与简单地查找某个变量的值别无二致,而LHS查询则是试图找到变量的容器本身
- LHS和RHS的含义是“赋值操作的左侧或右侧”并不一定意味着就是“=赋值操作符的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头(RHS)”。
1.3 作用域嵌套
- 作用域是根据名称查找变量的一套规则。
- 在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止
- 遍历嵌套作用域链的规则很简单:引擎从当前的执行作用域开始查找变量,如果找不到,就向上一级继续查找。当抵达最外层的全局作用域时,无论找到还是没找到,查找过程都会停止。
1.4 异常
- ReferenceError同作用域判别失败相关,而TypeError则代表作用域判别成功了,但是对结果的操作是非法或不合理的。
1.5 小结
- 作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用LHS查询;如果目的是获取变量的值,就会使用RHS查询。
第2章 词法作用域
- 作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法作用域,我们会对这种作用域进行深入讨论。另外一种叫作动态作用域,仍有一些编程语言在使用(比如Bash脚本、Perl中的一些模式等)。
2.1 词法阶段
- 大部分标准语言编译器的第一个工作阶段叫作词法化(也叫单词化)
- 词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。
- 没有任何函数的气泡可以(部分地)同时出现在两个外部作用域的气泡中,就如同没有任何函数可以部分地同时出现在两个父级函数中一样。
- 作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。
- 无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。
2.2 欺骗词法
- 欺骗词法作用域会导致性能下降。
- JavaScript中的eval(..)函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。
- 在严格模式的程序中,eval(..)在运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域。
- with通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。
- with可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。
- 另外一个不推荐使用eval(..)和with的原因是会被严格模式所影响(限制)。with被完全禁止,而在保留核心功能的前提下,间接或非安全地使用eval(..)也被禁止了。
2.3 小结
- 词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。
3.1 函数中的作用域
- 无论标识符声明出现在作用域中的何处,这个标识符所代表的变量或函数都将附属于所处作用域的气泡
- 函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)
3.2 隐藏内部实现
- 有很多原因促成了这种基于作用域的隐藏方法。它们大都是从最小特权原则中引申出来的,也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的API设计。
- “隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。冲突会导致变量的值被意外覆盖。
- 这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴露在顶级的词法作用域中。
3.3 函数作用域
- 在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐藏”起来,外部作用域无法访问包装函数内部的任何内容。
- 如果函数不需要函数名(或者至少函数名可以不污染所在作用域),并且能够自动运行,这将会更加理想。
- 区分函数声明和表达式最简单的方法是看function关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。如果function是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。
- 如果没有函数名,当函数需要引用自身时只能使用已经过期的arguments.callee引用,比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。
- IIFE,代表立即执行函数表达式(Immediately Invoked Function Expression);
- IIFE的另一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去。
- IIFE还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在IIFE执行之后当作参数传递进去
3.4 块作用域
- 但是,当使用var声明变量时,它写在哪里都是一样的,因为它们最终都会属于外部作用域。
- 非常少有人会注意到JavaScript的ES3规范中规定try/catch的catch分句会创建一个块作用域,其中声明的变量仅在catch内部有效。
- 也许catch分句会创建块作用域这件事看起来像教条的学院理论一样没什么用处,但是查看附录B就会发现一些很有用的信息。
- let关键字可以将变量绑定到所在的任意作用域中(通常是{ .. }内部)。换句话说,let为其声明的变量隐式地劫持了所在的块作用域。
- 只要声明是有效的,在声明中的任意位置都可以使用{ .. }括号来为let创建一个用于绑定的块。
- 但是使用let进行的声明不会在块作用域中进行提升。声明的代码被运行之前,声明并不“存在”。
- 由于click函数形成了一个覆盖整个作用域的闭包,JavaScript引擎极有可能依然保存着这个结构(取决于具体实现)。
- for循环头部的let不仅将i绑定到了for循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
- 除了let以外,ES6还引入了const,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误。
3.5 小结
- 但函数不是唯一的作用域单元。块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指{ .. }内部)
第4章 提升
- 任何声明在某个作用域内的变量,都将附属于这个作用域
4.2 编译器再度来袭
- 包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
- 只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。如果提升改变了代码执行的顺序,会造成非常严重的破坏。
- 可以看到,函数声明会被提升,但是函数表达式却不会被提升。
4.3 函数优先
- 函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个“重复”声明的代码中)是函数会首先被提升,然后才是变量。
- 一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代码暗示的那样可以被条件判断所控制:
4.4 小结
- 声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。
5.1 启示
- 闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意识地创建闭包。
5.2 实质问题
- 当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
- bar()依然持有对该作用域的引用,而这个引用就叫作闭包。
- 无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
5.3 现在我懂了
- 将一个内部函数(名为timer)传递给setTimeout(..)。timer具有涵盖wait(..)作用域的闭包,因此还保有对变量message的引用。
- 在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!
- 因为函数(示例代码中的IIFE)并不是在它本身的词法作用域以外执行的。它在定义时所在的作用域中执行(而外部作用域,也就是全局作用域也持有a)。a是通过普通的词法作用域查找而非闭包被发现的。
5.4 循环和闭包
- 延迟函数的回调会在循环结束时才执行。事实上,当定时器运行时即使每个迭代中执行的是setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个6出来。
- IIFE会通过声明并立即执行一个函数来创建作用域。
- for (var i=1; i<=5; i++) { (function(j) { setTimeout(function timer() { console.log(j); }, j*1000 ); })(i); }
- for循环头部的let声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
5.5 模块
- 这个模式在JavaScript中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露,这里展示的是其变体。
- 如果不执行外部函数,内部作用域和闭包都无法被创建。
- 可以将这个对象类型的返回值看作本质上是模块的公共API。
- 模块模式需要具备两个必要条件。1.必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。2.封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
- 通过在模块实例的内部保留对公共API对象的内部引用,可以从内部对模块实例进行修改,包括添加或删除方法和属性,以及修改它们的值。
- ES6中为模块增加了一级语法支持。在通过模块系统进行加载时,ES6会将文件当作独立的模块来处理。每个模块都可以导入其他模块或特定的API成员,同样也可以导出自己的API成员。
- import可以将一个模块中的一个或多个API导入到当前作用域中,并分别绑定在一个变量上(在我们的例子里是hello)。module会将整个模块的API导入并绑定到一个变量上(在我们的例子里是foo和bar)。export会将当前模块的一个标识符(变量、函数)导出为公共API。这些操作可以在模块定义中根据需要使用任意多次。
5.6 小结
- 当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。
附录A 动态作用域
- JavaScript中的作用域就是词法作用域
- 词法作用域是一套关于引擎如何寻找变量以及会在何处找到变量的规则。词法作用域最重要的特征是它的定义过程发生在代码的书写阶段(假设你没有使用eval()或with)。
- 作用域链是基于调用栈的,而不是代码中的作用域嵌套。
- 主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。
附录B 块作用域的替代方案
- catch分句具有块作用域,因此它可以在ES6之前的环境中作为块作用域的替代方案。
附录C this词法
- 问题在于cool()函数丢失了同this之间的绑定。解决这个问题有好几种办法,但最常用的就是var self = this;。
- self只是一个可以通过词法作用域和闭包进行引用的标识符,不关心this绑定的过程中发生了什么。
- 箭头函数在涉及this绑定时的行为和普通函数的行为完全不一致。它放弃了所有普通this绑定的规则,取而代之的是用当前的词法作用域覆盖了this本来的值。
第1章 关于this
- this关键字是JavaScript中最复杂的机制之一。它是一个很特别的关键字,被自动定义在所有函数的作用域中。
1.1 为什么要用this
- 这段代码可以在不同的上下文对象(me和you)中重复使用函数identify()和speak(),不用针对每个对象编写不同版本的函数。
- this提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将API设计得更加简洁并且易于复用
1.2 误解
- JavaScript中的所有函数都是对象
- 大家看到this并不像我们所想的那样指向函数本身。
- 实际上,如果他深入探索的话,就会发现这段代码在无意中创建了一个全局变量count(原理参见第2章),它的值为NaN。当然,如果他发现了这个奇怪的结果
- 但可惜它忽略了真正的问题——无法理解this的含义和工作原理——而是返回舒适区,使用了一种更熟悉的技术:词法作用域。
- arguments.callee已经被弃用,不应该再使用它。
- this在任何情况下都不指向函数的词法作用域。
- 每当你想要把this和词法作用域的查找混合使用时,一定要提醒自己,这是无法实现的。
1.3 this到底是什么
- this是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
1.4 小结
- this实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。
第2章 this全面解析
- 每个函数的this是在调用时被绑定的,完全取决于函数的调用位置(也就是函数的调用方法)。
2.1 调用位置
- 调用位置就是函数在代码中被调用的位置(而不是声明的位置)
- 最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)
2.2 绑定规则
- 如果使用严格模式(strict mode),则不能将全局对象用于默认绑定,因此this会绑定到undefined:
- 另一条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含,不过这种说法可能会造成一些误导。
- 对象属性引用链中只有上一层或者说最后一层在调用位置中起作用。
- 虽然bar是obj.foo的一个引用,但是实际上,它引用的是foo函数本身,因此此时的bar()其实是一个不带任何修饰的函数调用,因此应用了默认绑定。
- 参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值
- 它们的第一个参数是一个对象,是给this准备的,接着在调用函数时将其绑定到this。因为你可以直接指定this的绑定对象,因此我们称之为显式绑定。
- 如果你传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作this的绑定对象,这个原始值会被转换成它的对象形式(也就是new String(..)、new Boolean(..)或者new Number(..))。这通常被称为“装箱”。
- 由于硬绑定是一种非常常用的模式,所以ES5提供了内置的方法Function.prototype.bind
- bind(..)会返回一个硬编码的新函数,它会把你指定的参数设置为this的上下文并调用原始函数。
- 在JavaScript中,构造函数只是一些使用new操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。
- 使用new来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。1.创建(或者说构造)一个全新的对象。2.这个新对象会被执行[[Prototype]]连接。3.这个新对象会绑定到函数调用的this。4.如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。
- new是最后一种可以影响函数调用时this绑定行为的方法,我们称之为new绑定。
2.3 优先级
- 默认绑定的优先级是四条规则中最低的
- 显式绑定优先级更高,也就是说在判断时应当先考虑是否可以存在显式绑定。
- new绑定比隐式绑定优先级高
- 这段代码会判断硬绑定函数是否是被new调用,如果是的话就会使用新创建的this替换硬绑定的this。
- bind(..)的功能之一就是可以把除了第一个参数(第一个参数用于绑定this)之外的其他参数都传给下层的函数(这种技术称为“部分应用”,是“柯里化”的一种)
2.4 绑定例外
- 如果你把null或者undefined作为this的绑定对象传入call、apply或者bind,这些值在调用时会被忽略,实际应用的是默认绑定规则
- Object.create(null)和{}很像,但是并不会创建Object. prototype这个委托,所以它比{}“更空”:
- 对于默认绑定来说,决定this绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式。如果函数体处于严格模式,this会被绑定到undefined,否则this会被绑定到全局对象。
2.5 this词法
- 箭头函数不使用this的四种标准规则,而是根据外层(函数或者全局)作用域来决定this。
- 箭头函数的绑定无法被修改。(new也不行!)
- 箭头函数可以像bind(..)一样确保函数的this被绑定到指定对象,此外,其重要性还体现在它用更常见的词法作用域取代了传统的this机制。
- 1.只使用词法作用域并完全抛弃错误this风格的代码;2.完全采用this风格,在必要时使用bind(..),尽量避免使用self = this和箭头函数。
2.6 小结
- 1.由new调用?绑定到新创建的对象。 2.由call或者apply(或者bind)调用?绑定到指定的对象。 3.由上下文对象调用?绑定到那个上下文对象。 4.默认:在严格模式下绑定到undefined,否则绑定到全局对象。
3.1 语法
- 对象可以通过两种形式定义:声明(文字)形式和构造形式
3.2 类型
- null有时会被当作一种对象类型,但是这其实只是语言本身的一个bug,即对null执行typeof null时会返回字符串"object"。
- JavaScript中还有一些对象子类型,通常被称为内置对象
- 使用以上两种方法,我们都可以直接在字符串字面量上访问属性或者方法,之所以可以这样做,是因为引擎自动把字面量转换成String对象,所以可以访问属性和方法。
- null和undefined没有对应的构造形式,它们只有文字形式。相反,Date只有构造,没有文字形式。
3.3 内容
- 在引擎内部,这些值的存储方式是多种多样的,一般并不会存在对象容器内部。存储在对象容器内部的是这些属性的名称,它们就像指针(从技术角度来说就是引用)一样,指向这些值真正的存储位置。
- .a语法通常被称为“属性访问”, ["a"]语法通常被称为“键访问”。
- 这两种语法的主要区别在于.操作符要求属性名满足标识符的命名规范,而[".."]语法可以接受任意UTF-8/Unicode字符串作为属性名。
- 在对象中,属性名永远都是字符串。如果你使用string(字面量)以外的其他值作为属性名,那它首先会被转换为一个字符串。
- ES6增加了可计算属性名,可以在文字形式中使用[]包裹一个表达式来当作属性名
- 从技术角度来说,函数永远不会“属于”一个对象,所以把对象内部引用的函数称为“方法”似乎有点不妥。
- 如果你试图向数组添加一个属性,但是属性名“看起来”像一个数字,那它会变成一个数值下标(因此会修改数组的内容而不是添加一个属性)
- 由于Object.assign(..)就是使用=操作符来赋值,所以源对象属性的一些特性(比如writable)不会被复制到目标对象。
- Object.defineProperty(..)来添加一个新属性或者修改一个已有属性(如果它是configurable)并对特性进行设置。
- writable决定是否可以修改属性的值。
- 只要属性是可配置的,就可以使用defineProperty(..)方法来修改属性描述符
- 注意:如你所见,把configurable修改成false是单向操作,无法撤销!
- 不要把delete看作一个释放内存的工具(就像C/C++中那样),它就是一个删除对象属性的操作,仅此而已。
- 结合writable:false和configurable:false就可以创建一个真正的常量属性(不可修改、重定义或者删除):
- 如果你想禁止一个对象添加新属性并且保留已有属性,可以使用Object.prevent Extensions(..)
- Object.freeze(..)会创建一个冻结对象,这个方法实际上会在一个现有对象上调用Object.seal(..)并把所有“数据访问”属性标记为writable:false,这样就无法修改它们的值。
- 如果你引用了一个当前词法作用域中不存在的变量,并不会像对象属性一样返回undefined,而是会抛出一个ReferenceError异常
- 对象默认的[[Put]]和[[Get]]操作分别可以控制属性值的设置和获取。
- getter是一个隐藏函数,会在获取属性值时调用。setter也是一个隐藏函数,会在设置属性值时调用。
- 当你给一个属性定义getter、setter或者两者都有时,这个属性会被定义为“访问描述符”(和“数据描述符”相对)
- 不管是对象文字语法中的get a() { .. },还是defineProperty(..)中的显式定义,二者都会在对象中创建一个不包含值的属性,对于这个属性的访问会自动调用一个隐藏函数,它的返回值会被当作属性访问的返回值
- in操作符会检查属性是否在对象及其[[Prototype]]原型链中(参见第5章)。相比之下,hasOwnProperty(..)只会检查属性是否在myObject对象中,不会检查[[Prototype]]链。
- 在数组上应用for..in循环有时会产生出人意料的结果,因为这种枚举不仅会包含所有数值索引,还会包含所有可枚举属性。最好只在对象上应用for..in循环,如果要遍历数组就使用传统的for循环来遍历数值索引。
3.4 遍历
- for..in循环可以用来遍历对象的可枚举属性列表(包括[[Prototype]]链)
- forEach(..)会遍历数组中的所有值并忽略回调函数的返回值。every(..)会一直运行直到回调函数返回false(或者“假”值), some(..)会一直运行直到回调函数返回true(或者“真”值)。
- for..of循环首先会向被访问对象请求一个迭代器对象,然后通过调用迭代器对象的next()方法来遍历所有返回值。
3.5 小结
- 你可以使用ES6的for..of语法来遍历数据结构(数组、对象,等等)中的值,for..of会寻找内置或者自定义的@@iterator对象并调用它的next()方法来遍历数据值。
第4章 混合对象“类”
- 实例化(instantiation)、继承(inheritance)和(相对)多态(polymorphism)。
4.1 类理论
- 好的设计就是把数据以及和它相关的行为打包(或者说封装)起来。
- 类的另一个核心概念是多态,这个概念是说父类的通用行为可以被子类用更特殊的行为重写。实际上,相对多态性允许我们从重写行为中引用基础行为。
4.2 类的机制
- 类通过复制操作被实例化为对象形式:
4.3 类的继承
- 多态的另一个方面是,在继承链的不同层次中一个方法名可以被多次定义,当调用方法时会自动选择合适的定义。
- 多态并不表示子类和父类有关联,子类得到的只是父类的一份副本。类的继承其实就是复制。
4.5 小结
- 总地来说,在JavaScript中模拟类是得不偿失的,虽然能解决当前的问题,但是可能会埋下更多的隐患。
5.1 [[Prototype]]
- JavaScript中的对象有一个特殊的[[Prototype]]内置属性,其实就是对于其他对象的引用。
- 使用in操作符来检查属性在对象中是否存在时,同样会查找对象的整条原型链(无论属性是否可枚举)
- 所有普通的[[Prototype]]链最终都会指向内置的Object.prototype。
- 1.如果在[[Prototype]]链上层存在名为foo的普通数据访问属性(参见第3章)并且没有被标记为只读(writable:false),那就会直接在myObject中添加一个名为foo的新属性,它是屏蔽属性。2.如果在[[Prototype]]链上层存在foo,但是它被标记为只读(writable:false),那么无法修改已有属性或者在myObject上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。3.如果在[[Prototype]]链上层存在foo并且它是一个setter(参见第3章),那就一定会调用这个setter。foo不会被添加到(或者说屏蔽于)myObject,也不会重新定义foo这个setter。
5.2 “类”
- 在JavaScript中,类无法描述对象的行为,(因为根本就不存在类!)对象直接定义自己的行为。再说一遍,JavaScript中只有对象。
- 所有的函数默认都会拥有一个名为prototype的公有并且不可枚举(参见第3章)的属性,它会指向另一个对象
- 这个对象是在调用new Foo()(参见第2章)时创建的,最后会被(有点武断地)关联到这个“Foo.prototype”对象上。
- 调用new Foo()时会创建a(具体的4个步骤参见第2章),其中一步就是将a内部的[[Prototype]]链接到Foo.prototype所指向的对象。
- new Foo()这个函数调用实际上并没有直接创建关联,这个关联只是一个意外的副作用。new Foo()只是间接完成了我们的目标:一个关联到其他对象的新对象。
- 继承意味着复制操作,JavaScript(默认)并不会复制对象属性。相反,JavaScript会在两个对象之间创建一个关联,这样一个对象就可以通过委托访问另一个对象的属性和函数。
- Foo.prototype的.constructor属性只是Foo函数在声明时的默认属性。如果你创建了一个新对象并替换了函数默认的.prototype对象引用,那么新对象并不会自动获得.constructor属性。
- a1并没有.constructor属性,所以它会委托[[Prototype]]链上的Foo. prototype。但是这个对象也没有.constructor属性(不过默认的Foo.prototype对象有这个属性!),所以它会继续委托,这次会委托给委托链顶端的Object.prototype。这个对象有.constructor属性,指向内置的Object(..)函数。
- constructor并不表示被构造
- a1.constructor是一个非常不可靠并且不安全的引用。通常来说要尽量避免使用这些引用。
5.3 (原型)继承
- ES6添加了辅助函数Object.setPrototypeOf(..),可以用标准并且可靠的方法来修改关联。
- instanceof回答的问题是:在a的整条[[Prototype]]链中是否有指向Foo.prototype的对象?
5.4 对象关联
- 通常来说,这个链接的作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就会继续在[[Prototype]]关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的[[Prototype]],以此类推。这一系列对象的链接被称为“原型链”。
- Object.create(..)会创建一个新对象(bar)并把它关联到我们指定的对象(foo),这样我们就可以充分发挥[[Prototype]]机制的威力(委托)并且避免不必要的麻烦(比如使用new的构造函数调用会生成.prototype和.constructor引用)。
- Object.create(null)会创建一个拥有空(或者说null)[[Prototype]]链接的对象,这个对象无法进行委托。由于这个对象没有原型链,所以instanceof操作符(之前解释过)无法进行判断,因此总是会返回false。这些特殊的空[[Prototype]]对象通常被称作“字典”,它们完全不会受到原型链的干扰,因此非常适合用来存储数据。
- Object.create(..)的第二个参数指定了需要添加到新对象中的属性名以及这些属性的属性描述符
- 内部委托比起直接委托可以让API接口设计更加清晰
5.5 小结
- 虽然这些JavaScript机制和传统面向类语言中的“类初始化”和“类继承”很相似,但是JavaScript中的机制有一个核心区别,那就是不会进行复制,对象之间是通过内部的[[Prototype]]链关联的。
第6章 行为委托
- 如果在第一个对象上没有找到需要的属性或者方法引用,引擎就会继续在[[Prototype]]关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的[[Prototype]],以此类推。这一系列对象的链接被称为“原型链”。
6.1 面向委托的设计
- 通常来说,在[[Prototype]]委托中最好把状态保存在委托者(XYZ、ABC)而不是委托目标(Task)上。
- 委托行为意味着某些对象(XYZ)在找不到属性或者方法引用时会把这个请求委托给另一个对象(Task)。
6.2 类与对象
- 对象关联可以更好地支持关注分离(separation of concerns)原则,创建和初始化并不需要合并为一个步骤。
6.4 更好的语法
- 使用简洁方法时一定要小心这一点。如果你需要自我引用的话,那最好使用传统的具名函数表达式来定义对应的函数(· baz: function baz(){..}·),不要使用简洁方法。
6.5 内省
- 内省就是检查实例的类型。类实例的内省主要目的是通过创建方式来判断对象的结构和功能。
6.6 小结
- 行为委托认为对象之间是兄弟关系,互相委托,而不是父类和子类的关系。JavaScript的[[Prototype]]机制本质上就是行为委托机制。
附录A ES6中的Class
- 类是一种可选(而不是必须)的设计模式,而且在JavaScript这样的[[Prototype]]语言中实现类是很别扭的。
- class基本上只是现有[[Prototype]](委托!)机制的一种语法糖。
- 出于性能考虑,super并不像this一样是晚绑定(late bound,或者说动态绑定)的,它在[[HomeObject]].[[Prototype]]上,[[HomeObject]]会在创建时静态绑定。