Featured image of post 深入理解函数式编程思想(二)

深入理解函数式编程思想(二)

闭包、装饰器与函数柯里化

闭包

走近闭包

在犀牛书上,作者对闭包的定义作出了详细的阐释:

和多数现代编程语言一样, JavaScript 使用词法作用域*(Lexical Scoping)*,这意味着函数执行时使用的是定义函数时生效的变量作用域,而不是调用函数时生效的变量作用域。为了实现词法作用域, JavaScript 函数对象的内部状态不仅要包括函数代码,还要包括对函数定义所在作用域的引用。

这种函数对象与作用域组合起来(即一组变量绑定)解析函数变量的机制,在计算机科学文献中被称作为闭包

严格来说, JavaScript 中的所有函数都是闭包,但由于多数函数调用与定义都在同一作用域内,所以闭包的存在无关紧要,但在定义函数和调用函数的作用域不同时,闭包就显得尤为重要了,许多编程高级技术建立在闭包的基础上。

让我们从一个具有现实意义的场景来切入闭包这个概念

现有一个小游戏,人物初始有三条命,人物死亡时减一条命,完成任务时加一条命

简单写个页面演示一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let currentLives = 3;

function increaseLive() {
  currentLives += 1;
  console.log("当前生命值:" + currentLives);
}

function decreaseLive() {
  currentLives -= 1;
  console.log("当前生命值:" + currentLives);
}

大部分同学应该都会这么写,不过这么写会有一个致命的问题:我们可以直接从控制台操作currentLives变量,因为它是直接暴露出来的全局变量!

那么,有没有一种办法,能够让我们既能操作变量,而又能让它不暴露出来呢?答案就是闭包

什么是闭包?

我在《深入理解函数式编程思想(一)》一文中提到过“函数是 JavaScript 中的一等公民”这个概念,闭包就是基于函数可以作为结果返回这一特性实现的。

在 JavaScript 中,由于函数块级作用域的变量会由内而外进行寻访,这就决定,在一个函数外部是无法访问到函数内部的变量的。而闭包就利用了函数的这一特点,实现了保护变量的效果,首先我们先认识一下闭包的基本形式:

在 Javascript 中,只有函数内部的子函数才能读取局部变量,所以闭包可以理解成“定义在一个函数内部的函数“,基于这个特点,闭包可以通过在函数外部调用,并访问到函数内部的局部变量。

因此我们说:闭包是将函数内部函数外部连接起来的桥梁

根据上面的定义,思考几个问题:

  1. 什么是定义在一个函数内部的函数
  2. 如何让这个内部的函数和外界相连接?

我们知道,一个函数想要对外界交互,可以通过return输出结果。

1
2
3
4
5
6
7
const Rectangle = () => {
  let x = 10;
  let y = 20;
  return { x, y };
};
let rec = Rectangle();
console.log(rec); // { x: 10, y: 20 }

在这个例子中,xy都是定义在Rectangle函数内部的变量,我们是无法在外部访问到这两个变量的,函数通过return将内部状态进行了输出,与外界产生了交互。那么接下来,我们如果return一个函数,会发生什么

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const Rectangle = () => {
  let x = 10;
  let y = 20;
  function calcArea() {
    return x * y;
  }
  return calcArea;
};
let rec = Rectangle();
console.log(rec); // [Function: calcArea]

注意这里我们返回的是calcArea而非calcArea(),前者是函数本身,而后者是该函数调用后的返回值。现在我们成功获取了定义在Rectangle内部的函数calcArea,接下来我们尝试运行它:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const Rectangle = () => {
  let x = 10;
  let y = 20;
  function calcArea() {
    return x * y;
  }
  return calcArea;
};
let rec = Rectangle();
const result = rec();
console.log(result); // 200

非常神奇的事情发生了,我们成功在函数外部调用了函数内部的方法,这里我们注意到一点,那就是调用这个方法的时候,我们调用了函数内部的变量,这在原本看来似乎是不可能的事情,但是它实实在在地发生了,也就是说,这个函数构建起了一座将函数内部函数外部连接起来的桥梁,换言之,是一个闭包

