从闭包和高阶函数初探JS设计模式

2,362 阅读11分钟

在前一篇《这些JS设计模式中的基础知识点你都会了吗?》中讲到了原型、原型链、this指向、call()、apply()、bind()以及JS中如何实现继承,前一篇是必备基础知识,这篇文章将从闭包和高阶函数中初探JavaScript模式。

JavaScript是一门完整的面向对象的编程语言,JavaScript在设计之初参考并引入了Lambda表达式、闭包和高阶函数等特性。

而在JavaScript中的一些设计模式都依赖闭包和高阶函数来实现,因此非常有必要掌握闭包和高阶函数的知识点。

一、闭包(Closure)

闭包的形成与变量的“作用域(scope)”和“生命周期(lifecycle)”相关,所以先对这两个概念有一个清晰的认识。

1.1 变量的作用域

变量的作用域:变量的有效范围。

如下示例:

var a = 0;
function func() {
    var a = 1;
    var b = 2;
    console.log(a, b); 
}
func();          // output: 1 2;
console.log(a);  // output: 0
console.log(b);  // output: Uncaught ReferenceError: b is not defined

可以看出在函数内部声明的变量是局部变量,只在函数体内部执行环境有效,在函数外部是无法访问到的,并且JS执行时候会抛出一个未定义的错误。

当在函数中声明一个变量时,没有带上关键词 var,这个变量就会变成全局变量,所以推荐大家编程时候规范编程(借助TypeScript+Eslint),变量的声明尽可能都用 constlet, 避免不必要的内存占用。

在JavaScript中,函数可以用来创建函数作用域,此时的函数体内部的执行环境可以访问函数外部的变量,而外部却无法访问函数体内部的变量。如果函数内部搜索某个变量时,如果该变量不存在,那么就会在由内到外的作用域链上寻找该变量是否在对应的作用域上有声明,有则返回该变量的值,否则会返回“Uncaught ReferenceError: variable is not defined

这里大家可以试试在“脑内运行”下,以加深对“变量作用域”的理解:

var a = 1;
var func = function() {
    var b = 2;
    var func2 = function() {
        var c = 3;
        console.log(a);
        console.log(b);
        console.log(c);
    }
    func2();
    console.log(c);
}
func();

最后的正确输出:

1
2
3
Uncaught ReferenceError: c is not defined

1.2 变量的生命周期

如果说变量的作用域是一个规则,那么变量的生命周期就规则的施行者。

变量的生命周期:简单理解为变量的有效时间,例如全局变量在程序执行的整个过程中都有效,函数中的局部变量在函数执行结束后被销毁。

function func() {
    var a = 1;      //函数执行完成后将自动销毁
    console.log(a)
}
func();

1.3 闭包改变局部变量的生命周期

首先看个示例:

function func() {
    var a = 0;      //函数执行完成后将自动销毁
    return function() {
        a = a + 1;
        console.log(a);
    }
}
var f = func();

f();    // output: 1
f();    // output: 2
f();    // output: 3
f();    // output: 4
console.log(a);  // output: Uncaught ReferenceError: a is not defined

函数执行后的输出结果看起来有些违背“变量的生命周期”规则,似乎局部变量a并未被销毁,并且在最后的 console.log(a) 代码执行时候报了变量 a 未定义。那么变量 a 存储在什么地方呐?

在执行 var f = func(); 的时候,f 返回了一个匿名函数的引用,它可以访问到 func() 被调用时产生的环境,而局部变量 a 一直在这个环境中。局部变量 a 还能被外界访问,所以就有了不被销毁的理由。在这里产生了一个闭包结构,局部变量的生命周期被延续了。

通过查看 f.prototype中的 scopes(作用域):

f.prototype

函数 f 的作用域有两个一个是全局的,另一个是 Closure,在 Closure 中可以看到此时的变量 a 的值是 4。也就是说,局部变量 a,实际上是被存储在一个闭包环境中。

1.4 闭包的更多作用

“闭包”可以改变局部变量的生命周期,并且不更改局部变量的作用范围,这一特性使得闭包的运用非常广泛。

1.4.1 缓存

例如我们要实现一个“乘积”函数,乘法需要较大的计算资源,如果每次传入参数都需要重新计算将是对计算资源的浪费,那么就想到了缓存结果。

