JavaScript 作用域与闭包

257 阅读7分钟

浏览器执行代码的过程

浏览器执行一段代码,会在执行之前就进行编译,编译生成执行上下文与可执行代码。

执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。

在JS代码执行过程中,一般有这么三种情况:

  1. 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
  2. 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
  3. 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。

接下来先了解几个概念:

变量提升

在这个过程中,会出现变量提升:先编译,再执行,在编译时变量声明提升,赋值位置不变。由于变量声明的提升,变量会先被设置默认值undefined,随后等执行赋值语句,变量才有确定值。

调用栈

JavaSCript同样使用调用栈来管理函数调用。 调用栈:在执行 JavaScript 时,在全局上下文中执行函数调用,便会创建新的函数执行上下文,只要在全局环境中声明执行过函数,必然会存在多个执行上下文,JavaScript 引擎则通过栈来管理这些执行上下文。当一个函数执行完成时,调用栈将弹出其执行上下文。

块级作用域

var 的变量提升以及无块级作用域两个原因,造成了1.变量会在不知情的情况下被覆盖 2.本该被销毁时却仍存在。

1.
var myname = "极客时间"
function showName(){
  console.log(myname);
  if(0){
   var myname = "极客邦"
  }
  console.log(myname);
}
showName()
//undefined undefined
2.
function foo(){
  for (var i = 0; i < 7; i++) {
  }
  console.log(i); 
}
foo()
// 7

ES6提出了let与const,这两个关键字声明的变量是存在块级作用域的。 let的块级作用域是怎么实现的,这就涉及到了另一个执行上下文中的词法环境。

  1. 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。
  2. 通过 let 声明的变量,在编译阶段会被存放到当前块级作用域的词法环境(Lexical Environment)中。

强调当前块级作用域的原因:

function foo(){
    var a = 1
    let b = 2
    {
      let b = 3
      var c = 4
      let d = 5
      console.log(a)
      console.log(b)
    }
    console.log(b) 
    console.log(c)
    console.log(d)
}   
foo()

分析当执行到{}中的时候,以let声明的变量会单独在词法环境中包裹一个在一个块作用域。

TDZ 暂时性锁区

let与const,在块作用域中的声明,会被存在变量提升这一过程,但是由于其并没有赋值,在执行赋值语句前,执行使用该变量的语句都会报错,指明该变量在初始化前不能使用。 这一报错也是我判断let存在变量提升行为的猜测,由于我并没有仔细阅读官方文档和js源码,所以这里只是个人猜测,但是tdz这一现象确实存在,不用考虑太过细节(因为看了很多人对tdz的理解都不一,但是原因不知道,结果都一致,会报错),只要记得let声明的变量必须先声明赋值,再进行使用。

作用域链

要正确理解作用域链,要在执行上下文的变量环境中再加一个外部引用outer,outer指向该执行上下文所属的函数被声明时所在的作用域。

在查找一个变量时先从当前执行上下文开始查询,如果没有则到outer指向的执行上下文中查询。 这一查找过程所形成的链,即作用域链。

outer是在代码编译时确定的,JavaScript是词法作用域型的语言。

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。这就是说,函数b在执行上下文a中声明,那么其执行上下文中的outer,就指向该执行上下文a 。

此时,需要知道有关执行上下文的内容还剩this,但是变量的查询已经可以确定。其查找过程为,当前执行上下文.词法环境(由栈顶到栈底)-> 当前执行上下文.变量环境(由栈顶到栈底)-> outer指向的执行上下文.词法环境 -> xx.变量环境 -> ... -> 全局执行上下文.变量环境

闭包

定义, 浏览器工作原理与实践: 在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。

红宝书:闭包是指有权访问另外一个函数作用域中的变量的函数。

MDN:函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起构成闭包(closure)。也就是说,闭包可以让你从内部函数访问外部函数作用域。在 JavaScript 中,每当函数被创建,就会在函数生成时生成闭包。