趁热打铁,我们用闭包的思维重新实现一下开头提到的那个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function changeLives() {
  // 这里将 currentLives 和相关方法都定义在了 changeLives 函数内
  let currentLives = 3;
  return {
    increaseLive() {
      currentLives += 1;
      console.log("当前生命值:" + currentLives);
    },
    decreaseLive() {
      currentLives -= 1;
      console.log("当前生命值:" + currentLives);
    },
  };
}
// 函数必须调用后才能获取返回值
const Lives = changeLives();
Live.increaseLive(); // 当前生命值:4

此时,我们把currentLives定义在了函数changeLives中,并把increaseLivedecreaseLive两个操作生命值的函数return出来,这样我们就可以通过这两个函数对changeLives内部的变量进行操作了,而从控制台也再也无法直接访问到currentLives这个变量了。这就仿佛一个包被拉上了拉链,只剩下两个拉环,你只能通过定义好的接口去操作其内部,这样就成功保护了其内部结构。

当然你也可以使用立即执行函数(IIFE)来生成闭包,因为我们想要获取的仅仅是外层函数的返回值而已,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 使用立即执行函数直接获取返回值
const Lives = (() => {
  let currentLives = 3;
  return {
    increaseLive() {
      currentLives += 1;
      console.log("当前生命值:" + currentLives);
    },
    decreaseLive() {
      currentLives -= 1;
      console.log("当前生命值:" + currentLives);
    },
  };
})();

同理我们也可以实现一个使用闭包的私有属性访问器方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function addPrivateProperty(target, key) {
  const _value = {};

  target[`get${key}`] = () => _value[key];

  target[`set${key}`] = value => {
    _value[key] = value;
  };
}

const obj = {};
addPrivateProperty(obj, "Name");
obj.setName("myName");

console.log(obj); // {}
console.log(obj.getName()); // "myName"

在这里,我们实现了让obj对象的属性可以通过访问器获取和设置,而无法直接通过obj获取。

闭包是怎么产生的?

按照 js 的运行机制,照理说,一个函数的生命周期从其被创建开始,等到其内部的代码全部运行完毕后就会销毁其内部的所有变量,那么我们为什么还可以访问到其内部的值?

这里又涉及到了一个指针指向垃圾回收GC)的问题。理论上,在一个函数的生命周期内,js 引擎会将其内部数据储存到内存中,当函数执行完毕之后,js 引擎会自动回收没用的局部变量,也就是函数执行完毕就会销毁其内部的所有变量。但注意我给“没用的”三个字加粗了,这实际上就是闭包实现的一个关键原理——让函数内部的变量继续保持使用态

回到我们之前的例子:

1
2
3
4
5
6
7
8
9
const Rectangle = () => {
  let x = 10;
  let y = 20;
  function calcArea() {
    return x * y;
  }
  return calcArea;
};
let rec = Rectangle();

在这里,我们调用Rectangle()时,实际上将其返回的calcArea函数对应的地址传给了全局的rec变量,这就导致了rec这个变量的地址始终指向函数内部的calcArea函数,这就相当于让calcArea函数始终处于被使用的状态而一直保存在内存中,相对的,整个Rectangle函数内的变量也都不会被回收,因为 js 引擎无法判断他们是否还会被使用。但实际上,浏览器会对 GC 做不同程度的优化,因此在实际环境可能会有些不同。

拓展学习:

JS 探索-GC 垃圾回收

V8 引擎垃圾回收与内存分配

谈谈 V8 引擎 GC 原理

实际开发中的优化

高阶函数

什么是高阶函数

所谓高阶函数,就是接收其他函数作为参数传入,或者把其他函数作为结果返回的函数。我们常用的mapforEachfilter等数组原型方法其实都是高阶函数,回忆一下它们的用法就可以看出,其实就是传入了我们自己定义的函数,对每一个数组元素都调用我们传入的函数,从而达到目的。

而正如函数可以操作传入参数那样,在支持函数式编程的语言中,我们可以通过高阶函数操作传入的函数,并且返回一个新的改变后的函数,从而拓展限制原有函数的功能。前端开发中常用到的防抖Debounce)和节流Throttle)函数就是基于高阶函数实现的。

防抖函数(Debounce)

防抖函数的一个基本功能是:如果短时间内同一事件大量触发,则只会在事件停止触发一段时间后,执行一次事件。

例如对输入框的文本进行监听,如果用户每输入一个字符就执行一次搜索或者从后端拉取数据,不仅浪费资源,并且并不符合人们的使用习惯。我们理想的逻辑是:等用户输入完整的一段话后,停止输入了,再去执行请求。这就是防抖函数的基本功能。