如果用一个全局变量来存储结果,那么就有些“污染”全局变量,因为乘积仅用于在“乘积”函数内部,我们还是希望能够将变量降低耦合,所以可以借助闭包来实现。

const multiplication = (function() {
    const cache = {};
    return function() {
        const args = Array.prototype.join.call(arguments, ',');
        if (args in cache) {
            return cache[args];
        }
        let sum = 1;
        for (let i = 0; i < arguments.length; i++) {
            sum = sum * arguments[i];
        }
        return cache[args] = sum;
    }
})();

multiplication(1,2,3,4);

如此我们在计算相同乘法时候就可以直接通过缓存返回乘积结果,从而节省计算资源,提高程序性能和稳定性。

软件开发讲究一个“高内聚,低耦合”,有些通用方法函数可以独立出来,因此上面的代码还可以再优化。

const multiplication = (function() {
    const cache = {};

    const calculate = function() {
        let sum = 1;
        for (let i = 0; i < arguments.length; i++) {
            sum = sum * arguments[i];
        }
        return sum;
    }

    return function() {
        const args = Array.prototype.join.call(arguments, ',');
        if (args in cache) {
            return cache[args];
        }
        return cache[args] = calculate.apply(null, arguments);
    }
})();

multiplication(1,2,3,4);

1.4.2 面向对象编程:

/****************** 写法1 *******************/
var Person = function() {
    var age = 18;
    return {
        addAge: function() {
            age++;
            console.log('age:', age);
        }
    }
}
var person = Person();

person.addAge();    // output: age: 19
person.addAge();    // output: age: 20
person.addAge();    // output: age: 21


/****************** 写法2 *******************/
var person = {
    age: 18,
    addAge: function() {
        this.age = this.age + 1;
        console.log('age:', this.age);
    }
};
person.addAge();    // output: age: 19
person.addAge();    // output: age: 20
person.addAge();    // output: age: 21


/****************** 写法3 *******************/
var Person = function() {
    this.age = 18;
}
Person.prototype.addAge = function() {
    this.age++;
    console.log(this.age);
}
var person = new Person();

person.addAge();    // output: age: 19
person.addAge();    // output: age: 20
person.addAge();    // output: age: 21

闭包特性其实已经在面向对象的编程风格中得到了体现。

1.5 闭包与内存

在面试过程中经常被面试官问到:“说说你对闭包的认识?”

被面试者经常回答道闭包可能会因为没有被及时销毁导致内存泄漏,需要尽量减少闭包的使用,以及主动赋值null及时释放内存。

因为将局部变量放到全局变量其影响都是长期占用了内存没有释放,所以内存泄漏的真正原因并不是因为使用闭包。而内存泄漏的关键点在于使用了闭包容易形成“循环引用”,比如闭包的作用域链中保存着一些DOM节点,循环引用的两个对象都不会被基于“引用计数的垃圾回收机制”回收内存。所以其根本原因是对象的“循环引用”导致的内存泄漏。

二、高阶函数(HOF)

高阶函数(Higher-Order Function)是至少满足如下条件之一的函数:

  1. 函数可以作为参数被传递
  2. 函数可以作为返回值输出

在JavaScript中常见于回调函数则是作为了参数被传递,闭包则是返回了函数

2.1 简单示例

例如一个单例模式的例子,既将函数作为参数,也将函数作为返回值:

const getSingleBuider = function(fn) {
    let instance;
    return function() {
        return instance || (instance = fn.apply(this, arguments));
    }
}

2.2 高阶函数与AOP

AOP(面向切面编程)的主要作用是把一些跟核心业务逻辑无关的功能抽离出来,例如日志统计、异常处理、安全控制等。将这些功能抽离后,再通过“动态织入”的方式掺入业务逻辑模块中。能够保证业务逻辑模块的高内聚,以及抽离的功能能够很好的复用。

在JavaScript中实现AOP,一般是将一个函数“动态织入”另一个函数内,那么就可以通过咱在前一篇基础文章《这些JS设计模式中的基础知识点你都会了吗?》中讲到的原型链来实现。

来看一个简单的示例来更好理解高阶函数以及AOP:

