[你不知道的JavaScript(上)] 作用域和闭包读书笔记

199 阅读11分钟

作用域是什么

编译原理

JavaScript是一门编译语言,但与传统的编译语言不一样,它不是提前编译的,编译结果也不能在分布式系统移植。在传统编译语言中,编译分三个步骤。

  • 分词/词法分析(Tokenizing/lexing) 这个过程会将字符串分解为有意义的代码块,比如 var a = 2; 分解为var、a、=、2、;
  • 解析/语法分析(parsing) 将词法单元流转为抽象语法树(abstract syntax tree,AST)
  • 代码生成 将抽象语法树转为可执行代码的过程叫做代码生成,抛开具体细节,就是将var a = 2;这句代码变成机器能识别的机器指令,用来创建一个名叫a的变量并将一个值存储在a中。

JavaScript的编译自然比这个步骤要复杂得多,比如会在词法分析和代码生成阶段会有步骤进行代码优化。

理解作用域

我们先来理解三个概念

  • 引擎 从头到尾负责JavaScript的编译以及执行
  • 编译器 负责语法分析以及代码生成等
  • 作用域 负责收集并维护由所有声明的标识符(变量)组成一系列查询,并实行一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

变量的赋值操作会执行两个动作,首先编译器会在当前作用域声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域查找该变量,如果能找到就会对它进行赋值。

LHS 和 RHS

编译器生成了代码,引擎执行它的时候,会通过查找变量来判断是否声明过,但是怎么查找会影响查找的结果。

  • LHS 变量出现在赋值操作的左侧时叫做LHS,例如 a=2
  • RHS 变量出现在赋值操作的右侧时叫做RHS,例如a=b中的b

上面的只是一个简单的理解,更准确深入的说,LHS查询是试图获取到变量的容器本身,而RHS查询是为了获取到变量存储的值,赋值操作并不意味着是 =。举个栗子

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

上面这个栗子最后一行 foo(2)是对 foo函数的一次RHS查询,也有一个隐藏的LHS,就是将参数传递给foo函数的时候会执行a=2,还有console.log也是对console对象的一次RHS,console.log(a)中的a也是一次RHS。

作用域嵌套

function foo(a) {
    return a+b
}
var b = 2
foo(1)

在上面这个栗子中,我们有两重作用域,由foo函数创建的函数作用域和全局作用域,这两个作用域形成嵌套的作用域,全局作用域包裹着foo作用域。当我们在函数内对b进行RHS的时候无法查询到,这时引擎就会往上一层作用域(全局作用域)查找,并在全局作用域找到b变量。

所以遍历作用域链很简单,就是在当前作用域找不到变量,则往上一层作用域查找,直至全局作用域还找不到则停止查找。

异常

为什么我们需要理解这些?因为在非严格模式下,LHS如果变量还未声明将会自动声明一个全局变量,而RHS则会报Uncaught ReferenceError: a is not defined,严格模式下,两种查询都会报错Uncaught ReferenceError: a is not defined

PS:ReferenceError是作用域判别失败,意思就是找不到变量,而TypeError则是对变量的错误引用,例如

let a = 'a'
a() // TypeError

词法作用域

作用域一般分两种,词法作用域动态作用域。JavaScript使用的是词法作用域,也是就说作用域是由书写代码时变量函数声明的位置来决定的。编译时基本能知道所有标识符在哪里以及是如何声明的,从而能够预测在执行过程中如果对他们进行查找。

而JavaScript有两个机制可以"欺骗"词法作用域。

  • eval()
function foo(str, a) {
    eval(str)
    console.log(a,b)
}
var b = 2
foo("var b = 3;", 1) //输出 1,3 而不是1,2

但是需要注意的是,在严格模式下, eval函数有自己的作用域,所以不会影响所在的作用域。还有其他类似的比如 setTimeoutsetInerval第一个参数可以是字符串,可以被解析为动态生成的函数代码,不过已过时,不要使用。new Function()也很类似。

  • with with通常被当做重复引用同一个对象中的多个属性的快捷方式。