这里可以看出,我们只是给函数添加了一个新的功能,而其原本内部的逻辑并不会发生改变,因此,我们完全可以使用高阶函数来对其进行封装,其基本实现如下

1
2
3
4
5
6
7
const debounce = (fn, delay = 1000) => {
  let timer = null;
  return (...args) => {
    if (timer) clearInterval(timer);
    timer = setTimeout(fn, delay, ...args);
  };
};

这里使用到了上文所介绍的闭包概念,在函数外部定义一个计时器,在其内部对计时器进行判断,如果已有定义,则清除计时器,然后新建定时器,以起到“多次调用,执行一次”的目的。在使用时,将debounce包裹代监听的事件即可:

1
2
3
4
5
6
7
const testDebounce = () => {
  console.log("点击了");
};

const debounceClick = debounce(testDebounce, 1000);

debounceBtn.addEventListener("click", debounceClick);

节流函数(Throttle)

不难看出,我们如果使用防抖函数对某一个函数进行装饰,那么,如果这个函数一直在规定时间内不断触发,那么这个函数就完全不会执行。例如对滚动事件进行监听,如果用户一直滚动页面,那么事件就一直不会触发,而如果我们想要即使用户一直滚动,也能在规定的时间后定时触发时间,就像等技能 CD 一样,给予函数一个最短触发间隔,那么就需要用到节流函数了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const throttle = (fn, delay = 1000) => {
  let timer = null;
  return (...args) => {
    if (timer) return;
    fn(...args);
    timer = setTimeout(() => {
      timer = null;
    }, delay);
  };
};

首先判断有无正在进行的计时器,有的话就不执行回调,没有的话就执行回调,并新建定时器,在指定时间后清除定时器,从而实现效果

装饰器

装饰器通常使用在Class)中,用于装饰类的声明、方法、属性等

装饰器是一种特殊类型的声明,它能够被附加到类声明方法访问符属性参数上。 装饰器使用 @expression这种形式,expression求值后必须为一个函数,它会在运行时被调用,被装饰的声明信息做为参数传入。

上文的防抖和节流函数也可以使用装饰器模式实现。由于JavaScript的装饰器仍处于提案状态,需要结合babel转译才能使用,所以这里使用TypeScript实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function debounce(delay: number) {
  return (
    target: any,
    propertyName: string,
    descriptor: PropertyDescriptor
  ) => {
    let timer: any;
    const fn = descriptor.value;
    descriptor.value = (...args: any[]) => {
      if (timer) clearTimeout(timer);
      timer = setTimeout(fn, delay, ...args);
    };
  };
}

class Demo {
  @debounce(1000)
  public static Click() {
    console.log("clicked");
  }
}

const button = document.getElementById("btn");
button!.addEventListener("click", Demo.Click);

仅需一行@debounce(1000)就非常优雅地实现了对方法的装饰。

如果想在 ts 中使用装饰器,需要在tsconfig中把experimentalDecorators设置为true

除了装饰器之外,在函数式编程当中还有一种特殊的高阶函数编程技巧,我们称之为柯里化技术。

函数柯里化(Currying)

柯里化的概念

函数柯里化,指把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。这一点很好地符合了函数式编程只能有一个参数的要求

其基本结构如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function foo(fn) {
  return function (x) {
    return fn(x);
  };
}

function bar(y) {
  return y + 1;
}

foo(bar)(1); // 2

函数柯里化的作用

可以使用一个函数处理多种不同的情况

如以下函数:

1
2
3
4
5
6
function generateURL(protocol, hostname, path) {
  return protocol + "://" + hostname + "/" + path;
}

const Baidu = generateURL("https", "www.baidu.com", "tieba");
// https://www.baidu.com/tieba

该函数是用于生成网址的函数,接受protocolhostnamepath三个参数,那么如果我们需要生成很多不同协议,不同域名,不同路径的网址时,就需要维护很多变量和常量,而采用柯里化时,我们就可以实现按需生成的效果,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function generateURL(protocol) {
  return function (hostname) {
    return function (path) {
      return protocol + "://" + hostname + "/" + path;
    };
  };
}

