函数类型详解
高阶函数
高阶函数 指操作函数的函数,一般地,有以下两种情况:
- 函数可以作为参数被传递
- 函数可以作为返回值输出
JavaScript 中的函数显然满足高阶函数的条件,在实际开发中,无论是将函数当作参数传递,还是让函数的执行结果返回另外一个函数,这两种情形都有很多应用场景。
作为参数传递
把函数当作参数传递,代表可以抽离出一部分容易变化的业务逻辑,把这部分业务逻辑放在函数参数中,这样一来可以分离业务代码中变化与不变的部分。
回调函数
其中一个常见的应用场景就是回调函数。
- 在 AJAX 异步请求的过程中,回调函数使用得非常频繁
- 在不确定请求返回的时间时,将
callback
回调函数当作参数传入 - 待请求完成后执行
callback
函数
🌰 代码示例
const getUserInfo = function (userId, callback) {
$.ajax('http://example.com/getUserInfo?' + userId, function (data) {
if (typeof callback === 'function') {
callback(data);
}
});
};
getUserInfo(123, function (data) {
console.log(data.userName);
});
回调函数的应用不仅只在异步请求中,当一个函数不适合执行一些请求时,也可以把这些请求封装成一个函数,并把它作为参数传递给另外一个函数,委托 给另外一个函数来执行。
比如,想在页面中创建 100 个 div
节点,然后把这些 div
节点都设置为隐藏。
const appendDiv = function () {
for (let i = 0; i < 100; i++) {
const 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
方法
const appendDiv = function (callback) {
for (let i = 0; i < 100; i++) {
const 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
会执行之前客户传入的回调函数。
数组排序
函数作为参数传递的另一个常见场景是数组排序函数 sort()
。Array.prototype.sort
接受一个函数当作参数,这个函数里面封装了数组元素的排序方法。目的是对数组进行排序,这是不变的部分;而使用什么规则去排序,则是可变的部分。把可变的部分封装在函数参数里,动态传入 Array.prototype.sort
,使 Array.prototype.sort
方法成为了一个非常灵活的方法。
// 从小到大排列,输出: [ 1, 3, 4 ]
[1, 4, 3].sort(function (a, b) {
return a - b;
});
// 从大到小排列,输出: [ 4, 3, 1 ]
[1, 4, 3].sort(function (a, b) {
return b - a;
});
作为返回值输出
相比把函数当作参数传递,函数当作返回值输出的应用场景也有很多。让函数继续返回一个可执行的函数,意味着运算过程是可延续的。
下面是使用 Object.prototype.toString
方法判断数据类型的一系列的 isType
函数
let isString = function (obj) {
return Object.prototype.toString.call(obj) === '[object String]';
};
let isArray = function (obj) {
return Object.prototype.toString.call(obj) === '[object Array]';
};
let isNumber = function (obj) {
return Object.prototype.toString.call(obj) === '[object Number]';
};
实际上,这些函数的大部分实现都是相同的,不同的只是 Object.prototype.toString.call(obj)
返回的字符串。为了避免多余的代码,可以把这些字符串作为参数提前传入 isType
函数。
let isType = function (type) {
return function (obj) {
return Object.prototype.toString.call(obj) === '[object ' + type + ']';
};
};
const isString = isType('String');
const isArray = isType('Array');
const isNumber = isType('Number');
console.log(isArray([1, 2, 3]));
// true
其实上面实现的 isType 函数,也属于偏函数的范畴,偏函数实际上是返回了一个包含预处理参数的新函数,以便后续逻辑可以调用。
当然,还可以用循环语句,来批量注册这些 isType
函数:
let 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
AOP 面向切面编程
AOP 即面向切面编程,它的主要作用是 把一些跟核心业务逻辑模块无关的功能抽离出来,这些跟业务逻辑无关的功能通常包括日志统计、安全控制、异常处理等。把这些功能抽离出来之后,再通过 动态织入 的方式掺入业务逻辑模块中。这样做的好处首先是可以保持业务逻辑模块的纯净和高内聚性,其次是可以很方便地复用日志统计等功能模块。
通常,在 JavaScript 中实现 AOP,都是指把一个函数 动态织入 到另外一个函数之中。下面通过扩展 Function.prototype
来实现
Function.prototype.before = function (beforefn) {
// 保存原函数的引用
const _this = this;
// 返回包含了原函数和新函数的 "代理" 函数
return function () {
// 先执行新函数,修正 this
beforefn.apply(this, arguments);
// 再执行原函数
return _this.apply(this, arguments);
};
};
Function.prototype.after = function (afterfn) {
const _this = this;
return function () {
// 先执行原函数
const result = _this.apply(this, arguments);
// 再执行新函数
afterfn.apply(this, arguments);
return result;
};
};
const fn = function () {
console.log(2);
};
fn = fn
.before(function () {
console.log(1);
})
.after(function () {
console.log(3);
});
fn();
// 1 2 3
把负责输出数字 1 和输出数字 3 的两个函数通过 AOP 的方式动态植入 fn
函数。
通过执行上面的代码,控制台顺利地返回了执行结果 1、2、3。
const service = function () {
console.log('功能逻辑');
};
const proxyMethod = (function () {
let startTime;
return {
before: function () {
startTime = new Date();
console.log('计时开始');
},
after: function () {
const endTime = new Date() - startTime;
console.log('计时结束,用时:' + endTime);
},
};
})();
const aop = function (fn, proxy) {
proxy.before && proxy.before();
fn();
proxy.after && proxy.after();
};
aop(service, proxyMethod);
// 计时开始
// 功能逻辑
// 计时结束:1
惰性函数
惰性函数 表示函数执行的分支只会在函数 第一次调用 的时候执行,在第一次调用过程中,该函数会被覆盖为另一个按照合适方式执行的函数,这样任何对原函数的调用就不用再经过执行的分支了。
解决问题
在一个方法里面可能会涉及到一些兼容性的问题,不同的浏览器对应不同的方法,第一次我们遍历这些方法找到最合适的那个, 并将这个方法覆盖于遍历它的函数,这就是惰性函数即只遍历一次就找到最佳方案,下次再要找那个方法的时候就不用遍历了,提高了性能。
🌰 示例:常见的为 DOM 节点添加事件的函数
function addEvent(type, element, func) {
if (element.addEventListener) {
element.addEventListener(type, func, false);
} else if(element.attachEvent){
element.attachEvent('on' + type, func);
} else{
element['on' + type] = func;
}
}
每次调用 addEvent
函数的时候,它都要对浏览器所支持的能力进行检查,首先检查是否支持 addEventListener
方法,如果不支持,再检查是否支持 attachEvent
方法,如果还不支持,就用 DOM0 级的方法添加事件。这个过程,在 addEvent
函数每次调用的时候都要走一遍,其实,如果浏览器支持其中的一种方法,那么他就会一直支持了,就没有必要再进行其他分支的检测了,也就是说,if
语句不必每次都执行,代码可以运行的更快一些。解决的方案就是称之为 惰性载入 的技巧。
函数重写
在介绍惰性函数(或称惰性载入)之前,首先介绍函数重写技术。
由于一个函数可以返回另一个函数,因此可以用新的函数来覆盖旧的函数。
function foo(){
console.log('foo');
foo = function(){
console.log('bar');
}
}
这样一来,第一次调用该函数时会 console.log('foo')
会被执行,全局变量 foo
被重定义,并被赋予新的函数。当该函数再次被调用时,console.log('bar')
会被执行。
惰性载入
惰性函数的本质就是函数重写。所谓惰性载入,就是说函数执行的分支只会执行一次,之后调用函数时,直接进入所支持的分支代码。
有两种实现惰性载入的方式,第一种事函数在第一次调用时,对函数本身进行二次处理,该函数会被覆盖为符合分支条件的函数,这样对原函数的调用就不用再经过执行的分支了,我们可以用下面的方式使用惰性载入重写addEvent()
。
在函数被调用时处理函数
函数在第一次调用时,该函数会被覆盖为另外一个按合适方式执行的函数,这样任何对原函数的调用都不用再经过执行的分支了。代码重写如下
function addEvent(type, element, func) {
if (element.addEventListener) {
addEvent = function (type, element, func) {
element.addEventListener(type, func, false);
}
} else if(element.attachEvent){
addEvent = function (type, element, func) {
element.attachEvent('on' + type, func);
}
} else{
addEvent = function (type, element, func) {
element['on' + type] = func;
}
}
return addEvent(type, element, func);
}
在这个惰性载入的 addEvent()
中,if
语句的每个分支都会为 addEvent
变量赋值,有效覆盖了原函数。最后一步便是调用了新赋函数。下一次调用 addEvent()
时,便会直接调用新赋值的函数,这样就不用再执行 if
语句了。
但是,这种方法有个缺点,如果函数名称有所改变,修改起来比较麻烦。
声明函数时指定适当的函数
把嗅探浏览器的操作提前到代码加载的时候,在代码加载的时候就立刻进行一次判断,以便让 addEvent
返回一个包裹了正确逻辑的函数。
var addEvent = (function () {
if (document.addEventListener) {
return function (type, element, func) {
element.addEventListener(type, func, false);
}
}
else if (document.attachEvent) {
return function (type, element, func) {
element.attachEvent('on' + type, func);
}
}
else {
return function (type, element, func) {
element['on' + type] = func;
}
}
})();
函数记忆
函数记忆: 指将上次的(计算结果)缓存起来,当下次调用时,如果遇到相同的(参数),就直接返回(缓存中的数据)。
实现原理:将参数和对应的结果保存在对象中,再次调用时,判断对象 key
是否存在,存在返回缓存的值。
function memorize() {
const cache = {};
return function() {
const key = Array.prototype.call(arguments, ',');
if (key in cache) {
return cache[key];
}
return (cache[key] = fn.apply(this, arguments));
};
}
偏函数
维基百科中对偏函数(Partial)的定义为:
In computer science, partial application(or partial function application) refers to the process of fixing a number of arguments to a function, producing another function of smaller arity.
在计算机科学中,局部应用是指固定一个函数的一些参数,然后产生另一个更小元的函数。
什么是元?
元是指函数参数的个数,比如一个带有两个参数的函数被称为二元函数。
🌰 示例:
function add(a, b) {
return a + b;
}
// 执行 add 函数,一次传入两个参数即可
add(1, 2); // 3
// 假设有一个 partial 函数可以做到局部应用
var addOne = partial(add, 1);
addOne(2); // 3
偏函数与柯里化十分相像:
- **柯里化:**将多参数函数转换成多个单参数函数,也就是将一个 n 元函数转换成 n 个一元函数
- **偏函数:**则是固定一个函数的一个或多个参数,也就是将一个 n 元函数转换成一个 n - x 元函数
实际应用
bind
函数可以让我们传入一个或多个预设的参数,之后返回一个新函数,并拥有指定的 this
值和预设参数。当绑定函数被调用时,这些参数会被插入到目标函数的参数列表的开始位置,传递给绑定函数的参数跟在它们后面。
function addition(x, y) {
return x + y;
}
const plus5 = addition.bind(null, 5);
plus5(10);
// 15
plus5(25);
// 30
我们预先传入参数 5
,并返回一个新函数赋值给 plus5
,此函数可以接受剩余的参数。调用 plus5
传入剩余参数 10
得到最终结果 15
,又传入参数 20
得到结果 30
。偏函数通过设定预设值,帮助哦我们实现代码上的复用。
实现偏函数
在 Underscore.js 和 Lodash 均有实现 partial
偏函数,这里稍微实现一下:
var _ = {};
function partial(fn) {
var args = [].slice.call(arguments, 1);
return function() {
var position = 0,
leng = args.length;
for (var i = 0; i < len; i++) {
args[i] = args[i] === _ ? arguments[position++] : args[i];
}
while (position < arguments.length) args.push(arguments[position++]);
return fn.apply(this, args);
};
}
函数睡眠
伪命题,JavaScript 引擎线程无法挂起,通过异步实现类似 sleep 的效果。
代码实现
回调函数实现
const sleep = (cb, time) => setTimeout(cb, time);
sleep(() => {
console.log('Hello world!');
}, 1000);
Promise 实现
function sleep(time) {
return function() {
return new Promise(function(resolve, reject) {
setTimeout(resolve, time);
});
};
}
const promise = new Promise(function(resolve) {
console.log('do something');
resolve();
})
.then(sleep(2000))
.then(function() {
console.log('after sleep 2000');
});
- 优点:这种方式实际上是用了
setTimeout
,没有形成进程阻塞,不会造成性能和负载问题 - 缺点:虽然不像
callback
套那么多层,但仍不怎么美观,而且当我们需要在某过程中需要停止执行(或者在中途返回了错误的值),还必须得层层判断后跳出,非常麻烦,而且这种异步并不是那么彻底,还是看起来别扭
Generator 实现
function* sleep(time) {
yield new Promise(function(resolve, reject) {
setTimeout(resolve, time);
});
}
sleep(1000)
.next()
.value.then(() => {
console.log('Hello world!');
});
- 优点:同 Promise 优点,另外代码就变得非常简单干净,没有
then
那么生硬和恶心 - 缺点:但不足也很明显,就是每次都要执行
next
显得很麻烦,虽然有co
(第三方包)可以解决,但就多包了一层不好看,错误也必须按co
的逻辑来处理不爽
Async/Await 实现
function sleep(time) {
return new Promise(function(resolve) {
setTimeout(resolve, time);
});
}
async function test() {
const res = await sleep(1000);
console.log('Hello world!');
return res;
}
// 延迟 1000ms 输出 "Hello world!"
- 优点:同 Promise 和 Generator 的优点。Async/Await 可以看座是 Generator 的语法糖,Async 和 Await 相较于
*
和yield
更加语义,另外各个函数都是扁平的,不会产生多余的嵌套,代码更加清爽易读。 - 缺点:ES7 语法存在兼容性问题,有 Babel 一切兼容性都不是问题
使用 node-sleep
const sleep = requir('node-sleep');
const sec = 10;
sleep.sleep(sec);
// Sleep for sec seconds
sleep.msleep(sec);
// Sleep for sec milliseconds
sleep.usleep(sec);
// Sleep for sec microseconds(1 second is 1000000 microseconds)
回调函数
回调函数是一段可执行的代码段,它作为一个参数传递给其他的代码,其作用是在需要的时候方便调用这段(回调函数)代码。
在 JavaScript 中函数也是对象的一种,同样对象可以作为参数传递给函数,因此函数也可以作为参数传递给另外一个函数,这个作为参数的函数就是回调函数。
回调函数
function add(num1, num2, callback) {
const sum = num1 + num2;
// 数值相加后,将相加和作为参数传入回调函数
callback(sum);
}
function print(num) {
console.log(num);
}
add(1, 2, print);
// 3
匿名回调函数
function add(num1, num2, callback) {
const sum = num1 + num2;
// 数值相加后,将相加和作为参数传入回调函数
callback(sum);
}
add(1, 2, function(sum) {
console.log(sum);
// 3
});
函数特点
不会立即执行
回调函数作为参数传递给一个函数的时候,传递的只是函数的定义并不会立即执行。和普通的函数一样,回调函数在函调用函数数中也要通过 ()
括号运算符调用才会执行。
是个闭包
回调函数是一个闭包,也就是说它能访问到其外层定义的变量。
执行前类型判断
function add(num1, num2, callback) {
var sum = num1 + num2;
if (typeof callback === 'function') {
callback(sum);
}
}
this
的使用
注意在回调函数调用时 this
的执行上下文并不是回调函数定义时的那个上下文,而是调用它的函数所在的上下文。
var obj = {
sum: 0,
add: function(num1, num2) {
this.sum = num1 + num2;
},
};
function add(num1, num2, callback) {
callback(num1, num2);
}
add(1, 2, obj.add);
console.log(obj.sum);
// 0
console.log(window.sum);
// 3
上述代码调用回调函数的时候是在全局环境下,因此 this
指向的是 window
,所以 sum
的值是赋值给windows
的。
关于 this
执行上下文的问题可以通过 apply
方法解决。
const obj = {
sum: 0,
add: function(num1, num2) {
this.sum = num1 + num2;
},
};
function add(num1, num2, callbackObj, callback) {
callback.apply(callbackObj, [num1, num2]);
}
add(1, 2, obj, obj.add);
console.log(obj.sum);
// 3
console.log(window.sum);
// undefined
允许传递多个回调函数
// 一个函数中可以传递多个回调函数,典型的例子如 jQuery
function beforeCallback() {
// Do stuff before send
}
function successCallback() {
// Do stuff if success message received
}
function completeCallback() {
// Do stuff upon completion
}
function errorCallback() {
// Do stuff if error received
}
$.ajax({
url: 'https://example.com/api/collect',
before: beforeCallback,
success: successCallback,
complete: completeCallback,
error: errorCallback,
});
函数嵌套
一个回调函数中可以嵌入另一个回调函数,对于这种情况出现多层嵌套时,代码会难以阅读和维护,这个时候可以采用命名回调函数的方式调用,或者采用模块化管理函数,也可以用 Promise 模式编程。
优点和使用场景
优点
- DRY,避免重复代码
- 可以将通用的逻辑抽象
- 加强代码可维护性
- 加强代码可读性
- 分离专职的函数
级联函数
级联函数 也叫 链式函数,是一种在一个对象上使用一条连续的代码来重复调用不同方法的技巧。这种技巧在 jQuery 和其他一些 JavaScript 库中很流行,它甚至也是一些 JavaScript 原生方法的内在特性,比如常见的字符串方法。一定程度上可以减少代码量,提高代码可读性,缺点是它占用了函数的返回值。
级连函数的表达形式如下所示:
// jQuery
$('#wrapper').fadeOut().html('Welcome, Sir').fadeIn();
// 字符串操作
'kankuuii'.replace('k', 'R').toUpperCase().substr(0, 4);
// 'RANK'
实现方法
要使用级联函数,我们只需要在每个函数中返回 this
对象(也就是后面方法中操作的对象)。操作的对象就会在执行完一个函数后继续调用往后的方法,即实现了链式操作了。
function Person() {
this.name = '';
this.age = 0;
this.weight = 10;
}
Person.prototype = {
setName:function(name){
this.name = name;
return this;
},
setAge:function(age){
this.age = age;
return this;
},
setWeight:function(weight) {
this.weight = weight;
return this;
}
}
var uzi = new Person();
uzi.setName('Uzi').setAge(22).setWeight(160);
console.log(uzi);
// { name: "Uzi", age: 22, weight: 160 }
通过工厂函数创建实例对象,由于所有对象都会继承其原型对象的属性和方法,所以我们可以让定义原型对象中的那几个方法都返回用以调用方法的实例对象的引用,这样既可以对那些方法进行链式调用。
函数柯里化
在计算机科学中,柯里化(英语:Currying),又译为卡瑞化或加里化,是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术。
柯里化(Currying),又称部分求值(Partial Evaluation),是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。核心思想是把多参数传入的函数拆成单参数(或部分)函数,内部再返回调用下一个单参数(或部分)函数,依次处理剩余的参数。
// 传统写法
fn(1, 2, 3, 4);
// 柯里化
fn(1)(2)(3)(4);
假设这个函数是用于求和,那么就是把本来接收多个参数一次性求和的函数改成了接收单一参数逐个求和的函数。这样是不是容易理解了。
代码实现
一步步实现一个柯里化函数。
const sum3(x, y, z) {
return x + y + z
}
console.log(sum(1,2,3));
// 6
// 柯里化
const sum3(x) {
return function (y) {
return function (z) {
return x + y + z
}
}
}
console.log(sum(1)(2)(3));
// 6
function curry(fn) {
return function (y) {
return function (z) {
return fn(x, y, z);
};
};
}
var sum3 = curry((x, y, z) => {
return x + y + z;
});
console.log(sum3(1)(2)(3)); // 6
更多参数:
function curryN(fn) {
return function (a1) {
return function (a2) {
return function (a3) {
//......
return function (aN) {
return fn(a1, a2, a3, ...aN);
};
};
};
};
}
通过 递归 来简化这种写法:
function nest(fn) {
return function (x) {
return nest(fn);
};
}
function curry(fn) {
return nest(fn);
}
这里缺少一个循环终止的判断,所以 nest
函数先引入一个新参数 i
,当 i === N
时递归终止
function nest(fn, i) {
return function(x) {
if (i === N) {
return fn(...)
}
return nest(fn, i + 1)
}
}
function curry(fn) {
return nest(fn, 1)
}
接着,需要一个存放任意多个参数的数组,将这个数组命名为 args
,然后传入 nest
函数。
function nest(fn, i, args) {
return function (x) {
args.push(x);
if (i === fn.length) {
return fn(...args);
}
return nest(fn, i + 1, args);
};
}
function curry(fn) {
const args = [];
return nest(fn, 1, args);
}
最后在添加一个处理 0 个参数的情况,我们就完成了最终版的柯里化函数。
function curry(fn) {
if (fn.length === 0) {
return fn;
}
const args = [];
return nest(fn, 1, args);
}
代码示例
示例一:实现一个柯里化求和函数
const currying = function (fn, ...args) {
const len = fn.length;
args = args || [];
return () => {
const totalArgs = [...args].concat([...arguments]);
return totalArgs.length >= len ? fn.call(this, totalArgs) : currying.call(this, fn, totalArgs);
};
};
const sum = (a, b, c) => a + b + c;
const newSum = currying(sum);
newSum(1)(2)(3)(4);
// 10
看起来挺巧妙,但是这种案例明摆着就像不从实际出发的面试题。
示例二:查询数组中是否存在某值
const find = function (arr, value) {
return arr.indexOf(value) !== -1;
};
一个简单的函数用于查询数组中是否某个值,每次使用都需要这样调用。
find(arr, 1);
find(arr, 2);
既然 arr
是个固定参数,那么我们可以先保存一个接收过 arr
的函数,再用这个函数去处理变化的参数。
const collection = [5, 4, 3, 2, 1];
const findInCollection = currying(find)(collection);
findInCollection(1);
findInCollection(2);
函数柯里化的用途可以理解为:参数复用。本质上是降低通用性,提高适用性。
柯里化简便实现
const curry = (fn) =>
(judge = (...args) => (args.length === fn.length ? fn(...args) : (arg) => judge(...args, arg)));
// 展开
const currying = (fn) => {};
反柯里化
与柯里化相对应。
- 柯里化是为了缩小适用范围,创建一个针对性更强的函数;
- 反柯里化则是扩大适用范围,创建一个应用范围更广的函数。
对应的代码转换就变成这样。
fn(1)(2)(3)(4) -> fn(1, 2, 3, 4)
实例
Array.forEach = function () {
const fn = [].pop.call(arguments);
const arr = arguments.length > 1 ? arguments : arguments[0];
return [].forEach.call(arr, fn);
};
Array.forEach(1, 2, 3, function (i) {
console.log(i);
// 1 2 3
});
Array.forEach('123', function (i) {
console.log(i);
// 1 2 3
});
Array.forEach(
{
'0': 1,
'1': 2,
'2': 3,
length: 3,
},
function (i) {
console.log(i);
// 1 2 3
}
);
类数组借用 Array 原型函数,是很常见的应用了。这个例子应用 call
函数提取出一个新的函数,可以接收更多的参数和类型,适用性更广。