var obj = {
    a: 1,
    b: 2,
    c: 3
}
// 重新复制
obj.a = 2
obj.b = 3
obj.c = 4
//用with
with (obj) {
    a = 2;
    b = 3;
    c = 4
}

那with怎么欺骗作用域呢?

function foo(obj) {
    with(obj) {
        a = 2
    }
}
var obj1 = { a: 1 }
var obj2 = { b: 2 }
foo(obj1)
console.log(obj1.a) // 2
foo(obj2)
console.log(obj2.a) //undefined
console.log(a) //2 -- a被泄露到全局作用域了

当obj1传给with时,他使用的是obj1的作用域,obj1有a属性,而obj2传给with时,他没有a属性,所以它就执行了一次正常的LHS,声明了一个全局的变量a。

性能

因为这两个引擎无法在编译时对作用域查找进行优化,所以引擎只能认为这样的优化是无效的,这会导致代码运行变慢,所以不要使用,只是作为了解学习就好。

函数作用域、块作用域

函数作用域

函数作用域是函数在定义时创建的只属于函数内部的变量,函数外部无法直接访问这些变量。

function foo() {
    var a = 'a'
    function bar() {
        var b = 'b'
    }
}
console.log(a) // 报错
console.log(b) //报错
bar() //报错

我们可以利用这个函数作用域隐藏一些内部实现,软件设计有个最小特权原则,也叫最小授权或者最小暴露原则,指在软件设计中,应该最小限度的暴露一些必要内容,其他内容则隐藏其实现,比如模块或者对象的API设计。 举个栗子

function doSomething(a) {
    const b = a + doSomethingElse(a*2)
    return b
}
function doSomethingElse(b) {
    return b - 1
}
const c = doSomething(2)

我们可以根据最小特权原则改为以下代码

function doSomething(a) {
    function doSomethingElse(b) {
        return b - 1
    }
    return a + doSomethingElse(a*2)
}

doSomethingElse函数是一些内部实现,不应该被外部所访问,这没必要,还有可能是有风险的,所以我们应该隐藏他们,只需要向外部暴露 doSomething这个函数就足够了,功能性和实现效果都是完整且不受影响,还有一个比较重要的优点,就是可以避免命名冲突。一个典型例子就是在全局作用域导入多个包,一般会在全局作用域声明一个特殊且唯一的变量,而且通常是一个对象,库里面所有的标识符都通过这个变量的属性去访问,而不是所有标识符都暴露到全局变量里。

匿名和具名函数

函数分为函数声明和函数表达式,function关键字出现在声明的第一个词,那么就是函数声明,否则则是函数表达式。

// 函数声明
function foo() {}

//函数表达式
const bar = function() {}

对于函数表达式来说,又细分为具名函数和匿名函数,对于匿名函数,见得最多的就是回调函数里面的。

setTimeout(function() {
    console.log('are you ok?')
}, 1000)

因为function()...没有名称标识符,所以这是一个匿名函数表达式函数声明必须要有名称,否则非法。 匿名函数表达式又几个缺点需要慎重考虑。

  1. 匿名函数在栈追踪不会显示有意义的函数名,所以调试困难。
  2. 不能引用自身。除非使用已过时的arguments.callee
  3. 省略了名称让代码可读性降低。

与之相对的就是具名函数,例如 const a = function foo() {}

立即执行函数

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

因为函数被()包裹住,因此成为一个函数表达式而不是函数声明,末尾增加一个()可以立即执行这个函数。

块作用域

forifwith还有try/catch这些可以创建一个块作用域。

for (var a = 0; i < 10;i++) {...}

if (a) {...}

with(obj) {...}

try {
    undefined() // 强行制造错误进入catch执行
} catch(err) {...}
console.log(err) // ReferenceError

也可以直接通过一个大括号来创建一个块作用域

{
    let a = 'a'
}

let和const

因为let和const声明的变量会绑定对应的作用域。例如典型例子就是let循环

for (var i = 0; i < 10; i++) {
    setTimeout(() => {
        console.log(i)
    })
}
// 输出 10个10
for (let i = 0; i < 10; i++) {
    setTimeout(() => {
        console.log(i)
    })
}
// 输出 0 到 10

