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

人生不能像作菜,把全部的料都准备好了才下锅。前端

前言

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

一、 一个变量的诞生

var name = 'Jake Zhang'
复制代码

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

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

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

证实以上的说法:编程

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

复制代码

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

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

二、什么是做用域

先看这段代码:bash

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,这里还涉及到变量提高、暂时性死区等知识点(限于篇幅这里不作展开,以后会写相关文章)。

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

三、做用域链

咱们回到刚开始讲做用域的那段代码:

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

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

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

来两张图帮助理解:

代码表示:

四、从面试题解析做用域和做用域链

一、解密原理

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

第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
*/
复制代码

五、闭包

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

(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我的主页

掘金我的主页

简书我的主页

相关文章
相关标签/搜索