跳到主要内容

高阶函数

高阶函数(JS设计模式与开发实践)

tip

高阶函数是指至少满足下列条件之一的函数:

  • 函数可以作为参数被传递
  • 函数可以作为返回值输出

函数作为参数传递

把函数当作参数传递,这代表我们可以抽离出一部分容易变化的业务逻辑,把这部分业务逻辑放在函数参数中,这样一来可以分离业务代码中变化与不变的部分。其中一个重要应用场景就是常见的回调函数。

回调函数

  • 当我们想在ajax 请求返回之后做一些事情,但又并不知道请求返回的确切时间时,最常见的方案就是把callback 函数当作参数传入发起ajax 请求的方法中,待请求完成之后执行callback 函数
var getUserInfo = function( userId, callback ){ 
$.ajax( 'http://xxx.com/getUserInfo?' + userId, function( data ){
if ( typeof callback === 'function' ){
callback( data );
}
});
}

getUserInfo( 13157, function( data ){
alert ( data.userName );
});
  • 回调函数的应用不仅只在异步请求中,当一个函数不适合执行一些操作时,我们也可以把这些操作封装成一个函数,并把它作为参数传递给另外一个函数,“委托”给另外一个函数来执行。

    比如,我们想在页面中创建100 个div 节点,然后把这些div 节点都设置为隐藏。隐藏节点的操作实际上是由客户发起的,但是客户并不知道节点什么时候会创建好,于是把隐藏节点的逻辑放在回调函数中,“委托”给 appendDiv 方法。appendDiv 方法当然知道节点什么时候创建好,所以在节点创建好的时候,appendDiv 会执行之前客户传入的回调函数。

// 不使用回调函数
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成为了一个难以复用的函数

// 使用回调函数
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';
});

Array.prototype.sort

Array.prototype.sort 接受一个函数当作参数,这个函数里面封装了数组元素的排序规则。从Array.prototype.sort 的使用可以看到,我们的目的是对数组进行排序,这是不变的部分;而使用什么规则去排序,则是可变的部分。把可变的部分封装在函数参数里,动态传入Array.prototype.sort,使Array.prototype.sort 方法成为了一个非常灵活的方法。

函数作为返回值输出

让函数继续返回一个可执行的函数,意味着运算过程是可延续的。

  • 使用Object.prototype.toString.call( obj )判断数据的类型
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]';
};

// 上面这些函数的大部分实现都是相同的。为了避免多余的代码,我们尝试把这些字符串作为参数提前传入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
  • 单例模式。下面这个单例模式的例子,既把函数当作参数传递,又让函数执行后返回了另外一个函数。
var getSingle = function ( fn ) { 
var ret;
return function () {
return ret || ( ret = fn.apply( this, arguments ) );
};
};

var getScript = getSingle(function(){
return document.createElement( 'script' );
});

var script1 = getScript();
var script2 = getScript();
console.log ( script1 === script2 ); // 输出:true

高阶函数实现AOP

tip
  • AOP(面向切面编程)的主要作用是把一些跟核心业务逻辑模块无关的功能抽离出来,这些跟业务逻辑无关的功能通常包括日志统计、安全控制、异常处理等。把这些功能抽离出来之后,再通过“动态织入”的方式掺入业务逻辑模块中。这样做的好处首先是可以保持业务逻辑模块的纯净和高内聚性,其次是可以很方便地复用日志统计等功能模块。
  • 通常,在JavaScript 中实现AOP,都是指把一个函数“动态织入”到另外一个函数之中。

通过扩展Function.prototype 来实现AOP:

这种使用AOP 的方式来给函数添加职责,也是JavaScript 语言中一种非常特别和巧妙的装饰者模式实现。

高阶函数的其他应用

currying

tip

currying 又称部分求值。一个currying 的函数首先会接受一些参数,接受了这些参数之后,该函数并不会立即求值,而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来。待到函数被真正需要求值的时候,之前传入的所有参数都会被一次性用于求值。

假设我们要编写一个计算每月开销的函数。在每天结束之前,我们都要记录今天花掉了多少钱。

但我们其实并不太关心每天花掉了多少钱,而只想知道到月底的时候会花掉多少钱。也就是说,实际上只需要在月底计算一次。月底前都只是保存好当天的开销。虽然下面的 cost 函数还不是一个 currying 函数的完整实现,但有助于我们了解其思想:

编写一个通用的function currying(){},function currying(){}接受一个参数,即将要被currying 的函数。在这个例子里,这个函数的作用是遍历本月每天的开销并求出它们的总和。

uncurrying

tip

