你不知道的JavaScript(上)
作用域和闭包
编译原理
程序中的一段源代码在执行之前会经历三个步骤
分词/词法分析(Tokenizing/Lexing)
这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。
例如,考虑程序 var a = 2;。这段程序通常会被分解成 为下面这些词法单元:var、a、=、2 、;。
解析/语法分析(Parsing)
这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法 结构的树
这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)
代码生成
将AST 转换为可执行代码的过程称被称为代码生成。
JavaScript编译
对于 JavaScript 来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短!)的时 间内
- 引擎
- 从头到尾负责整个 JavaScript 程序的编译及执行过程
- 编译器
- 引擎的好朋友之一,负责语法分析及代码生成等脏活累活
- 作用域
- 引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查 询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
var a = 2
编译:
变量的赋值操作会执行两个动作:
var a LHS
a = 2 RHS
编译器
- 声明(LHS操作):当遇到
var a
时,编译器会询问作用域是否已经有一个该名称的变量存在于当前作用域的集合中
存在:则忽略该声明,继续进行编译 不存在:要求当前作用域申明一个新的变量,并命名为a
- 赋值(RHS操作):当遇到
a=2
这个赋值操作时,引擎会首先询问作用域,在当前作用域中是否存在一个叫a的变量。
存在:使用这个变量,并赋值 不存在:继续一层一层向外层作用域查找,直到一直找到这个变量。
LHS和RHS
当变量出现在赋值操作的左侧时进行 LHS 查询,出现在非左侧时进行 RHS 查询
LHS:左侧查询变量容器本身
如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询
RHS:非左侧查询变量的值
如果目的是获取变量的值,就会使用 RHS 查询。
LHS:
a = 2;
这里对 a 的引用则是 LHS 引用
RHS:
console.log( a );
其中对 a 的引用是一个 RHS 引用,因为这里 a 并没有赋予任何值。
相应地,需要查找并取得 a 的值,这样才能将值传递给 console.log(..)
function foo(a) {
console.log( a ); // 2
}
foo( 2 )
其中foo(..) 函数的调用需要对 foo 进行 RHS 引用,意味着“去找到 foo 的值并把它给我
异常
LHS:不成功的 LHS 引用会导致自动隐式 地创建一个全局变量(非严格模式下),
该变量使用 LHS 引用的目标作为标识符,或者抛 出 ReferenceError 异常(严格模式下)
RHS:不成功的 RHS 引用会导致抛出 ReferenceError 异常。
LHS和RHS小测
function foo(a) {
var b = a;
return a + b;
}
var c = foo( 2 );
- 找出所有的 LHS 查询(这里有 3 处!)
- c = ..;
- a = 2(隐式变量分配)
- b = ..
- 找出所有的 RHS 查询(这里有 4 处!)
- foo(2)
- = a;
- a .. --(a+b)
- .. b
作用域是什么
作用域就是根据名称来查找变量的一套规则。
作用域嵌套
所谓作用域嵌套就是当一个块或者函数在另一个块或者函数中时,就会发生作用域嵌套。
作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量, 或抵达最外层的作用域(也就是全局作用域)为止。
查找规则
- 在当前作用域中查找变量,例如上例中的变量b,没有找到时
- 往外层作用域中查找,一直到最顶层(全局作用域)为止,有则返回,无则报错(严格模式下
词法作用域
作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法作用域
词法作用域意味着作用域是由书写代码时函数声明的位置来决定的
编译的词法分析阶段 基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它 们进行查找
遮蔽效应
作用域会在查找到第一个匹配的标识符时停止,所用在多层作用域嵌套时,内层的变量会屏蔽掉外层的同名变量,这叫变量的遮蔽效应
function foo(){
var a = 1;
var var = function(){
var a = 2;
console.log(a); // 变量遮蔽,输出2
}
}
访问被遮蔽的变量
一般来说,全局变量会自动的变量全局对象(window)的属性,可以借助window[]
或者window.
的形式访问被遮蔽的全局变量。
访问被遮蔽的变量案例
var a = 2;
function foo(){
var a = 3;
console.log(a); // 变量遮蔽,输出3
console.log(window.a) // 访问全局对象属性a,输出2
}
欺骗词法
词法作用域完全由写代码期间函数所声明的位置来定义,怎样才能在运行时来“修 改”(也可以说欺骗)词法作用域呢
eval
JavaScript 中的 eval(..) 函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。
function foo(str,b){
// 欺骗,解析成var a = 3;
eval(str);
// 输出3,1
console.log(a,b)
}
var a = 2;
foo('var a = 3;',1);
with
JavaScript 中另一个难以掌握(并且现在也不推荐使用)的用来欺骗词法作用域的功能是 with 关键字。
with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。
var obj = {
a: 1,
b: 2,
c: 3
}
// 重复单调的引用obj
obj.a = 2;
obj.b = 3;
obj.c = 4;
// with快捷引用
with(obj){
a = 2;
b = 3;
c = 4;
}
本质上是通过将一个对象的引用当作作用域来处理,将对象的属性当作作 用域中的标识符来处理,从而创建了一个新的词法作用域
性能
这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认 为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们。
函数作用域
定义:是指属于这个函数的全部变量都可以在整个函数的范围内使用及复用
函数作用域隐藏内部变量和函数定义
var a = 2;
function foo(){
var a = 3;
// 输出3
console.log(3);
}
foo();
// 输出2
console.log(a);
函数隐藏内部变量和函数定义的问题
- 必须声明一个具名函数(foo),意味着foo本身也污染了它所在的作用域。
- 必须显示的调用这个具名函数,才能运行其中的代码。
解决办法(立即执行函数表达式IIFE)
// 此处为函数表达式,不是一个函数声明
(function foo(){
var a = 3;
// 输出3
console.log(a);
})();
IIFE进阶用法
// IIFE进阶用法:函数传参
var a = 2;
(function IIFE(window){
var a = 3;
// 输出3
console.log(a);
// 输出2
console.log(window.a);
})(window);
// 输出2
console.log(a);
匿名函数的缺点
- 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
- 因为没有函数名,当引用自身时,会比较麻烦。
- 没有函数名,可读性变差了
解决办法
// 始终给匿名函数一个函数名是一个很好的解决办法
setTimeout(function timerHandler(){
console.log('ok');
},1000);
块作用域
for和if没有块作用域
for(var i = 0;i<10; i++){
console.log(i);
}
// 变量i泄露
console.log(10);
var flag = true;
if(flag){
var bar = 1;
var foo = 2 * bar;
// 输出2
console.log(foo);
}
// 变量bar泄露,输出1
console.log(bar);
// 变量foo泄露,输出2
console.log(foo)
with块作用域的表现形式之一
with从对象中创建出来的作用域仅在with声明中而非外部作用域中有效
try/catch块作用域的表现形式之二
try{
undefined();
}catch(err){
// err只在catch中有效
console.log(err);
}
// 报错
console.log(err);
ES6 let和 const块作用域的表现形式之三
var flag = true;
if(flag){
let bar = 1;
// 输出2
console.log(bar*2);
}
// 报错
console.log(bar);
for(let i=0;i<10;i++){
console.log(i);
}
// 报错,访问不到i
console.log(i);
变量、函数提升
变量声明会提升到它当前作用域的顶部
// 示例
function foo(){
var a = 2;
}
// 提升后
function foo(){
// 变量声明提升
var a;
a = 2;
}
函数声明会提升到它当前作用域的顶部
// 先调用
foo();
// 再声明
function foo(){
// 输出undefined
console.log(a);
var a = 2;
}
// 提升后相当于
function foo(){
// 此时a没有赋值,为undefined
var a;
// 输出undefined;
console.log(a);
// a变量正式赋值
a = 2;
}
// 函数调用
foo();
函数表达式不会被提升
// 先调用,报错TypeError
foo();
var foo = function (){
console.log('fun foo');
}
函数会优先变量首先提升
// 先调用
foo();
// 声明foo变量
var foo;
// 声明foo函数
function foo(){
console.log(1);
}
// 声明foo表达式
var foo = function(){
console.log(2);
}
foo();
// 提升后相当于
function foo(){
console.log(1);
}
// 输出1
foo();
// 重复声明,忽略
var foo;
// 声明foo表达式
var foo = function(){
console.log(2);
}
// foo表达式覆盖前面的foo函数,输出2
foo();
作用域闭包
闭包定义
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行
闭包的第一种表现形式
function foo() {
var a = 2;
function bar() {
console.log( a ); // 2
}
bar();
}
foo();
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2 —— 朋友,这就是闭包的效果
函数 bar() 的词法作用域能够访问 foo() 的内部作用域。
然后我们将 bar() 函数本身当作 一个值类型进行传递。
闭包的第二种表现形式
函数传递:无论通过何种手段将内部函数传递到它所在词法作用域之外,它都会持有队原始作用域的引用,无论在何处执行这个函数,都会产生闭包
当函数在别处被调用时都可以观察到 闭包
function foo() {
var a = 2;
function baz() {
console.log( a ); // 2
}
bar( baz );
}
function bar(fn) {
fn(); // 妈妈快看呀,这就是闭包!
}
把内部函数 baz 传递给 bar,当调用这个内部函数时,它涵盖的 foo() 内部 作用域的闭包就可以观察到了,因为它能够访问 a。
闭包的第三种表现形式
回调函数:在定时器、事件监听、Ajax请求、跨窗口通信、Web Workers或者任何异步中,只要使用了回调函数,实际上就是在使用闭包
function wait(message) {
setTimeout( function timer() {
console.log( message );
}, 1000 );
}
wait( "Hello, closure!" );
将一个内部函数(名为 timer)传递给 setTimeout(..)。
timer 具有涵盖 wait(..) 作用域 的闭包,因此还保有对变量 message 的引用。
wait(..) 执行 1000 毫秒后,它的内部作用域并不会消失,timer 函数依然保有 wait(..) 作用域的闭包。
深入到引擎的内部原理中,内置的工具函数 setTimeout(..) 持有对一个参数的引用,这个 参数也许叫作 fn 或者 func,或者其他类似的名字。引擎会调用这个函数,在例子中就是 内部的 timer 函数,而词法作用域在这个过程中保持完整。
这就是闭包
一个非典型的闭包案例
var a = 2;
(function IIFE(){
// 输出2
console.log(a);
})();
为什么说是一个非典型的闭包案例?
- 首先IIFE函数并不是在它本身词法作用域之外执行
- 其次变量a是通过普通的词法作用域查找而来,而不是闭包被发现
那么,IIFE到底是不是一个闭包? 是,IIFE的确创建了闭包。
循环和闭包
要说明闭包,for 循环是最常见的例子。
// 经典for循环+闭包案例
for(var i=1;i<=5;i++){
setTimeout(function timer(){
console.log(i)
},i*1000)
}
// 6 6 6 6 6
最终解决方案
for(let i=1;i<=5;i++){
setTimeout(function timer(){
console.log(i)
},i*1000)
}
// 1 2 3 4 5
缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。但是 根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的, 但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i
- for循环,创建6个定时器,循环完毕后,i等于6
- 执行每一个定时器,输出i,而i此时等于6
改进一
// 改进一: 使用IIFE
for(var i=1;i<=5;i++){
(function(){
setTimeout(function timer(){
console.log(i)
},i*1000)
})()
}
// 6 6 6 6 6
IIFE虽然创建了闭包作用域,但创建的的闭包作用域是空的
变量i依然是通过词法作用域向外查找,即i等于6
改进二
// 改进二: 使用IIFE,在闭包作用域中缓存变量
// 结果:正确依次输出1 2 3 4 5
for(var i=1;i<=5;i++){
(function(){
var j = i;
setTimeout(function timer(){
console.log(j)
},i*1000)
})()
}
// 1 2 3 4 5
// 使用IIFE,闭包传参
// 结果:正确依次输出1 2 3 4 5
for(var i=1;i<=5;i++){
(function(j){
setTimeout(function timer(){
console.log(j)
},i*1000)
})(i)
}
最终解决方案
// 结果:正确依次输出1 2 3 4 5
for(let i=1;i<=5;i++){
setTimeout(function timer(){
console.log(i)
},i*1000)
}
闭包的模块模式
要形成模块模式则需要两个必要的条件
- 必须有外部的封闭函数,该函数至少被调用一次
- 封闭函数内部必须至少返回一个内部函数,这样才能在封闭函数作用域中形成闭包
// 一个闭包模块的案例
function CoolModule() {
var a = 1;
var b = 2 * a
function _foo() {
console.log(a)
}
function _bar() {
console.log(b)
}
// 必要条件二:返回内部函数
return {
foo: _foo,
bar: _bar
}
}
// 必要条件一:被调用
var fun = CoolModule();
fun.foo(); // 输出1
fun.bar(); // 输出2
闭包模块的单例模式
在上例中,封闭函数每被调用一次,都会创建一个新的模块实例,如何达到单例的目的呢 可以使用IIFE把封闭函数包裹起来,已达到单例的目的
// 模块的单例模式
var fun = (function CoolModule(){
var a = 1;
var b = 2 * a
function _foo() {
console.log(a)
}
function _bar() {
console.log(b)
}
// 必要条件二:返回内部函数
return {
foo: _foo,
bar: _bar
}
})();
fun.foo(); // 输出1
fun.bar(); // 输出2
闭包
就好像从 JavaScript 中分离出来的一个充满神秘色彩的未开化世界,只有最勇敢的人 才能够到达那里。
但实际上它只是一个标准,显然就是关于如何在函数作为值按需传递的 词法环境中书写代码的。
当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包
如果没能认出闭包,也不了解它的工作原理,在使用它的过程中就很容易犯错,比如在循环中。但同时闭包也是一个非常强大的工具,可以用多种形式来实现模块等模式。