作用域、闭包、JS高阶函数理解

970 阅读15分钟

理解闭包前置知识点点

喜欢的老铁点个关注,点个赞👍 感谢您的支持

前言

在JavaScript中的”神兽“,很多同学会觉得闭包这玩意太恶心了,怎么着都理解不了...其实刚接触JavaScript的时候我也是这样的。

But,闭包真的非常重要!非常重要!非常重要!好话说三遍,绝不会多说一遍。在《你不知道的JavaScript》中甚至这样写道”对于那些有一点JavaScript使用经验但从未真正理解闭包概念的人来说,理解闭包可以看作某种意义上的重生“。接下来,我会带着大家真正意义上的理解闭包。

But,真正理解闭包之前,有一个非常重要的知识点我们需要铺垫,那就是作用域和词法作用域

作用域

先抛出来一个概念:”词法作用域是作用域的一种工作模型“。这句话意味着,没有作用域何谈词法作用域,先谈作用域

什么是作用域

一句话概括,”作用域就是一套规则,用于确定在何处以及如何查找变量的规则。“

就先从查找变量开始吧

先看一段及其简答的代码有助于更好的理解

function foo(){
    var a = '小马哥';
    //输出a的原因是因为在当前foo函数中查找找到a这个变量
    console.log(a); //输出"小马哥"
}
foo();

再看一段代码

var b = '小马哥2';
function foo2(){
    //输出b的原因是因为在当前foo2函数中没有查找到变量b,此时就在外层的全局查找,找到了就停止查找并输出了,如果查找不到就会报错
    console.log(b); //输出"小马哥2"
}
foo2();

注意:以上两段代码都有查找变量,第一段代码是在函数作用域中找到a变量,第二段代码是在全局作用域中找到b变量。

所以,通俗的讲,作用域就是查找变量的地方。 我们在查找b变量的时候,先在函数作用域中查找,没有找到,再去全局作用域中查找,有一个往外层查找的过程。我们好像是顺着一条链条从下往上查找变量,这条链条,我们就称之为作用域链

作用域嵌套

在还没有接触到ES6的let、const之前,只有函数作用域和全局作用域,函数作用域肯定是在全局作用域里面的,而函数作用域又可以继续嵌套函数作用域,如图

用代码表示:

以上两张图可以很直观的看出作用域的嵌套关系了吧。查找变量也是顺着红色的箭头走的,从里到外,这从里到外的各层作用域就组成了作用域链。

作用域中变量的查找规则

首先声明一点,JavaScript是有编译过程的,不要惊讶,真的有!也就是说var a = '小马哥'这段代码,其实有两个动作:

  • 编译器在当前作用域中声明一个变量a
  • 运行时引擎在作用域中查找该变量,找到了a变量并为a赋值

证明:

console.log(a);//输出undefined
var a = '小马哥'

var a = '小马哥'的上一行输出name变量,并没有报错,输出undefined,说明输出的时候该变量已经存在了,只是没有赋值而已。

其实编译器是这样工作的,在代码执行之前从上到下的进行编译,当遇到某个用var声明的变量的时候,先检查在当前作用域下是否存在了该变量。如果存在,则忽略这个声明;如果不存在,则在当前作用域中声明该变量。

上面的这段简单的代码包含两种查找类型:输出变量值的时候的查找类型是RHS,找到变量为其赋值的查找类型是LHS。

我猜各位同学一定可以猜到“L”和“R”的含义,这里的左侧和右侧指的是在赋值操作的左侧和右侧。也就是说,变量出现在赋值操作的左侧时进行LHS查询,出现在右侧时进行RHS查询。

用一句通俗的话来讲,RHS就是取到它的源值。

在作用域中查找变量都是RHS,并且查找的规则是从当前作用域开始找,如果没找到再到父级作用域中找,一层层往外找,如果在全局作用域如果还没找到的话,就会报错了:ReferenceError: 某变量 is not defined

所有的赋值操作中查找变量都是LHS。其中a=小马哥这类赋值操作,也是会从当前作用域中查找,如果没有找到再到外层作用域中找,外层作用域没有找到去全局作用域中查找,直到找到为止

词法作用域

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

我们在前面有抛出一个概念:词法作用域是作用域的一种工作模型,作用域有两种工作模式,在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

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

词法作用域就是作用域,是由书写代码时函数声明的位置来决定的。编译阶段就能够知道全部变量在哪里以及是如何声明的。

所以词法作用域是静态的作用域,也就是词法作用域能够预测在执行代码的过程中如何查找标识符

闭包

前言

现在去面试前端开发的岗位,如果你面对的面试官也是个前端,并且不是太水的话,你有很大的概率被问到JavaScript的闭包。

