第一章 作用域是什么
作用域简单来讲就是一套规则,用来规定存储变量,并且如何方便地找到这些变量
1.1 编译原理
传统语言的编译过程
在传统语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”
-
分词/词法分析
-
**这个过程会将有字符组成的字符串(一段源代码就是字符串)分解成有意义的代码块,这些代码块就叫作词法单元。**例如,考虑程序 var a = 2;。这段程序通常会被分解成为下面这些词法单元:var、a、=、2 、;。空格是否会被当作词法单元,取决于空格在
这门语言中是否具有意义。
-
分词(tokenizing)和词法分析(Lexing)之间的区别是非常微妙、晦涩的,主要差异在于词法单元的识别是通过有状态还是无状态的方式进行的。简单来说,如果词法单元生成器在判断 a 是一个独立的词法单元还是其他词法单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为词法分析。
-
-
解析/语法分析
-
这个过程会把词法单元流(即是上一过程生成的词法单元组成的数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的数,这个树被称为“抽象语法树”(AST)。
-
var a = 2; 的抽象语法树中可能会有一个叫作 VariableDeclaration 的顶级节点,接下
来是一个叫作 Identifier(它的值是 a)的子节点,以及一个叫作 AssignmentExpression
的子节点。AssignmentExpression 节点有一个叫作 NumericLiteral(它的值是 2)的子
节点。
-
-
代码生成
- 即为将AST转换成可执行代码的过程。简单来说就是有某种方法可以将 var a = 2; 的 AST 转化为一组机器指令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中。
1.2 理解作用域(理解js编译过程)
1.2.1 参与编译的组成
js在编译的过程中,需要几个部分的参与
-
引擎
从头到尾负责整个js程序的编译及执行过程
-
编译器
负责词法分析及代码生成等
-
作用域
负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行代码对这些标识符的访问权限
1.2.2 编译过程
通过 var a = 2 作为例子进行讲解
- 首先,编译器再遇到这句代码时(未编译前),会进行编译的前2个过程,即进行词法分析与语法分析,但在代码生成这一过程中,与传统的编译语言不同
- 第三个过程编译器会进行一下处理
- 遇到 var a,编译器会先询问作用域中是否已经有一个改名称的变量存在于同一作用域的集合中,如果是,则会忽略该声明,否则会要求作用域在当前作用域的集合中声明一个新的变量命名为a
- 接下来编译器将会为引擎生成运行时所需的代码,此时这些代码用来处理a = 2这个赋值操作。引擎运行时就会沿着作用域链查找改变量
- 如果引擎最终找到了 a 变量,就会将 2 赋值给它。否则引擎就会举手示意并抛出一个异常
总结:变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。
1.2.3 查找过程
作用域就是一套规则,规定了查询的规则,分别为LHS查询和RHS查询,具体用哪种查询,有以下区别
LHS和RHS区别
- 简单讲,两者的区别在于在赋值操作的左边还是右边,这里要区别赋值操作而非赋值操作符=
- 当变量出现在赋值操作左边时,进行LHS查询,此时就是为该变量赋值,即需要找到该变量所在内存并重新赋值
- 当变量出现在赋值操作右边时,进行RHS查询,此时只是想找到该变量的值而已
- 举个例子
function foo(a) {
console.log( a ); // 2
}
foo( 2 );
当调用foo这个函数时,会先进行RHS查询,查询是否有foo这个值,当找到之后,会把参数 2赋值给形参 a,此时就需要LHS查询是否有这个形参a,接着会再次进行2次RHS查询,分别是查询是否有console和a的值
1.3 作用域嵌套
当一个块或者函数在另一个块或者函数中时,就发生了作用域的嵌套。所以,如果在当前作用域无法找到某变量时,引擎就会在外层嵌套的作用域中开始查找,直到找到该变量或者到了最外层,即全局作用域为止。注意,此时的查找包括LHS和RHS。
1.3.1 查找不到异常
当要查找的变量还未声明时,LHS和RHS查找的行为是不一样的。简单讲,当进行LHS查找时,若沿着作用域到了最外层仍然还没用找到该变量时,就会在全局作用域中创建改变量,然后把变量给引擎;当进行RHS查找不到时,引擎就会抛出referenceerror(引用错误)异常报错
function fn1() {
l = 1; // 进行LHS查找,找不到就在全局作用域声明改变量
console.log(l + r); // RHS查找r, ReferenceError: r is not defined
}
fn1()
console.log(l); // 1, 就是在函数fn1中创建的·全局变量
-
注意,ES5 中引入了“严格模式”。同正常模式,或者说宽松 / 懒惰模式相比,严格模式在行为上有很多不同。其中一个不同的行为是严格模式禁止自动或隐式地创建全局变量。因此,在严格模式中 LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出同 RHS 查询失败时类似的 ReferenceError 异常。
-
如果 RHS 查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作,比如试图对一个非函数类型的值进行函数调用,或者引用 null 或 undefined 类型的值中的属性,那么引擎会抛出另外一种类型的异常,叫作 TypeError。
第二章 词法作用域
作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法作用域,我们会对这种作用域进行深入讨论。另外一种叫作动态作用域,仍有一些编程语言在使用(比如 Bash 脚本、Perl 中的一些模式等)。js使用的是词法作用域。
2.1 词法阶段
在编译器编译的第一阶段会进行词法分析。而词法作用域就是定义在词法阶段的作用域。即词法作用域是由你在写代码时将变量和块写在哪里决定的,在大部分情况下,词法分析器在处理代码时会保持作用域不变
查找
在作用域查找的过程中,有以下的注意点:
- 作用域在查找到第一个匹配的标识符之后就会停止查找
- 在多层的嵌套作用域中可以定义同名的标识符,叫做“遮蔽效应”
- 全局变量会自动成为全局对象(比如浏览器的window对象)的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问。如window.a
- 无论函数在什么时候什么位置被调用,它的词法作用域都只由函数被声明时所处的位置决定
- 词法作用域查找只会查找一级标识符,比如 a、b 和 c。如果代码中引用了 foo.bar.baz,词法作用域查找只会试图查找 foo 标识符,找到这个变量后,对象属性访问规则会分别接管对 bar 和 baz 属性的访问。
2.2 欺骗词法
函数作用域在声明时就已经确定了作用域,但有以下方法可以修改它的词法作用域,但是欺骗词法作用域会导致性能下降
2.2.1 eval
JavaScript 中的 eval(…) 函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。换句话说,可以在你写的代码中用程序生成代码并运行,就好像代码是写在那个位置的一样。
在执行 eval(…) 之后的代码时,引擎并不“知道”或“在意”前面的代码是以动态形式插入进来,并对词法作用域的环境进行修改的。引擎只会如往常地进行词法作用域查找
function fn(str, a) {
// 'use strict'
eval(str) // 欺骗词法
console.log(a, b); // 3, 4
}
var b = 7;
fn('var b = 4', 3)
默认情况下,如果 eval(…) 中所执行的代码包含有一个或多个声明(无论是变量还是函数),就会对 eval(…) 所处的词法作用域进行修改。
在严格模式的程序中,eval(…) 在运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域。
function fn(str, a) {
'use strict'
eval(str) // 欺骗词法
console.log(a, b); // 3, 7 即会找到全局的b
}
var b = 7;
fn('var b = 4', 3)
2.2.2 with
with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。
let obj = {
a: 1,
b: 2
}
with(obj) {
a = 2,
b = 3
}
console.log(obj); // { a: 2, b: 3 }
但是,如果使用with对该对象没有的属性赋值时,并不会给该对象创建这个属性,而是直接在全局对象中创建这个属性,如:
let obj = {
a: 1,
b: 2
}
with(obj) {
a = 2,
b = 3,
c = 5
}
console.log(obj); // { a: 2, b: 3 }
console.log(c); // 5
console.log(obj.c); // undefined 没有的属性时会显示undefined
原因
实际上,with可以将将一个没有或有多个属性的对象处理成一个完全隔离的词法作用域,因此这个对象的属性也会被定义在这个作用域的词法标识符
尽管 with 块可以将一个对象处理为词法作用域,但是这个块内部正常的 var声明并不会被限制在这个块的作用域中,而是被添加到 with 所处的函数作用域中
所以上面的例子可以理解成,with给obj声明了一个作用域,在进行LHS查询时找不到c,便沿着作用域链寻找最后找不到就在全局声明了一个c变量,以下代码也可以验证
function fn(obj) {
var c = 2
with(obj) {
c = 0
}
console.log(c); // // 0, 不是 2!!! with语句进行LHS查询时找到上一个作用域,即函数fn,里面有c并赋值
}
var obj1 = {
a: 1,
b: 2
}
fn(obj1)
console.log(obj1); // { a: 1, b: 2 }
不推荐使用 eval(…) 和 with 的原因是会被严格模式所影响(限制)。with 被完全禁止,而在保留核心功能的前提下,间接或非安全地使用eval(…) 也被禁止了。
2.2.3 性能
JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。
但如果引擎在代码中发现了 eval(…) 或 with,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道 eval(…) 会接收到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象的内容到底是什么。
第三章 函数作用域和块作用域
3.1 函数中的作用域
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用和复用
function fn1() {
console.log(1);
function fn2() {
console.log(2);
}
fn2()
}
fn1() // 1, 2
fn2() // ReferenceError: fn2 is not defined
上面例子中,fn2定义在函数fn1的作用域中,就无法从全局作用域或其他的作用域中去调用他,否则会报错
3.1.1 隐藏内部实现
对于一部分代码,用函数进行封装,就可以把这部分代码放在这个函数的作用域中,把它们“隐藏”起来
隐藏代码有下列的好处:
有很多原因促成了这种基于作用域的隐藏方法。它们大都是从最小特权原则中引申出来的,也叫最小授或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都藏”起来,比如某个模块或对象的 API 设计
-
如果把所有的变量和函数都写在全局作用域中,就会违背最小授权原则,对于有一些代码,只在特定的作用域中使用,就可以将这些代码私有化
function doSomething(a) { b = a + doSomethingElse( a * 2 ); console.log( b * 3 ); } function doSomethingElse(a) { return a - 1; } var b; doSomething( 2 ); // 15上面的代码写成下面这种会更好,即把变量b,跟函数doSomethingElse私有化,放在函数doSomething的作用域中
function doSomething(a) { function doSomethingElse(a) { return a - 1; } var b; b = a + doSomethingElse( a * 2 ); console.log( b * 3 ); } doSomething( 2 ); // -
可以避免同名标识符的冲突
3.2 函数作用域
虽然我们用函数声明的方式可以让一部分代码添加到其他的作用域中,但这种方法必须声明一个具名的函数,意味着这个函数名本身就“污染”了全局作用域,其次必须显示地通过这个函数名调用这个函数才能运行其中的代码
js提供了函数表达式来解决这个问题
3.2.1 函数声明与函数表达式
js中创建函数的方法有2中,为函数声明式和函数表达式
函数声明
函数声明可以定义命名的函数变量,而无需给变量赋值。函数声明是一种独立的结构,不能嵌套在非功能模块中。可以将它类比为 变量声明。就像变量声明必须以“var”开头一样,变量声明必须以“function”开头。函数名在自身作用域和父作用域内是可获取的。
函数表达式
函数表达式将函数定义为表达式语句(通常是变量赋值)的一部分。通过函数表达式定义的函数可以是命名的,也可以是匿名的。函数表达式不能以“function”开头。函数名在作用域外是不可获取的。
function fn1() {
// 这种是函数声明式
}
let fn2 = function() {
// 这种是函数表达式
}
它们有以下的区别:
-
函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。
var a = 2; (function foo(){ // <-- 函数表达式的一种 var a = 3; console.log( a ); // 3 })(); // <-- 以及这一行 console.log( a ); // 2foo 被绑定在函数表达式自身的函数中而不是所在作用域中。换句话说,(function foo(){ … }) 作为函数表达式意味着 foo 只能在 … 所代表的位置中被访问,外部作用域则不行。foo 变量名被隐藏在自身中意味着不会非必要地污染外部作用域。
-
以函数声明的方法定义的函数,函数名是必须的,而函数表达式的函数名是可选的。(函数声明整体会被提升到当前作用域的顶部,函数表达式也提升到顶部但是只有其变量名提升)。故函数声明式的函数可以在函数声明之前调用,而函数表达式的不可以
-
以函数声明的方法定义的函数并不是真正的声明,它们仅仅可以出现在全局中,或者嵌套在其他的函数中,但是它们不能出现在循环,条件或者try/catch/finally中,而函数表达式可以在任何地方声明。换句话说,函数声明不是一个完整的语句,所以不能出现在if-else,for循环,finally,try catch语句以及with语句中。
-
但是,在普通的块级作用域中的函数声明会被提升到当前所在作用域的顶部(此处就是全局作用域),因此后面执行foo()的时候可以正常的输出。
if(true) { function foo() { console.log(111); } foo() // 111 } try { function fn() { console.log(11); } fn() // 11 }catch(e) { console.log(e); }
-
3.2.2 函数表达式
匿名函数表达式
setTimeout( function() {
console.log("I waited 1 second!");
}, 1000 );
对于一般的回调函数,没有名称标识符的,就是函数表达式,叫做匿名函数表达式
匿名函数表达式有以下的缺点:
- 匿名函数在栈追踪中不会显示有意义的函数名,使得调试困难
- 如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用,比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。
- 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。
行内函数表达式非常强大且有用——匿名和具名之间的区别并不会对这点有任何影响。给函数表达式指定一个函数名可以有效解决以上问题。始终给函数表达式命名是一个最佳实践:
setTimeout( function timeoutHandler() { // <-- 快看,我有名字了!
console.log( "I waited 1 second!" );
}, 1000 );
立即执行函数表达式IIFE
var a = 2;
(function foo() {
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2
由于函数被包含在一对 ( ) 括号内部,因此成为了一个表达式,通过在末尾加上另外一个( ) 可以立即执行这个函数,比如 (function foo(){ … })()。第一个 ( ) 将函数变成表达式,第二个 ( ) 执行了这个函数。
相较于传统的 IIFE 形式,很多人都更喜欢另一个改进的形式:(function(){ … }())。仔细观察其中的区别。第一种形式中函数表达式被包含在 ( ) 中,然后在后面用另一个 () 括号来调用。第二种形式中用来调用的 () 括号被移进了用来包装的 ( ) 括号中。
IIFE 的另一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去。例如:
var a = 2;
(function IIFE( global ) {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
})( window );
console.log( a ); // 2
3.3 块作用域
块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息扩展为在块中隐藏信息。
var没有块作用域
3.3.1 let
let 关键字可以将变量绑定到所在的任意作用域中(通常是 { … } 内部)。换句话说,let为其声明的变量隐式地劫持了所在的块作用域。
使用 let 进行的声明不会在块作用域中进行提升。声明的代码被运行之前,声明并不“存在”。
{
console.log( bar ); // ReferenceError!
let bar = 2;
}
垃圾回收
考虑以下代码:
function process(data) {
// 在这里做点有趣的事情
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt) {
console.log("button clicked");
}, /*capturingPhase=*/false );
click 函数的点击回调并不需要 someReallyBigData 变量。理论上这意味着当 process(…) 执行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于 click 函数形成了一个覆盖整个作用域的闭包,JavaScript 引擎极有可能依然保存着这个结构(取决于具体实现)。
块作用域可以打消这种顾虑,可以让引擎清楚地知道没有必要继续保存 someReallyBigData 了:
function process(data) {
// 在这里做点有趣的事情
}
// 在这个块中定义的内容完事可以销毁!
{
let someReallyBigData = { .. };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /*capturingPhase=*/false );
为变量显式声明块作用域,并对变量进行本地绑定是非常有用的工具,可以把它添加到你的代码工具箱中了。
let循环
一个 let 可以发挥优势的典型例子就是之前讨论的 for 循环。
for (let i=0; i<10; i++) {
console.log( i );
}
console.log( i ); // ReferenceError
let与var区别
for(let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 0, 1, 2
})
}
for(var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 3, 3, 3
})
}
- 由于var定义变量时,变量是被定义在当前作用域里的,setTimeout是异步,要在for循环后执行,所以当settineout函数执行的时候,输出变量i时,会从当时作用域里面去找变量i,总共进行了5次查找,这5次查找都是在当前作用域执行的,所以它们找到的是当时作用域下的同一个i,这个i是3,所以结果是3个3
- let是块级作用域,每执行一次setTimeout就向每个块中寻找i值,执行了5次,每个块中分别是0 1 2 3 4,所以结果为0 1 2 3
3.3.2 const
除了 let 以外,ES6 还引入了 const,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误。
第四章 提升
4.1 先声明,后赋值
引擎会在解释 JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。第 2 章中展示了这个机制,也正是词法作用域的核心内容。
因此,正确的思考思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理
当你看到 var a = 2; 时,可能会认为这是一个声明。但 JavaScript 实际上会将其看成两个声明:var a; 和 a = 2;。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在原地等待执行阶段。
提升
这个过程就好像变量和函数声明从它们在代码中出现的位置被“移动”到了最上面。这个过程就叫作提升。
注意,函数声明会被提升,但函数表达式不会被提升
foo(); // 不是 ReferenceError, 而是 TypeError! 因为此时foo的值为undefined
var foo = function bar() {
// ...
}
4.2 函数优先
函数优先的意思是在同时有函数跟变量声明时,会先提升函数,再是变量
j() // 11 函数声明提升之后才是变量提升
function j() {
console.log(11);
}
var j; // 由于j已经在函数声明中被声明了,所以会忽略这次声明
//无论 var j 和 function j 的先后顺序是怎么样的,结果j()都是输出 11
后面的函数声明可以把前面的覆盖掉
fn() // 222 即后面的声明会替换之前的声明
function fn() {
console.log('111');
}
function fn() {
console.log('222');
}
一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代码暗示的那样可以被条件判断所控制:
foo(); // "b"
var a = true;
if (a) {
function foo() { console.log("a"); }
}
else {
function foo() { console.log("b"); }
}
本文详细介绍了JavaScript的作用域概念,包括词法作用域、函数作用域和块作用域,强调了编译过程中的变量声明提升和函数声明优先。讲解了LHS和RHS查询在赋值操作中的区别,并通过实例展示了作用域嵌套和查找规则。此外,文章还探讨了eval和with的危险性,以及如何通过立即执行函数表达式(IIFE)来管理作用域。最后,提到了let和const的块级作用域特性,以及它们在循环和变量提升中的表现。
830

被折叠的 条评论
为什么被折叠?