Function.prototype.before = function(beforeFn) {
    var _self = this;                           // 存储原函数的引用
    // 返回原函数与新函数的“代理”函数
    return function() {
        beforeFn.apply(this, arguments);        // 执行新函数
        return _self.apply(this, arguments);    // 执行原函数返回执行结果
    }
}
Function.prototype.after = function(afterFn) {
    var _self = this;
    return function() {
        const result = _self.apply(this, arguments);
        afterFn.apply(this, arguments);
        return result;
    }
}

let func = function() {
    console.log('run');
}

func = func.before(function(){
    console.log('berfore run');
}).after(function() {
    console.log('after run');
});

func();

运行结果

这样我们就可以在完全不影响原函数原有逻辑的情况下给加入了新的中间件,类似于Koa的“洋葱模型”。

Koa洋葱模型

使用AOP来给函数动态添加职责(功能),这与设计模式之一的“装饰者模式”的思想一致。

2.3 柯里化(Curring)

柯里化又称“部分求值”,一个curring函数首先会接受一些参数,接受了这些参数后,该函数不会立即求值,而是继续返回另一个函数,刚才传入的参数在函数的闭包环境中存储起来,待到函数真正需要被求值的时候,之前传入的参数都会被一次性用于求值。

例如面试中会通过让大家实现一个求和函数,使用的方法如下:

sum(1)(2)(3); // output: 6

看到这个我们首先会想到用高阶函数不断返回函数,让参数在闭包中存起来,也就是上述的柯里化,我们的第一版代码可能长这样:

function sum(a) {
    return function(b) {
        return function(c) {
            return a + b + c;
        }
    }
}
sum(1)(2)(3);  // output: 6

第一版的代码看起来不太优雅?如果想要 sum(1)(2)...(1000) 咋办,不可能去写一千遍return函数吧,因此想到了递归。

递归的次数依赖于函数行参的长度,所以再来一个通用的curring,我们实际上递归的是“两数求和”这一行为,思考也就是可以将函数柯里化,那么就可以链式接受参数执行。

我们先针对两数求和来实现柯里化

// 柯里化函数第一版
function curry(fn) {
    // 将传入的函数fn从实参数组中移除
    const args = Array.prototype.slice.call(arguments, 1);
    // 返回函数用于接受下一个参数
    return function() {
        // 将返回函数需要接受的下一次入参保存到newArgs中,slice浅拷贝
        const newArgs = args.concat(Array.prototype.slice.call(arguments));
        // 将newArgs参数放到被柯里化函数中执行
        return fn.apply(this, newArgs);
    };
}

function add(a, b) {
    return a + b;
}

const addCurry = curry(add, 1, 2);
addCurry() // 3
// 或者
const addCurry = curry(add, 1);
addCurry(2) // 3
// 或者
const addCurry = curry(add);
addCurry(1, 2) // 3

第一版代码我们可以发现有一个确定就是没有实现我们想要的链式类似于sum(1)(2)(3)这样形式,其实现思路就是将返回的函数也柯里化。

已声明的函数,可以通过原型里length属性获取到函数行参的长度。

所以改造第二版:

const curry = function(fn) {
  return function inner() {
    // 浅拷贝入参
    const args = Array.prototype.slice.call(arguments);
    // 如果下一个参数的长度大于了函数的行参个数,则跳出递归
    if (arguments.length >= fn.length) {
      return fn.apply(undefined, args);
    } else {
      // 否则继续处理后续参数,返回curring函数
      return function() {
        // 获取合并上一次和下一次的入参
        const allArgs = args.concat(Array.prototype.slice.call(arguments));
        return inner.apply(undefined, allArgs);
      };
    }
  };
}

function sum(a, b, c) {
    return a + b + c;
}

const currySum = curry(sum);

柯里化

如果利用ES6,那么可以有更简洁的写法:

const curry = fn =>
  judge = (...args) =>
    args.length >= fn.length 
      ? fn(...args) 
      : arg => judge(...args, arg);

2.4 防抖(debounce)和节流(throttle)

一般我们都是将这两个概念放在一起来讲,两者都是防止用户频繁触发函数调用,只是两者的处理策略不同,笔者总结了一句帮助大家记忆区分的口诀: “防抖多次触发,最后一次生效;节流多次触发,周期性生效”。

