js中的作用域,作用域链,预编译(执行上下文),this指针和闭包

177 阅读10分钟

作用域

一个区域,在这个区域中声明的变量和函数只能在这个区域内部使用;

举个例子:
小明盖了一个牛场,那么牛场里面所有的牛在正常情况下只能在牛场里面活动,不能在牛场外面活动;那么这个牛场就相当于作用域,里面的牛相当于声明的函数和变量;当然在一定情况下牛是可以在牛场外面进行活动,比如你打开了门把牛放出去了,那么你打开了门相当于你在这个作用域中通过return等向外部暴露了变量,那么外部就可以使用这个变量了;

js中的作用域分为全局作用域,函数作用域和块级作用域;
函数在定义的时候,会在函数对象上创建一个[[scope]]属性,这个属性就是当前函数的作用域;

a[[scope]] = []
全局作用域

在一个js脚本中没有声明任何函数的时候此时的作用域就是全局作用,它是作用域中的最顶层的作用域;

举个例子
小明在未盖牛场前,他的牛在一片空地上,这片空地就相当于全局作用域,牛可以随意的奔跑

全局作用域的特点:

  1. 在全局作用域中定义的变量和函数,在全局中的任何地方都可以被使用
  // 在全局作用域中定义一个变量
  const a = 2
  // 在全局作用域中定义一个函数
  function b(){
      // 在函数中使用全局作用域中定义的变量a
      console.log(a) // 2
  }
  b()
  1. 在非严格模式下,未定义就使用的变量会被默认定义为全局变量,严格模式下会报错
function a(){
    // 在函数内部给未定义的变量b赋值为2
    b = 2
}
a()
// 在函数外部访问b
console.log(b) // 2
  1. 全局作用域中通过var关键字定义的变量都会被定义正window对象上,换句话就是window对象相当于全局对象,通过let和const关键字定义的变量不具有此特性
var a = 2
window.a // 2
const b = 3
window.b // undefined

全局作用域的缺点:
全局作用域中定义过多的变量和函数,容易造成命名冲突;
优点:
全局作用域中定义的变量和函数在全局任何地方都可以被访问;

函数作用域

函数作用域就是函数体这个范围,在函数内部定义的变量和函数,只能在函数内部使用,在函数外部是访问不到的;函数作用域在全局作用域的内部;函数作用域可以访问全局作用域中的变量,但是全局作用域无法访问函数作用域中定义的变量;

举个例子
和第一个例子一样,你盖的牛场相当于函数作用域,你的牛场内部的牛相当于在函数中定义的变量,正常情况下只能在函数内部使用,如果想在函数外部使用,那么需要return或window暴露出去供外部使用;

function a(){
    // 函数内部定义一个变量b
    var b = 1
    // 函数内部声明一个变量
    var c = 5
    // 通过window暴露给外部使用
    window.c = c
}
a()
// 在函数外部使用
console.log(b) // undefined
console.log(c) // 5

函数作用域的优点:
可以私有化变量,在函数内部定义的变量可以防止全局命名冲突和变量的污染;在jquery中就使用了函数作用域的特点;通过立即执行函数私有化变量,通过给window对象上添加$等属性暴露给外部使用;

块级作用域

块级作用域就是{}中let或const定义的变量只能在这个{}内部使用,因此{}对let或const就形成了块级作用域,通过var定义的变量不受块级作用域的限制;

// 在块级作用域中通过let定义的变量只能在块级作用域内部使用
for (let i = 0; i < 5; i ++){
    console.log(i) // 0 1 2 3 4
}
console.log(i) // i is not defined

// var是不受块级作用域的限制
for (var i = 0; i < 5; i ++){
    console.log(i) // 0 1 2 3 4
}
console.log(i) // 5

函数作用域和块级作用域的区别?
函数作用域中使用let或const定义变量,那么这个函数作用域也可看做是let或const的块级作用域;
块级作用域特点:

  1. 在块级作用域中通过let或const定义的变量不会进行变量提升,如果在定义前使用会形成暂时性死区;
  function a(){
      console.log(b) // Cannot access 'b' before initialization 暂时性死区
      const b = 3
  }

面试题:

// 下面代码输出什么?为什么?
for (var i = 0; i < 5; i ++){
    setTimeout(()=>{
        console.log(i) // ?
    },0)
}
// 答案 会输出5个5,因为这里的var相当于在全局中定义的变量,0秒之后会在回调的箭头函数中查
// 是否有i这个变量,如果没有就在全局作用域找,此时的i已经是5了;
// 怎么才能按序输出?
// 1. 把var改成let,这样就形成了块级作用域,每次循环一次都会在这个块级作用域中定义一个i 
// 并且赋值为当前的值;2. 通过函数传参的形式,这样就形成了函数作用域,会在函数作用域中查找; 
// 3 通过立即执行函数传递参数的形式,也是利用了函数作用域

注意:
js中的作用域是静态作用域(词法作用域)也就是在定义的时候就确定了,而不是在执行的时候

const a = 1
function b(){
    console.log(a) //1 不是2
}
function c(){
    const a = 2
    b()
}
c()

虽然b函数在c中执行,c函数中也有变量a,但是作用域在函数b定义的时候就已经被确定了,所以在函数b中访问a的时候,会首先在b函数的作用域中查找,如果没有找到就向b函数所在的作用域中查找,这里也就是全局作用域,从全局作用域中找到了a=1,因此输出1

作用域链

一个个嵌套的作用域就形成了作用域链;全局作用域中声明了函数,那么全局作用域中就包含了函数作用域,这个就是作用域链;当访问一个变量的时候,首先会从它所在的作用域中查找,如果没有找到就会顺着作用域链一层层的向上查找,如果一直没有找到,就会一直查找到全局作用域;

const a = 1
function b(){
    function c(){
        console.log(a) // 1
    }
    c()
}
b()

c中访问了a属性,那么会在c的作用域中查找,如果没有找到就会在c函数所声明的地方b函数的作用域中查找,如果也没找到就会在b函数所声明的地方全局作用域查找找a=1;

image.png

面试题:
下面输出什么?

function a(fn, x){
    if (x < 1) {
        a(g, 1)
    } else {
        fn()
    }
    function g(){
        console.log('x' + x)
    }
}
function h(){
    console.log('hh' + x)
}
a(h, 0)

答案:'x0' 注意变量的查找一定是根据作用域查找的,作用域一定是静态的也就是在声明的时候就确定了,因此x等于1的时候再执行fn,这个fn就是传递进来的g,g执行的时候输出x,g中没有x,就会在a的作用域中查找,此时的a作用域中的fn是h,x是0,因此输出'x0';

预编译

js运行时会进行三件事,语法分析,预编译,解释执行;预编译发生在函数执行前的那一刻;
语法分析:
判断代码有没有基本的书写语法错误;
解释执行:
一行一行执行代码;
预编译:
创建执行上下文;

执行上下文

函数每次执行都会创建一个执行上下文内部对象,一个执行上下文定义了函数执行时的环境,多次调用函数就会创建多个执行上下文,每个执行上下文都是独一无二的,创建好的执行上下文会被推入到执行栈中进行执行,当函数执行完毕,会弹出执行栈,执行上下文也会随之销毁;
执行上下文主要包含以下三个部分:

  • VO(AO)变量对象
  • this指向
  • 作用域链
    function a(){}
    // a函数的执行上下文
    ascopeContext = {
        VO: {}, // 变量对象
        thisnull, // this
        scope: null, // 作用域链
    }
变量对象

存储函数内部定义的变量,形参和声明的函数;函数的变量对象叫AO;
创建变量对象的5个步骤:

  1. 创建AO对象;
  2. 根据形参初始化arguments对象;
  3. 查找定义的变量和形参,并作为AO的属性值为undefined;
  4. 把实参赋值为形参和arguments的值;
  5. 把函数式声明的函数作为AO的属性和值(重名的变量会进行替换,注意是函数式声明,表达式定义的函数不会进行第四步);
    function a(){}
    
    ascopeContext = {
        AO: AO,
        thisnull,
        scope: null,
    }

举例说明

function a(c){
    console.log(b) // undefined
    var b = 2
    console.log(b) // 2
    console.log(d()) // 3
    var d = 4
    function d(){
        return c
    }
    console.log(f) // v
    var f = function(){}
}
a(3)

解析:

1. 函数a在执行前一刻进行预编译,创建执行上下文;
2. 创建了AO对象;
3. 根据形参初始化arguments,arguments: { 0: undefined, length: 1 }
3. 找到了变量b,d和形参c,并作为AO的属性赋值为undefined;
    AO = { arguments: { 0: undefined, length: 1 }, b: undefined, d: undefined, f: undefined, c: undefined }