Wiki:在计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是在支持头等函数的编程语言中实现词法绑定的一种技术。在支持头等函数的语言中,如果函数f内定义了函数g,那么如果g存在自由变量,且这些自由变量没有在编译过程中被优化掉,那么将产生闭包。闭包在实现上是一个结构体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包括约束变量(该函数内部绑定的符号),也要包括自由变量(在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。闭包跟函数最大的不同在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即便脱离了捕捉时的上下文,它也能照常运行。捕捉时对于值的处理可以是值拷贝,也可以是名称引用,这通常由语言设计者决定,也可能由用户自行指定(如C++)。

上面的定义从各个角度试图阐述闭包,但只需了解,由内部函数捕捉了外部函数本该被清除的垃圾变量,并与自身执行上下文绑定后形成了闭包。

function foo() {
    var myName = "极客时间"
    let test1 = 1
    const test2 = 2
    var innerBar = {
        getName:function(){
            console.log(test1)
            return myName
        },
        setName:function(newName){
            myName = newName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName("极客邦")
bar.getName()
console.log(bar.getName())

由于闭包并非编造的而是真实存在的,在debug代码时也可以看到。个人对闭包的理解是对变量的一种使用方式,但并不理解为什么要这样做。

闭包与性能相关的地方就是,闭包无法正常被垃圾处理器处理,如果是一个全局变量使用生成了闭包的一个函数,会存在到页面结束。使用频率高尚可,不高则导致内存泄漏,最好使用局部变量保存使用频率少的闭包。


代码的执行过程继续,当全局执行上下文从调用栈中弹出时,代码执行完成,并不意味页面结束。此时要理解宏任务与微任务,及页面循环概念,再另一篇文章继续。

一道囊括了块级作用域,变量提升,作用域链的变量查询题

下面代码会如何输出?

var b = 'boy';
console.log(b);
function fighting() {
  console.log(a);
  console.log(c);
  if( a === 'Apple') { a = 'Alice' }
  else { a = 'Ada' }
  console.log(a);
  var a = 'Andy';
  middle();
  function middle () {
    console.log(c++);
    var c = 100;
    console.log(++c);
    small();
    function small() {console.log( a );}
  }
  var c = a = 88;
  function bottom () {
    console.log(this.b);
    b = 'baby'
    console.log(b);
  }
  bottom();
}
fighting();
console.log(b);

将变量以及函数提升后可以得到:

var b = 'boy';  
console.log(b); // boy
function fighting() {
  var a; //a = undefined
  var c; //c = undefined
  
  function middle () {
    var c; //c = undefined
    console.log(c++); // NaN
    c = 100;
    console.log(++c); // 1 + 100 = 101
    function small() {console.log( a );} // Andy
      small();// Andy
  }

  function bottom () {
    console.log(this.b);//this == window, this.b = boy
    b = 'baby'
    console.log(b); //baby
  }
  
  console.log(a); //undefined
  console.log(c); //undefined
  if( a === 'Apple') { a = 'Alice' }
  else { a = 'Ada' }
  console.log(a); //Ada
  
  a = 'Andy';
  middle(); // NaN, 101, Andy
  c = a = 88;
  bottom(); // boy, baby
}

fighting(); // undefined, undefined, Ada, NaN, 101, Andy, boy, baby
console.log(b); // baby

// boy, undefined, undefined, Ada, NaN, 101, Andy, boy, baby, baby

本题的视频讲解技术蛋——作用域

this的值,是在函数执行时确认的,不是在函数定义时确定的。(this取值的不同场景)

this 的指向在这几种情况下会不同

  • 作为普通函数
  • 使用 call apply bind
function fn1() {
	console.log(this);
}
fn1() //window

fn1.call({x: 100}) // {x: 100}

const fn2 = fn1.bind({x: 200})
fn2() // {x: 200}
  • 作为对象方法被调用
  • 箭头函数,永远会找上一级作用域的this来绑定
const zhangsan = {
	name: '张三',
    sayHi() {
    	//this 即当前对象
		console.log(this);
	},
    wait() {
		setTimeout(function() {
			//this === window
			console.log(this) //这里为特殊情况,setTimeout中是异步函数,这里的this指向了window
		}
	}
}

如果将 setTimeout 中的异步函数换为箭头函数,由于箭头函数的this来自父作用域,此时this指向当前对象。(setTimeout的这一特性不要抠细节,记住就好,以后有机会再细讲)
  • 在 class 方法中调用
class People {
	constructor(name) {
		this.name = name;
        this.age = 20;
	}
    sayHi() {
    	console.log(this);
    }
}

const zhangsan = new People('张三');
zhangsan.sayHi(); //zhangsan

手写 bind 函数

实际开发中闭包的应用场景,举例说明

1. 返回一个函数。

2. 作为函数参数传递

var a = 1;
function foo(){
  var a = 2;
  function baz(){
    console.log(a);
  }
  bar(baz);
}
function bar(fn){
  // 这就是闭包
  fn();
}
// 输出2,而不是1
foo();

3. 在定时器、事件监听、Ajax请求、跨窗口通信、Web Workers或者任何异步中,只要使用了回调函数,实际上就是在使用闭包。

以下的闭包保存的仅仅是window和当前作用域。
// 定时器
setTimeout(function timeHandler(){
  console.log('111');
},100)

// 事件监听
$('#app').click(function(){
  console.log('DOM Listener');
})

4. IIFE(立即执行函数表达式)创建闭包, 保存了全局作用域window和当前函数的作用域,因此可以全局的变量。

var a = 2;
(function IIFE(){
  // 输出2
  console.log(a);
})();

作者:神三元
链接:https://juejin.cn/post/6844903974378668039
来源:掘金

箭头函数this自动绑定的原理

箭头函数本身就没有this,其this都是父作用域的。

// ES6 箭头函数
function foo() {
  setTimeout(() => {
    console.log('id:', this.id);
  }, 100);
}

// ES5
function foo() {
  var _this = this;

  setTimeout(function () {
    console.log('id:', _this.id);
  }, 100);
}