const generateURLForHttps = generateURL("https");
const tieba = generateURLForHttps("www.baidu.com")("tieba");
// https://www.baidu.com/tieba
const generateURLForBaidu = generateURL("https")("www.baidu.com");
const yunpan = generateURLForBaidu("yunpan");
// https://www.baidu.com/yunpan
const ditu = generateURLForBaidu("ditu");
// https://www.baidu.com/ditu

通过这种方法我们就可以根据需求自定义生成器。

需要生成https协议的 URL 时,就可以使用

1
const generateURLForHttps = generateURL("https");

给协议为https的 URL 重新定义一个生成器。

需要生成https://www.baidu.com的 URL 时就可以使用

1
const generateURLForBaidu = generateURL("https")("www.baidu.com");

给域名为https://www.baidu.com的 URL 重新定义一个单独的生成器

拓展应用:判断浏览器支持的 API 后选择相应方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const whichEvent = (function () {
  if (window.addEventListener) {
    return function (element, type, listener, useCapture) {
      element.addEventListener(
        type,
        function (e) {
          listener.call(element.e); // 绑定this
        },
        useCapture
      );
    };
  } else if (window.attachEvent) {
    return function (element, type, handler) {
      element.attachEvent("on" + type, function () {
        handler.call(element.e); // 绑定this
      });
    };
  }
})(); // 立即执行函数

问:如何实现如下所示的函数?

1
2
3
4
5
add(1, 2)(3); // 6
add(1)(2)(3)(4); // 10
add(1, 2, 3, 4, 5)(1, 2, 3); // 21

// 要求实现:无限参数传入 + 无限调用

对于第一个问题,无限参数的实现比较简单,只需要使用...args接受参数即可

函数的参数默认会储存在函数上下文的arguments对象中

arguments是一个对应于传递给函数的参数的类数组对象

类数组对象并不是数组,而是一个形如以下结构的对象:

1
2
3
4
5
6
7
const args = {
  0: 'arg0'
  1: 'arg1',
  2: 'arg2',
  3: 'arg3',
  length: 4
}

这个对象很自然地可以通过下标访问,例如访问args[0]就可以得到"arg0",就好像数组一样,因此得名。类数组对象可以转化为数组:

1
2
3
// 两种方法
const args = Array.from(arguments);
const args = [...arguments];

ps. 直接使用...args即可直接接收参数数组

无限参数解决了,接下来实现无限调用,想要实现无限调用,要求函数的返回值也是函数,一个思路是,每次调用后,都把新传入的参数都添加到初始的参数数组中,最后统一计算:

1
2
3
4
5
6
7
const add = (...args) => {
  const _add = (...innerArgs) => {
    args.push(...innerArgs);
    return funcs;
  };
  return _add;
};

这样一来,每次传入的新参数(innerArgs)都会被添加到args数组中,那么我们只需要在某个时机对其进行计算即可。

1
args.reduce((a, b) => a + b); // 最终加和

这里有一个小问题,我们如果想在控制台打印函数调用结果,由于函数的返回值仍然是函数,所以我们打印出的一定是函数体,而不是最终求值。我们可以覆写函数原型上的toString方法,使其在被调用时执行求和,即可输出结果:

1
2
3
4
5
6
7
8
const add = (...args) => {
  const _add = (...innerArgs) => {
    args.push(...innerArgs);
    return funcs;
  };
  _add.toString = () => args.reduce((a, b) => a + b);
  return _add;
};

现在我们可以通过

1
add(1, 2, 3)(4, 5, 6)(7).toString(); // 28

来获取最终结果了。但事实上,只有函数在发生类型转换时才会调用这个方法,比如将其与某个值相比较:

1
2
console.log(add(1, 2, 3)(4, 5, 6)(7) == 28);
// true

或者使用Number进行强制类型转换:

1
2
console.log(Number(add(1, 2, 3)(4, 5, 6)(7)));
// 28

再或者直接使用alert打印:

1
2
alert(add(1, 2, 3)(4, 5, 6)(7));
// 28

同样也可以实现效果。

那么实际上还有一种思路,就是每次调用都计算一遍,并将求值结果储存在一个value变量中,最终只需要返回这个value变量即可:

1
2
3
4
5
6
7
8
const add = (...args) => {
  const _value = args.reduce((a, b) => a + b);
  const _add = (...args) => {
    return add(value, ...args);
  };
  _add.toString = () => _value;
  return _add;
};

殊途同归,都实现了最初的要求。

Built with Hugo
主题 StackJimmy 设计