javascript基础系列之作用域与闭包

160 阅读9分钟

本文核心内容都出自《你不知道的JavaScript》及《深入理解ES6》,其中加入了自己寻找的一些练习题,以加深理解。

编译器

对于 var a = 2;这段代码,JavaScript因此会认为这里有两个完全不同的声明

  1. 编译器在编译时期的处理,
  2. 引擎在运行时的处理

其工作过程如下:

  1. 遇到 var a, 编译器会询问作用域是否已经有了该名称的变量存在于【同一个作用域】中。如果有了,就忽略;如果没有,就要求作用域在当前作用域中声明一个新的变量,并命名为a; //PS, 这里只是声明变量,并没有为其赋值

  2. 编译器为引擎生成运行代码,代码被用来处理a = 2 这个复制操作。 引擎在运行的时候会询问作用域,在【当前】作用域是否存在一个叫a的变量,如果是,引擎就使用这个变量,如果不是,就继续查找该变量。

作用域查找

书中还有LHS和RHS的讲解,本部分就一句话概括:LHS,赋值操作的目标是谁(LHS),即查找的目的是对变量进行赋值;RHS(谁是赋值操作的源头),即查找的目的是获取变量的值,有兴趣的可以查阅书本。

作用域是用于确定在何处以及如何查找变量的一套规则,当一个块/函数嵌套在另一个块/函数中时,就发生了作用域的嵌套。当引擎在当前作用域中无法找到该变量的时候就会在其【外层】嵌套的作用域中继续查找,一直找到该变量为止。

特殊情况/异常

LHS和RHS在变量还未声明的情况下,其表现形式是不一样的

function foo(a){
    //这是一个RHS行为,需要找到数据源,而a和b都没有声明,因此会报错
    console.log(a + b) //抛出错误
    b = a
}
foo(2)

而对于LHS查询而言,如果搜索了全部的作用域仍然未发现该变量,那么就直接在【全局】作用域中声明改变量(在非严格模式下)。 如果在严格模式下,并不会创建全局变量,会报错。

词法作用域

词法作用域是由在编写代码时候,将代码和块作用域卸载哪里来决定。

遮蔽效应

作用域查找的时候,会找到第一个匹配的标志符,然后停止查找。引起更外层的同名标识符不会被找到。

运行时修改作用域:

eval()

然而,在严格模式下

function foo(str){
    'use strict'
    eval(str)//ReferenceError: a is not defined
    console.log(a)
}

setTimeout()和setInterval()也支持第一个参数是字符串内容,会将其解释为函数代码。 new Function()最后一个参数也支持接受代码字符串。

with()

用来重复引用同一个对象多个属性的快捷方式,但是有副作用

function foo(obj){
    with(obj){
        a = 2
    }
}
var o1 = {
    a: 3
}
var o2 = {
    b: 3
}

foo(o1)
console.log(o1.a)   //2
foo(o2)
console.log(o2.a)   //undefined
console.log(a)      //2

with和eval会导致代码运行变慢。

函数作用域及块级作用域

函数作用域:属于这个函数的全部变量都可以在整个函数范围内使用复用

扩展:最小暴露原则/最小授权原则,在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来

作用:规避冲突

function foo(){
    function bar(a){
        i = 3           //var i = 3 即可解决问题
        console.log(a + i)
    }
    
    for (var i = 0; i < 10; i++){
        bar( i * 2) //无限循环
    }
}

更加优雅的解决方案:立即运行函数

函数作用域可以帮助我们“隐藏”一些变量及函数,但是需要一个函数来创造包含隐藏内容的作用域,一般的显示创造会污染了该显式函数所在的作用域(因为显式的函数名),因此有了立即运行函数。

(function foo(){...})(args) / (function(){...}(args))

匿名函数 function(){}

但是其有以下缺点:

  1. 匿名函数在栈追踪的时候不会显示有意义的函数名,使得调试困难
  2. 没有函数名,当函数要引用自身的时候只能使用过期的arguments.callee引用,如递归和取消订阅
  3. 降低了代码的可读性

块级作用域

何为块?:{...}中的内容,都可以称为块。

ES6中引入了let:let为期声明的变量隐式地劫持了所在的块作用域

关于let会放在变量提升中详细说明

var foo = true
if(foo){
    let bar = foo * 2
    bar = something(bar)
    console.log(bar)
}
console.log(bar) // undefined

if(foo){
    {//显式的块
        let bar = foo * 2
        bar = something(bar)
        console.log(bar)
    }
}

try/catch是天生具有块级作用域的

try{
    
}catch(err){
    console.log(err)
}
console.log(err)//err not defined

变量提升

下面看一个例子,输出的是什么???

console.log(a)
var a = 2

结合文章开头部分的引擎功能做流程,我们可以将上述内容分解:


var a           //编译过程:声明变量
console.log(a)  //执行过程:打印a, undefined
a = 2           //执行过程:为变量a赋值

这种在代码运行过程中,只有声明被提升的过程就叫做变量提升。而赋值或者其他操作会被留在原地。

