第一部分: 作用域和闭包
一、作用域
1. 作用域:存储并查找变量的规则
2. 源代码在执行之前(编译)会经历三个步骤:
- 分词/此法分析:将代码字符串分解成有意义的代码块(词法单元)
- 解析/语法分析:将词法单元流转换成抽象语法树(AST)
- 代码生成:将抽象语法树转换成可执行代码
3. LHS查询: 变量出现在赋值操作左侧, RHS查询: 变量出现在赋值操作右侧
4. 作用域嵌套:在当前作用域中无法找到某个变量的时候,引擎就会在外层的嵌套作用域中继续查找,直到找到该变量,或抵达最外层的作用域
5. 当引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非 “严格模式”下
6. 不成功的 RHS 引用会导致抛出 ReferenceError 异常。不成功的 LHS 引用会导致自动隐式 地创建一个全局变量(非严格模式下),该变量使用 LHS 引用的目标作为标识符,或者抛出 ReferenceError 异常(严格模式下)。
function foo(a) {
console.log( a + b );
b = a; // b is not defined
}
foo(2);
【解析】
第一次对 b 进行 RHS 查询时是无法找到该变量的。也就是说,这是一个“未声明”的变 量,因为在任何相关的作用域中都无法找到它。
如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError 异常。值得注意的是,ReferenceError 是非常重要的异常类型。
相较之下,当引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非 “严格模式”下。
二、词法作用域
1. 在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。
抛开遮蔽效应, 作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见 第一个匹配的标识符为止。
2. 全局变量会自动成为全局对象(比如浏览器中的 window 对象)的属性,因此 可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问。
三、函数作用域和块作用域
1. 函数作用域
属于这个函数的全部变量都可以在整个函数的范围内使用及复 用(事实上在嵌套的作用域中也可以使用)
2. 函数声明
function foo () {}
函数表达式
var foo = function () {}
匿名函数表达式
function () {} // 在setTimeout 中使用
3. 立即执行函数表达式(IIFE): Immediately Invoked Function Expression
var a = 2; (function foo() { var a = 3; console.log( a ); // 3 })(); console.log( a ); // 2 //另一种形式:(function(){ .. }()),功能上是一致的。选择哪个全凭个人喜好
4. 块作用域
for (var i=0; i<10; i++) { console.log( i ); }
【解析】 for 循环的头部直接定义了变量 i,通常是因为只想在 for 循环内部的上下文中使用 i,而忽略了 i 会被绑定在外部作用域(函数或全局)中的事实。
这就是块作用域的用处。变量的声明应该距离使用的地方越近越好,并最大限度地本地化。
if 和 for 中使用 var 声明变量时,它写在哪里都是一样的,因为它们最终都会属于外部作用域。这样写只是为了风格更易读而伪装出的形式上的块作用域。
5. let
let 关键字可以将变量绑定到所在的任意作用域中, let 为其声明的变量隐式地了所在的块作用域。
var foo = true; if (foo) { let bar = foo * 2; console.log( bar ); } console.log( bar ); // ReferenceError
6. 提升
声明会被视为存在于其所出现的作用域的整个范围内。使用 let 进行的声明不会在块作用域中进行提升。声明的代码被运行之前,声明并不“存在”。
{ console.log( bar ); // ReferenceError! let bar = 2; }
四、提升
1. 包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。 可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的 最顶端,这个过程被称为提升。
var a = 2 // JavaScript 实际上会将其看成两个声明: // var a; // a = 2; // 第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在 原地等待执行阶段。
a = 2; var a; console.log( a ); // 2 console.log(a); var a = 2; // undefined
2. 函数优先
多个不重复声明的代码中,函数会首先被提升,然后才是变量。
foo(); // 1 var foo; function foo() { console.log(1); } foo = function() { console.log(2); };
会被引擎理解为如下形式:
function foo() { console.log(1); } foo(); // 1 foo = function() { console.log(2); };
五、作用域闭包
1. 定义
当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。
function foo() { var a = 2; function bar() { console.log(a); } return bar; } var baz = foo(); baz(); // 2
【解析】
bar() 的词法作用域能够访问 foo() 的内部作用域。然后我们将 bar() 函数本身当作 一个值类型进行传递,bar() 在自己定义的词法作用域以外的地方执行。
在 foo() 执行后,因为 bar() 本身在使用内部作用域,因此内部作用域依然存在。——bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。
【结论】
无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
2. 循环和闭包
for (var i=1; i<=5; i++) { setTimeout( function timer() { console.log( i ); }, i*1000 ); } // 6, 6, 6, 6, 6
【解析】
延迟函数的回调会在循环结束时才执行。事实上, 当定时器运行时即使每个迭代中执行的是setTimeout(.., 0),所有的回调函数依然是在循 环结束后才会被执行,因此会每次输出一个 6 出来。
缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。
【改进】
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); }
迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。
3. 块作用域和闭包
for (var i=1; i<=5; i++) { let j = i; // 是的,闭包的块作用域! setTimeout( function timer() { console.log( j ); }, j*1000 ); }
【解析】
let 声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量。
本质上这是将一个块转换成一个可以被关闭的作用域
for (let i=1; i<=5; i++) { setTimeout( function timer() { console.log( i ); }, i*1000 ); }
【解析】
for 循环头部的 let 声明还会有一 个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随 后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量
第二部分 this 和对象原型
一、关于 this
1. 误解:指向自身
function foo () { this.num = 0 } foo() console.log(foo.num) // undefined console.log(window.num); // console.log(global.num) // 0
【解析】
执行 foo.count = 0 时,的确向函数对象 foo 添加了一个属性 count。但是函数内部代码 this.count 中的 this 并不是指向那个函数对象。
这段代码在 无意中创建了一个全局变量 count,它的值为 NaN
2. this 到底是什么?
this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调 用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式
【补充】
函数调用的三种方式:
- thisObj.myFunc()
- myFunc.call(thisObj, ...arg)
- myFunc.apply(thisObj, [...arg])
obj.call(thisObj, ...):把 obj 的 this 绑定到 thisObj,从而使 thisObj 具备 obj 中的属性和方法
A.call(B, ...args): A 打电话给 B, B 把参数 args 给 A,A 接受参数把结果返回给 B
二、this 全面解析
1. 调用位置
分析调用栈,调用位置就在当前正在执行的函数的前一个调用中。
2. 绑定规则
(1)默认绑定
纯粹的函数调用,函数内 this 指向全局对象(window / global)
注意:只有函数运行在非严格模式下,默认绑定才能绑定到全局对象。
function foo() { "use strict"; console.log(this.a); } var a = 2; foo(); // TypeError: this is undefined
(2)隐式绑定
当函数引 用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。
function foo() { console.log(this.a); } var obj2 = { a: 42, foo: foo }; var obj1 = { a: 2, obj2: obj2 }; obj1.obj2.foo(); // 42
【注意】对象属性引用链中只有最顶层或者说最后一层会影响调用位置
(3)显示绑定:
function foo() { console.log(this.a); } var obj = { a:2 }; foo.call( obj ); // 2
通过 foo.call(..),我们可以在调用 foo 时强制把它的 this 绑定到 obj 上。
(4)作为构造函数调用
函数内的 this 指向通过构造函数生成的对象实例
【补充】
实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。
使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
- 创建(或者说构造)一个全新的对象。
- 这个新对象会被执行[[原型]]连接。
- 这个新对象会绑定到函数调用的this。
- 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。
3. this 绑定的优先级
(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 绑定规则:
(1) 由new调用?绑定到新创建的对象。
(2) 由call或者apply(或者bind)调用?绑定到指定的对象。
(3) 由上下文对象调用?绑定到那个上下文对象。
(4) 默认:在严格模式下绑定到undefined,否则绑定到全局对象。
4. 箭头函数
箭头函数不使用 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 ! // foo() 内部创建的箭头函数会捕获调用时 foo() 的 this。由于 foo() 的 this 绑定到 obj1, bar(引用箭头函数)的 this 也会绑定到 obj1,箭头函数的绑定无法被修改。(new 也不 行!)
箭头函数最常用于回调函数中,例如事件处理器或者定时器:
function foo() { setTimeout(() => { // 这里的 this 在此法上继承自 foo() console.log(this.a); },100); } var obj = { a:2 }; foo.call( obj ); // 2
三、对象
1. JavaScript 中的六种主要类型
- undefined
- null
- number
- boolean
- string
- object
前五个是基本类型,但是typeof null === object,why?
不同的对象在底层都表示为二进制,在 JavaScript 中二进制前三位都为 0 的话会被判 断为 object 类型,null 的二进制表示是全 0,自然前三位也是 0,所以执行 typeof 时会返回“object”
2. 对象的组成
obj = {
key: value
}
存储在对象容器内部的是这些属性的名称,它们就像指针(从技术角度 来说就是引用)一样,指向这些值真正的存储位置
var myObject = { a: 2 }; myObject.a; // 2 myObject["a"]; // 2 // obj.[‘key‘]:键访问 // obj.key:属性访问(属性名满足标识符的命名规范,形如‘super-fun‘ 则不能使用属性访问)
属性名永远是字符串,如果你使用 string(字面量)以外的其他值作为属性 名,那它首先会被转换为一个字符串
3. 数组
数组也是对象,所以虽然每个下标都是整数,你仍然可以给数组添加属性。
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"
4. 复制对象:
var newObj = JSON.parse( JSON.stringify(someObj));
对于 JSON 安全(也就是说可以被序列化为一个 JSON 字符串并且可以根据这个字符串解 析出一个结构和值完全一样的对象)的对象适用
var newObj = Object.assign({}, myObject); Object.assign(target, [...source])
遍历一个或多个源对象的所有可枚举的自有键(owned key) 并把它们复制 (使用 = 操作符赋值) 到目标对象,最后返回目标对象
5. 属性描述符
获取
Object.getOwnPropertyDescriptor(myObject, key) var myObject = { a:2 }; Object.getOwnPropertyDescriptor(myObject, "a"); // { // value: 2, // writable: true, // enumerable: true, // configurable: true // }
添加或修改
// Object.defineProperty(myObject, key, {...}) var myObject = {}; Object.defineProperty( myObject, "a", { value: 2, writable: true, configurable: true, enumerable: true, }); myObject.a; // 2 //注意:即便属性是 configurable:false,我们还是可以 把 writable 的状态由 true 改为 false,但是无法由 false 改为 true。
6. 不变性
浅不变性:
const obj = { a: 1, b: 2, } obj.a = 3 console.log(obj) // { a: 3, b:2 }
如何让obj的属性也不可变?
1. 对象常量
结合 writable:false 和 configurable:false 就可以创建一个真正的常量属性(不可修改、 重定义或者删除)
var myObject = {}; Object.defineProperty( myObject, "FAVORITE_NUMBER", { value: 42, writable: false, configurable: false });
2. 禁止扩展
Object.prevent Extensions(..) var myObject = { a:2 }; Object.preventExtensions( myObject ); myObject.b = 3; myObject.b; // undefined
3. 密封
Object.seal(obj)
创建一个“密封”的对象,这个方法实际上会在一个现有对象上调用 Object.preventExtensions(..) 并把所有现有属性标记为 configurable:false。
4. 冻结
Object.freeze(obj)
创建一个冻结对象,这个方法实际上会在一个现有对象上调用 Object.seal(..) 并把所有“数据访问”属性标记为 writable:false
【补充】
深度冻结:这个对象上调用 Object.freeze(..), 然后遍历它引用的所有对象并在这些对象上调用 Object.freeze(..)
冻结的逻辑
Object.freeze(obj) => Object.seal(obj) => Object.preventExtensions(obj) => { configurable: false }
7. 对象属性的访问
[[get]] [[put]] [[getter]] [[setter]]
8. 存在性
针对可能的情况:
var myObject = { a: undefined }; myObject.a; // undefined myObject.b; // 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]] 链。
【注意】虽然数组也是对象,但是 in 操作符在数组中检查的是数组的属性名,而不是值
4 in [2, 4, 6] // false
所有的普通对象都可以通过对于 Object.prototype 的委托来访问 hasOwnProperty(..), 但是有的对象可能没有连接到 Object.prototype ( 通 过 Object. create(null) )。在这种情况下,形如 myObejct.hasOwnProperty(..) 就会失败。
这时可以使用一种更加强硬的方法来进行判断:Object.prototype.hasOwnProperty. call(myObject,"a"),它借用基础的 hasOwnProperty(..) 方法并把它显式绑定到 myObject 上。
获取对象所有的属性
Object.keys(..) // 会返回一个数组,包含所有可枚举属性。
类似地:
for (key in obj) { console.log(key) console.log(obj[key]) // 不能写成 obj.key }
Object.getOwnPropertyNames(..) // 返回一个数组,包含所有属性,无论它们是否可枚举。
in 和 hasOwnProperty(..) 的区别在于是否查找 [[Prototype]] 链,然而,Object.keys(..) 和 Object.getOwnPropertyNames(..) 都只会查找对象直接包含的属性。
并没有内置的方法可以获取 in 操作符使用的属性列表(对象本身的属性以 及 [[Prototype]] 链中的所有属性)。不过你可以递归遍历某个对象的整条 [[Prototype]] 链并保存每一层中使用 Object.keys(..) 得到的属性列表——只包含可枚举属性。
【注意】遍历对象属性时的顺序是不确定的,遍历数组下标时采用的是数字顺序,如何直接遍历值而不是数组下标(或者对象属性)呢
var myArray = [ 1, 2, 3 ]; for (var v of myArray) { console.log(v); } // 1 // 2 // 3
四、混合对象"类"
- JavaScript 中的 "类"
- 构造函数和类
- 类的继承
- 多态
- 多重继承
- 显示混入
- 隐式混入
五、原型
1. [[prototype]] __proto__
var anotherObject = { a:2 }; // 创建一个关联到 anotherObject 的对象 var myObject = Object.create( anotherObject ); myObject.a; // 2 // Object.create(...) 会创建一个 对象并把这个对象的 [[Prototype]] 关联到指定的对象。
2. Object.prototype
所有普通的 [[Prototype]] 链最终都会指向内置的 Object.prototype,这个 Object.prototype 对象,所以它包含 JavaScript 中许多通用的功能。
3. 属性设置和屏蔽
属性设置
obj.foo = ‘bar‘
(1)obj 中包含 foo,则直接修改
(2)obj 中存在 foo,且在 obj.__proto__ 上也存在 foo,则会发生属性屏蔽,即:myObject 中包含的 foo 属性会屏蔽原型链上层的所有 foo 属性,因为 myObject.foo 总是会选择原型链中最底层的 foo 属性
(3)obj 中不存在 foo,但存在于 obj.__proto__ 原型链上,则会出现三种情况:
- 如果在[[Prototype]]链上层存在名为 foo 的普通数据访问属性并且没有被标记为只读(writable:false),那就会直接在 myObject 中添加一个名为 foo 的新 属性,它是屏蔽属性。
- 如果在[[Prototype]]链上层存在foo,但是它被标记为只读(writable:false),那么 无法修改已有属性或者在 myObject 上创建屏蔽属性。如果运行在严格模式下,代码会 抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。
- 如果在[[Prototype]]链上层存在 foo 并且它是一个 setter,那就一定会 调用这个 setter。foo 不会被添加到(或者说屏蔽于)myObject,也不会重新定义 foo 这个 setter。
4. "类"
function Foo(name) { this.name = name } Foo.prototype.getName = function () { return this.name } var a = new Foo(‘a‘); var b = new Foo(‘b‘); a.getName() // ‘a‘ b.getName() // ‘b‘ Object.getPrototypeOf( a ) === Foo.prototype; // true
new Foo() 会生成一个新对象(我们称之为 a),这个新对象的内部链接 [[Prototype]] 关联 的是 Foo.prototype 对象。
最后我们得到了两个对象,它们之间互相关联,就是这样。我们并没有初始化一个类,实 际上我们并没有从“类”中复制任何行为到一个对象中,只是让两个对象互相关联。
创建的过程中,a 和 b 的内部 [[Prototype]] 都会关联到 Foo.prototype 上。当 a 和 b 中无法找到 myName 时,它会(通过委托,参见第 6 章)在 Foo.prototype 上找到。
5. 原型链
如果在对象上没有找到需要的属性或者方法引用,引擎就 会继续在 [[Prototype]] 关联的对象上进行查找。同理,如果在后者中也没有找到需要的 引用就会继续查找它的 [[Prototype]],以此类推。这一系列对象的链接被称为“原型链”。
// Object.create(...) 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 代码
if (!Object.create) { Object.create = function(o) { function F() {} F.prototype = o; return new F(); }; }
可以发现,本质上还是使用构造函数
6. 类构造函数的本质
这些 JavaScript 机制和传统面向类语言中的“类初始化”和“类继承”很相似,但 是 JavaScript 中的机制有一个核心区别,那就是不会进行复制,对象之间是通过内部的 [[Prototype]] 链关联的。
六、行为委托
var anotherObject = { cool: function() { console.log( "cool!" ); } }; var myObject = Object.create( anotherObject ); myObject.doCool = function() { this.cool(); // 内部委托! }; myObject.doCool(); // "cool!"
调用的 myObject.doCool() 是实际存在于 myObject 中的,这可以让我们的 API 设 计更加清晰。从内部来说,我们的实现遵循的是委托设计模式,通过 [[Prototype]] 委托到 anotherObject.cool()。
换句话说,内部委托比起直接委托可以让 API 接口设计更加清晰。
委托行为意味着某些对象(XYZ)在找不到属性或者方法引用时会把这个请求委托给另一个对象(Task)。
这是一种极其强大的设计模式,和父类、子类、继承、多态等概念完全不同。在你的脑海中对象并不是按照父类到子类的关系垂直组织的,而是通过任意方向的委托关联并排组织的。
以上就是 《你不知道的 JavaScript 上卷》的学习笔记,下周开始看下卷。