阅读 2366

「前端料包」深究JavaScript作用域(链)知识点和闭包

人生不能像做菜,把所有的料都准备好了才下锅。

前言

在学习作用域和作用域链知识的时候,我一度都是处于犯迷糊的边缘,直到前两天,有人在群里聊了有关作用域的面试题,我当时闭上眼睛心想要是问到我该怎么回答。这两天查了资料,做了几道面试题,觉得要输出一点我自己的理解,本文将通过几个简单的代码片段和面试题对JavaScript作用域和闭包的相关知识一探究竟。文中的表述如有不对,欢迎指正~ 如觉得还行请点亮左侧的👍🙈

1、 一个变量的诞生

var name = 'Jake Zhang'
复制代码

当我们看到var name = 'Jake Zhang'的时候,我们认为是一条声明,但是对于js引擎来说,这是一个编译过程,分为下面两部分:

1、遇到 var name,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为name(严格模式下报错)。

2、接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 name = 'Jake Zhang'这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作 name的变量。如果是,引擎就会使用这个变量;反之,引擎会继续查找该变量。

证明以上的说法:

console.log(name); // 输出undefined
var name = 'Jake Zhang'; 

复制代码

var name = 'Jake Zhang'的上一行输出name变量,并没有报错,输出undefined,说明输出的时候该变量已经存在了,只是没有赋值而已。由以上两步操作一个名为name的变量就此诞生。

上面提到本文的核心词——作用域,那什么是作用域呢,接下来咱们一探究竟。

2、什么是作用域

先看这段代码:

function fun() {
  var name = 'Jake Zhang';
   console.log(name); 
}
fun();// 输出"Jake Zhang"
复制代码

fun() 执行的时候,输出一个name变量 ,那么这个name变量是哪里来?有看到函数第一行有 定义 name变量的代码var name = 'Jake Zhang'

继续看另外一段代码:

var name2 = 'Jake Zhang2';
function fun() {
   console.log(name2);
}
fun(); // 输出"Jake Zhang2"
复制代码

同样,在输出 name2 时,自己函数内部没有找到变量name2 ,那么就 在外层的全局中查找 ,找到了就停止查找并输出结果。

可以注意到以上两段代码都有查找变量。第一段代码是在函数fun中找到name变量,第二段代码是在全局中找到name2变量。 现在给加粗的这两个词的后面加上作用域三个字,再读一遍:第一段代码是在函数作用域fun中找到name变量,第二段代码是在全局作用域中找到name2变量。

其实我们可以发现,作用域,本质是一套规则,用于确定在何处以及如何查找变量(标识符)的规则。关键点在于:查找变量(或标识符)。

由此我们便可引出

(1)作用域的定义:

作用域是定义变量的区域,它有一套访问变量的规则,这套规则用来管理浏览器引擎如何在当前作用域以及嵌套的作用域中根据变量(标识符)进行变量查找。

(2)词法作用域

在上面的作用域介绍中,我们将作用域定义为一套规则,这套规则来管理浏览器引擎如何在当前作用域以及嵌套的作用域中根据变量(标识符)进行变量查找。

现在我们提出一个概念:“词法作用域是作用域的一种工作模型”,作用域有两种工作模型,在JavaScript中的词法作用域(静态作用域)是比较主流的一种,另一种动态作用域(是不关心函数和变量是如何声明以及在何处声明的,只关心它们从何处调用)。 所谓的词法作用域就是在你写代码时将变量和块作用域写在哪里来决定,也就是词法作用域是静态的作用域,在你书写代码时就确定了。

请看以下代码:

function fn1(x) {
	var y = x + 4;
	function fn2(z) {
		console.log(x, y, z);
	}
	fn2(y * 5);
}
fn1(6); // 6 10 50
复制代码

复制代码这个例子中有个三个嵌套的作用域,如图:

  • A 为全局作用域,有一个标识符:fn1
  • B 为fn1所创建的作用域,有三个标识符:x、y、fn2
  • C为fn2所创建的作用域,有一个标识符:z

作用域是由其代码写在哪里决定的,并且是逐级包含的。

(3)块级作用域

在ES6之前JavaScript并没有块级作用域的概念,我们来看一段代码:

for(var i=0;i<5;i++){
console.log(window.i)
    
} //0 1 2 3 4
复制代码