uncurrying 就是泛化this的过程,即让方法中用到this 的地方就不再局限于原来规定的对象。

Array.prototype 上的方法原本只能用来操作array 对象。但callapply可以把任意对象当作this 传入某个方法,这样一来,方法中用到this 的地方就不再局限于原来规定的对象,而是加以泛化并得到更广的适用性。

通过uncurrying 的方式,可以把 Array.prototype.push.call 变成一个通用的push 函数:

把Array.prototype 上的方法“复制”到Array 对象上:

甚至Function.prototype.call 和Function.prototype.apply 本身也可以被uncurrying,不过这没有实用价值:

下面的代码是uncurrying 的另外一种实现方式:

Function.prototype.uncurrying = function(){ 
var self = this;
return function(){
return Function.prototype.call.apply( self, arguments );
}
};

函数节流

用于解决函数有可能被非常频繁地调用,而造成大的性能问题。节流的一种实现方式如下:

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(1);
}, 500);

分时函数

在短时间内往页面中大量添加DOM节点会让浏览器吃不消,我们看到的结果往往就是浏览器的卡顿甚至假死。比如:

var ary = []; 

for ( var i = 1; i <= 1000; i++ ){
ary.push( i );
};

var renderFriendList = function( data ){
for ( var i = 0, l = data.length; i < l; i++ ){
var div = document.createElement( 'div' );
div.innerHTML = i;
document.body.appendChild( div );
}
};

renderFriendList( ary );

这个问题的解决方案之一是下面的timeChunk 函数,timeChunk 函数让创建节点的工作分批进行,比如把1 秒钟创建1000 个节点,改为每隔200 毫秒创建8 个节点。

var timeChunk = function( ary, fn, count ){ 

var obj, t;

var len = ary.length;

var start = function(){
for ( var i = 0; i < Math.min( count || 1, ary.length ); i++ ){
var obj = ary.shift();
fn( obj );
}
};

return function(){
t = setInterval(function(){
if ( ary.length === 0 ){ // 如果全部节点都已经被创建好
return clearInterval( t );
}
start();
}, 200 ); // 分批执行的时间间隔,也可以用参数的形式传入
};

};

var ary = [];

for ( var i = 1; i <= 1000; i++ ){
ary.push( i );
};

var renderFriendList = timeChunk( ary, function( n ){
var div = document.createElement( 'div' );
div.innerHTML = n;
document.body.appendChild( div );
}, 8 );

renderFriendList();

惰性加载函数

比如我们需要一个在各个浏览器中能够通用的事件绑定函数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函数里不再存在条件分支语句):

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 );
};

运行示例

JavaScript Callbacks

tip

By definition, a callback is a function that you pass into another function as an argument for executing later. 回调是一个函数,作为另一个函数的参数,用于以后执行。

function filter(numbers) {
let results = [];
for (const number of numbers) {
if (number % 2 != 0) {
results.push(number);
}
}
return results;
}
let numbers = [1, 2, 4, 7, 3, 5, 6];
console.log(filter(numbers));

为了使filter()更通用和可复用(more generic and reusable),增加一个入参用于判断,我一般是使用一个“type”,但是使用一个函数作为入参可能更灵活:

function isOdd(number) {
return number % 2 != 0;
}
function isEven(number) {
return number % 2 == 0;
}

function filter(numbers, fn) {
let results = [];
for (const number of numbers) {
if (fn(number)) {
results.push(number);
}
}
return results;
}
let numbers = [1, 2, 4, 7, 3, 5, 6];

console.log(filter(numbers, isOdd));
console.log(filter(numbers, isEven));

By definition, the isOdd and isEven are callback functions or callbacks. Because the filter() function accepts a function as an argument, it’s called a high-order function. 高阶函数

A high-order function is a function that accepts another function as an argument.

A callback can be an anonymous function, which is a function without a name. 回调可以是匿名函数,它是没有名称的函数。

function filter(numbers, callback) {
let results = [];
for (const number of numbers) {
if (callback(number)) {
results.push(number);
}
}
return results;
}

let numbers = [1, 2, 4, 7, 3, 5, 6];

let oddNumbers = filter(numbers, function (number) {
return number % 2 != 0;
});

console.log(oddNumbers);

In ES6, you can use an arrow function:

function filter(numbers, callback) {
let results = [];
for (const number of numbers) {
if (callback(number)) {
results.push(number);
}
}
return results;
}

let numbers = [1, 2, 4, 7, 3, 5, 6];

let oddNumbers = filter(numbers, (number) => number % 2 != 0);

console.log(oddNumbers);

There are two types of callbacks: synchronous and asynchronous callbacks. 回调有两种类型:同步回调和异步回调。