因为每次迭代时都会绑定新的作用域,所以每个i都是一个新的i并绑定到了不同的块作用域上不会公用。

变量提升

一般直觉会告诉我们代码会一行一行的从上往下执行。但是实际上并不完全是正确的。

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

// 另一个例子
console.log(a) //undefined
var a = 2

上面这第一段代码输出的是2而不是undefined,第二段输出undefined,都是因为变量在声明的时候发生了变量提升。

我们回忆一下上面说到的编译器相关的,编译阶段一部分工作就是找到所有的声明,并关联对应的作用域。 var a = 2;这段代码JavaScript会将其看成两个部分,第一部分var a在编译阶段,第二部分a = 2 则是执行阶段。所以上面第一个例子可以看做以下代码

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

第二段代码则是

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

函数声明也会进行变量提升

foo() // 666
function foo() { console.log(666) }

但是函数表达式不会

foo() //TypeError
var foo = function() { console.log(777) }

因为代码实际运行的时候是这样的

var foo
foo() // 当然报错啦
foo = function() { console.log(777) }

函数优先

函数声明和变量声明都会进行变量提升,但是函数优先被提升

foo() //输出1 而不是 2

var foo

function foo() { console.log(1) }

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

但是后面的声明还是可以覆盖前面的

foo() //输出3 

var foo

function foo() { console.log(1) }
function foo() { console.log(3) }

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

let 和 const

ES6中提供的 letconst 也可以用来定义变量,const定义时就必须赋值且不能更改(如果是变量的话不能更改内存地址,详细的以后另说)。使用let和const创建的变量不会产生变量提升,并且会在所在的作用域产生一个暂时性死区,在变量声明之前不能使用。

{
    x = 1 // ReferenceError: Cannot access 'x' before initialization
    let x
}

for (let i = 0; i < 10;i++) {}
console.log(i) // ReferenceError: i is not defined

作用域闭包

一句话解释闭包,就是函数可以 记住访问 所在的词法作用域时,就产生了闭包。


function outer() {
    var a = 2
    function inner(b) {
        a += b
        return a
    }
    // bar 记住了当前作用域,并可以访问当前作用域
    return inner
}

const func = outer()

console.log(func(2)) // 4
console.log(func(2)) // 6
console.log(func(2)) // 8
console.log(func(2)) // 10

在这个栗子里,inner能访问outer的内部作用域,然后我们把inner作为一个值进行传递到func,我们调用func只不过是通过不同的标识符去调用inner而已。按理说,outer执行之后内部作用域应该被垃圾回收释放空间,但是闭包可以阻止这个事情发生,因为inner依然被外部func引用,所以这个内部作用域可以一直存活不被回收。inner一直持有这个作用域的引用,这个引用就叫做闭包。

所以闭包并不是什么特别高深的概念,他在我们的代码里随处可见。

🌰

// ajax 里
let data = []
$.ajax({
    url: 'xxx',
    data: {},
    method: 'post',
    success(res) {
        data = res.data // 产生闭包
    }
})

// 事件监听器
const btn = document.querySelector('#button')
let count = 0
btn.addEventListener('click', function() {
    count++ //产生闭包
}, false)

需要注意

因为作用域没被垃圾回收,会占用内存,那么在一些极端情况下(至少暂时我还没遇到,所以暂定为极端情况把哈哈哈)会造成内存泄漏,所以我们可以手动清除闭包,就是解除对函数的引用。

function outer() {
    var a = 2
    function inner(b) {
        a += b
        return a
    }
    // bar 记住了当前作用域,并可以访问当前作用域
    return inner
}

const func = outer()

console.log(func(2)) // 4

func = null // 解除对内部函数的引用,垃圾回收将会回收掉

好了,以上就是我读《你不知道的JavaScript(上)》的读书笔记,希望对你有帮助,如果时间足够最好是自己买一本来自己看看更好。至于书中对闭包还有一些关于模块化的应用我感觉能专门写一篇文章,这里就不详细说了。谢谢观看么么哒~