如果你没在函数内使用for循环的话,你会惊奇的发现,妈耶,我这个var不等于白var嘛,反正都是全局变量,要知道我们的变量只能从下往上查找,不能反过来。所以JavaScript并没有块级作用域的概念。 块级作用域是ES6中新添加的概念,常指的是{}中的语句,如 ifswitch 条件语句或 forwhile 循环语句,不像函数,它们不会创建一个新的作用域。块级作用域通常通过letconst来体现。

for(let j=0;j<5;j++)(console.log(window.j));//undefined *5
复制代码

看上面的代码,可以和上一个的var i的循环做对比。其实,提到let,const,这里还涉及到变量提升、暂时性死区等知识点(限于篇幅这里不做展开,之后会写相关文章)。

好了,现在如果面试再问你什么是作用域,这下应该清晰明了了吧。记得顺便提一下词法作用域。 接下来让我们继续探索作用域链。

3、作用域链

我们回到刚开始讲作用域的那段代码:

var name2 = 'Jake Zhang2';
function fun() {
   console.log(name2);
}
fun(); // 输出"Jake Zhang2"
复制代码

我们在查找 name2 变量时,先在函数作用域中 查找,没有找到,再去 全局作用域中 查找。你会注意到,这是一个往外层查找的过程,即顺着一条链条 从下往上查找变量 。这条链条,我们就称之为作用域链

这样我们就得出作用域链的概念:在作用域的多层嵌套中查找自由变量的过程是作用域链的访问机制。而层层嵌套的作用域,通过访问自由变量形成的关系叫做作用域链。

来两张图帮助理解:

代码表示:

4、从面试题解析作用域和作用域链

1、解密原理

  • 每当执行完一块 作用域里的函数后,它就进入一个新的作用域下(一般从下往上找)
  • 当你使用一个变量 或者 给一个变量赋值时,变量是从当前的作用域先找,再从上层作用域找。

第1题:以下代码的输出结果

var a = 1
function fn1(){  
  function fn2(){
    console.log(a)
  }
  function fn3(){
    var a = 4
    fn2()
  }
  var a = 2   
  return fn3   
}
var fn = fn1() 
fn() //输出?

//输出a=2
//执行fn2函数,fn2找不到变量a,接着往上在找到创建当前fn2所在的作用域fn1中找到a=2
复制代码

第2题:以下代码的输出结果

var a = 1        
function fn1(){
  function fn3(){  
    var a = 4
    fn2()        
  }
  var a = 2
  return fn3    
}

function fn2(){
  console.log(a)  
}
var fn = fn1()   
fn() //输出多少

//输出a=1
//最后执行fn2函数,fn2找不到变量a,接着往上在找到创建当前fn2所在的全局作用域中找到a=1
复制代码

第3题(重点):以下代码的输出结果

var a = 1
function fn1(){
  function fn3(){
    function fn2(){
      console.log(a)
    }
    var a
    fn2()
    a = 4
  }      
  var a = 2
  return fn3
}
var fn = fn1()
fn() //输出多少

//输出undefined
//函数fn2在执行的过程中,先从自己内部找变量找不到,再从创建当前函数所在的作用域fn去找,注意此时变量声明前置,a已声明但未初始化为undefined
复制代码

再来看一组在作用域链中查找过程的伪代码:

第1道题

var x = 10
bar() 
function foo() {
   console.log(x) 
}
function bar(){
   var x = 30
   foo() 
}

/*
第2行,bar()调用bar函数
第6行,bar函数里面调用foo函数
第3行,foo函数从自己的局部环境里找x,结果没找到
第1行,foo函数从上一级环境里找x,即从全局环境里找x,找到了var x=10。
foo()的输出结果为10。
*/
复制代码

第2道题


var x = 10;
bar()  //30
function bar(){
  var x = 30;
  function foo(){
    console.log(x) 
  }
  foo();
}   
/*
第2行,bar()调用bar函数
第3行,bar函数里面是foo函数
第4行,foo函数在自己的局部环境里寻找x,没找到。
foo函数到自己的上一级环境,即bar函数的局部环境里找x,找到var x = 30
所以第2行的bar()输出为30
*/
复制代码

第3道题

