第一部分
1 作用域
作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。赋值操作符会导致LHS查询。=操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作。
JavaScript 引擎首先会在代码执行前对其进行编译,在这个过程中,像 var a = 2 这样的声明会被分解成两个独立的步骤:
- 首先,var a 在其作用域中声明新变量。这会在最开始的阶段,也就是代码执行前进行。
- 接下来,a = 2 会查询(LHS 查询)变量 a 并对其进行赋值。
LHS 和 RHS 查询都会在当前执行作用域中开始,如果有需要(也就是说它们没有找到所需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一级作用域(一层楼),最后抵达全局作用域(顶层),无论找到或没找到都将停止。
不成功的 RHS 引用会导致抛出 ReferenceError 异常。不成功的 LHS 引用会导致自动隐式地创建一个全局变量(非严格模式下),该变量使用 LHS 引用的目标作为标识符,或者抛出 ReferenceError 异常(严格模式下)。
2.1 词法作用域
简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)
2.2 欺骗词法
2.2.1 eval
在严格模式的程序中,eval(..) 在运行时有其自己的词法作用域,意味着其
中的声明无法修改所在的作用域
1 | function foo(str) { |
JavaScript 中 还 有 其 他 一 些 功 能 效 果 和 eval(..)
很 相 似。setTimeout(..)
和setInterval(..)
的第一个参数可以是字符串,字符串的内容可以被解释为一段动态生成的
函数代码。这些功能已经过时且并不被提倡。不要使用它们!
new Function(..)
函数的行为也很类似,最后一个参数可以接受代码字符串,并将其转
化为动态生成的函数(前面的参数是这个新生成的函数的形参)。这种构建函数的语法比eval(..)
略微安全一些,但也要尽量避免使用
避免使用eval和Funciton构造器来避免双重求值(在js代码中执行另一段代码,此时首先会以正常方式求值,然后在执行过程中对包含字符串中的代码发起另一个求值运算)带来的性能消耗。给setTimeout和setInterval传递函数而不是字符串作为参数。
2.2.2 with
1 | function foo(obj) { |
3 函数作用域和块作用域
函数是 JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。
但函数不是唯一的作用域单元。块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指 { .. } 内部)。
在严格模式的程序中,eval(..) 在运行时有其自己的词法作用域,意味着其 中的声明无法修改所在的作用域。
我们在第 2 章讨论过 with关键字。它不仅是一个难于理解的结构,同时也是块作用域的一个例子(块作用域的一种形式),用 with 从对象中创建出的作用域仅在 with声明中而非外部作用域中有效
从 ES3 开始,try/catch 结构在 catch 分句中具有块作用域。
在 ES6 中引入了 let 关键字(var关键字的表亲),用来在任意代码块中声明变量。if(..) { let a = 2; } 会声明一个劫持了 if 的 { .. }块的变量,并且将变量添加到这个块中。
除了 let 以外,ES6 还引入了const,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误。
有些人认为块作用域不应该完全作为函数作用域的替代方案。两种功能应该同时存在,开发者可以并且也应该根据需要选择使用何种作用域,创造可读、可维护的优良代码。
4 变量提升
函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个“重复”声明的代码中)是函数会首先被提升,然后才是变量。
5 闭包
5.1 简单的模块化
大多数模块依赖加载器 / 管理器本质上都是将这种模块定义封装进一个友好的 API。
1 | var MyModules = (function Manager() { |
下面展示了如何使用它来定义模块:
1 | MyModules.define("bar", [], function() { |
5.2 小结
当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。
模块有两个主要特征:
(1)为创建内部作用域而调用了一个包装函数;
(2)包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域闭包。
6 动态作用域
需要明确的是,事实上JavaScript并不具有动态作用域。它只有词法作用域,简单明了。但是 this 机制某种程度上很像动态作用域
主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this 也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何调用。
第二部分
1 this
最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的调用位置就在当前正在执行的函数的前一个调用中。
2.2 绑定规则
2.2.1 默认绑定
首先要介绍的是最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则。
如果使用严格模式(strict mode),那么全局对象将无法使用默认绑定,因此 this 会绑定到 undefined:
2.2.2 隐式绑定
另一条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含,不过这种说法可能会造成一些误导。
隐式丢失
一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把 this 绑定到全局对象或者 undefined上,取决于是否是严格模式
1 | function foo() { |
虽然 bar 是 obj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,因此此时的bar()其实是一个不带任何修饰的函数调用,因此应用了默认绑定
一种更微妙、更常见并且更出乎意料的情况发生在传入回调函数时
1 | function foo() { |
2.2.3 显式绑定
- call/apply
- 硬绑定:bind
call实现:1
2
3
4
5
6
7
8
9
10
11Function.prototype.myCall = function(context) {
if (typeof this !== 'function') {
throw new TypeError('Error')
}
context = context || window
context.fn = this
const args = [...arguments].slice(1)
const result = context.fn(...args)
delete context.fn
return result
}
apply 实现:
1 | Function.prototype.myApply = function(context) { |
bind实现:
1 | Function.prototype.myBind = function (context) { |
2.2.4 new绑定
- 创建(或者说构造)一个全新的对象。
- 这个新对象会被执行 [[ 原型 ]] 连接。
- 这个新对象会绑定到函数调用的 this。
- 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象
1 | function create() { |
2.3 判断this
函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。
var bar = new foo()
函数是否通过 call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是指定的对象。
var bar = foo.call(obj2)
函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象
var bar = obj1.foo()
如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到全局对象。
var bar = foo()
2.4 绑定例外
2.4.1 被忽略的this
如果你把 null 或者 undefined 作为 this 的绑定对象传入 call、apply 或者 bind,这些值在调用时会被忽略,实际应用的是默认绑定规则:
更安全的this
一种“更安全”的做法是传入一个特殊的对象,把this绑定到这个对象不会对你的程序产生任何副作用。
在 JavaScript 中创建一个空对象最简单的方法都是 Object.create(null)。Object.create(null) 和 {} 很 像, 但 是 并 不 会 创 建 Object.prototype 这个委托,所以它比 {}“更空”:
2.4.2 间接引用
1 | function foo() { |
赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,因此调用位置是 foo() 而不是p.foo() 或者 o.foo()。根据我们之前说过的,这里会应用默认绑定。
2.4.3 软绑定
硬绑定会大大降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改 this。
1 | if (!Function.prototype.softBind) { |
2.5 箭头函数
ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定this,具体来说,箭头函数会继承外层函数调用的 this 绑定(无论 this 绑定到什么)。这其实和 ES6 之前代码中的 self = this 机制一样。
“箭头函数”的this,总是指向定义时所在的对象,而不是运行时所在的对象。
参考链接
3.2 类型
- string
- number
- boolean
- null
- undefined
- object
- symbol
内置对象
- String
- Number
- Boolean
- Object
- Function
- Array
- Date
- RegExp
- Error
在必要时语言会自动把字符串字面量转换成一个 String 对象,也就是说你并不需要显式创建一个对象。JavaScript 社区中的大多数人都认为能使用文字形式时就不要使用构造形式。
1 | var strPrimitive = "I am a string" |
使用以上两种方法,我们都可以直接在字符串字面量上访问属性或者方法,之所以可以这样做,是因为引擎自动把字面量转换成 String 对象,所以可以访问属性和方法。
3.3 内容
.a 语法通常被称为“属性访问”,[“a”] 语法通常被称为“键访问”。
这两种语法的主要区别在于 . 操作符要求属性名满足标识符的命名规范,而 [“..”] 语法可以接受任意 UTF-8/Unicode字符串作为属性名。举例来说,如果要引用名称为”SuperFun!” 的属性,那就必须使用 [“Super-Fun!”] 语法访问,因为 Super-Fun!并不是一个有效的标识符属性名
此外,由于 [“..”] 语法使用字符串来访问属性,所以可以在程序中构造这个字符串,
1 | var myObject = { |
在对象中,属性名永远都是字符串。如果你使用string(字面量)以外的其他值作为属性名,那它首先会被转换为一个字符串。即使是数字也不例外,虽然在数组下标中使用的的确是数字,但是在对象属性名中数字会被转换成字符串,所以当心不要搞混对象和数组中数字的用法:
1 | var myObject = { }; |
3.3.1 可计算属性名
ES6 增加了可计算属性名,可以在文字形式中使用 [] 包裹一个表达式来当作属性名
3.3.10 存在性
1 | var myObject = { |
in 操作符会检查属性是否在对象及其 [[Prototype]] 原型链中(参见第 5 章)。
hasOwnProperty(..) 只会检查属性是否在 myObject 对象中,不会检查 [[Prototype]] 链
5 原型
5.1 属性设置和屏蔽
给一个对象设置属性并不仅仅是添加一个新属性或者修改已有的属性值。
现在我们完整地讲解一下这个过程:
1 | myObject.foo = "bar"; |
- 如果 myObject 对象中包含名为 foo的普通数据访问属性,这条赋值语句只会修改已有的属性值。
- 如果 foo 不是直接存在于 myObject 中,[[Prototype]] 链就会被遍历,类似 [[Get]] 操作。如果原型链上找不到 foo,foo 就会被直接添加到 myObject 上。
如果 foo 存在于原型链上层,赋值语句 myObject.foo = “bar” 的行为就会有些不同(而且可能很出人意料)。
在于原型链上层时 myObject.foo = “bar” 会出现的三种情况。
- 如果在 [[Prototype]] 链上层存在名为 foo 的普通数据访问属性(参见第 3 章)并且没有被标记为只读(writable:false),那就会直接在 myObject 中添加一个名为 foo的新属性,它是屏蔽属性
- 如果在 [[Prototype]] 链上层存在foo,但是它被标记为只读(writable:false),那么无法修改已有属性或者在myObject上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽
如果在 [[Prototype]] 链上层存在 foo 并且它是一个 setter(参见第 3 章),那就一定会调用这个setter。foo不会被添加到(或者说屏蔽于)myObject,也不会重新定义foo 这个 setter。
大多数开发者都认为如果向 [[Prototype]] 链上层已经存在的属性([[Put]])赋值,就一定会触发屏蔽,但是如你所见,三种情况中只有一种(第一种)是这样的。
如果你希望在第二种和第三种情况下也屏蔽 foo,那就不能使用 =操作符来赋值,而是使用 Object.defineProperty(..)(参见第 3 章)来向 myObject 添加 foo。
如果属性名 foo 既出现在 myObject 中也出现在 myObject 的 [[Prototype]] 链上层,那么就会发生屏蔽。myObject 中包含的 foo 属性会屏蔽原型链上层的所有 foo属性,因为myObject.foo 总是会选择原型链中最底层的 foo 属性。
隐式屏蔽
有些情况下会隐式产生屏蔽,一定要当心。思考下面的代码
1 | var anotherObject = { |
6 行为委托
如果在第一个对象上没有找到需要的属性或者方法引用,引擎就会继续在 [[Prototype]]关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的[[Prototype]],以此类推。这一系列对象的链接被称为“原型链”。