新手秒懂 - 闭包 & 柯里化

3,538 阅读8分钟

前言

闭包一直是前端小白们头疼的东西,往往自己能写出来但又不能讲出个所以然,更不能深入体会到它的用处。而很多大佬的文章都喜欢用一堆专业的词表述,这就无故增加了很多新人的理解难度,而这篇我将用最朴实的话,写出自己对闭包的浅薄理解,同时也聊聊面试中出现率极高的高阶函数 -- 柯里化

闭包

概念理解

理解闭包之前我们先理解下自由变量 的意思:

指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。

而闭包的笼统理解就是:

指那些能够访问自由变量的函数。

弄个例子来,一看就懂

var a = 1;

function foo() {
    console.log(a);
}

foo();

有人一看就要喷我啦,这不就是最普通的函数嘛,跟闭包有个鸡毛关系.... 别急啊大哥,你看, foo 函数可以访问变量 a,但是 a 既不是 foo 函数的局部变量,也不是 foo 函数的参数,所以 a 就是自由变量

那函数 foo不就形成了一个闭包嘛?!

  • 从理论的角度讲,javascript 的所有函数都是闭包。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
  • 我们常说的闭包是从实践角度上来说的,它会满足如下条件
  1. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
  2. 在代码中引用了自由变量

分析

我们以一个被刷了无数遍的题目来看

var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}

data[0](); // ?
data[1](); // ?
data[2](); // ?

有点基础的小伙伴都知道,三个输出的答案都是 3

  • 为什么输出3 ? -> 因为在执行函数的时候 i 已经完成遍历了, data[i]执行后寻找i, 内部没有,向上寻找,这时i 是全局变量, 并且此时的值为3 (顺便一提,这就是没有块级作用域的时候所产生的一种问题, 详细请看俺的上一篇文章 新手秒懂 - 作用域 & 作用域链 )

而我们用闭包的方式来看

var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = (function (i) {
        return function(){ // 暂时取名 fn
            console.log(i);
        } // i是自由变量,所以这是一个闭包
  })(i);
}

data[0]();
data[1]();
data[2]();

此时我们在for循环中给data[i]赋予立即执行函数, 内部返回一个函数。

  1. data[0]执行,即fn执行,此时的全局变量 i依旧是 3(但跟fn中的i无关)
  2. fn内部没有i,向上查找, 找到在for内,匿名函数传进来的i0,它依旧存在在内存中。所以到此不会再向上去到全局作用域中查找。所以此时会打印 0

在 JS 中,闭包存在的意义就是让我们可以间接访问函数内部的变量。

柯里化

我们可以发现,柯里化现在在周围的面试中屡见不鲜,每个公司的大佬都会出有这知识点的问题,刚学习的小伙伴可能就会出现无所适从的情况,导致被一顿地鄙视。其实柯里化也是运用了闭包的特点。通过这篇文章,可以像大家简要地介绍柯里化,最起码能让你完美地应付一般的面试题目。

概念理解

在说柯里化之前,先说下一个概念 --- 高阶函数

高阶函数: 函数可以作为参数传递 && 函数可以作为返回值输出

柯里化就是高阶函数的一种应用实现

柯里化(Currying): 把接受多个参数的函数变换成接受一个单一参数(或部分)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。

说白了就是 fun(a, b, c...) -> curryFun(a)(b)(c)... 或者 curryFun(a,b)(c,d,e)...

案例解析

我是一看定义啥的就蒙圈,不来点形象的东西就转不过来,就以当年面试本菜鸟遇到的一道相对简单的面试题作为敲门砖打开新世界:

问: 编写一个add 函数, 使得 add(1, 2) 以及 add(1)(2) 都可以执行,并返回 3

// 单独实现第一种
function add (a, b) {
    return a + b;
} // ...我为啥要写一遍,这个貌似白痴都会


// 单独实现第二种, 这也是最最最简单的柯里化,但并不完整
function add (a) {
    return function(b) {
        return a + b
    }
} // 这里其实就是一个闭包,内部函数掉用了 b 自由变量, add(1)(2) --> 3


// 两种皆实现,就需要判断函数的参数值
function add (a, b) {
    return arguments.length === 1 ? (b) => a + b : a + b
} // add(1,2) | add(1)(2)  --> 3

虽然解答了这个题目,但并不是完整的柯里化,这样的东西是没有灵魂的!!

  1. add(1) 输出啥 ? --> (b) => a + b
  2. add(1)(2)(3) 输出啥 ? --> Uncaught TypeError: add(...)(...) is not a function
  3. add(1,2)(3,4)(5,6) 输出啥 ? --> Uncaught TypeError: add(...) is not a function

综上所属,这种写法只能支持一个参数并且只嵌套了一层,所以这样的并不完美地符合柯里化的定义。

