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

在前一篇《这些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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容