【JS】作用域、闭包

时间:2021-09-12 22:46:19

本文知识点

  • JavaScript执行流程
  • LBS、RBS查询
  • ReferenceError、TypeError异常
  • 词法作用域、函数作用域、块作用域
  • 立即调用函数表达式
  • 声明提升
  • 闭包

JavaScript执行流程

摘取自原文设定场景

角色:引擎、编译器、作用域

代码:var a = 2

当你看见 “var a = 2” 这段程序时, 很可能认为这是一句声明。 但我们的新朋友引擎却不这么看。 事实上, 引擎认为这里有两个完全不同的声明, 一个由编译器在编译时处理, 另一个则由引擎在运行时处理。

具体的处理过程:

  1. 遇到 “var a” 编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。 如果是, 编译器会忽略该声明, 继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量, 并命名为 a。

  2. 接下来编译器会为引擎生成运行时所需的代码, 这些代码被用来处理 “a = 2” 这个赋值操作。 引擎运行时会首先询问作用域, 在当前的作用域集合中是否存在一个叫作 a 的变量。 如果是, 引擎就会使用这个变量; 如果否, 引擎会继续查找该变量。如果引擎最终找到了 a 变量, 就会将 2 赋值给它。 否则引擎就会举手示意并抛出一个异常!

LBS、RBS查询

在上面的例子中, 引擎会为变量 a 进行 LHS 查询。 另外一个查找的类型叫作 RHS。换句话说, 赋值操作的目标是谁( LHS)” 以及“ 谁是赋值操作的源头( RHS)”。

A = 2;

如上代码则是一个LBS查询,在这里我们并不关心当前变量A的值是什么,只是想要为 “= 2” 这个赋值操作找到一个目标,所以也可以称LBS查询为( 赋值查询 )。

console.log( a );

而在 “console.log( a )” 代码中a变量则是一个RBS查询,这时我们需要取到一个值返回给 “console.log()” 所以我们需要获取a变量所引用的值。

截取一段书中比较有意思的对话,方便大家理解:

function foo(a) 
{

console.log( a ); // 2
}
foo( 2 );

【JS】作用域、闭包

这里留个小测验,看看大家的掌握程度:

function foo(a) 
{

var b = a;
return a + b;
}
var c = foo( 2 );
  1. 找到其中所有的 LHS 查询。( 这里有 3 处! )
  2. 找到其中所有的 RHS 查询。( 这里有 4 处! )

ReferenceError、TypeError异常

上面说了LBS、RBS查询,但是对于我们有什么实际的意义,为什么区分 LHS 和 RHS 是一件重要的事情?

因为在变量还没有声明( 在任何作用域中都无法找到该变量) 的情况下, 这两种查询的行为是不一样的。

function foo( a ) 
{
console.log( a + b );
b = a;
}

foo( 2 );

如上代码中,第一次对 b 进行 RHS 查询时是无法找到该变量的。 也就是说, 这是一个“ 未声明” 的变量, 因为在任何相关的作用域中都无法找到它。如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量, 引擎就会抛出 ReferenceError异常。 值得注意的是, ReferenceError 是非常重要的异常类型。

相反当引擎执行 LHS 查询时, 如果在顶层( 全局作用域) 中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量, 并将其返还给引擎, 前提是程序运行在非“ 严格模式” 下。反之则会抛出类似RHS查询失败的ReferenceError异常。

接下来, 如果 RHS 查询找到了一个变量, 但是你尝试对这个变量的值进行不合理的操作,比如试图对一个非函数类型的值进行函数调用, 或着引用 null 或 undefined 类型的值中的属性, 那么引擎会抛出另外一种类型的异常, 叫作 TypeError。

ReferenceError 同作用域判别失败相关, 而 TypeError 则代表作用域判别成功了, 但是对结果的操作是非法或不合理的。

词法作用域

简单地说, 词法作用域就是定义在词法阶段的作用域。 换句话说, 词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的, 因此当词法分析器处理代码时会保持作用域不变( 大部分情况下是这样的)。

【JS】作用域、闭包

如上代码是我们程序编写完成的结果,在这个例子中有三个逐级嵌套的作用域:

  1. 包含着整个全局作用域, 其中只有一个标识符: foo。
  2. 包含着 foo 所创建的作用域, 其中有三个标识符: a、 bar 和 b。
  3. 包含着 bar 所创建的作用域, 其中只有一个标识符: c。

