前言
细读《你不知道的 javascript》,以“零部件”的概念逐个了解javascript内部运行原理。
第一个“零部件”:作用域
作用域是什么
简单来说,作用域就是一套规则,用来存储变量,并且可以方便的找到这些变量
编译原理
对于传统编译语言,在执行一段代码之前会经历三个步骤(统称编译):
- 分词/词法分析(Tokenizing/Lexing) 将我们编写的代码分解成有意义的代码块(词法单元) 例如:
var a = 2; //词法单元为:var、a、=、2 复制代码
- 解析/语法分析(Parsing) 这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法 结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
- 代码生成 将 AST 转换为可执行代码的过程被称为代码生成。
任何javascript代码片段在执行前都会进行编译(通常就在执行前)
编译器如何处理代码?
//例如:
var a = 2;
//1、var a时,编译器会询问作用域“a”在当前作用域集合中是否存在,是则忽略var声明,否则在当前作用域声明变量“a”;
//2、a = 2时,引擎会在当前作用域集合中查找变量a,有则使用,没有则继续查找,如果最终能找到,则会将2的值赋值给a,否则抛出异常。
复制代码
如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。赋值操作符会导致 LHS 查询。=操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作。
作用域嵌套
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。 也就是当前作用域中无法找到某个变量时,引擎会向外层嵌套作用域继续查找,直到找到该变量,或者到达全局作用域(最外层)时为止。
异常
- ReferenceError 一般是同作用域判别失败相关 例如:function foo(a){console.log(a + b);b = a};foo(2) //Uncaught ReferenceError: b is not defined
- TypeError 代表作用域判别成功了,但是对结果的操作是非法或不合理的 例如:var a = 2;a()//Uncaught TypeError: a is not a function
词法作用域
词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)
词法阶段
- 作用域查找 作用域查找会在找到第一个匹配的标识符时停止。多层嵌套的作用域可以定义同名的标识符,叫做“遮蔽效应”(内层标识符“遮蔽”外层标识符)。也就是说,作用域查找始终从运行时所处的最内层作用域开始,逐级向外部查找,直到找到第一个需要的标识符为止。
词法作用域查找只会查找一级标识符,比如 a、b 和 c。如果代码中引用了 foo.bar.baz,词法作用域查找只会试图查找 foo 标识符,找到这个变量后,对象属性访问规则会分别接管对 bar 和 baz 属性的访问。
欺骗词法(运行时修改词法作用域)
- eval(str)
接受一个字符串参数,并将字符串内容视为本来就书写在程序中此位置的代码。也就是做代码占位,欺骗引擎以当前作用域进行词法分析。如果 eval(..) 中所执行的代码包含有一个或多个声明(无论是变量还是函数),就会对 eval(..) 所处的词法作用域进行修改。
注意:在严格模式的程序中,eval(..) 在运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域。
- with()
with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。 with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域.
欺骗词法使得引擎在编译阶段无法对作用域进行查找,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法阶段明确的知道这两种机制会接收到什么代码。结果就是不进行任何词法分析阶段的优化。
函数作用域和块作用域
函数作用域
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用),外部不能对该函数内属性进行访问。
function foo(a){
var b = 2;
function bar(){
//...
}
var c = 3;
}
bar()//VM447:1 Uncaught ReferenceError: bar is not defined
console.log(a,b,c)//Uncaught ReferenceError: a is not defined
复制代码
以上bar,a,b,c都不能在函数外部作访问,都被foo函数作用域“隐藏”起来,这种“隐藏”带来的好处就是规避同名标识符之间的冲突。
块作用域
块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息扩展为在块中隐藏信息。 对于for循环的例子:
for(var i = 0;i < 4;i++){
console.log(i)
}
复制代码
我们在 for 循环的头部直接定义了变量 i,通常是因为只想在 for 循环内部的上下文中使用 i,而忽略了 i 会被绑定在外部作用域(函数或全局)中的事实。
- with
用 with关键字 从对象中创建出的作用域仅在 with 声明中而非外部作用域中有效
- try/catch
ES3规范中规定,try/catch中的cathc分句会创建一个块作用域,在其中声明的变量只能在catch内部有效。
try {
undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
console.log( err ); // 正常输出:TypeError: undefined is not a function
}
console.log(err)//Uncaught ReferenceError: err is not defined
复制代码
- let
ES6引入的let关键字,提供了除var之外的另一种变量声明方式。 let 关键字可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。使用 let 进行的声明不会在块作用域中进行提升。声明的代码被运行之前,声明并不“存在”。
for(let i = 0;i < 4;i++){
console.log(i)
}
console.log(i)//VM615:1 Uncaught ReferenceError: i is not defined
复制代码
for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
- const
ES6 引入了 const,同样可以用来创建块作用域变量,但其值是固定的 (常量)。之后任何试图修改值的操作都会引起错误。
var foo = true;
if (foo) {
var a = 2;
const b = 3; // 包含在 if 中的块作用域常量
a = 3; // 正常 !
b = 4; // Uncaught TypeError: Assignment to constant variable.
}
console.log( a ); // 3
console.log( b ); // Uncaught ReferenceError: b is not defined
复制代码
提升
当你看到 var a = 2; 时,可能会认为这是一个声明。但 JavaScript 实际上会将其看成两个声明:var a; 和 a = 2;。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。
a = 2;
var a;
console.log( a );//2
console.log( a ); //undefined
var a = 2;
复制代码
包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
- 函数优先 函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个“重复”声明的代码中)是函数会首先被提升,然后才是变量。
foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};
复制代码
作用域闭包
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var bar = foo()
bar()//2
复制代码
foo执行后,通常整个内部作用域会被引擎的回收器销毁,但是,在上面代码中,外部声明的bar对foo内部作用域有引用,使得该作用域一直存活,而这个引用就叫做闭包。
闭包使得函数可以继续访问定义时的词法作用域
function foo() {
var a = 2;
function baz() {
console.log( a ); // 2
}
bar( baz );
}
function bar(fn) {
fn();
}
foo()//2
复制代码
- 循环和闭包
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
复制代码
//输出五个6,此时共享一个全局的i
for (var i=1; i<=5; i++) {
(function(j) {
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})( i );
}
复制代码
//此时以传参的形式传入,在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。
for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
复制代码
//let 声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量,for循环头部的 let 声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。