什么是闭包

什么是闭包,百度、Google之后,你可能会搜索很多答案...

《JavaScript高级程序设计》这样描述

闭包是指有权访问另一个函数作用域中的变量的函数

《JavaScript权威指南》这样描述

从技术的角度讲,所有的JavaScript函数都是闭包:它们都是对象,它们都关联到作用域链

《你不知道的JavaScript》这样描述

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是当前词法作用域之外执行

最认可的当属《你不知道的JavaScript》,前面两种说话都没有错。

但闭包应该是基于词法作用域书写代码时产生的自然结果,是一种现象!你也不用为了利用闭包而特意的创建,因为闭包的在你的代码中随处可见,只是你还不知道当时你写的那一段代码其实就产生了闭包

讲解闭包

上面已经说到,当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行

看段代码

function fn1(){
    var name='小马哥'
    function fn2(){
        console.log(name);
    }
    fn2();
}
fn1();

如果是根据《JavaScript高级程序设计》和《JavaScript权威指南》来说,上面的代码已经产生闭包了。fn2访问到了fn1的变量,满足了条件“有权访问另一个函数作用域中的变量的函数”,fn2本身是个函数,所以满足了条件“所有的JavaScript函数都是闭包”。

这的确是闭包,但是这种方式定义的闭包不太好观察。

再看一段代码:

function fn1(){
    var name='小马哥'
    function fn2(){
        console.log(name);
    }
    return fn2;
}
var fn3 = fn1();
fn3();

这样就清晰地展示了闭包:

  • fn2的词法作用域能访问fn1的作用域
  • 将fn2当做一个值返回
  • fn1执行后,将fn2的引用赋值给fn3
  • 执行fn3,输出了变量name

我们知道通过引用关系,fn3就是fn2函数本身。执行fn3能正常输出name,这不就是fn2能记住并访问它所在的词法作用域,并且fn2函数的运行还是在当前词法作用域之外

正常来说,当fn1函数执行完毕之后,其作用域是会被销毁的,然后垃圾回收器会释放那段内存空间。而闭包却很神奇的将fn1的作用域存活了下来,fn2依然持有该作用域的引用,这个引用就是闭包

总结:某个函数在定义时的词法作用域之外的地方被调用,闭包可以使该函数极限访问定义时的词法作用域

注意:对函数值的传递可以通过其他的方式,并不一定只有返回该函数这一条路,比如可以用回调函数:

function fn1() {
	var name = '小马哥';
	function fn2() {
		console.log(name);
	}
	fn3(fn2);
}
function fn3(fn) {
	fn();
}
fn1();

本例中,将内部函数fn2传递给fn3,当它在fn3中被运行时,它是可以访问到name变量的。

所以无论通过哪种方式将内部的函数传递到所在的词法作用域以外,它都会持有对原始作用域的引用,无论在何处执行这个函数都会使用闭包。

再次解释闭包

以上的例子会让人觉得有点学院派了,但是闭包绝不仅仅是一个无用的概念,你写过的代码当中肯定有闭包的身影,比如类似如下的代码:

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

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

另一个很经典的例子就是for循环中使用定时器延迟打印的问题:

for (var i = 1; i <= 10; i++) {
	setTimeout(function () {
		console.log(i);
	}, 1000);
}

我们预期的结果为1~10,但却输出10此11。这是因为setTimeout中的匿名函数执行的时候,for循环都已经结束了,for循环结束的条件是i大于10,所以输出10此11。

原因:i是声明在全局作用域中的,定时器中的匿名函数也是执行在全局作用域中,那当时是每次都输出11

原因知道了,解决起来就简单了,我们可以让i在每次迭代的时候,都产生一个私有的作用域,在这个私有的作用域中保存当前i的值

for (var i = 1; i <= 10; i++) {
	(function () {
		var j = i;
		setTimeout(function () {
			console.log(j);
		}, 1000);
	})();
}

这样就达到我们的预期了呀,让我们用一种比较优雅的写法改造一些,将每次迭代的i作为实参传递给自执行函数,自执行函数中用变量去接收:

for (var i = 1; i <= 10; i++) {
	(function (j) {
		setTimeout(function () {
			console.log(j);
		}, 1000);
	})(i);
}

闭包的应用

  • setTimeout
//原生的setTimeout传递的第一个函数不能带参数
setTimeout(function(param){
    alert(param)
},1000)


//通过闭包可以实现传参效果
function func(param){
    return function(){
        alert(param)
    }
}
var f1 = func(1);
setTimeout(f1,1000);
  • 闭包的应用比较典型的是定义模块和封装变量,我们将操作函数暴露给外部,而细节隐藏在模块内容
