什么是作用域
对变量值的存储、访问、修改带给程序状态的改变
编程语言的基本功能之一是能够存储变量当中的值,在之后对这个值进行访问或修改。事实上,正是这种存储和访问变量值的能力将状态带给了程序。若没有这个状态的概念,程序虽然能执行简单的任务,但会受到高度限制。作用域是存放与访问变量的一套设计规范
变量存储在哪里呢?程序需要时又是如何找到它们的呢?这些问题说明需要一套设计良好的规则来存储变量,并便捷地寻找到这些变量。这套规则便是作用域。
编译原理
传统编译语言的流程中,程序源代码在执行前会经历三个步骤,它们统称为“编译”。
- 分词/词法分析(Tokenizing/Lexing)
词法分析阶段会将由字符组成的字符串分解成有意义的代码块,这些代码块又称为词法单元(token)。
// 词法分析时被分解为词法单元:var、a、=、2、;
// 空格是否被当做词法单元,取决于空格在语言中是否具有意义。
var a = 2;
分词(tokenizing)和词法分析(lexing)之间的区别非常微妙,差异在于词法单元的识别是通过有状态还是无状态的方式进行的。若词法单元生成器在判断a是一个独立的词法单元还是其他词法单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为词法分析。
- 解析/语法分析(Parsing)
语法分析时将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表程序语法结构的树,即抽象语法树(AST, Abstract Syntax Tree)。
// var a = 2; 的抽象语法树
VariableDeclaration 顶级节点
|- Identifier 其值为a
|- AssignmentExpression
|- NumericLiteral 其值为2
- 代码生成
代码生成是将AST转换为可执行代码的过程,此过程与语言、目标平台等信息相关。
// 某方法将它的AST转化为一组机器指令,用来创建一个叫做a的变量并将一个值存储在a中。
var a = 2;
浏览器
浏览器的核心是渲染引擎、JS引擎(JS解释器)
- 渲染引擎,将网页代码渲染为用户视觉上可感知的平面文档。
- JS引擎,读取网页中JS代码并对其处理。
渲染引擎
浏览器内核即网页浏览器排版引擎(LayoutEngine/Rending Engine),又称为页面渲染引擎或模板引擎,负责获取页面内容(如HTML、XML、图片等)、整理消息(如加入CSS等)、计算网页显示方式,然后输出至显示器或打印机。网页浏览器、电子邮件客户端等终端则根据表示性标记语言(Presentational Markup)来显示内容的应用程序都需要排版引擎。
浏览器典型内核:
- Microsoft Internet Explorer 的 Trident
- Mozilla Firefox 的 Gecko
- Google Chrome 的 Blink
- Apple Safari 的 Webkit
其实一个完整的浏览器不会只有一个排版引擎,还有自己的界面框架与其他功能支持,排版引擎本身不可能实现浏览器的所有功能。
渲染引擎处理网页的流程:
- 解析代码,将HTML代码解析为DOM,将CSS代码解析为CSSOM(CSS Object Model)。
- 对象合成,将DOM和CSSOM合成一颗渲染树(Render Tree)。
- 布局,计算出渲染树的布局
- 绘制,将渲染树绘制到屏幕
JS引擎
JS引擎是用来执行JS代码的,JS引擎的快慢对网页速度有着重大影响。简单来说,JS解析引擎是能够“读懂JS代码并准确地给出代码运行结果的一段程序。”。
对于静态语言而言,如C、C++、Java等,处理上述事情的叫做“编译器(compiler)”,对于动态的JS语言而言则叫做“解释器(interpreter)”。编译器与解释器的区别在于“编译器将源代码编译成另一种代码(如机器码或字节码等),而解释器是直接解析并将代码运行结果输出。”。例如Firebug的console就是一个JS的解释器。
典型JS引擎:
- Microsoft IE 的 Chakra,Chakra由Opera公司编写。
- Mozilla Firefox 的 SpiderMonkey,第一款JS引擎。
- Google Chrome 的 V8,C/C++编写。
既然JS引擎是一段程序,JS代码也是程序,那么如何让程序去读懂程序呢?那就需要定义规则,而ECMAScript就是专门定义规则的,JS引擎根据ECMAScript定义的标准规则去解析JS代码。简单来说,ECMAScript定义语言的标准,JS引擎根据标准的规则去实现。
JS虽被归类为“动态”或“解释执行”的语言,实际上它是一门编程语言。由于JS引擎可按需创建并存储变量,尽管JS引擎进行编译的步骤和传统编译语言相似,不同的是JS不是提前编译的,而且编译结果也不能在分布式系统中进行移植。
JS引擎不会有大量时间进行优化,与其他语言不同的是JS的编译过程不是发生在构建之前的。大部分情况下编译发生在代码执行前几微秒。在作用域背后,JS引擎用尽各种办法来保证性能最佳。如使用JIT进行延迟编译甚至实施重编译。
// 任何JS代码片段在执行前需编译,JS编译器先对代码进行编译,然后做好执行的准备。
var a = 2;
理解作用域
要理解JS的工作原理,你需要像JS引擎一样思考,从它的角度提出问题并回答。
演员表
- JS引擎:主角,从头到尾负责整个JS程序的编译及执行过程。
- 编译器:JS引擎的好友之一,负责语法分析及代码生成等脏累活。
- 作用域:JS引擎的好友之一,负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
场景
var a = 2;
对话
- 我们:这是一句声明
- JS引擎:错,这完全是两个不同的声明,一个由编译器在编译时处理,一个则由我JS引擎在运行时处理。
流程
- 编译器:将代码分解为词法单元,将词法单元解析为树结构。不过代码生成时对代码的处理方式会与预期有所不同。
var a = 2;
“为一个变量分配内存,将其命名为a,然后将值2保存到这个变量中。”这样理解对吗?
事实上编译器是这样处理的:
- 遇到
var a
,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。若有编译器会忽略该声明并继续进行编译,否则它会要求作用域在当前作用域的集合中声明一个全新的变量并命名为a
。 - 编译器为JS引擎生成运行时所需代码,这些代码被用来处理
a = 2
这个赋值操作。JS引擎运行时会首先询问作用域,在当前作用域集合中是否存在一个叫做a
的变量,若有JS引擎就会使用此变量,否则JS引擎会继续查找该变量。若JS引擎最终找到a
变量就会将2赋值给它,否则JS引擎就会举手示意并抛出一个异常。
编译器在编译阶段生成代码,JS执行代码时会通过查找变量a
来判断它是否已经声明过。查找的过程由作用域进行协助,但是JS引擎执行怎样的查找方式会影响最终的查询结果。
变量赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量,若之前未声明,然后在运行时JS引擎会在作用域中查找该变量,若能够找到就会对其赋值。
LHS与RHS
var a = 2;// JS引擎会为变量a进行LHS左侧查询
JS引擎会为变量a进行LHS查询(赋值操作的左侧),另外一种查询类型叫做RHS(赋值操作的 右侧)。LHS查询指的是当变量出现在赋值操作的左侧时进行LHS查询,出现在右侧时进行RHS查询。准确来讲,RHS查询与简单地查询某个变量的值别无二致,而LHS查询则是试图找到变量的容器本身,从而可以对其赋值。从这个角度来讲,RHS并不是真正意义上的“赋值操作的右侧”,而是“非左侧”。可将RHS理解为Retrieve His Source Value
即获取到它的源值,这意味着“得到某某的值”。
LHS和RHS的含义是“赋值操作的左侧或右侧”,并不意味着就是“=赋值操作符的左侧或右侧”,赋值操作还有其他几种形式。因此在概念上最好将其理解为:
- LSH “赋值操作的目标是谁”
- RHS “谁是赋值操作的源头”
function log(msg){
console.log(msg); // 1
}
log(1); // log(1)函数调用需要对log进行右侧引用RHS,意味着“去查询到log的值,并它给我。”
// 被忽略却非常重要的细节:
// 代码中隐式地 msg=1 操作可能很容易被忽略。
// 这个操作发生在1被当做参数传递给log()函数时,1会被分配给参数msg。
// 为了给参数msg隐式地分配值,需要进行一次LHS查询。
作用域嵌套
作用域使根据名称查找变量的一套法则,实际情况中,通常需要同时顾及几个作用域。
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某变量时,JS引擎就会在外层嵌套的作用域中继续查找,直到找到该变量或抵达最外层的作用域(全局作用域)为止。
function add(n){
console.log(n+m);
}
var m= 10; // 对m进行的RHS引用无法在函数add内部完成,但可在上一级作用域中完成。
add(1);// 11
-----------------------------------------------------------------------------------------------------------------
JS引擎:add的作用域兄弟啊,你见过m吗?我需要对它进行RHS引用啊!
作用域:走开,听都没听过...
JS引擎:add的上级作用域啊,咦?大兄弟,有眼不识泰山,原来你是全局作用域大哥,太好了。你见过m吗?我需要对它进行RHS引用。
作用域:当然了,给你吧!
为了将作用域处理的过程可视化,你在脑中想想如下这个高大的建筑物,这个建筑代表程序中的嵌套作用域链。第一层代表当前的执行作用域,也就是你所处的位置,建筑的顶层代表全局作用域。
LHS和RHS引用都会在当前楼层进行查找,如果没有找到,就会坐电梯前往上一层楼,如果还是没有找到就继续向上,以此类推。一旦抵达顶层(全局作用域),可能找到了你所需的变量,也可能也没找到,但无论如何查找过程都将停止。
为什么区分LHS和RHS是一件重要的事情呢?
因为在变量还没声明时,即在任何作用域中都无法找到该变量的情况下,这2种查询的行为是不一样的。
function handler(n){
console.log(n+m);
m = n;
}
------------------------------------------------------------------------------------------------------------------
第一次对m进行RHS查询时是无法找到该变量的。
也就是说,这是一个“未声明”的变量,因为在任何相关的作用域中都无法找到它。
如果RHS查询在所有嵌套的作用域中遍寻不到所需的变量
JS引擎就会抛出 ReferenceError异常,值得注意的是,ReferenceError是非常重要的异常类型。
相比之下,当JS引擎执行LHS查询时,如果在顶层(全局作用域)中也无法找到目标变量。
全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非严格模式下。
ES5引入了严格模式,同正常模式或者说宽松/懒惰模式相比,严格模式在行为上有很多不同。
其中一个不同的行为是严格模式禁止自动或隐式地创建全局变量。
因此,在严格模式中LHS查询失败时,并不会创建并返回一个全局变量。
JS引擎会抛出同RHS查询失败时类似的ReferenceError异常。
如果RHS查询到一个变量,尝试对这个变量的值进行不合理的操作。
JS引擎会抛出另一种类型的异常,叫做TypeError。
管理作用域
作用域(scope)概念是理解JS的关键,不仅从性能的角度,而且从功能的角度。作用域对JS有许多影响,从确定哪些变量可被函数访问,到确定this
的值。JS作用域也关系到性能,但是要理解速度与作用域的关系,首先要理解作用域的工作原理。
作用域链和标识符解析
每个JS函数都被表示为对象,进一步说,它是一个函数实例。函数对象和其他对象一样,拥有可供编程访问的属性和仅供JS引擎使用的内部属性。其中一个内部属性是[[Scope]]
,由ECMA-262标准第3版定义。
内部属性[[Scope]]
包含了一个函数被创建时的作用域中对象的集合,此集合被称为函数的作用域链,它决定哪些数据可由函数访问。此函数作用域链中的每个对象被称为一个可变对象,每个可变对象都以“键值对”的形式存在。
// 创建全局函数sum后,它的作用域链中填入一个单独的可变对象,此全局对象代表了所有全局范围定义的变量。
// 此全局变量包含诸如窗口、浏览器、文档之类的访问接口。
function sum(n1, n2){
return n1 + n2;
}
当一个函数创建后,它的作用域链被填充以对象,这些对象代表创建此函数的环境中可访问的数据。
// 函数作用域链将会在运行时使用
var total = sum(1,2);
函数运行时会建立一个内部对象,称作“运行期上下文”。一个运行期上下文定义了一个函数运行时的环境。对函数的每次运行而言,每次运行期上下文都是独一无二的,所以多次调用同一个函数就会导致多次创建运行期上下文。当函数执行完毕,运行期上下文就会被销毁。
一个运行期上下文有它自己的作用域链,用于标识符解析。当运行期上下文被创建时,它的作用域链被初始化,连同运行函数的[[Scope]]
属性中所包含的对象。这些值按照它们出现在函数中的顺序,被复制到运行期上下文的作用域链中。这项工作一旦完成,一个被称作“激活对象(AO,Active Object)”的新对象就为运行期上下文创建好了。此激活对象作为函数执行器的一个可变对象,包含访问所有局部变量、命名参数、参数结合、this
的接口。然后,此对象被推入作用域链的前端。当作用域链被销毁时,激活对象也一同被销毁。
在函数运行过程中,每遇到一个变量,标识符识别过程要决定从哪里获得或存储数据。此过程搜索运行期上下文的作用域链,查找同名的标识符。搜索工作从运行函数的激活目标之作用域链的前端开始。如果找到就使用这个具有指定标识符的变量。若没有找到,搜索工作将进入作用域链的下一个对象。此过程持续运行,直到标识符被找到,或者没有更多对象可用于搜索,这种情况下标识符将被认为是未定义(undefined)的。函数运行时每个标识符都要经过这样的搜索过程,正是这种搜索过程影响了性能。
标识符识别性能
标识符识别不是免费的,事实上没有哪种电脑操作可以不产生性能开销。在运行期上下文的作用域链中,一个标识符所处的位置越深,它的读写速度就越慢。所以,函数中局部变量的访问速度总是最快的,而全局变量通常是最慢的。记住,全局变量总是处于运行期上下文作用域链的最后一个位置,所以总是最远才能触及的。
总的趋势是,对现代浏览器来说,一个标识符所处的位置越深,读写它的速度就越慢。采用优化的JS引擎的浏览器,如Safari 4.0 访问域外标识符时没有这种性能损失,而IE、Safari3.2和其他浏览器则由较大幅度的影响。值得注意的是,早期浏览器如IE6和Firefox2,有着令人难以置信的陡峭斜坡。
因此在没有优化JS引擎的浏览器中,最好尽可能使用局部变量。一个好的经验法则是:用局部变量存储本地范围之外的变量值,如果它们在函数中使用多于一次。
function init(){
var body = document.body;
var links = document.getElementsByTagName_r('a');
var i = 0, len = links.length;
while(i < len){
update(links[i++]);
}
document.getElementById('goBtn').onclick = function(){
start();
}
body.className = "active";
}
init()
函数包含三个对document
的引用,document
是一个全局对象。搜索此变量必须遍历整个作用域链,直至最后在全局变量对象中找到它。你可以通过这种方法减轻重复的全局变量访问对性能的影响:首先将全局变量的引用存储在一个局部变量中,然后使用这个局部变量代替全局变量。
function init(){
var doc = document; // 将document的引用存入局部变量doc中,性能优化。
var body = doc.body;
var links = doc.getElementsByTagName_r('a');
var i = 0, len = links.length;
while(i < len){
update(links[i++]);
}
doc.getElementById('goBtn').onclick = function(){
start();
}
body.className = 'active';
}
改变作用域链
一般而言,一个运行期上下文的作用域链不会被改变。但是有两种表达式可在运行时临时改变运行期上下文作用域链。
...
动态作用域
无论是with
表达式还是try-catch
表达式的catch
子句,以及包含()
的函数,都被认为是动态作用域。一个动态作用域只因代码运行而存在,因此无法通过静态分析(查看源代码结构)来确定(是否存在)。
function exec(code){
(code);
function subrouting(){
return window;
}
var w = subrouting();
}
// test
exec('var window={};');
优化的JS引擎,如Safari的Nitro引擎,企图通过分析代码来确定哪些变量应该在任意时刻被访问,来加快标识符识别过程。这些引擎企图避开传统作用域链查找,取代以标识符索引的方式进行快速查找。当涉及一个动态作用域后,这种优化方法就不起作用了。JS引擎需要切回慢速的基于哈希表的标识符识别方法,更像传统的作用域链搜索。正是以为这个原因,只在绝对必要时才推荐使用动态作用域。