对于防抖节流的示例分析这里便不展开了,相信大家也在学习或工作中都已经运用过,例如lodash中的debounce和throttle,或者单独的防抖或节流的三方库,对于这俩的认知都已经比较清晰。

推荐阅读:《debounce(防抖)和throttle(节流)

2.5 分时函数

分时函数是一个用于程序性能优化上的一个运用,最近在做程序性能优化的过程中接触到了,笔者觉得非常有必要一说。

一个常见的案例是大量DOM节点插入,那么就会导致页面初始化load的时候非常卡顿(假死现象)

一次性插入:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>分时函数</title>
</head>
<body>
    <div>分时函数性能优化验证</div>

    <script>
        // 一次性添加到页面
        const dataSource = new Array(10000).fill('DYBOY');
        // 创建DOM
        const createDiv = (text = 'DYBOY') => {
            const div = document.createElement('div');
            div.innerHTML = text;
            document.body.appendChild(div);
        }
        // 批量添加
        for (data of dataSource) {
            createDiv(data);
        }
    </script>
</body>
</html>

一次性插入的性能表现

分时函数的思想就是将一次性执行大量重复操作时,分批次时间周期的进行,这样就可以不阻塞页面首屏的渲染,避免出现假死现象。

改造后的代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>分时函数</title>
</head>
<body>
    <div>分时函数性能优化验证</div>

    <script>
        // 一次性添加到页面
        const dataSource = new Array(10000).fill('DYBOY');
        // 创建DOM
        const createDiv = (text = 'DYBOY') => {
            const div = document.createElement('div');
            div.innerHTML = text;
            document.body.appendChild(div);
        }
        // 批量添加
        // for (data of dataSource) {
        //     createDiv(data);
        // }

        /**
         * 分时函数
         * @param dataSource - 数据数组
         * @param fn - 分时执行的函数
         * @param count - 每分段时间内执行函数的次数
         * @param duration - 分段时长,单位ms
         **/
        const timeChunk = (dataSource, fn, count = 1, duration = 200) => {
            let timer;
            const start = () => {
                const minCount = Math.min(count, dataSource.length);
                for(let i = 0; i < minCount; i++) fn(dataSource.shift());
            }
            return () => {
                timer = setInterval(() => {
                    if (dataSource.length === 0) return clearInterval(timer);
                    start();
                }, duration);
            }
        }

       const newRender = timeChunk(dataSource, createDiv, 100, 300);

       newRender();
    </script>
</body>
</html>

image.png

通过对比可以看到后者经过分时函数的首屏里scripting的时间只有425ms,前者是2410ms,通过分时函数使得首屏性能提节省了500%的时间,非常可观。

除了分时函数,性能优化过程中,如果某个函数计算任务的时间非常长,那么就会导致“长时间页面白屏”的现象,这里我们可着手该长时间计算任务,看看该任务里有啥耗时的操作,看看针对耗时操作能不能做缓存,时间切片,以及宏任务微任务插队,在后续的文章中将整理并分享给大家。

2.6 惰性加载函数

后续将梳理专项的关于性能优化方法,这里仅仅提一下概念,惰性加载属于程序性能优化中的一种方法,其目的是使得函数的执行分支仅发生一次。

类似于我们将某个耗时操作的函数结果保存到一个变量中,而不是在每次for循环中都去重新执行函数拿到计算结果。

惰性加载函数的方式有两种:

  1. 在函数调用时处理:函数内部复写函数,直接返回值;
  2. 在函数声明时处理:函数声明时,确定返回值。

三、总结

这篇文章是承接前一篇《这些JS设计模式中的基础知识点你都会了吗?》内容,从Javascript中的this指向、原型、原型链、JS继承实现到闭包(Closure)和高阶函数(HOF),这些都是学习设计模式的必要基础,因为在JavaScript中的设计模式很多地方都需要依赖于闭包和高阶函数来实现,所以能够掌握并熟练运用闭包和高阶函数,有助于大家能够快速理解并在JS中实现程序设计。

对于设计模式和前端进阶的同学不妨关注微信公众号:DYBOY,添加笔者微信,交流学习,内推大厂!

Reference