参考: 《你不知道的JavaScript(上)》
作用域
- 作用域是一套规则,用于确定在何处以及如何查找变量(标识符)
- JavaScript 中的作用域就是词法 作用域(事实上大部分语言都是基于词法作用域的)
编译原理
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”:
- 分词/词法分析
-
- 这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代 码块被称为词法单元(token)。例如,考虑程序 var a = 2;。这段程序通常会被分解成 为下面这些词法单元:var、a、=、2 、;。空格是否会被当作词法单元,取决于空格在 这门语言中是否具有意义。
-
-
- 分词(tokenizing)和词法分析(Lexing) 之间的区别是非常微妙、晦涩的, 主要差异在于词法单元的识别是通过有状态还是无状态的方式进行的。简 单来说,如果词法单元生成器在判断 a 是一个独立的词法单元还是其他词法 单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为词法 分析。
-
- 解析/语法分析
-
- 这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法 结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
- 代码生成
-
- 将 AST 转换为可执行代码的过程称被称为代码生成。
理解作用域
- 引擎从头到尾负责整个 JavaScript 程序的编译及执行过程。
- 编译器负责语法分析及代码生成等
- 作用域负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
- 变量赋值
-
- 变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如 果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对 它赋值。
- 变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询
-
- LHS 查询是找到变量的容器本身,从而对变量进行赋值
- RHS 查询是查找某个变量的值
词法作用域
词法阶段
- 词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。
- 词法作用域是一套关于引擎如何寻找变量以及会在何处找到变量的规 则。词法作用域最重要的特征是它的定义过程发生在代码的书写阶段
- 词法作用域是关联在编译期间的,对于函数来说就是函数的定义文本段的位置决定这个函数所属的范围。
- 动态作用域是关联在程序执行期间的,对于函数来说就是函数执行的位置决定这个函数所属的范围。
- 动态作用域似乎暗示有很好的理由让作用域作为一个在运行时就被动态确定的形式,而不 是在写代码时进行静态确定的形式
- 而动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调 用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套
- 需要明确的是, 事实上 JavaScript 并不具有动态作用域。它只有词法作用域,简单明了。 但是 this 机制某种程度上很像动态作用域。
- 主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定 的。(this 也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。
查找
- 作用域查找会在找到第一个匹配的标识符时停止
- 无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定
- 词法作用域查找只会查找一级标识符,比如 a、b 和 c。如果代码中引用了 foo.bar.baz, 词法作用域查找只会试图查找 foo 标识符,找到这个变量后,对象属性访问规则会分别接 管对 bar 和 baz 属性的访问。
欺骗词法
- eval():函数可以接受一个字符串为参数,并将其中的内容视为好像在书 写时就存在于程序中这个位置的代码。
- new Function()
- setTimeout('xxx',timeout)
- with():通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。
函数作用域和块级作用域
- 区分函数声明和表达式最简单的方法是看 function 关键字出现在声明中的位 置(不仅仅是一行代码,而是整个声明中的位置)。如果 function 是声明中 的第一个词,那么就是一个函数声明,否则就是一个函数表达式。
- 函数表达式的缺陷:
-
- 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
- 如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用, 比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑 自身。
- 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。一个描述性的名称可以让 代码不言自明。
块作用域
- let
- const
- with
- try catch
提升
- 先声明,后赋值
- 引擎会 在解释 JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的 声明,并用合适的作用域将它们关联起来。
- 包括变量和函数在内的所有声明都会在任何代码被执行前首先 被处理
- 第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在 原地等待执行阶段。
// 1
a = 2;
var a;
console.log( a ); //2
// 等同于:
var a;
a = 2;
console.log( a );
// 2
console.log( a ); // undefined
var a = 2;
// 等同于:
var a;
console.log( a );
a = 2;
- 只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。
- 每个作用域都会进行提升操作
- 函数作用域内的变量声明也会提升
- 声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升
函数优先
- 函数声明和变量声明都会被提升。但是一个值得注意的细节是函数会首先被提升,然后才是变量。
作用域闭包
- 在函数外依然持有对该作用域的引用,而这个引用就叫作闭包
- 闭包在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的 词法作用域。
- 无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用 域的引用,无论在何处执行这个函数都会使用闭包
- 本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一 级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、 Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使 用了回调函数,实际上就是在使用闭包!
模块模式
-
必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块 实例)。
-
封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并 且可以访问或者修改私有的状态。
this和对象原型
关于this
this是什么
- this 既不指向函数自身也不指向函数的词法作用域,
- this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调 用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式
- 当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中用到
- this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用
this解析
调用位置
- 调用位置就是函数在代码中被调用的 位置(而不是声明的位置)
- 最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的 调用位置就在当前正在执行的函数的前一个调用中。
绑定规则
默认绑定
- 函数调用时 this 的默认绑定指向全局对象
- 严格模式this 会绑定 到 undefined
隐式绑定
- 在一个对象内部包含一个指向函 数的属性,并通过这个属性间接引用函数,从而把 this 间接(隐式)绑定到这个对象上
- 调用位置是否有上下文对象,或者说是否被某个对象拥有或者包 含,不过这种说法可能会造成一些误导
- 当函数引 用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象
- 对象属性引用链中只有最后一层会影响调用位置
- 传入回调函数时也会隐式绑定
隐式丢失
- 一个最常见的 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"
- 参数传递时同理也是一种隐式赋值
显式绑定
- call()
- apply()
- 硬绑定的典型应用场景就是创建一个包裹函数,传入所有的参数并返回接收到的所有值:
function foo(something) {
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
// 包裹函数
var bar = function() {
return foo.apply( obj, arguments );
};
var b = bar( 3 ); // 2 3
console.log( b ); // 5
//其实就是bind函数
new绑定
- 在 JavaScript 中,构造函数只是一些 使用 new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上, 它们甚至都不能说是一种特殊的函数类型,它们只是被 new 操作符调用的普通函数而已
- 实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。
-
-
- 创建(或者说构造)一个全新的对象。
-
- 这个新对象会被执行 [[ 原型 ]] 连接。
-
- 这个新对象会绑定到函数调用的 this。
-
- 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象
-
判断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()
绑定例外的情况
被忽略的this
- 使用apply或者bind展开数组时,需要忽略第一个参数,传入null,会默认切换this为window
- 更安全的this
-
- 上述情况会造成不可预想的bug;更加安全的方式是使用Object.create(null)替代传入的this,使this绑定到特定的空对象中
间接引用
软绑定
可以给默认绑定指定一个全局对象和 undefined 以外的值,那就可以实现和硬绑定相 同的效果,同时保留隐式绑定或者显式绑定修改 this 的能力
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;
};
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词法
ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定 this,具体来说,箭头函数会继承外层函数调用的 this 绑定(无论 this 绑定到什么)。这 其实和 ES6 之前代码中的 self = this 机制一样。
原型
- 在面向类的语言中,类可以被复制(或者说实例化)多次,就像用模具制作东西一样。
- 在 JavaScript 中,并没有类似的复制机制。你不能创建一个类的多个实例,只能创建 多个对象,它们 [[Prototype]] 关联的是同一个对象。但是在默认情况下并不会进行复制, 因此这些对象之间并不会完全失去联系,它们是互相关联的。
- new Xxx() 这个函数调用实际上并没 有直接创建关联,这个关联只是一个意外的副作用。new Foo() 只是间接完成了我们的目 标:一个关联到其他对象的新对象。
- 继承意味着复制操作,JavaScript(默认)并不会复制对象属性。相反,JavaScript 会在两 个对象之间创建一个关联,这样一个对象就可以通过委托访问另一个对象的属性和函数。 委托这个术语可以更加准确地描述 JavaScript 中对象的关联机制。
function Foo() {
// ...
}
Foo.prototype.constructor === Foo; // true
var a = new Foo();
a.constructor === Foo; // true
//实际上,.constructor 引用同样被委托给了 Foo.prototype,而
//Foo.prototype.constructor 默认指向 Foo。
注意,下面这两种方式是常见的错误做法,实际上它们都存在一些问题:
// 和你想要的机制不一样!
Bar.prototype = Foo.prototype;
// 基本上满足你的需求,但是可能会产生一些副作用 :(
Bar.prototype = new Foo();
- Bar.prototype = Foo.prototype 并不会创建一个关联到 Bar.prototype 的新对象,它只 是让 Bar.prototype 直接引用 Foo.prototype 对象。因此当你执行类似 Bar.prototype. myLabel = ... 的赋值语句时会直接修改 Foo.prototype 对象本身
- Bar.prototype = new Foo() 的确会创建一个关联到 Bar.prototype 的新对象。但是它使用 了 Foo(..) 的“构造函数调用”,如果函数 Foo 有一些副作用(比如写日志、修改状态、注 册到其他对象、给 this 添加数据属性,等等)的话,就会影响到 Bar() 的“后代”,后果不堪设想。
- 检查‘类’的关系
-
- a instanceof Foo
-
-
- 这个方法只能处理对象a和函数Foo(带 .prototype 引用的 Foo)之间的关系
-
-
- Foo.prototype.isPrototypeOf( a ) //Foo 是否出现在 a 的 [[Prototype]] 链中
-
-
- 可以判断两个对象之间的关系
-
- 获取一个对象的 [[Prototype]] 链
-
- Object.getPrototypeOf( xxx )
- a.proto
-
-
- .proto 实际上并不存在于你正在使用的对象中 (本例中是 a)。它和其他的常用函数(.toString()、.isPrototypeOf(..),等等)一样,存在于内置的 Object.prototype 中。
-
- 对象关联
-
- [[Prototype]] 机制就是存在于对象中的一个内部链接,它会引用其他对象。