乐趣区

JS基础篇之作用域执行上下文this闭包

这是一篇很短的文章,介绍了 js 几个比较重要的概念,适合通勤路上快速阅读加深理解和记忆。

作用域和执行上下文

作用域:

js 中的作用域是词法作用域,即由 函数声明时 所在的位置决定的。词法作用域是指在编译阶段就产生的,一整套函数标识符的访问规则。(区别于词法作用域,动态作用域是在函数执行的时候确认的,js 的没有动态作用域,但 js 的this 很像动态作用域,后面会提到。词法作用域的概念十分重要,请多加记忆并理解。)说到底 js 的作用域只是一个“空地盘”,其中并没有真实的变量,但是却定义了变量如何访问的规则。

作用域链本质上是一个指向变量对象的指针列表,它只引用不包含实际变量对象,是作用域概念的延申。作用域链定义了变量在当前上下文访问不到的时候如何沿作用域链继续查询的一套规则。

执行上下文:

执行上下文是指 函数调用时 产生的变量对象,这个变量对象我们无法直接访问,但是可以访问其中的变量、this 对象等。例如:

let fn, bar; // 1、进入全局上下文环境
bar = function(x) {
  let b = 5;
  fn(x + b); // 3、进入 fn 函数上下文环境
};
fn = function(y) {
  let c = 5;
  console.log(y + c); //4、fn 出栈,bar 出栈
};
bar(10); // 2、进入 bar 函数上下文环境


每次函数调用时,都会产生一个新的执行上下文环境,JavaScript 引擎会以栈的方式来处理它们,这个栈,我们称其为函数调用栈(call stack)。栈底永远都是全局上下文,而栈顶就是当前处于活动状态的正在执行的上下文,也称为活动对象(running execution context,图中蓝色的块),区别与底下被挂起的上下文(变量对象)。

总结:作用域是在函数声明的时候就确定的一套变量访问规则,而执行上下文是函数执行时才产生的一系列变量的环境。也就是说作用域定义了执行上下文中的变量的访问规则,执行上下文在这个作用域规则的前提下进行变量查找,函数引用等具体操作。

理解函数执行过程

函数的执行过程分成两部分,一部分用来生成执行上下文环境,确定 this 的指向、声明变量以及生成作用域链;另一部分则是按顺序逐行执行代码。

  • 建立执行上下文阶段(发生在:函数被调用时 && 函数体内的代码执行前)
  1. 生成 变量对象,顺序:创建 arguments 对象 –> 创建 function 函数声明 –> 创建 var 变量声明
  2. 生成作用域链
  3. 确定 this 的指向
  • 函数执行阶段
  1. 逐行执行代码,这个阶段会完成变量赋值,函数引用,以及执行其他代码。

this 指向

关于 js 的 this 关键字,我记得第一次接触还是在做前端半年或一年的时候(哈哈我就是这么水)。那时候徐哥(java 大佬)教我在绑定 click 事件的时候把 this 传给事件处理函数,类似 <button onclick="handle(this)"> 确认 </button>,我当时就懵了,this 是什么鬼?!从此正式开启了我三年的 js 痛苦之旅:封装啊、闭包啊、面向对象啊、继承啊等等等等。this的指向说来说去其实只有四种:

let fn = function(){alert(this.name)
}
let obj = {
  name: '',
  fn
}
fn() // 方法 1
obj.fn() // 方法 2
fn.call(obj) // 方法 3
let instance = new fn() // 方法 4 
  1. 方法 1 中直接调用函数 fn(),这种看着像光杆司令的调用方式,this 指向window(严格模式下是undefined)。
  2. 方法 2 中是点调用 obj.fn(),此时this 指向 obj 对象。点调用中 this 指的是点前面的对象。
  3. 方法 3 中利用 call 函数把 fn 中的 this 指向了第一个参数,这里是 obj。即利用callapplybind 函数可以把函数的 this 变量指向第一个参数。
  4. 方法 4 中用 new 实例化了一个对象 instance,这时fn 中的 this 就指向了实例instance

如果同时发生了多个规则怎么办?其实上面四条规则的优先级是递增的:

fn() < obj.fn() < fn.call(obj) < new fn()

首先,new调用的优先级最高,只要有 new 关键字,this就指向实例本身;接下来如果没有 new 关键字,有 call、apply、bind 函数,那么 this 就指向第一个参数;然后如果没有 new、call、apply、bind,只有obj.foo() 这种点调用方式,this指向点前面的对象;最后是光杆司令 foo() 这种调用方式,this 指向window(严格模式下是undefined)。

es6 中新增了箭头函数,而箭头函数最大的特色就是没有自己的 this、arguments、super、new.target,并且箭头函数没有原型对象prototype 不能用作构造函数(new一个箭头函数会报错)。因为没有自己的 this,所以箭头函数中的this 其实指的是包含函数中的 this。无论是点调用,还是call 调用,都无法改变箭头函数中的this

闭包

js 的闭包是新手的噩梦,在学 js 的前三年,我查阅了无数的博文,苦苦搜索闭包的概念,然而最终一无所获。MDN 上这样定义闭包:闭包是函数和声明该函数的词法环境的组合。

what?能说人话吗?

很长时间以来我对闭包都停留在“定义在一个函数内部的函数”这样肤浅的理解上。事实上这只是闭包形成的必要条件之一。直到后来看了 kyle 大佬的《你不知道的 JAVASCRIPT》上册中关于闭包的定义,我才豁然开朗:

当函数能够记住并访问所在的词法作用域时,就产生了闭包。

let single = (function(){
  let count = 0
  return {plus(){
      count++
      return count
    },
    minus(){
      count--
      return count
    }
  }
})()
single.plus() // 1
single.minus() // 0

这是个单例模式,这个模式返回了一个对象 single,对象中包含两个函数plusminus,而这两个函数都用到了所在词法作用域中的变量 count,所以在函数执行结束时count 所在的执行环境不会被销毁,这就产生了闭包。每次调用 single.plus() 或者 single.minus(),就会对闭包中的count 变量进行修改,这两个函数保持住了对所在的词法作用域的引用。

闭包其实是一种特殊的函数,它可以访问函数内部的变量,还可以让这些变量的值始终保持在内存中,不会在函数调用后被垃圾回收机制清除。

看个经典安利:

// 方法 1
for (var i = 1; i <= 5; i++) {setTimeout(function() {console.log(i)
  }, 1000)
}
// 方法 2
for (let i = 1; i <= 5; i++) {setTimeout(function() {console.log(i)
  }, 1000)
}

方法 1 中,循环设置了五个定时器,一秒后定时器中回调函数将执行,打印变量 i 的值。毋庸置疑,一秒之后 i 已经递增到了 5,所以定时器打印了五次 5。(定时器中并没有找到当前作用域的变量i,所以沿作用域链找到了全局作用域中的i

方法 2 中,由于 es6 的 let 会创建局部作用域,所以循环设置了五个作用域,而五个作用域中的变量 i 分布是 1 -5,每个作用域中又设置了一个定时器,打印一秒后变量 i 的值。一秒后,定时器从各自父作用域中分别找到的变量 i 是 1 -5。这是个利用闭包解决循环中变量发生异常的新方法。

最后

我真的学不动了。

退出移动版