var x = 10;
bar() 
function bar(){
  var x = 30;
  (function (){
    console.log(x)
  })() 
}
/*
第2行,bar()调用bar函数
第三行,bar函数里的function()在自己的局部环境里寻找x,但没找到
function()在上级环境即bar的局部环境里寻找x,找到var x =30,于是显示结果为30
*/
复制代码

5、闭包

前面所说的作用域及词法作用域都是为讲闭包做准备,词法作用域也是理解闭包的前置知识,所以如果对 作用域还有点模糊的可以回头再看一遍。

(1)从实例解析闭包

闭包(closure),是基于词法作用域书写代码时产生的一种现象。各种专业文献的闭包定义都非常抽象,我的理解是:** 闭包就是能够读取其他函数内部变量的函数**。通过下面的实践你会知道,闭包在代码中随处可见,不用特意为其创建而创建,随着深入做项目后,打代码的不经意间就已经用了闭包。

实例1:

function a(){
    var n = 0;
    function add(){
       n++;
       console.log(n);
    }
    return add;
}
var a1 = a(); //注意,函数名只是一个标识(指向函数的指针),而()才是执行函数;
a1();    //1
a1();    //2

复制代码

分析如下:

  • add的词法作用域能访问a的作用域。根据条件执行a函数内的代码,add当做值返回;
  • add执行后,将a的引用赋值给a1
  • 执行a1,分别输出1,2

通过引用的关系,a1就是a函数本身(a1=a)。执行a1能正常输出变量n的值,这不就是“a能记住并访问它所在的词法作用域”,而a(被a1调用)的运行是在当前词法作用域之外。

add函数执行完毕之后,其作用域是会被销毁的,然后垃圾回收器 会释放闭包那段内存空间,但是闭包就这样神奇地将add的作用域存活了下来,a依然持有该作用域的引用。

为什么会这样呢?原因就在于aadd的父函数,而add被赋给了一个全局变量,这导致add始终在内存中,而add的存在依赖于a,因此a也始终在内存中,不会在调用结束后,被垃圾回收机制(garbage collection)回收。 所以,在本质上,闭包是将函数内部和函数外部连接起来的桥梁。

总结:闭包就是一个函数引用另外一个函数的变量,因为变量被引用着所以不会被回收,因此可以用来封装一个私有变量。

(2)闭包的用途

闭包可以用在许多地方。它的最大用处有两个,一个是前面提到的可以读取函数内部的变量,另一个就是让这些变量的值始终保持在内存中

(3)闭包的实际应用

使用闭包,我们可以做很多事情。比如模拟面向对象的代码风格;更优雅,更简洁的表达出代码;在某些方面提升代码的执行效率。

实例2:随处可见的定时器

function waitSomeTime(msg, time) {
	setTimeout(function () {
		console.log(msg)
	}, time);
}
waitSomeTime('hello', 1000);

复制代码

定时器中有一个匿名函数,该匿名函数就有涵盖waitSomeTime函数作用域的闭包,因此当1秒之后,该匿名函数能输出msg。

实例3:用for循环输出函数值的问题

var fnArr = [];
for (var i = 0; i < 10; i++) {
  fnArr[i] =  function(){
    return i
  };
}
console.log( fnArr[3]() ) // 10
复制代码

通过for循环,预期的结果我们是会输出0-9,但最后执行的结果,在控制台上显示则是全局作用域下的10个10。

这是因为当我们执行fnArr[3]时,先从它当前作用域中找 i 的变量,没找到i 变量,从全局作用域下找。开始了从上到下的代码执行,要执行匿名函数function时,for循环已经结束(for循环结束的条件是当i大于或等于10时,就结束循环),然后执行函数function,此时当 i 等于[0,1,2,3,4,5,6,7,8,9]时,此时i 再执行函数代码,输出值都是 i 循环结束时的最终值为:10,所以是输出10次10。

由此可知:i是声明在全局作用域中,function匿名函数也是执行在全局作用域中,那当然是每次都输出10了。

延伸:

那么,让 i 在每次迭代的时候都产生一个私有作用域,在这个私有的作用域中保存当前i的值

var fnArr = [];
for (var i = 0; i < 10; i++) {
  fnArr[i] = (function(){
    var j = i
    return function(){
        return j
     }  
  })()
}
console.log(fnArr[3]()) //3
复制代码

用一种更简洁、优雅的方式改造:

将每次迭代的 i 作为实参传递给自执行函数,自执行函数用变量去接收输出值


