作用域和闭包
1、作用域
编译原理:
总所周知,JavaScript是一门解释型语言,但事实上它是一门编译语言。
传统编译语言编译的步骤:
- 分词/词法分析(Tokenizing/Lexing)
将由字符组成的字符串分解成( 对编程语言来说)有意义的代码块, 这些代码块被称为词法单元(token)。
var a = 2;
// 分解成 var、a、=、2、;
- 解析/语法分析(Parsing)
这个过程是将词法单元流( 数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
var a = 2;的抽象语法树中可能会有一个叫作VariableDeclaration的顶级节点,接下来是一个叫作Identifier(它的值是a)的子节点,以及一个叫作AssignmentExpression的子节点。AssignmentExpression节点有一个叫作NumericLiteral(它的值是2)的子节点。
- 代码生成
将AST转换为可执行代码的过程称被称为代码生成。 这个过程与语言、 目标平台等息息相关。抛开具体细节,简单来说就是有某种方法可以将var a = 2;的AST转化为一组机器指令,用来创建一个叫作a的变量(包括分配内存等),并将一个值储存在a中。
JavaScript中的编译
- 首先,JavaScript引擎不会有大量的(像其他语言编译器那么多的)时间用来进行优化,因为与其他语言不同,JavaScript的编译过程不是发生在构建之前的。
- 对于JavaScript来说, 大部分情况下编译发生在代码执行前的几微秒( 甚至更短!) 的时间内。
- 简单地说, 任何JavaScript代码片段在执行前都要进行编译( 通常就在执行前)。因此,JavaScript编译器首先会对var a = 2;这段程序进行编译, 然后做好执行它的准备, 并且通常马上就会执行它。
总而言之,JavaScript不会像Java那样,先进行编译,再把编译过后的文件拿去运行,而是直接去运行写好的文件,但在运行的时候会作一次内部的编译,再逐行执行(那么报错到底是发生在预编译还是代码执行?)。
解析作用域
三个角色:
- 引擎:从头到尾负责整个JavaScript程序的编译及执行过程。
- 编译器:引擎的好朋友之一,负责语法分析及代码生成等脏活累活。
- 作用域:引擎的另一位好朋友, 负责收集并维护由所有声明的标识符( 变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
对于 var a = 2 的处理
遇到var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。 如果是, 编译器会忽略该声明, 继续进行编译; 否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a。
接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理a = 2这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作a的变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量。
LHS查询与RHS查询
当变量出现在赋值操作的左侧时进行LHS查询,出现在右侧时进行RHS查询。
RHS查询与简单地查找某个变量的值别无二致, 而LHS查询则是试图找到变量的容器本身, 从而可以对其赋值。
var a
a = 2 // LHS查询
console.log(a) // RHS查询
作用域嵌套查询:
当一个块或函数嵌套在另一个块或函数中时, 就发生了作用域的嵌套。 因此, 在当前作用域中无法找到某个变量时, 引擎就会在外层嵌套的作用域中继续查找, 直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。
当抵达最外层的全局作用域时, 无论找到还是没找到, 查找过程都会停止。
LHS查询和RHS查询未找到预期结果的时候
LHS查询
function foo() {
b = 3
return b
}
foo()
/**
* 在执行 b = 2 语句的时候,引擎首先会通过一次LHS查询,试图查询 b 标识符
* 在 foo 内部没有找到,然后再去找上一层作用域(也就是全局作用域),也没有找到
* 此时全局作用域就会创建一个变量,接着引擎顺利执行了后面的赋值操作(非严格模式下)
* 这也是为什么直接给一个变量赋值,会在全局作用域声明一个同名变量并赋值
*/
RHS查询
function bar() {
return b
}
bar()
/**
* 引擎通过RHS查询首先找寻变量b
* 在本层作用域(bar内部)没有找到,会继续向外层作用域
* 上一层全局作用域,依然没有找到,这时候作用域就会抛出一个错误
* ReferenceError: b is not defined
*/
2、词法作用域
词法作用域就是定义在词法阶段的作用域。 换句话说, 词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的, 因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。
作用域嵌套
function foo(a) {
var b = a + 1
function bar(c) {
console.log(a, b, c)
}
bar(b * 3)
}
foo(2)
/**
* 全局作用域 {foo: func}
* foo作用域 {a:, b:, bar:}
* bar作用域 {c: ,}
*/
全局变量会自动成为全局对象,可通过 window.xxx 访问。
两种欺骗语法:
- eval:eval是魔鬼
JavaScript中的eval(..)函数可以接受一个字符串为参数, 并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。
function foo(str) {
eval(str)
console.log(a)
}
var a = 1
var evalStr = 'var a = 2'
foo(evalStr) // 2
这段代码会被当作本来就在那里一样来处理。这段代码foo(..)的词法作用域进行了修改,声明了一个新的变量a。
- with:
var obj = {
name: 'normal',
age: 5,
}
with(obj) {
name = 'with'
type = 'with'
}
/**
* with() {}
* () 里面表示 {} 里面写的代码的作用域
*/
console.log(obj) // { name: 'with', age: 5 }
eval(..)和with会在运行时修改或创建新的作用域, 以此来欺骗其他在书写时定义的词法作用域。总而言之,不要使用它们。
3、函数作用域和块作用域
函数作用域的含义是指, 属于这个函数的全部变量都可以在整个函数的范围内使用及复用( 事实上在嵌套的作用域中也可以使用)。
使用函数作用域的好处:
- 使用函数作用域把变量包装起来,可以避免污染全局变量
- 可以实现模块化管理,向外暴露尽量少的东西。
函数声明和函数表达式
/**
* es6函数新增了name属性
* 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
*/
function foo() {}
foo.name // 'foo'
// 函数表达式可以是匿名的
var bar = function () {}
bar.name // 'bar'
// 函数表达式也可以是具名的
var foo = function bar() {}
foo.name // 'bar'
立即执行函数
两种形式:
- (function () {}())
- (function () {})()
块级作用域
一个容易忽视的点:
for (var i = 0; i < 5; i ++) {
console.log(i) // 0 1 2 3 4
}
// 这里的 i 在全局也能访问
console.log(i) // 5 当 i 等于 5 的时候,就不符合 i < 5,就不会走后面的 i++
我们在for循环的头部直接定义了变量i,通常是因为只想在for循环内部的上下文中使用i,而忽略了i会被绑定在外部作用域(函数或全局)中的事实。
try/catch
JavaScript的ES3规范中规定try/catch的catch分句会创建一个块作用域,其中声明的变量仅在catch内部有效。
try {
console.log(a)
} catch (err) {
console.log(err) // 程序不会报错,打印出出错信息 a is not defined
}
console.log(err) // 程序出错,a is not defined
let 声明
let关键字可以将变量绑定到所在的任意作用域中(通常是{ .. }内部) 。换句话说,let为其声明的变量隐式地了所在的块作用域。
for(let i = 0; i < 5; i ++) {
}
console.log(i) // 报错