我们来优化他,修复这三个问题,让它可以称作是完整的柯里化函数(这回我们使用柯里化最通用的写法)

function curry (fn, length) { //fn 表示要转换的函数, length 表示参数长度
  var len = length || fn.length
  
  function fun (...args) {
      return len <= args.length ? fn.apply(null, args) : fun.bind(null, ...args)
  }
  // 比较 定义的参数长度 与 传入的参数长度, 如果等于定义的长度,则执行 fn 方法,而如果不足则使用 bind 让参数合并, 返回新函数(Function.prototype.bind 方法其实也是柯里化应用) 
  return fun
}

// 计算总和
function sum (...args) {
    return args.reduce((prev, next) => prev += next ,0)
}

var add = curry(sum, 5)
add(1, 2)(3, 4)(5) // 15

上方写法相对通俗易懂一点,根据社区优秀库 30-seconds-of-code 中提供的柯里化写法可以改成

const curry = (fn, arity = fn.length, ...args) =>
  arity <= args.length ? fn(...args) : curry.bind(null, fn, arity, ...args);
  
function sum (...args) {
    return args.reduce((prev, next) => prev += next ,0)
}
const add = curry(sum, 5)
add(1, 2)(3, 4)(5) // 15

其实也啥区别,只不过这样写起来更有大佬范了。。

追求完美

第一次看这写法的时候就觉得要定义长度感觉好烦, 所以也 Google 了很多大佬的解决方案,真正完美地实现 add(1, 2)(3, 4).....

function add() {
  var args = [].slice.call(arguments) // 把类数组格式的 arguments 转换成数组
  var fun = function () {
    // 利用闭包特性合并 arguments 参数, 生成 newArgs
    var newArgs = args.concat([].slice.call(arguments))
    // 传入合并后的 newArgs, 再次掉用 add 函数(即再次返回 fun)
    return add.apply(null,newArgs)
  }
  // 利用函数 会自动执行 toString 方法的特性,进行求和操作
  fun.toString = function () {
     return args.reduce(function(a, b) {
        return a + b;
    })
  }
  return fun
}
add(1, 2, 3)(5) // 15

实战作用

到这小伙伴应该都知道柯里化到底是个啥,也知道改怎么写了,但问题又来了,这货到底在开发中有啥子用处呢?感觉根本就是个面试专考,工作绝缘的东西啊.

其实,柯里化有3个常见作用:

  1. 参数复用 - 对部分参数的复用,无需重复添加
  2. 提前返回 - 提前可以返回存在返回值并且可以继续接收参数的函数
  3. 延迟计算/运行 -不断的柯里化,累积传入的参数,最后执行

下面一个例子,某官员总是换着娶小老婆,但不管换多少小老婆,合法老婆都必须存在,通过柯里化方法,getWife方法就无需添加多余的合法老婆,实现了参数的复用...

function curry(fn) {
    var args = [].slice.call(arguments , 1) // fn 指官员消化老婆的手段, args 指的是那个合法老婆
    return function (...rest) {
        // 已经有的老婆和新搞定的老婆们合成一体,方便控制
        var newArgs = args.concat(...rest);
        // 这些老婆们用 fn 这个手段消化利用,完成韦小宝前辈的壮举并返回
        return fn.apply(null, newArgs)
    }
}
var getWife = curry(function() {
    console.log ([... arguments].join(';'))
}, '合法老婆')

getWife('老婆1','老婆2','老婆3') // 合法老婆;老婆1;老婆2;老婆3
getWife('超越韦小宝的老婆') // 合法老婆;超越韦小宝的老婆
getWife('超级老婆') // 合法老婆;超级老婆

再比如, 马云需要记录每天的收入, 在未来随便某一天都会去看自己的财产总额:

var income = (function () {
    var total = []
    return function (money) {
        return money ? total.push(money) : total.reduce((prev, next) => prev += next, 0)
    }
    // money 是每次传进来的参数
    // 使用立即执行函数形成闭包,用 total 这个自由变量存储所有传进来的值(total一直存在于内存中)
    // money 没有值的时候进行求和, 算出总共的资产
})()

// income(10) -> 第一天赚了 10 亿
// income(20) -> 第二天赚了 20 亿
// income(30)-> 第三天赚了 30 亿
// income() -> 求和,三天一共赚了 60 亿
// 延迟执行 --> 不断的柯里化,累积传入的参数,最后执行

结尾

整个过程用了好几种不同的写法,为的也是让小伙伴更能清楚地理解柯里化,万变不离其宗,结合闭包的特性来看,就可以看得稍微轻松一点,最关键的其实就是参数的合并。希望对一直不怎么了解柯里化的小伙伴有一点点小小的帮助,那我也算没白忙活一把。努力,奋斗。💪💪