Synchronous callbacks

A synchronous callback is executed during the execution of the high-order function that uses the callback. 同步回调 在高阶函数(其入参中有该回调的函数) 执行期间 执行。

Asynchronous callbacks

tip

Asynchronicity means that if JavaScript has to wait for an operation to complete, it will execute the rest of the code while waiting. 异步性意味着如果 JavaScript 必须等待操作完成,它将在等待期间执行其余代码。

Note that JavaScript is a single-threaded programming language. It carries asynchronous operations via the callback queue and event loop. 请注意,JavaScript 是一种单线程编程语言。它通过回调队列和事件循环进行异步操作。

An asynchronous callback is executed after the execution of the high-order function that uses the callback. 异步回调 在高阶函数(其入参中有该回调的函数) 执行之后 执行。

function download(url, callback) {
setTimeout(() => {
// script to download the picture here
console.log(`Downloading ${url} ...`);
// process the picture once it is completed
callback(url);
}, 1000);
}

let url = 'https://www.javascripttutorial.net/pic.jpg';
download(url, function(picture) {
console.log(`Processing ${picture}`);
});

Handling errors: 引入两个回调分别处理成功和失败情况

function download(url, success, failure) {
setTimeout(() => {
console.log(`Downloading the picture from ${url} ...`);
!url ? failure(url) : success(url);
}, 1000);
}

download(
'',
(url) => console.log(`Processing the picture ${url}`),
(url) => console.log(`The '${url}' is not valid`)
);

不使用callback:

function getUsers() {
let users = [];
setTimeout(() => {
users = [
{ username: 'john', email: 'john@test.com' },
{ username: 'jane', email: 'jane@test.com' },
];
}, 1000);
return users;
}

function findUser(username) {
const users = getUsers(); // []
const user = users.find((user) => user.username === username);
return user;
}

console.log(findUser('john')); // undefined

使用callback:

function getUsers(callback) {
setTimeout(() => {
callback([
{ username: 'john', email: 'john@test.com' },
{ username: 'jane', email: 'jane@test.com' },
]);
}, 1000);
}

function findUser(username, callback) {
getUsers((users) => {
const user = users.find((user) => user.username === username);
callback(user);
});
}

findUser('john', console.log); // { username: 'john', email: 'john@test.com' }

Nesting callbacks and the Pyramid of Doom 嵌套回调和厄运金字塔

How do you download three pictures and process them sequentially? A typical approach is to call the download() function inside the callback function. 如何下载三张图片并依次处理?一种典型的方法是在回调函数中调用 download() 函数

function download(url, callback) {
setTimeout(() => {
console.log(`Downloading ${url} ...`);
callback(url);
}, 1000);
}

const url1 = 'https://www.javascripttutorial.net/pic1.jpg';
const url2 = 'https://www.javascripttutorial.net/pic2.jpg';
const url3 = 'https://www.javascripttutorial.net/pic3.jpg';

download(url1, function (url) {
console.log(`Processing ${url}`);
download(url2, function (url) {
console.log(`Processing ${url}`);
download(url3, function (url) {
console.log(`Processing ${url}`);
});
});
});

However, this callback strategy does not scale well when the complexity grows significantly. 然而,当复杂性显着增加时,这种回调策略无法很好地扩展。

Nesting many asynchronous functions inside callbacks is known as the pyramid of doom or the callback hell. 在回调中嵌套许多异步函数被称为厄运金字塔或回调地狱。

To avoid the pyramid of doom, you use promises or async/await functions.

函数柯里化

tip
  • 柯里化是一种函数的转换,柯里化不会调用函数。它只是对函数进行转换。

1. 简单的例子:

// 执行柯里化转换
function curry(f) {
return function(a) {
return function(b) {
return f(a, b);
};
};
}

// 用法
function sum(a, b) {
return a + b;
}

let curriedSum = curry(sum);

alert( curriedSum(1)(2) ); // 3
  • curry(func) 的结果就是一个包装器 function(a)
  • 当它被像 curriedSum(1) 这样调用时,它的参数会被保存在词法环境中,然后返回一个新的包装器 function(b)
  • 然后这个包装器被以 2 为参数调用,并且,它将该调用传递给原始的 sum 函数

2. 柯里化更高级的实现

例如 lodash 库的 _.curry,会返回一个包装器,该包装器允许函数被正常调用或者以部分应用函数(partial)的方式调用:

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

let curriedSum = _.curry(sum); // 使用来自 lodash 库的 _.curry

alert( curriedSum(1, 2) ); // 3,仍可正常调用
alert( curriedSum(1)(2) ); // 3,以部分应用函数的方式调用