function module(){
    var arr = []; //私有变量
    function add(val){
        if(typeof val ==='number'){
            arr.push(val);
        }
    }
    function get(index){
        if(index < arr.length){
            return arr[index];
        }else {
            return null
        }
    }
    return {
        add:add,
        get:get
    }
}

var mod1 = module();
mod1.add(1);
mod1.add(2);
mod1.add('xxx');
console.log(mod1.get(2));
  • 缓存
function getNewValue(key) {
    var obj = {
      name:'张三'
    }
    return obj[key]
  }
  var CacheCount = (function () {
    var cache = {};
    return {
      getCache: function (key) {
        if (key in cache) { // 如果结果在缓存中
          return cache[key]; // 直接返回缓存中的对象
        }
        var newValue = getNewValue(key); // 外部方法,获取缓存
        cache[key] = newValue; // 更新缓存
        return newValue;
      }

    };

  })();

  console.log(CacheCount.getCache("name"));
  console.log(CacheCount.getCache("name"));

JavaSript的高级函数

回调函数

function createDiv(cb){
    let oDiv = document.createElement('div');
    document.body.appendChild(oDiv);
    if(typeof cb === 'function'){
        cb(oDiv);
	}
}
createDiv(function(oDiv){
   oDiv.style.color = 'red'; 
});

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

这是体现出了抽象,既然不知道div接下来的操作,那么就直接给调用者,让调用者去实现

抽象就是隐藏更具体的实现细节,从更高的层次看待我们要解决的问题

数组中遍历

在编程的时候,并不是所有功能都是现成的,比如上面例子中,可以创建好几个div,对每个div的处理都可能不一样,需要对未知的操作做抽象,预留操作的入口,作为一名程序员,我们需要具备这种在恰当的时候将代码抽象的思想

接下来看一下ES5中提供的几个数组操作方法,可以更深入的理解抽象的思想,ES5之前遍历数组的方式是:

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等。思想都是一样的,通过这种抽象的方式可以让使用者更方便,同事又让代码变得更加清晰。

抽象是一种很重要的思想,让可以让代码变得更加优雅,并且操作起来更方便。在高阶函数中也是使用了抽象的思想,所以学习高阶函数得先了解抽象的思想

高阶函数

什么是高阶函数

至少满足以下条件中的一个,就是高阶函数

  • 将其他函数作为参数传递
  • 将函数作为返回值

简单来说,就是一个函数可以操作其他函数,将其他函数作为参数或将函数作为返回值。我相信,写过JS代码的同学对这个概念都是很容易理解的,因为在JS中函数就是一个普通的值,可以被传递,可以被返回。

参数可以被传递,可以被返回

函数作为参数传递

函数作为参数传递就是我们上面提到的回调函数,回调函数在异步请求中用的非常多,使用者想要在请求成功后利用请求回来的数据做一些操作,但是又不知道请求什么时候结束。

用jQuery来发一个Ajax请求

function getDetailData(sub_category_id, callback) {
$.ajax(`https://www.luffycity.com/api/v1/courses/?sub_category=${sub_category_id}&ordering=`, function (res) {
    if (typeof callback === 'function') {
      callback(res);
    }
  });
}
getDetailData('1', function (res) {
  // do some thing
});

类似Ajax这种操作非常适合用回调去做,当一个函数里不适合执行一些具体的操作,或者说不知道要怎么操作时,可以将相应的数据传递给另一个函数,让另一个函数来执行,而这个函数就是传递进来的回调函数。

另一个典型的例子就是数组排序

函数作为值返回

在判断数据类型的时候最常用的是typeof,但是typeof有一定的局限性,比如:

console.log(typeof []);//object
console.log(typeof {});//object

判断数组和对象都是输出object,如果想要更细致的判断应该要使用Object.prototype.toString

console.log(Object.prototype.toString.call([])); // 输出[object Array]
console.log(Object.prototype.toString.call({})); // 输出[object Object]

于是,我们可以写出判断对象、数组、数字的方法

function isObject(obj){
    return Object.prototype.toString.call(obj) === '[object Object]';
}
function isArray(arr) {
  return Object.prototype.toString.call(arr) === '[object Array]';
}
function isNumber(number) {
  return Object.prototype.toString.call(number) === '[object Number]';
}

我们发现这三个方法太像了,可以做一些抽取:

function isType(type) {
  return function (obj) {
    return Object.prototype.toString.call(obj) === '[object ' + type + ']';
  }
}
var isArray = isType('Array');
console.log(isArray([1,2]));

这个isType方法就是高阶函数,该函数返回了一个函数,并且利用闭包,将代码变得优雅。