JavaScript高阶函数
Posted onebox
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JavaScript高阶函数相关的知识,希望对你有一定的参考价值。
高阶函数是指至少满足下列条件之一的函数:
1、函数可以作为参数被传递;
2、函数可以作为返回值输出。
一、函数作为参数传递
把函数当作参数传递,这代表我们可以抽离出一部分容易变化的业务逻辑,把这部分业务逻辑放在函数参数中,这样一来可以分离业务代码中变化与不变的部分。其中一个重要应用场景就是常见的回调函数。
1. 回调函数
在 ajax 异步请求的应用中,回调函数的使用非常频繁。当我们想在 ajax 请求返回之后做一些事情,但又并不知道请求返回的确切时间时,最常见的方案就是把 callback 函数当作参数传入发起 ajax 请求的方法中,待请求完成之后执行 callback 函数:
var fetchData = function(param, callback) { $.ajax(‘/api/fetchDataApi‘ + param, data => { if (typeof callback === ‘function‘) { callback(data) } }) } fetchData(13157, function(data) { console.log(data) })
回调函数的应用不仅只在异步请求中,当一个函数不适合执行一些请求时,我们也可以把这些请求封装成一个函数,并把它作为参数传递给另外一个函数,“委托”给另外一个函数来执行。
比如,我们想在页面中创建 100 个 div 节点,然后把这些 div 节点都设置为隐藏。下面是一种编写代码的方式:
var appendDiv = function () { for (var i = 0; i < 100; i++) { var div = document.createElement(‘div‘); div.innerhtml = i; document.body.appendChild(div); div.style.display = ‘none‘; } }; appendDiv();
把 div.style.display = ‘none‘ 的逻辑硬编码在 appendDiv 里显然是不合理的,appendDiv 未免有点个性化,成为了一个难以复用的函数,并不是每个人创建了节点之后就希望它们立刻被隐藏。于是我们把 div.style.display = ‘none‘ 这行代码抽出来,用回调函数的形式传入 appendDiv 方法:
var appendDiv = function (callback) { for (var i = 0; i < 100; i++) { var div = document.createElement(‘div‘); div.innerHTML = i; document.body.appendChild(div); if (typeof callback === ‘function‘) { callback(div); } } }; appendDiv(function (node) { node.style.display = ‘none‘; });
可以看到,隐藏节点的请求实际上是由客户端发起的,但是客户端并不知道节点什么时候会创建好,于是把隐藏节点的逻辑放在回调函数中,“委托”给 appendDiv 方法。appendDiv 方法当然知道节点什么时候创建好,所以在节点创建好的时候,appendDiv 会执行之前客户传入的回调函数。
2. Array.prototype.reduce
Array.prototype.reduce 接受一个函数当作参数,这个函数对数组中的每个元素执行一个由你提供的 reducer 函数(升序执行),将其结果汇总为单个返回值。
reduce方法接收两个参数即, reduce(callback,initialValue):
(1)callback,这是一个回调函数,被称之为 reducer,该函数接收4个参数:
accumulator(累计器):累计器累计回调的返回值; 它是上一次调用回调时返回的累积值,或 initialValue;
currentValue(当前值):数组中正在处理的元素;
currentIndex(当前索引,可选):数组中正在处理的当前元素的索引。如果提供了 initialValue,则起始索引号为 0,否则从索引1起始;
array(源数组,可选):调用 reduce() 的数组。
const array = [0, 1, 2, 3, 4]; const reducer = (accumulator, currentValue) => accumulator + currentValue; // 0 + 1 + 2 + 3 + 4 console.log(array.reduce(reducer)); // 输出:10
(2)initialValue,作为第一次调用 callback函数时的第一个参数的值。如果没有提供初始值,则将使用数组中的第一个元素。 在没有初始值的空数组上调用 reduce 将报错。
// 5 + 0 + 1 + 2 + 3 + 4 console.log(array.reduce(reducer, 5)); // 输出:15
(3)通过reducer来实现子级元素找到所对应父级节点
const data = [{ title: ‘1‘, }, { title: ‘2‘, children: [{ title: ‘2-1‘ }, { title: ‘2-2‘, children: [{ title: ‘2-2-1‘, }] } ] } ]; const ParentByChild = (data = [], title) => { const get = (data, value, record = []) => ( data.reduce( ( result, { title, children } ) => { if (title === value) { return [...record, value] } if (children) { return [ ...result, ...get(children, value, [...record, title]) ] } return result }, [] ); ); return get(data, title); }; console.log(ParentByChild(data, ‘2-2-1‘)); // 输出:["2", "2-2", "2-2-1"]
上述代码当然也有不完整的地方,如涉及到递归计算,如果数据量大,在相同数据和参数的情况下必然会引起重复元素,解决这个问题你可以封闭在闭包里面,延长返回数据的生命周期则可以解决这个问题。
二、函数作为返回值输出
相比把函数当作参数传递,函数当作返回值输出的应用场景也许更多,也更能体现函数式编程的巧妙。让函数继续返回一个可执行的函数,意味着运算过程是可延续的。
1. 判断数据的类型
我们来看看这个例子,判断一个数据是否是数组,在以往的实现中,可以基于鸭子类型的概念来判断,比如判断这个数据有没有 length 属性,有没有 sort 方法或者 slice 方法等。但更好的方式是用 Object.prototype.toString 来计算。Object.prototype.toString.call( obj )返回一个字符串,比如 Object.prototype.toString.call( [1,2,3] ) 总是返回 "[object Array]" , 而Object.prototype.toString.call( “str”)总是返回"[object String]"。所以我们可以编写一系列的isType 函数。代码如下:
var isString = function (obj) { return Object.prototype.toString.call(obj) === ‘[object String]‘; }; var isArray = function (obj) { return Object.prototype.toString.call(obj) === ‘[object Array]‘; }; var isNumber = function (obj) { return Object.prototype.toString.call(obj) === ‘[object Number]‘; };
我们发现,这些函数的大部分实现都是相同的,不同的只是 Object.prototype.toString.call( obj ) 返回的字符串。为了避免多余的代码,我们尝试把这些字符串作为参数提前值入 isType 函数。代码如下:
var isType = function (type) { return function (obj) { return Object.prototype.toString.call(obj) === ‘[object ‘ + type + ‘]‘; } }; var isString = isType(‘String‘); var isArray = isType(‘Array‘); var isNumber = isType(‘Number‘); console.log(isArray([1, 2, 3])); // 输出:true
我们还可以用循环语句,来批量注册这些 isType 函数:
var Type = {}; for (var i = 0, type; type = [‘String‘, ‘Array‘, ‘Number‘][i++];) { (function (type) { Type[‘is‘ + type] = function (obj) { return Object.prototype.toString.call(obj) === ‘[object ‘ + type + ‘]‘; } })(type) }; Type.isArray([]); // 输出:true Type.isString("str"); // 输出:true
2.getSingle
下面是一个单例模式的例子:
var getSingle = function (fn) { var ret; return function () { return ret || (ret = fn.apply(this, arguments)); }; };
这个高阶函数的例子,既把函数当作参数传递,又让函数执行后返回了另外一个函数。我们可以看看 getSingle 函数的效果:
var getScript = getSingle(function () { return document.createElement(‘script‘); }); var script1 = getScript(); var script2 = getScript(); alert(script1 === script2); // 输出:true
三、高阶函数实现AOP
AOP(面向切面编程)的主要作用是把一些跟核心业务逻辑模块无关的功能抽离出来,这些跟业务逻辑无关的功能通常包括日志统计、安全控制、异常处理等。把这些功能抽离出来之后,再通过“动态织入”的方式掺入业务逻辑模块中。这样做的好处首先是可以保持业务逻辑模块的纯净和高内聚性,其次是可以很方便地复用日志统计等功能模块。
通常,在 javascript 中实现 AOP,都是指把一个函数“动态织入”到另外一个函数之中,具体的实现技术有很多,本节我们通过扩展 Function.prototype 来做到这一点。代码如下:
Function.prototype.before = function (beforefn) { var __self = this; // 保存原函数的引用 return function () { // 返回包含了原函数和新函数的"代理"函数 beforefn.apply(this, arguments); // 执行新函数,修正 this return __self.apply(this, arguments); // 执行原函数 } }; Function.prototype.after = function (afterfn) { var __self = this; return function () { var ret = __self.apply(this, arguments); afterfn.apply(this, arguments); return ret; } }; var func = function () { console.log(2); }; func = func.before(function () { console.log(1); }).after(function () { console.log(3); }); func(); // 分别输出1、2、3
我们把负责打印数字 1 和打印数字 3 的两个函数通过 AOP 的方式动态植入 func 函数。通过执行上面的代码,我们看到控制台顺利地返回了执行结果 1、2、3。这种使用 AOP 的方式来给函数添加职责,也是 JavaScript 语言中一种非常特别和巧妙的装饰者模式实现,这儿只是简要说明了一下,等待后续更新。
四、高阶函数的其他应用
1. currying
首先我们讨论的是函数柯里化(function currying)。currying 又称部分求值。一个 currying 的函数首先会接受一些参数,接受了这些参数之后,该函数并不会立即求值,而是继续返回另外一个函数,刚才传入的这些参数在函数形成的闭包中被保存起来。待到函数被真正需要求值的时候,之前传入的所有参数都会被一次性用于求值。从字面上理解 currying 并不太容易,我们来看下面的例子。
假设我们要编写一个计算每月开销的函数。在每天结束之前,我们都要记录今天花掉了多少钱。代码如下:
var monthlyCost = 0; var cost = function (money) { monthlyCost += money; }; cost(100); // 第 1 天开销 cost(200); // 第 2 天开销 cost(300); // 第 3 天开销 console.log(monthlyCost); // 输出:600
通过这段代码可以看到,每天结束后我们都会记录并计算到今天为止花掉的钱。但我们其实并不太关心每天花掉了多少钱,而只想知道到月底的时候会花掉多少钱。也就是说,实际上只需要在月底计算一次。如果在每个月的前 29 天,我们都只是保存好当天的开销,直到第 30 天才进行求值计算,这样就达到了我们的要求。虽然下面的 cost 函数还不是一个 currying 函数的完整实现,但有助于我们了解其思想:
var cost = (function () { var args = []; return function () { if (arguments.length === 0) { var money = 0; for (var i = 0, l = args.length; i < l; i++) { money += args[i]; } return money; } else { [].push.apply(args, arguments); } } })(); cost(100); // 未真正求值 cost(200); // 未真正求值 cost(300); // 未真正求值 console.log(cost()); // 求值并输出:600
接下来我们编写一个通用的 currying,currying 接受一个参数,即将要被 currying 的函数。在这个例子里,这个函数的作用遍历本月每天的开销并求出它们的总和。代码如下:
var currying = function (fn) { var args = []; return function () { if (arguments.length === 0) { return fn.apply(this, args); } else { [].push.apply(args, arguments); // 返回当前正在执行的函数 return arguments.callee; } } }; var costCurrent = (function () { var money = 0; return function () { for (var i = 0, l = arguments.length; i < l; i++) { money += arguments[i]; } return money; } })(); var cost = currying(costCurrent); // 转化成 currying 函数 cost(100); // 未真正求值 cost(200); // 未真正求值 cost(300); // 未真正求值 alert(cost()); // 求值并输出:600
至此,我们完成了一个 currying 函数的编写。当调用 cost()时,如果明确地带上了一些参数,表示此时并不进行真正的求值计算,而是把这些参数保存起来,此时让 cost 函数返回另外一个函数。只有当我们以不带参数的形式执行 cost()时,才利用前面保存的所有参数,真正开始进行求值计算。
2. uncurrying
在 JavaScript 中,当我们调用对象的某个方法时,其实不用去关心该对象原本是否被设计为拥有这个方法,这是动态类型语言的特点,也是常说的鸭子类型思想。同理,一个对象也未必只能使用它自身的方法,那么有什么办法可以让对象去借用一个原本不属于它的方法呢?
答案对于我们来说很简单,call 和 apply 都可以完成这个需求:
var obj1 = { name: ‘sven‘ }; var obj2 = { getName: function () { return this.name; } }; console.log(obj2.getName.call(obj1)); // 输出:sven
我们常常让类数组对象去借用 Array.prototype 的方法,这是 call 和 apply 最常见的应用场景之一:
(function () { Array.prototype.push.call(arguments, 4); // arguments 借用 Array.prototype.push 方法 console.log(arguments); // 输出:[1, 2, 3, 4] })(1, 2, 3);
在我们的预期中,Array.prototype 上的方法原本只能用来操作 array 对象。但用 call 和 apply 可以把任意对象当作 this 传入某个方法,这样一来,方法中用到 this 的地方就不再局限于原来规定的对象,而是加以泛化并得到更广的适用性。
那么有没有办法把泛化 this 的过程提取出来呢?本小节讲述的 uncurrying 就是用来解决这个问题的。uncurrying 的话题来自 JavaScript 之父 Brendan Eich 在 2011 年发表的一篇 Twitter。以下代码是 uncurrying 的实现方式之一:
Function.prototype.uncurrying = function () { var self = this; return function () { var obj = Array.prototype.shift.call(arguments); return self.apply(obj, arguments); }; };
在讲解这段代码的实现原理之前,我们先来瞧瞧它有什么作用。
在类数组对象 arguments 借用 Array.prototype 的方法之前,先把 Array.prototype.push.call 这句代码转换为一个通用的 push 函数:
var push = Array.prototype.push.uncurrying(); (function () { push(arguments, 4); console.log(arguments); // 输出:[1, 2, 3, 4] })(1, 2, 3);
通过 uncurrying 的方式,Array.prototype.push.call 变成了一个通用的 push 函数。这样一来,push 函数的作用就跟 Array.prototype.push 一样了,同样不仅仅局限于只能操作 array 对象。而对于使用者而言,调用 push 函数的方式也显得更加简洁和意图明了。
我们还可以一次性地把 Array.prototype 上的方法“复制”到 array 对象上,同样这些方法可操作的对象也不仅仅只是 array 对象:
for (var i = 0, fn, ary = [‘push‘, ‘shift‘, ‘forEach‘]; fn = ary[i++];) { Array[fn] = Array.prototype[fn].uncurrying(); }; var obj = { "length": 3, "0": 1, "1": 2, "2": 3 }; Array.push(obj, 4); // 向对象中添加一个元素 console.log(obj.length); // 输出:4 var first = Array.shift(obj); // 截取第一个元素 console.log(first); // 输出:1 console.log(obj); // 输出:{0: 2, 1: 3, 2: 4, length: 3} Array.forEach(obj, function (i, n) { console.log(n); // 分别输出:0, 1, 2 });
目前我们已经给出了 Function.prototype.uncurrying 的一种实现。现在来分析调用 Array.prototype.push.uncurrying() 这句代码时发生了什么事情:
Function.prototype.uncurrying = function () { var self = this; // self 此时是 Array.prototype.push return function () { var obj = Array.prototype.shift.call(arguments); // obj 是{ // "length": 1, // "0": 1 // } // arguments 对象的第一个元素被截去,剩下[2] return self.apply(obj, arguments); // 相当于 Array.prototype.push.apply( obj, 2 ) }; }; var push = Array.prototype.push.uncurrying(); var obj = { "length": 1, "0": 1 }; push(obj, 2); console.log(obj); // 输出:{0: 1, 1: 2, length: 2}
除了刚刚提供的代码实现,下面的代码是 uncurrying 的另外一种实现方式:
Function.prototype.uncurrying = function () { var self = this; return function () { return Function.prototype.call.apply(self, arguments); } };
3.节流函数
JavaScript 中的函数大多数情况下都是由用户主动调用触发的,除非是函数本身的实现不合理,否则我们一般不会遇到跟性能相关的问题。但在一些少数情况下,函数的触发不是由用户直接控制的。在这些场景下,函数有可能被非常频繁地调用,而造成大的性能问题。下面将列举一些这样的场景。
(1) 函数被频繁调用的场景
window.onresize 事件,我们给 window 对象绑定了 resize 事件,当浏览器窗口大小被拖动而改变的时候,这个事件触发的频率非常之高。如果我们在 window.onresize 事件函数里有一些跟 DOM 节点相关的操作,而跟 DOM 节点相关的操作往往是非常消耗性能的,这时候浏览器可能就会吃不消而造成卡顿现象。
mousemove 事件,如果我们给一个 div 节点绑定了拖曳事件(主要是 mousemove),当 div 节点被拖动的时候,也会频繁地触发该拖曳事件函数。
(2) 函数节流的原理
我们整理上面提到的场景,发现它们面临的共同问题是函数被触发的频率太高。比如我们在 window.onresize 事件中要打印当前的浏览器窗口大小,在我们通过拖曳来改变窗口大小的时候,打印窗口大小的工作 1 秒钟进行了 10 次。而我们实际上只需要 2 次或者 3 次。这就需要我们按时间段来忽略掉一些事件请求,比如确保在 500ms 内只打印一次。很显然,我们可以借助 setTimeout 来完成这件事情。
(3) 函数节流的代码实现
关于函数节流的代码实现有许多种,下面的 throttle 函数的原理是,将即将被执行的函数用 setTimeout 延迟一段时间执行。如果该次延迟执行还没有完成,则忽略接下来调用该函数的请求。throttle 函数接受 2 个参数,第一个参数为需要被延迟执行的函数,第二个参数为延迟执行的时间。具体实现代码如下:
var throttle = function (fn, interval) { var __self = fn, // 保存需要被延迟执行的函数引用 timer, // 定时器 firstTime = true; // 是否是第一次调用 return function () { var args = arguments, __me = this; if (firstTime) { // 如果是第一次调用,不需延迟执行 __self.apply(__me, args); return firstTime = false; } if (timer) { // 如果定时器还在,说明前一次延迟执行还没有完成 return false; } timer = setTimeout(function () { // 延迟一段时间执行 clearTimeout(timer); timer = null; __self.apply(__me, args); }, interval || 500); }; }; window.onresize = throttle(function () { console.log(‘加载了‘); }, 500);
4. 防抖函数
假如我们现在在打游戏,我们敲打键盘的频率是非常高,总是希望能快速的应用于某个操作上面,而且键盘在按下时并一定就接触的很好。所以我们希望开关只捕获到那次最后的精准的状态切换。
在 Javascript 中,那些 DOM 频繁触发的事件,我们想在某个时间点上去执行我们的回调,而不是每次事件每次触发,我们就执行该回调。我们希望多次触发的相同事件的触发合并为一次触发。
我们大概知道了防抖函数的一个应用场景,在实现原理上就是每次触发事件后将之前的动作给清除掉,在连续的时间内不再频繁触发事件后才真正执行这个回调函数。当然防抖函数的实现方式有很多种,我这里说明一下 debounce 的实现原理,代码如下:
var debounce = function (fn, delay) { var __self = fn, // 保存需要被延迟执行的函数引用fn timer; // 定时器 return function () { // __me保存函数调用时的上下文和参数,传递给 fn var __me = this, args = arguments; // 每次这个返回的函数被调用,就清除定时器,以保证不执行 fn timer && clearTimeout(timer); // 当返回的函数被最后一次调用后(也就是用户停止了某个连续的操作), // 再过 delay 毫秒就执行 fn timer = setTimeout(function () { __self.apply(__me, args); }, delay || 500); } } window.onresize = debounce(function () { console.log(‘加载了‘); }, 500);
防抖函数应用场景还有,在一个表单的输入框中(包括多行文本输入框),想当用户停止输入后,再发送 http 请求。与节流函数类似,应用于业务函数有可能被非常频繁地调用,而造成大的性能问题。
节流函数,是在规定时间(阀值)内将所触发的事件合并成一次执行,并且在达到规定时间(阀值)必定会执行一次,即拥有固定的函数执行效率,从而降低频繁事件的函数执行次数。
防抖函数,是将频繁触发的事件合并成一次执行,即在某段连续的事件内,在事件触发后到达规定时间(阀值)只执行一次。
5. 分时函数
下面我们将遇到另外一个问题,某些函数确实是用户主动调用的,但因为一些客观的原因,这些函数会严重地影响页面性能。
现在有一个业务需求就是我们要在地图上面创建成千上万个预警识别点。如果一个预警识别点用一个节点来表示,当我们在页面中渲染这个地图的时候,可能要一次性往页面中创建成百上千个节点。在短时间内往页面中大量添加 DOM 节点显然也会让浏览器吃不消,我们看到的结果往往就是浏览器的卡顿甚至假死。
这个问题的解决方案之一是下面的 timeChunk 函数,timeChunk 函数让创建节点的工作分批进行,比如把 1 秒钟创建 1000 个节点,改为每隔 200 毫秒创建 8 个节点。
timeChunk 函数接受 3 个参数,第 1 个参数是创建节点时需要用到的数据,第 2 个参数是封装了创建节点逻辑的函数,第 3 个参数表示每一批创建的节点数量。代码如下:
var timeChunk = function (ary, fn, count) { var obj, t, len = ary.length; var start = function () { for (var i = 0; i < Math.min(count || 1, len); i++) { obj = ary.shift(); fn(obj); } }; return function () { t = setInterval(function () { if (len === 0) { // 如果全部节点都已经被创建好 return clearInterval(t); } start(); }, 200); // 分批执行的时间间隔,也可以用参数的形式传入 }; };
最后我们进行一些小测试,假设我们有 1000 个预警识别点的数据,我们利用 timeChunk 函数,每一批只往页面中创建 8 个节点:
var ary = []; for (var i = 1; i <= 1000; i++) { ary.push(i); }; var renderNode = timeChunk(ary, function (n) { var div = document.createElement(‘div‘); div.innerHTML = n; document.body.appendChild(div); }, 8);
renderNode();
6. 惰性加载函数
在 Web 开发中,因为浏览器之间的实现差异,一些嗅探工作总是不可避免。比如我们需要一个在各个浏览器中能够通用的事件绑定函数 addEvent,常见的写法如下:
var addEvent = function (elem, type, handler) { if (window.addEventListener) { return elem.addEventListener(type, handler, false); } if (window.attachEvent) { return elem.attachEvent(‘on‘ + type, handler); } };
这个函数的缺点是,当它每次被调用的时候都会执行里面的 if 条件分支,虽然执行这些 if 分支的开销不算大,但也许有一些方法可以让程序避免这些重复的执行过程。
第二种方案是这样,我们把嗅探浏览器的操作提前到代码加载的时候,在代码加载的时候就立刻进行一次判断,以便让 addEvent 返回一个包裹了正确逻辑的函数。代码如下:
var addEvent = (function () { if (window.addEventListener) { return function (elem, type, handler) { elem.addEventListener(type, handler, false); } } if (window.attachEvent) { return function (elem, type, handler) { elem.attachEvent(‘on‘ + type, handler); } } })();
目前的 addEvent 函数依然有个缺点,也许我们从头到尾都没有使用过 addEvent 函数,这样看来,前一次的浏览器嗅探就是完全多余的操作,而且这也会稍稍延长页面 ready 的时间。
第三种方案即是我们将要讨论的惰性载入函数方案。此时 addEvent 依然被声明为一个普通函数,在函数里依然有一些分支判断。但是在第一次进入条件分支之后,在函数内部会重写这个函数,重写之后的函数就是我们期望的 addEvent 函数,在下一次进入 addEvent 函数的时候,addEvent 函数里不再存在条件分支语句:
<div id="div">点我试试</div> var addEvent = function (elem, type, handler) { if (window.addEventListener) { addEvent = function (elem, type, handler) { elem.addEventListener(type, handler, false); } } else if (window.attachEvent) { addEvent = function (elem, type, handler) { elem.attachEvent(‘on‘ + type, handler); } } addEvent(elem, type, handler); }; var div = document.getElementById(‘div‘); addEvent(div, ‘click‘, function () { alert(1); }); addEvent(div, ‘click‘, function () { alert(2); });
五、总结
无论任何语言,高阶函数都是coder走向高级的必修课程,它为我们在日常编码中也提供很多新的思路和解决方案,也为工程设计模式提供了依据铺垫。同时函数柯里化、节流函数、防抖函数、分时函数、惰性加载函数等在工程中使用也非常普遍。
以上是关于JavaScript高阶函数的主要内容,如果未能解决你的问题,请参考以下文章