3. 柯里化的好处

柯里化让我们能够更容易地获取 部分应用函数(partially applied function)(或者说 部分函数(partial))。

// 例如一个普通的日志函数
function log(date, importance, message) {
alert(`[${date.getHours()}:${date.getMinutes()}] [${importance}] ${message}`);
}
// 将它柯里化
log = _.curry(log);

log(new Date(), "DEBUG", "some debug"); // (a, b, c)形式使用
log(new Date())("DEBUG")("some debug"); // (a)(b)(c)形式使用

// 为当前日志创建便捷函数
let logNow = log(new Date()); // logNow 会是带有固定第一个参数的日志的部分应用函数
logNow("INFO", "message"); // [HH:mm] INFO message

let debugNow = logNow("DEBUG");
debugNow("message"); // [HH:mm] DEBUG message

4. 手写实现柯里化

  • 如果传入的 args 长度与原始函数所定义的(func.length)相同或者更长,那么只需要使用 func.apply 将调用传递给它即可。
  • 否则,获取一个部分应用函数,它将重新应用 curried,将之前传入的参数与新的参数一起传入。
  • 然后,如果我们再次调用它,我们将得到一个新的部分应用函数(如果没有足够的参数),或者最终的结果。
  • 这种方式实现的柯里化,要求函数具有固定数量的参数;使用 rest 参数的函数,例如 f(...args),不能以这种方式进行柯里化。
function curry(func) {

return function curried(...args) {
if (args.length >= func.length) {
return func.apply(this, args);
} else {
return function(...args2) {
return curried.apply(this, args.concat(args2));
}
}
};

}

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

let curriedSum = curry(sum);

alert( curriedSum(1, 2, 3) ); // 6,仍然可以被正常调用
alert( curriedSum(1)(2,3) ); // 6,对第一个参数的柯里化
alert( curriedSum(1)(2)(3) ); // 6,全柯里化

5. 柯里化在实际项目中的应用

请求是多样化的,比如method的不同;有的需要data,有的没有入参不需要data;有的需要配置请求头等参数,有的不需要配置:

// get
fn('a/b', {header:{}})
fn('a/b')

// post
fn('a/b', {a:1}, {header:{}})
fn('a/b', {a:1})
fn('a/b', {header:{}})

我封装了2个版本(还有更好更优雅的封装方式):

  • 1) 调用时得写两个括号
// fn('a/b')({header:{}})
// fn('a/b', {a:1})({header:{}})
// fn('a/b')()
// fn('a/b', {a:1})()
function requestApi(...rest: any[]) {
const {tokenReducer} = store.getState();

let params;
if (rest.length > 1) {
params = {
method: rest[0] ?? 'POST',
url: rest[1],
data: rest[2],
};
} else {
params = { url: rest[0], method: 'POST', };
}
return async function withConfig(config?: any) {
const result = await Taro.request({
...params,
header: {
'Authorization': `Bearer ${tokenReducer.token || my.getStorageSync({ key: 'selfAppToken' })?.data}`
}
});
return result.data;
};
}

// 使用
export const getStudentInfoListApi = () => requestApi('POST', `${prefix}student/queryStudentInfoList`);

let childrenResult = await getStudentInfoListApi()();
  • 2) 使用柯里化
function curry(func) {
return function curried(...args) {
if (args.length >= func.length) {
return func.apply(this, args);
} else {
return function(...args2) {
return curried.apply(this, args.concat(args2));
}
}
}
}
// fn('a/b', {a:1}, {header:{}})
// fn('a/b', {a:1})
// fn('a/b', {header:{}})
function commonRequest(method: keyof Taro.request.Method | undefined, url: string, {data, config}: {data: any, config: any} = {} as {data: any, config: any}) { // 使用对象解构可以不用考虑data config顺序不固定的问题
const {tokenReducer} = store.getState();
const prefix = 'https://xxx/';
return new Promise((resolve, reject) => {
Taro.request({
method,
url: `${prefix}${url}`,
...{
data,
config,
header: {
'Authorization': `Bearer ${tokenReducer.token || my.getStorageSync({ key: 'selfAppToken' })?.data}`
}
},
success: function (successResult) {
resolve(successResult.data);
},
fail: function (failResult) {
reject(failResult);
}
});
}).catch(error => {
Taro.showToast({
title: error,
icon: 'error',
duration: 1500,
});
})
}

const postApi = curry(commonRequest)('POST');
const getApi = curry(commonRequest)('GET');

// 使用
export const getParentInfoApi = () => postApi('getParentInfo');

let result = await getParentInfoApi();