当引擎执行 “console.log( a, b, c )” 程序时,会在当前第3层作用域中进行RBS查询,未找到则向上一级进入到第二层进行查找,找到a的引用并把值返回,同理b和c也会进行相应查询,若查询到顶层(第一层)也未找到该变量的引用则抛出 ReferenceError 错误。作用域查找会在找到第一个匹配的标识符时停止。

在顶层作用域中无法访问嵌套作用域中的变量,而嵌套作用域可以访问上级作用域中的变量,所以在多层的嵌套作用域中可以定义同名的标识符, 这叫作“ 遮蔽效应”( 内部的标识符“ 遮蔽” 了外部的标识符)。

欺骗词法作用域:如果词法作用域完全由写代码期间函数所声明的位置来定义, 怎样才能在运行时来“ 修改”( 也可以说欺骗) 词法作用域呢?
文中介绍了 “eval” 函数和 “with” 关键字,但是因为性能问题,作者不推荐大家使用,所以本文也不多做描述,若有兴趣可以自行度娘。

函数作用域

函数作用域:属于这个函数的全部变量都可以在整个函数的范围内使用及复用(包含嵌套的作用域);可以规避同名标识符之间的冲突,避免污染全局作用域;

如何区分函数声明和函数表达式:可以观察它们的名称标识符将会绑定在何处,如果绑定在所在作用域中就是函数声明,如果绑定在变量或者自身上时则称为函数表达式。

匿名函数表达式的缺点:

  1. 匿名函数在栈追踪中不会显示出有意义的函数名, 使得调试很困难。
  2. 如果没有函数名, 当函数需要引用自身时只能使用已经过期的 arguments.callee 引用,比如在递归中。 另一个函数需要引用自身的例子, 是在事件触发后事件监听器需要解绑自身。
  3. 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。 一个描述性的名称可以让代码不言自明。

函数表达式可以匿名或者具名,而函数声明则必须标明函数名称。