var fnArr = []
for (var i = 0; i < 10; i ++) {
  fnArr[i] =  (function(j){
    return function(){
      return j
    } 
  })(i)
}
console.log( fnArr[3]() ) // 3
复制代码

实例4:数组中的遍历抽象

在这里我先通过Java的抽象类讲一下抽象的概念:

学过Java的对抽象的思想一定不会陌生,先来看Java中的一个抽象类:

public abstract class SuperClass {
  public abstract void doSomething();
}
复制代码

复制代码这是Java中的一个类,类里面有一个抽象方法doSomething,现在不知道子类中要doSomething方法做什么,所以将该方法定义为抽象方法,具体的逻辑让子类自己去实现。 创建子类去实现SuperClass

public class SubClass  extends SuperClass{
  public void doSomething() {
    System.out.println("say hello");
  }
}
复制代码

复制代码SubClass中的doSomething输出字符串“say hello”,其他的子类会有其他的实现,这就是Java中的抽象类与实现。

那么JS中的抽象是怎么样的,我想回调函数应该是一个:

function createDiv(callback) {
  let div = document.createElement('div');
  document.body.appendChild(div);
  if (typeof callback === 'function') {
    callback(div);
  }
}
createDiv(function (div) {
  div.style.color = 'red';
})
复制代码

复制代码这个例子中,有一个createDiv这个函数,这个函数负责创建一个div并添加到页面中,但是之后要再怎么操作这个divcreateDiv这个函数就不知道,所以把权限交给调用createDiv函数的人,让调用者决定接下来的操作,就通过回调的方式将div给调用者。

这也是体现出了抽象,既然不知道div接下来的操作,那么就直接给调用者,让调用者去实现。 这也是我们在学习vue等框架组件开发的一个基本思想。

好了,现在总结一下抽象的概念:抽象就是隐藏更具体的实现细节,从更高的层次看待我们要解决的问题。

数组中的遍历抽象

在编程的时候,并不是所有功能都是现成的,比如上面例子中,可以创建好几个div,对每个div的处理都可能不一样,需要对未知的操作做抽象,预留操作的入口。

接下来看一下JavaScript的几个数组操作方法,可以更深入的理解抽象的思想:

var arr = [1, 2, 3, 4, 5];
for (var i = 0; i < arr.length; i++) {
  var item = arr[i];
  console.log(item);
}
复制代码

这段代码中用for循环,然后按顺序取值,有没有觉得如此操作有些不够优雅,为出现错误留下了隐患,比如把length写错了,一不小心复用了i。既然这样,能不能抽取一个函数出来呢?最重要的一点,我们要的只是数组中的每一个值,然后操作这个值,那么就可以把遍历的过程隐藏起来:

function forEach(arr, callback) {
  for (var i = 0; i < arr.length; i++) {
    var item = arr[i];
    callback(item);
  }
}
forEach(arr, function (item) {
  console.log(item);
});
复制代码

复制代码以上的forEach方法就将遍历的细节隐藏起来的了,把用户想要操作的item返回出来,在callback中还可以将i、arr本身返回:callback(item, i, arr)。 JS原生提供的forEach方法就是这样的:

arr.forEach(function (item) {
  console.log(item);
});
复制代码

forEach相似的方法还有map、some、every等。思想都是一样的,通过这种抽象的方式可以让使用者更方便,同时又让代码变得更加清晰。

好了,抽象的简单介绍就到这了,再往后就是高阶函数的知识了,我这小白对高阶函数也还是懵懵懂懂,等我长本事儿了,再来更新。

后话

这篇在我草稿箱躺了大概有两周了,之前写了一半硬是写不下去了,今天终于写完😪~ 再来默写一遍作用域:作用域是定义变量的区域,它有一套访问变量的规则,这套规则来管理浏览器引擎如何在当前作用域以及嵌套的作用域中根据变量(标识符)进行变量查找。最后,小生乃前端小白一枚,写文章的最初衷是为了让自己对该知识点有更深刻的印象和理解,写的东西也很小白,文中如有不对,欢迎指正~ 然后就是希望看完的朋友可以点个喜欢,如不嫌弃,也可以关注一波~ 我会持续输出!

个人博客链接

GitHub

CSDN个人主页

掘金个人主页

简书个人主页