4. 把实参作为形参的值
    AO = { arguments: { 0: 3, length: 1 }, b: undefined, d: undefined, f: undefined, c: 3 }
5. 把函数式声明作为AO的属性和值,(重名的变量会进行替换)
   AO = { arguments: { 0: 3, length: 1 },b: undefined, c: 3, d: fn, f: undefined }
ascopeContext = {
    AO: { 
        arguments: { 0: '3', length: 1 },
        b: undefined, 
        c: 3, 
        d: fn, 
        f: undefined,
    },
    thisnull,
    scope: null,
}

注意:
全局环境下执行代码也会进行预编译,全局执行上下文会创建GO对象,和上面的步骤一样;

this指向

谁调用这个函数执行,那么this就指向谁,如果函数没有调用者,在非严格模式下this指向window,严格模式下是undefined;

function a(){}
a()
ascopeContext = {
    VO: {},
    thiswindow,
    scope: null,
}
生成作用域链

当查找变量的时候会从当前函数的执行上下文中查找,如果没有找到就会沿着当前执行上下文的作用域链向上查找;
执行上下文中生成作用域链的步骤:

  1. 复制函数的作用域,添加到scope中;
  2. 把函数的变量对象添加到作用域链栈顶;
// a函数的作用域在定义的时候就确定了,其中包含了全局作用域 a[[scope]] = [window[[scope]]]
function a(){ var b = 1}
a()

ascopeContext = {
    AO: {
        arguments: { 0: 1, length:1}
        b: 1
    },
    thiswindow,
    scope: a[[scope]], // a[[scope]] = [VO, window[[scope]]]
}

举例说明:

 var a = 1
 funtion b () {
     var c = 2
     return c
 }
 b()

分析:

1. 创建全局作用域
  window[[scope]] = []
2. 创建b函数的作用域,b函数的作用域中存放了它所在的作用域
  b[[scope]] = [window[[scope]]]
3. 开始执行全局代码前进行预编译
    3.1 创建全局执行上下文
        GoscopeContext = {}
    3.2 查找变量和函数声明,创建变量对象VO,同时修改window[[scope]] = [vo]
        GoscopeContext = {
            vo:{
                a: undefined,
                b: fn,
            },
        }
     3.3 确定this
         GoscopeContext = {
            vo:{
                a: undefined,
                b: fn,
            },
            this: window,
         }
      3.4 生成作用域链
        GoscopeContext = {
            vo:{
                a: undefined,
                b: fn,
            },
            this: window,
            scope: [vo,window[[scope]]]
        }
       3.5 执行代码,变量进行赋值,
         window[[scope]] = [vo]
         GoscopeContext = {
            vo:{
                a: 1,
                b: fn,
            },
            this: window,
            scope: window[[scope]]
        } 
4. 执行代码b()
    4.1. b函数进行预编译
    4.2. 创建b函数的执行上下文
        bscopeContext = {
        }
    4.3. 给执行上下文添加环境变量,创建AO对象的四部,b[[scope]] = [AO,window[[scope]]]
        bscopeContext = {
            AO:{
                arguments: { length:0}
                c: undefined,
            },
        }
    4.4 确定this指向
        bscopeContext = {
            AO:{
                arguments: { length:0}
                c: undefined,
            },
            this: window,
        }
    4.5 生成作用域链,添加b函数的作用域,并且添加AO对象到栈顶
        bscopeContext = {
            AO:{
                arguments: { length:0}
                c: undefined,
            },
            this: window,
            scope: b[[scope]]
        }
    4.6 执行c=2,修改vo中的c为2
8. 执行retrun c,从bscopeContext中的vo中查找c,找到就返回没有找到就继续从b[[scope]]进行查找;

闭包

个人理解一个函数a内部返回了另一个函数b,并且b函数内部使用了a函数的变量,比过去b函数在外部被执行了,导致b函数使用的a函数内部的变量无法被释放,这个就是闭包

总结

作用域在函数定义的时候就已经确定了,执行上下文是在函数执行的时候创建的,执行上下文是创建了函数的执行环境;函数的每次执行都会创建一个新的执行上下文推入到执行栈中,函数执行完毕,推出执行栈;