2.闭包的理解
1. 基本知识
1. 作用域
在JavaScript中执行某个函数时,如果遇见变量且需要读取其值,就会“就近”先在函数内部查找该变量的声明或赋值情况。如果在函数内无法找到该变量,就要跳出函数作用域,到更上层作用域中查找。更上层作用域也可以顺着作用域范围向外扩散,一直到全局作用域。
2. 作用域链
变量作用域的查找是一个扩散的过程,就像各个环节相扣的链条,逐次递进,这就是“作用域链”的由来。
3. 块级作用域和暂时性死区
使用let或const声明变量时会针对这个变量形成一个封闭的块级作用域,在这个块级作用域中,如果在声明变量前访问该变量,就会报referenceError错误;如果在声明变量后访问该变量,则可以正常获取变量。
function foo() {
console.log(bar)
var bar = 3
}
undefined
执行以上代码会输出undefined,原因是在 JavaScript 中,当一个变量被声明但未被赋值时,它的默认值是 undefined
。因此,当 console.log(bar)
执行时,bar
的值为 undefined
。然后,在下一行代码中,bar
被赋值为 3
。但这个赋值语句不会影响之前的 console.log()
语句,因为它已经在变量被赋值之前执行了。
使用var声明的变量会被提升至其作用域的顶部,但它的赋值不会,因此这里是undefined。
function foo() {
console.log(bar)
let bar = 3
}
报错:referenceError
在函数 foo
中,使用 let
声明了变量 bar
。与 var
不同,let
声明的变量不会被提升到其作用域的顶部。所以当 console.log(bar)
执行时,bar
还没有被初始化,因此会抛出 ReferenceError
错误。这个错误是因为 let
声明的变量有一个称为“暂时性死区(Temporal Dead Zone,简称 TDZ)”的概念。在 TDZ 中,访问变量会抛出一个错误,直到该变量被声明和初始化。
在JavaScript中,变量的声明、初始化和赋值是不同的概念,尽管它们通常在同一行代码中完成。
变量声明是指使用关键字(如 var、let 或 const)声明一个新变量。声明一个变量只是告诉JavaScript,我们有一个新的变量,并为它分配一个内存空间。
变量初始化是指在声明变量时为其赋一个初始值。如果在声明时没有赋值,变量的初始值将为undefined。
变量赋值是指将一个新值赋给变量。这可以在变量声明后的任何时候进行,而不是在声明时。赋值将覆盖变量的当前值,使其指向新的值。
4. 执行上下文和调用栈
执行上下文是指当前代码的执行环境/作用域
执行JavaScript代码主要分为以下两个阶段:
- 代码预编译阶段:虽然JS是解释型语言,编译一行,执行一行。但在执行前,JS引擎确实会做一些预先工作
- 在预编译阶段进行变量声明
- 在预编译阶段对变量声明进行提升,但是值为undefined
- 在预编译阶段对所有非表达式函数声明进行提升
- 代码执行阶段
例题1
function bar(){
console.log('bar1')
}
var bar = function(){
console.log('bar2')
}
bar2
例题2
var bar = function(){
console.log('bar2')
}
function bar(){
console.log('bar1')
}
bar2
以上代码的输出结果都是bar2,因为在预编译阶段虽然对变量bar进行了声明,但不会对其赋值;函数bar则被创建和提升。在代码的执行阶段,变量bar才会通过表达式被赋值,赋值的内容是函数体为console.log('bar2')
的函数,输出结果为bar2
例题3
foo(10)
function foo (num){
console.log(foo)
foo = num
console.log(foo)
var foo
}
console.log(foo)
foo = 1
console.log(foo)
undefined
10
function foo (num){
console.log(foo)
foo = num
console.log(foo)
var foo
}
1
在foo(10)执行时,会在函数体内进行变量提升,此时执行函数体内的第一行会输出undefined,执行函数内的第三行会输出foo。接着运行代码,运行到函数体外的console.log时,会输出foo函数的内容。(foo函数内的foo=num,num被赋值给函数作用域内的foo变量)
作用域在预编译阶段确定,但是作用域连是在执行上下文的创建阶段完全生成的,因为函数在调用时才会开始创建对应的执行上下文。执行上下文包括变量对象、作用域连以及this的指向。
**JavaScript引擎执行机制最基本的原理:**在预编译阶段创建变量对象(Variable Object,VO)此时只是创建,而未进行赋值。在代码执行阶段,变量对象转为激活对象(Active Object,AO),即完成VO向AO的转换。此时,作用域也被确定,它由当前执行环境的变量对象和所有外层已经完成的激活对象组成。
调用栈:在执行一个函数时,如果这个函数又调用了另外一个函数,而这“另外一个函数”又调用了另外一个函数,这样便形成了一系列的调用栈,类似以下代码:
function foo1(){
foo2()
}
function foo2(){
foo3()
}
function foo3(){
foo4()
}
function foo4(){
console.log('foo4')
}
foo1()
foo4
扩展:正常来说,在函数执行完毕并出栈时,函数内的局部变量在下一个垃圾回收(GC)节点会被回收,该函数对应的执行上下文将会被销毁,这也正是我们在外界无法访问函数内定义的变量的原因。
5.闭包
函数嵌套函数时,内层函数引用了外层函数作用域下的变量,并且内层函数在全局环境下可访问,进而形成闭包。
例题
function numGenerator(){
let num = 1
num++
return ()=>{
console.log(num)
}
}
var getNum = numGenerator()
genNum()
2
这是个简单的闭包示例,numGenerator创建了一个变量num,接着返回了 一个匿名函数,这个函数引用了变量num,使得外部可以通过调用getNum方法访问变量num。即相关调用栈出栈后,变量num不会消失,仍然有机会被外界访问。
在JavaScript引擎分析中,num值被标记为Closure,即闭包变量
我们知道在正常情况下外界是无法访问函数内变量的,函数执行之后,上下文即被销毁。但是在函数(外层)中,如果我们返回了另外一个函数,且这个返回的函数使用了函数(外层)内的变量,那么外界便能通过这个返回的函数获取原函数(外层)内部的变量值,这就是闭包的基本原理。
可以利用闭包实现“模块化”;Redux源码的中间件实现机制也大量运用了闭包(函数式理念)。
6. 内层管理
内存管理基本概念
内层空间分为栈空间和堆空间,具体如下:
- 栈空间:由操作系统自动分配释放,存放函数的参数值、局部变量的值等,类似于数据结构中的栈
- 堆空间:一般由开发者分配释放,关于这部分空间要考虑垃圾回收的问题
在JavaScript中,数据类型包括基本数据类型和引用类型,具体如下:
- 基本数据类型:undefined、null、number、boolean、string等
- 引用类型:object、array、function
一般情况下,基本数据类型按照值大小保存在栈空间中,占有固定大小的内存空间;引用类型保存在堆空间中,内存空间大小并不固定,需按引用情况来进行访问。
内存泄漏场景举例
var element = document.getElementById("element")
element.mark = "marked"
//移除element节点
function remove(){
element.parentNode.removeChild(element)
}
这里只是把element节点移除了,但变量element依然存在,该节点占用的内存无法被释放。
为了解决该问题,需要在remove方法中添加element=null,这样更为稳妥
var element = document.getElementById('element')
element.innerHTML = '<button id="button">click</button>'
var button = document.getElementById('button')
button.addEventListener('click',function (){
...
})
element.innerHTML = ''
因为存在element.innerHTML = ''
,所以button元素已经从DOM中移除了,但是由于其事件处理句柄还在,所以该节点变量依然无法被回收。因此还需要添加removeEventListener函数,以防止内存泄漏。
function foo(){
var name = 'lucas'
window.setInterval(function(){
console.log(namme)
},1000)
}
foo()
这段代码中,由于存在window.setInterval,所以name内存空间始终无法释放,如果不是业务要求的话,一定要记得在合适的时机使用clearInterval对其进行清理。
浏览器垃圾回收
大部分场景下浏览器都会依靠标记清除、引用计数两种算法进行回收
2. 例题分析
实战例题1
const foo = (function(){
var v = 0
return ()=>{
return v++
}
}()
)
for (let i = 0; i < 10; i++){
foo()
}
//for之后v=9
console.log(foo())
10
foo是一个立即执行函数,为()=>{return v++}
,在循环执行foo时,v自增10次,最后执行foo时,得到10。变量v是闭包变量
实战例题2
const foo = ()=>{
var arr = []
var i
for (i = 0; i < 10; i++){
arr[i] = function () {
console.log(i)
}
}
return arr[0]
}
foo()()
10
i
是自由变量,arr[0]=function(){console.log(i)}
,由于arr[0]是个函数,未执行时并没有获取i的值,执行时才去查找i的指向,由于i是自由变量,因此会一直保留循环后的值。
实战例题3
var fn = null
const foo = () => {
var a = 2
function innerFoo() {
console.log(a)
}
fn = innerFoo
}
const bar = () =>{
fn()
}
foo()
bar()
2
通过将innerFoo函数赋值给全局变量fn,foo的变量对象a也会被保留下来。所以,函数fn在函数bar内部执行时,依然可以访问这个被保留下来的变量对象,输出结果为2
实战例题4
var fn = null
const foo = () => {
var a = 2
function innerFoo() {
console.log(c)
console.log(a)
}
fn = innerFoo
}
const bar = () =>{
var c = 100
fn()
}
foo()
bar()
报错:ReferenceError: c is not defined
在bar中执行fn时,fn已经被复制为innerFoo,但变量c不在其作用域链上,c只是bar函数的内部变量,因此会报错