JS 中的 作用域、作用域链、内存闭包等相关概念
1.作用域
1.1 函数作用域和全局作用域
在JS中一般只有全局作用域和函数作用域。大家应该非常熟悉函数作用域了,下面就来展示一下函数作用域和全局作用域。
function foo(){
var a = "bar"
console.log(a)
}
foo()
// bar
// 这个时候 a 在 foo 函数作用域内
=============>
var b = "bar"
function foo(){
console.log(b)
}
foo()
// bar
// 这个时候 b 就在全局作用域中,foo 函数中没有 b 这个变量,然后就到上一层的全局作用域中去寻找 b。得出来 bar
==============》
function bar(){
var b = "bar"
}
function foo(){
console.log(b)
}
foo()
// Uncaught ReferenceError: b is not defined
// 这个时候呢,在 foo 函数作用域中没有找到 b,然后就去外层作用域中(全局作用域)去寻找 b,但是我们发现没有b 这个变量。然后就会报错。
简单来说,在JS执行的某一个函数时,就会就近取值。如果在当前函数作用域中没有找到该变量的时候就去上一层的作用域中去寻找。
1.2 块级作用域和暂时性死区
随着JS的发展,ES6中增加了let和const生命声明的块级作用域,使得JS中的作用域更加丰富,同时也出现一个暂时性死区(TDZ),后面都会讲到。
function foo(){
console.log(bar)
var bar = 3
}
foo()
// undefined
// 这个时候代码会输出 undefinede,原因是变量 bar 在函数内部进行了提升了。
==========>
// 上面的代码等同于
function foo(){
var bar
console.log(bar)
bar = 3
}
==========>
// 但是在使用 let 和 const 进行声明的时候,则会产生 Uncaught ReferenceError: bar is not defined.
function foo(){
console.log(bar)
let bar = 3
}
// 这个时候会报错。
==========>
function(){
let var = 3
console.log(bar)
}
// 这个时候就不会报错了,因为这个时候就不在暂时性死区里面了,就不会存在引用报错了。
// 对于TDZ这个概念,在相应的花括号形成的作用域中存在一个“死区”,起始于函数开头,终止于相关函数的声明所在行。就像
function foo(){
console.log(bar)
let bar = 3
}
// 这个函数,console.log的时候就在TDZ中,但是TDZ终止于let bar = 3这一行。
此外对于TDZ还有一个特殊的情况就是在函数参数的默认值的用法中。
function foo(arg1,arg2){
console.log(`${arg1} ${arg2}`)
}
foo(1,2) // 1 2
foo(null,2) // null 2
foo(undefined,2) // can't access "arr2" before initialization
这个地方涉及到了 null 和undefined的区别,我们后面会去讲述这个事情。也就是当第一个变量没有值的时候,我们再去使用默认值。
2. 执行上下文与调用栈
其实这两个变量一直都在伴随在我们的左右,执行上下文就是当前代码的执行环境。作用域和作用域链相辅相成,但是又不能完全分离开来。直观上来看执行上下文中包含了作用域链。
2.1代码执行的两个阶段与执行上下文
执行JS代码主要分为两个阶段:
- 代码预编译阶段
- 代码执行阶段
在预编译阶段:
- 在预编译阶段进行变量声明
- 在预编译阶段对变量声明进行提升,但是其值为undefined
- 在预编译阶段对所有非表达式的函数声明进行提升
下面我们将使用代码来演示这三点内容:
function bar(){
console.log("bar1")
}
var bar = function(){
console.log("bar2")
}
bar() // bar2
==========>
var bar = function(){
console.log("bar2")
}
function bar(){
console.log("bar1")
}
bar() // bar2
因为预编译阶段对 var
声明的变量进行了提前,同时其值为 undefined
,然后后面对function声明进行提升,这个时候function在全局范围内就覆盖了原来的声明。所以说第一次的时候,bar再次被赋值为函数bar2,然后执行的时候就是这样了。对于第二个,函数声明已经提升了,在对bar 进行赋值之前bar在全局作用域内也是bar1,但是赋值之后就是bar2了,所以输出也是bar2.
现在去思考下面的代码
foo(10)
function foo(num){
console.log(foo)
foo = num
console.log(foo)
var foo
}
console.log(foo)
var foo=1
console.log(foo)
======> 结果
undefined
10
[Function: foo]
1
这个代码我就不去解释了,就是函数声明提升和函数声明提升。
在上面的过程中,作用域在与编译阶段确定,但是作用域链是在执行上下文创建的阶段完全生成的,因此函数在调用时才会开始创建对应的执行上下文、执行上下文包含变量对象、作用域链及this指向。
因此代码执行的过程就像是一条流水线,第一阶段是在预编译阶段生成变量对象(Variable Object)(注意这个阶段只是创建,并没有赋值)到了下一个阶段,变量会变为激活对象(Active Object),此时作用域就会被确定了,它由当前执行环境的所有变量和所有外层的变量对象组成。这个工序保证了所有的变量和函数的访问,即如果在当前执行上下文中没有找到该变量或者函数,那么就回去外层去寻找,直到全局作用域。
2.2调用栈
这个知识点比较简单,我就不展开解释了。
正常来说,在函数解释执行阶段,函数内的局部变量在下一个垃圾回收的时间节点都会回收,该函数对应的执行上下文也将会被销毁,这也正是我们在外界无法访问函数内部定义的变量的原因。也就是说,只有哦在函数执行的时候,相关函数才可以访问该变量,该变量会在预编译阶段被创建,子啊型阶段被击毁,子啊函数执行完毕之后,其相关的上下文环境就会被销毁。
3.闭包
在函数嵌套的时候,内层函数引用了外层函数作用域下的变量,并且内层函数可以在全局范围内被访问就可以形成闭包。其实防抖,节流,单例模式等等都会涉及到闭包。
// 防抖
let debounce = function(fn,delay){
let timer = null
return function(){
if(timer){
clearTimeout(timer)
timer = setTimeout(fn,delay)
}else{
timer = setTimeout(fn,delay)
}
}
}
// 节流
let throttle = function(){
let timer = null
return function(){
if(timer){
return
}else{
timer = setTimeout(()=>{
fn()
clearTimeout(timer)
timer = null
},delay)
return
}
}
}
// 单例模式
function single(fn){
let item = null
return function(...arg){
return item || (item = new fn(...arg))
}
}
// 这个是我写的单例的装饰器。
后面还有大量的技巧使用到闭包,等到后面我们再去讲。byebye 喽。