# 作用域与闭包
# 1. 作用域
# 1.1 词法作用域&语法作用域
词法作用域 (静态作用域) 是指作用域是由书写代码时的位置决定的
语法作用域 (动态作用域) 是由代码运行时的上下文决定的。
闭包的实现就是基于词法作用域。PS: JS
中的this
有语法作用域的特征,this
在运行时才会被确认,是动态的。
let value = 1
function foo() {
console.log(value)
}
function bar() {
let value = 2
foo()
}
如果按照 js
的词法作用域,输出的是 1
,即 foo
函数的作用域链是在定义的时候已经被确定了,foo
内部没有 value
变量,则向上寻找全局作用域下的 value
变量。
如果是语法作用域,输出的是 2
,foo
内部没有 value
变量,向上寻找 bar
函数内部有没有 value
变量,找到了输出 2
。
# 1.2 JS 作用域类型
JS
作用域分为函数作用域,块作用域,全局作用域三种
# 1.2.1 函数作用域
function foo(a) {
var b = 2
// 一些代码
function bar() {
// ...
}
// 更多的代码
var c = 3
}
全局作用域只有一个变量:foo
foo
函数作用域内部:变量 b, bar, a, c
# 1.2.2 块作用域
# try...catch
try {
new Error('发生错误')
} catch (err) {
console.log(err) // Error: 发生错误
}
catch
分句会创建块作用域,在 catch
语句外拿不到 err
变量
# let, const
let
为其声明的变量隐式的劫持了所在的作用域
var foo = true
if (foo) {
let bar = 2
console.log(bar) // 2
}
console.log(bar) // ReferenceError: bar is not defined
let
声明的变量不会提升,但是声明依然会被收集。let
不会被初始化,var
会被初始化为 undefined
console.log(a) // ReferenceError: Cannot access 'a' before initialization
let a
let 用于 for 循环
for (let i = 1; i < 10; i++) {
console.log(i)
}
console.log(i) // Reference Error
const
与 let
表现行为一致,唯一不同的是 const
声明的变量无法被重新赋值,const 声明的引用类型不可以重新赋值(即改变指针),但是可以对该类型增删改属性(如对象), 也可以增删改项(如数组)
# 1.2.3 全局作用域
在浏览器中全局作用域是 window
,在全局作用域声明的变量可以在任何地方访问到
# 1.2.4 变量提升与函数提升
var 声明的变量存在变量提升,函数声明存在提升,并优先于变量提升,但是函数表达式并不会提升,比如:
b() // b函数执行了
console.log(a) // undefined
c() // Uncaught ReferenceError: Cannot access 'c' before initialization
var a = 1
function b() {
// 函数声明
console.log('b函数执行了')
}
const c = function() {
// 函数表达式
console.log('c函数执行了')
}
以下两种写法会报错吗,为什么?
const handleChange = handleTrim('top')
const handleTrim = (val) => val && val.trim()
// handleChange()
// console.log(handleTrim)
const handleChange = () => {
console.log(handleTrim)
handleTrim('top')
}
const handleTrim = (val) => val && val.trim()
handleChange();
第一种写法会报错,因为我们在函数声明的上面直接调用了.
第二种写法不会报错
# 1.3 执行环境,作用域与作用域链
作用域链的存在与执行环境密不可分。
下面几段摘自JS高级程序设计
关键词:执行环境(execution context) 变量对象(variable object) 活动对象(activation object)
执行环境(execution context),有时简称为环境(context)。或者叫做上下文环境(context)。是 JS 中一个重要概念。执行环境定义了 变量 或 函数有权访问的其他数据(比如 this, arguments,callee 等) 每个执行环境都有一个与之关联的变量对象(variable object),执行环境中定义的所有变量和函数都保存在这个变量对象中。虽然我们编写的代码无法访问这个的对象,但解析器在处理数据是会在后台使用它。
全局执行环境是最外围的一个执行环境,根据 JS 实现所在的宿主环境不同,表示执行环境的对象也不一样,在 Web 浏览器中,全局执行环境被认为是 window
对象。因此所有的全局变量和函数都是作为 window
对象的属性和方法创建的。
某个执行环境的所有代码执行完毕后,该环境被销毁,保存在其中的所有变量和函数定义也随之销毁(全局执行环境直到应用程序退出——比如关闭网页浏览器时才被销毁)。
每个函数都有自己的执行环境,当执行流进入一个函数时,函数的环境就会被推入一个环境栈中,而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境。
当代码在一个执行环境中执行时,会创建变量对象的一个作用域链。作用域链的用途,就是可以保证变量和函数可以被执行环境有效访问。作用域的前端,始终是当前执行的代码所在环境的变量对象,如果这个是函数,则将其活动对象(activation object)作为变量对象(variable object)。活动对象在最开始时值包含一个变量。即 arguments 对象(这个对象在全局环境中是不存在的)。作用域链的下一个变量对象来自外部环境,再下一个变量对象则来自下一个外部环境。这样,一直延续到全局执行环境window
。全局执行环境放入变量对象始终都是作用域链的最后一个对象。
标识符解析是沿着作用域链一级一级的搜索标识符的过程。搜索过程始终从作用域链的前端开始,然后逐级的向后回溯,直到找到标识符为止(如果找不到标识符,会报错)。
var color = 'blue'
function changeColor() {
if (color === 'blue') {
color = 'red'
} else {
color = 'blue'
}
}
changeColor()
作用域链分析:
在上面这个简单的例子中,函数changeColor
的作用域链包含两个对象:它自己的变量对象(其中定义这个 arguments
对象),和全局环境的变量对象。可以在函数内部访问到变量 color
,就是因为可以在全局环境window
这个作用域俩找到它。
# 2. 闭包
闭包:函数在自己定义的词法作用域以外的地方执行,但是可以记住并访问到所在的词法作用域。闭包是针对于函数而言
上述概念比较抽象,可以理解为执行完的函数其执行环境应该被销毁了,但是由于其他函数引用了这个函数的内部变量,导致其执行环境还存在,这就形成了闭包。
了解闭包,需要知道作用域链和函数执行机制(即变量查找过程和函数执行环境的存在)
# 2.1 函数执行机制与上下文环境
由于 js 函数执行的原理,当函数被调用时,会被推送到一个执行栈中,形成自己的执行环境(执行环境中包含定义的变量与函数),函数执行完则出栈,因而函数的执行环境也被销毁
那么如何获取执行完毕的函数内部的变量呢,即如何让函数执行的执行环境不被销毁呢,这就要通过闭包了,闭包使得在执行的函数与应当被销毁的父函数产生了关联,使得作用域一直存在,这也是闭包最常见的一种用法
function foo() {
let count = 1
return function bar() {
count++
console.log(count) // 输出2
}
}
const addCount = foo() // 赋值,addCount指向引用类型bar()
addCount()
上述例子,bar
引用了父级函数 foo
里面的 count
变量,所以在函数 foo
执行过后,count
变量没有被立即销毁,我们通过 addCount
函数又可以获取到 foo
内部变量 count
的值,即 bar
函数被定义的地方。
闭包阻止了 foo
函数的销毁,但要注意的是,大量的使用闭包会造成 js
内存无法及时得到释放。
# 2.2 理解闭包的例子
# 2.2.1 常见的闭包例子
最简单的一种
function foo() {
let a = 2
function bar() {
console.log(a)
}
return bar
}
const baz = foo()
baz() // 2
当函数可以记住并访问所在的词法作用域,即使函数名是在当前词法作用域之外执行,这就产生了闭包。
在上例中:foo
函数执行, baz
函数即等同于 bar
函数,baz
函数执行的时候,要打印 2
,由于 baz
函数本身没有变量 a
, 向父级作用域查找,找到了 a
是 2
function foo() {
let a = 2
function baz() {
console.log(a) // 2
}
bar(baz)
}
function bar(fn) {
let a = 3
fn() // 这里的fn实际是baz函数
}
foo()
上例中,baz
函数访问到被定义时的作用域的变量 a=2
, 而不是执行时的 a=3
。也间接证明了 JS
遵循词法作用域(静态作用域)的规则。
# 2.2.2 定时器与循环
function wait(message) {
setTimeout(function timer() {
console.log(message)
}, 1000)
}
wait('Hello, closure!')
wait
函数在执行完毕后,局部变量 message
本应被立即销毁,但是由于 timer
函数引用了 message
变量,使得 wait
函数的执行环境依然存在。使得 timer
能够访问到 message
变量,也就形成了闭包。
深入到引擎的内部原理中,内置的工具函数 setTimeout(..)
持有对一个参数的引用,这个参数也许叫作 fn
或者 func
,或者其他类似的名字。引擎会调用这个函数,在例子中就是内部的 timer
函数,而词法作用域在这个过程中保持完整。这就是闭包。也就是说,只要使用了定时器函数,必然会形成闭包。
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i)
}, i * 1000)
}
// 输出5次6
这段代码在运行时会以每秒一次的频率输出五次 6。
为什么呢,又要涉及到 js
的执行机制了,由于 js
是单线程引擎,所以 js
会首先执行同步代码。再执行异步代码。也即是说延迟函数的回调会在同步for
循环结束时才执行。关于 JS
异步知识可以参考事件循环 (opens new window)
上述例子中,js
会首先执行 for
循环,执行完毕后,i
已经变成了 6
,这时候在执行定时器,定时器现在作用域链查找到的 i
值为 6
。
是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i
。只不过 i
一直被重新赋值而已
为了能够打印出 1~6
。我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。
如何打印出 1~6
呢,这里我们会想到给setTimeout
内部储存一个 i
变量,每次执行setTimeout
都访问自己独一无二的变量。再 ES6
之前,我们这样做:
使用 IIFE(Immediately Invoked Function Expression)
,即立即调用函数表达式。利用函数作用域保存每一次循环产生的变量
for (var i = 1; i <= 5; i++) {
;(function(j) {
console.log(j) // 1,2,3,4,5,6 立即打印
setTimeout(function timer() {
console.log(j) //1,2,3,4,5,6 隔一秒打印一次
}, i * 1000)
})(i)
}
这里,我们使用了一个立即执行函数,形成闭包作用域,每次 for
循环时,立即执行函数被执行,i
变量传入立即执行函数内部,每个立即执行函数都会保存输入自己作用域内部的 i
变量,所以当定时器执行时,输出的就是各自匿名函数内部的 j
变量,即1,2,3,4,5,6
了。
也就是上述例子存在了两个闭包,一个是 IIFE
,一个是定时器函数。
在 ES6
之后,我们就不用这么麻烦了,
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i) //1,2,3,4,5,6
}, i * 1000)
}
我们使用了 ES6
新增的声明变量的方式 let
, let
声明的是局部变量,因为 ES6
新增了块作用域的概念,所以 for (...) {...}
大括号里面就是一个单独的作用域,即闭包作用域,for
循环的每次执行,都会把 i
的值传入块作用域内,所以 for
的每个循环体都有各自不同的 i
变量,所以输出了1,2,3,4,5,6
# 2.2.3 闭包实现缓存
下面的函数实现了,输入的值+1
的效果,使用了闭包,可以减少运算次数。
function memorize(fn) {
let cache = {}
return function() {
const args = Array.prototype.slice.call(arguments) // argumnets是伪数组,经过slice处理之后,成为了真数组
let key = JSON.stringify(args) // 把参数作为cache对象的键,首先将其转换成字符串
console.log(`cache`, cache[key])
return cache[key] || (cache[key] = fn.apply(fn, args))
// 如果与之前计算的参数相同,则返回值。否则将计算结果赋值给cache对象的一个键,apply的第二个参数是个数组
}
}
// 计算的回调函数
function add(a) {
console.log(`add参数a`, a)
return a + 1
}
const adder = memorize(add)
adder(1) // 打印 cache: undefined, console.log: 2, 当前 cache: {'[1]'}
adder(1) // 打印 cache: 2 console.log: 2 当前: cache: { '[1]': 2 }
adder(2) // 打印 cache: undefined console.log: 3 当前: cache: { '[1]': 2, '[2]': 3 }
ES6 的写法
function memorize(fn) {
const cache = {}
return function(...args) {
// args变成了可迭代对象,数组
const key = JSON.stringify(args)
return cache[key] || (cache[key] = fn(...args))
}
}
function add(a) {
console.log(`add参数a`, a)
return a + 1
}
const adder = memorize(add)
adder(1)
adder(1)
adder(2)
闭包可以用来保存变量,缓存变量,但是过多的闭包会使得内存无法及时得到释放