立即执行函数表达式:(function 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
var a = 2;
(function IIFE( def ) {
def( window );
})( function def( global ) {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
} );

块作用域

JavaScript中不存在块作用域,但是有一些类似块作用域的例子:如 with关键字、try / catch还有ES6中新引入的 “let” 关键字。因篇幅有限,故不多做介绍。

声明提升

你认为下面代码 console.log(..) 声明会输出什么呢?

a = 2;
var a;
console.log( a );

很多开发者会认为是 undefined, 因为 var a 声明在 a = 2 之后, 他们自然而然地认为变量被重新赋值了, 因此会被赋予默认值 undefined。 但是, 真正的输出结果是 2。

那下面这段代码 console.log(..) 又会输出什么呢?

console.log( a );
var a = 2;

鉴于上一个代码片段所表现出来的某种非自上而下的行为特点, 你可能会认为这个代码片段也会有同样的行为而输出 2。 还有人可能会认为, 由于变量 a 在使用前没有先进行声明,因此会抛出ReferenceError 异常。不幸的是两种猜测都是不对的。 输出来的会是 undefined。

在上面的JavaScript执行流程中说过,程序执行时编译器会首先对代码进行编译,而编译阶段中的一部分工作就是找到所有的声明, 并用合适的作用域将它们关联起来。

因此,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理;但是,只有声明本身会被提升, 而赋值或其他运行逻辑则会留在原地;并且,每个作用域都会在其自身内部进行提升操作;

所以上面的代码被编译为

var a;
a = 2;
console.log( a );

----------

var a;
console.log( a );
a = 2;

这个过程就好像变量和函数声明从它们在代码中出现的位置被“ 移动”到了最上面。 这个过程就叫作提升。

foo( );
function foo( )
{

console.log( a ); // undefined
var a = 2;
}

如上的函数声明会被编译为

function foo( ) 
{

var a;
console.log( a ); // undefined
a = 2;
}
foo( );

函数声明和变量声明都会被提升。 但是一个值得注意的细节( 这个细节可以出现在有多个“ 重复” 声明的代码中) 是函数会首先被提升, 然后才是变量。

foo( ); // 3
function foo( )
{

console.log( 1 );
}
var foo = function( )
{

console.log( 2 );
};
function foo( )
{

console.log( 3 );
}


闭包

当函数可以记住并访问所在的词法作用域时, 就产生了闭包, 即使函数是在当前词法作用域之外执行。

Code-1 : 

function foo()
{

var a = 2;
function bar( )
{

console.log( a ); // 2
}
bar( );
}
foo( );

// 这段代码使用了闭包,函数bar在当前作用域内执行,并且函数内部访问了foo作用域 ;
// 但严格来说这并不是我们想要的闭包,虽然这就是个闭包(作用域的查找规则本身就属于闭包的一部分);
// 我们想要的闭包是在foo作用域外执行bar,并且bar依然保持着对foo作用域内部的引用 ;
Code-2 :

function foo( )
{

var a = 2;
function bar( )
{

console.log( a );
}
return bar;
}
var baz = foo( );
baz( ); // 2 —— 朋友, 这就是闭包的效果。

// 这就是我们想要的闭包效果,我们通过return把bar函数的引用返回给了变量baz,这时在全局作用域中我们同样可以访问foo作用域中的bar函数并保持对foo内部的引用。
Code-3 :

function foo( )
{

var a = 2;
function baz( )
{

console.log( a ); // 2
}
bar( baz );
}
function bar(fn)
{

fn( ); // 妈妈快看呀, 这就是闭包!
}

// 这是另一种闭包的形式,只不过我们变换了一种姿势,通过将baz函数当做参数返回给了bar函数,从而使得我们在全局作用域中依旧可以调用到foo作用域的内部函数。
Code-4 :

var fn;
function foo( )
{

var a = 2;
function baz( )
{

console.log( a );
}
fn = baz; // 将 baz 分配给全局变量
}
function bar( )
{

fn( ); // 妈妈快看呀, 这就是闭包!
}
foo( );
bar( ); // 2

// 这又是另一种姿势的闭包,通过将baz分配给全局变量而实现闭包。
Code-5 :

function wait(message)
{

setTimeout( function timer( ) {
console.log( message );
}, 1000 );
}
wait( "Hello, closure!" );

// 其实闭包在我们日常应用中很常见,只不过我们可能并不知道这就是个闭包。

举些我们日常编码中使用到的场景:

应用场景一:

for ( var i =1; i <= 5; i++ )
{
setTimeout( function timer( ) {
console.log( i );
}, i*1000 );
}

// 你们觉得Code-6会向控制台输出些什么内容;答案是程序会向控制台输入5个6;Why?我们的本意可并不是这样,我希望它每隔一秒依次向控制台输入1~5;那么问题在什么地方,其实是因为JS作用域的访问机制问题,因为循环改变的是变量 "i" 的值,所以当循环到第5次后 "i++"为6,定时器延时触发后所访问到的 “i” 的值始终是6;

// 那么如何处理呢?

for ( var i = 1; i<= 5; i++ )
{
(function( ) {
var j = i;
setTimeout( function timer( ) {
console.log( j );
}, j*1000 );
})( );
}
// 简化后:
for ( var i = 1; i<= 5; i++ )
{
( function( i ) {
setTimeout( function timer( ) {
console.log( i );
}, i*1000 );
})( i );
}

// 当我们把 “i” 当做参数传递给匿名函数表达式时,会在函数内部隐式声明一个变量 “i” 并且只为函数内部私有,所以当定时器访问变量 “i” 时,会优先访问函数内部的变量 “i”。


应用场景二(模块模式):

function CoolModule( )
{

var something = "cool";
var another = [1, 2, 3];
function doSomething( )
{

console.log( something );
}
function doAnother( )
{

console.log( another.join( " ! " ) );
}
return
{
doSomething: doSomething,
doAnother: doAnother
};
}
var foo = CoolModule( );
foo.doSomething( ); // cool
foo.doAnother( ); // 1 ! 2 ! 3

// 改写成单例模式

var foo = ( function CoolModule ( ){
var something = "cool";
var another = [1, 2, 3];
function doSomething( )
{

console.log( something );
}
function doAnother( )
{

console.log( another.join( " ! " ) );
}
return
{
doSomething: doSomething,
doAnother: doAnother
};
}( ));
foo.doSomething( ); // cool
foo.doAnother( ); // 1 ! 2 ! 3

// 模块模式另一个简单但强大的变化用法是, 命名将要作为公共 API 返回的对象:
var foo = ( function CoolModule (id) {
function change( ) {
// 修改公共 API
publicAPI.identify = identify2;
}
function identify1( ) {
console.log( id );
}
function identify2( ) {
console.log( id.toUpperCase( ) );
}
var publicAPI =
{
change: change,
identify: identify1
};
return publicAPI;
})( "foo module" );

foo.identify( ); // foo module
foo.change( );
foo.identify( ); // FOO MODULE

本文参考:《你不知道的JavaScript》