注意:

1. 每个作用域内部都会发生变量提升;

2.函数声明被提升,函数表达式不会被提升;

foo(); //TypeError而不是ReferenceError
var foo = function bar(){
    ...
}
//实际流程如下
var foo
foo()//TypeError
bar()//ReferenceError
foo = function bar(){
    
}

3.函数声明会优先于变量声明被提升;

4.重复的var声明会被忽略掉(只要第一次声明),而后面出现的函数声明会覆盖掉前面的函数声明

foo() //3
function foo(){
    console.log(1)
}
var foo = function(){
    console.log(2)
}
function foo(){
    console.log(3)
}

5.例外:尽量在块内部声明函数

foo() // TypeError:fpp is not a function
var a = true
if(a){
    function foo(){console.log('a')}
}else{
    function foo(){console.log('b')}
}

6:let与var的区别

  1. var有变量提升,let没有:因此在开发中,通常将let的声明放置于块级作用域的顶部
  2. var没有块级作用域,let有
  3. var可以重复声明,let不可以
  4. js引擎在运行的是会将let/const放在TDZ(temporal dead zone)中,访问TDZ内的变量会报错,只有执行过变量声明语句后,才会将变量从TDZ中取出,然后可以正常访问。
var count = 30
let count = 20 //报错

闭包

函数可以记住并访问其所在的词法作用域,既是函数是在当前词法作用域之外执行,这时就产生了闭包。(存在引用关系)

function foo(){
    var a = 2
    function bar(){
        console.log(a)
    }
    return bar
}
var baz = foo()
baz()//2---------这里是在foo()外部作用域中访问了其内部变量a

一些常规的回调函数使用也属于闭包,例如下方代码中 activator函数是被作为参数传入click()函数中,然而在activator函数运行的时候它依然可以访问其此法作用域中的变量name。 另外,定时器,事件监听器,axjx请求,跨窗口通信,Web Workers或者任何异步任务中,只要使用了回调函数,其实就是一件在使用闭包了。

jQuery中
function setupBot(name, selector){
    $(selector).click(function activator(){
        console.log('Activing: ' + name)
    })
}
setupBot("Closure Bot 1", "#btn1")

经典的面试题

下方代码启动或会每隔1S输出6,总共会有5个6 由于没有局部作用域,每个{...}内的函数共享一个变量i

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

运行过程

下方的时间是因为i的值每次+1就立即调用了,而()=>console.log(i)是属于回调函数,要引用外部作用域的变量i。但是由于var是没有块级作用域的,因此所有的setTimeout函数都共享同一个i变量。 输出6是因为第五次的时候i为5满足条件,所以+1称为6,但是第六次的时候不满足for循环的条件,for循环就终止了。但是变量的值以及变为6了。

var i = 1 
setTimeout(()=>console.log(i), 1000) 
i = 2
setTimeout(()=>console.log(i), 2000)
i = 3 
setTimeout(()=>console.log(i), 3000)
i = 4 
setTimeout(()=>console.log(i), 4000)
i = 5 
setTimeout(()=>console.log(i), 5000)
i = 6

解决方法一:IFEE

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

运行过程

IFEE在运行的时候内部变量j保存了档次循环的时i的值。

var i = 1 
(var j = 1 ; setTimeout(()=>console.log(j), 1000))(i) //作用域1
i = 2
(var j = 2 ; setTimeout(()=>console.log(j), 2000))(i) //作用域2
i = 3 
(var j = 3 ; setTimeout(()=>console.log(j), 3000))(i) //作用域3
i = 4 
(var j = 4 ; setTimeout(()=>console.log(j), 4000))(i) //作用域4
i = 5 
(var j = 5 ; setTimeout(()=>console.log(j), 5000))(i) //作用域5
i = 6

解决方法二:使用let

for(var i = 1; i<=5; i++){
    {//显式声明
        let j = i
        setTimeout(()=>{
            console.log(j)
        },1000 * j)
    }    
}

更进一步:在for循环头部的let声明有一个特殊声明:变量在循环的时候不止声明一次,每次迭代都会声明。每次迭代都会用上次迭代结束的时的值来初始化这个变量i。

for(let i =5; i<=5; i++){
    setTimeout(()=>{
        console.log(i)
    })
}

补充:const

  1. const在不同循环中表现:
//常见的for循环
var funcs =[]
for (const i = 0; i < 10; i++){ //第一次迭代后报错,因此每次迭代都要改变i的值
    funcs.push(function(){
        console.log(i)
    }
}

//for...of for...in
var funcs = []
    object = {
        a: true,
        b: true,
        c: true
    }
//不会产生错误,因此每次迭代之后都会**创建一个新的绑定**
for (const key in object){
    funcs.push(function(){
        console.log(key)
    })
}

一些常见题目,补充中...

  1. 下面这道题中,控制台输出的是 100 ,window中b为200, c为100
b = 100
console.log(b)      //100
function test(){
    b = 200
    c = 100
}
test()
console.log(window) //b:200 c:100