javascript 高阶函数

img

高阶函数是指至少满足下列条件之一的函数:
1. 函数可以作为参数被传递
2. 函数可以作为返回值输出

一、函数作为参数传递

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

1. 回调函数

(1)在 ajax 异步请求的应用中,回调函数的使用非常频繁。当我们想在 ajax 请求返回之后做一些事情,但又并不知道请求返回的确切时间时,最常见的方案就是把 callback 函数当作参数传入发起 ajax 请求的方法中,待请求完成之后执行 callback 函数:

const fetchData = (params, callback) => {
    const isObject = Object.prototype.toString.call(params);
    // `params`参数拦截
    if (isObject !== '[object Object]') return;
    // `ajax`相关配置
    $.ajax({
        timeout: 10000, // 默认为`10m`请求超时
        type: 'POST', // 默认为`POST`请求
        dataType: 'json', // 默认预期服务器返回的数据类型`json`
        contentType: 'application/json;charset=UTF-8', // 默认发送信息至服务器时内容编码`Content-Type`类型
        success(response) {
            // `code`、`data`、`msg`与后端约定
            const {
                code,
                data = [],
                msg = ''
            } = response;
            // 提前设置响应拦截
            if (code !== 200) {
                return alert(msg);
            }
            // 成功后的业务数据处理,`callback`
            typeof callback === 'function' && callback(data);
        },
        error(...errs) {
            // 请求失败时处理,如`timeout`
            const [, textStatus] = errs;
            textStatus === 'timeout' && alert('请求超时!');
        },
        // 可以对`ajax`配置进行自定义
        ...params
    });
}

const params = {
    data: {
        uid: '123',
        page: 1,
        rows: 20
    },
    url: '/api/fetchDataApi'
}
fetchData(params, data => {
    console.log(data)
});

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

比如,我们想在页面中创建 100 个 div 节点,然后把这些 div 节点都设置为隐藏。下面是一种编写代码的方式:

const appendDiv = () => {
    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 = callback => {
    for (let i = 0; i < 100; i++) {
        const div = document.createElement('div');
        div.innerHTML = i;
        document.body.appendChild(div);
        typeof callback === 'function' && callback(div);
    }
};

appendDiv(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. 简单实现单例模式

下面是一个单例模式的例子:

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(面向切面编程)的主要作用是把一些跟核心业务逻辑模块无关的功能抽离出来,这些跟业务逻辑无关的功能通常包括日志统计、安全控制、异常处理等。把这些功能抽离出来之后,再通过动态织入的方式掺入业务逻辑模块中。这样做的好处首先是可以保持业务逻辑模块的纯净和高内聚性,其次是可以很方便地复用日志统计等功能模块。

1. 链式调用实现

通常,在 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 语言中一种非常特别和巧妙的装饰者模式实现,这儿只是简要说明了一下。

2. 链式调用的弊端

我们想象一下以下业务场景:用户提交表单数据成功后返回首页的交互流程,但我们在实际业务设计中,会在发送提交请求后对提交数据进行验证,验证合法之后在进行上报,上报成功则返回刚刚提交表单数据的请求表示成功,再进行下一步的业务流程返回首页,如下图所示:

aop1

function report(){
    console.log('上报数据');
}

function validate(){
    console.log('验证数据');
}

function submit(){
    console.log('提交成功');
}

function goBack(){
    console.log('返回首页');
}

从上面的代码上来看,我们已经做好了业务的隔离,互不影响。那我们怎么才能够按照上面的流程图将业务串联起来呢?
如果我们用动态织入的方式,按照上面我们封装Function.prototype.before以及Function.prototype.after的方法,我们需要写出如下:

submit.before(validate.before(report)).after(goBack)();
// 或者
submit.before(validate).before(report).after(goBack)();
// 【执行结果】
// 上报数据
// 验证数据
// 提交成功
// 返回首页

根据代码执行的结果,确实达到了我们想要的结果,但是:

  • 出现了一长串且流程复杂的链式调用,看着我感觉好懵
  • 如果beforeafter传递异步函数的参数呢?

显然动态织入的方式还是有些不足,那么还有没有其他解决办法呢那么还有没有其他解决办法呢?请接着往下看。

3. 中间件实现

现在我们有如下业务流程:

aop2

上图中有提交数据、验证数据、上报数据、返回结果业务节点,每一个业务节点我们都可以看作是一个中间件,它在 javasript 中本身就是一个函数。每个中间件通过next关键字传递执行权给下一个中间件,如果要把上一个中间件传递给下一个中间件必须使用next,这个next其实就是通过一定手段封装的一个方法。

因此,我们可以借用中间件思想来分解我们的业务逻辑,通过 next 方法层层传递给下一个业务。我们先创建一个名为Middleware的对象,用来管理这些中间件:

class MiddleWare {
    // cache:缓存当前所用实例的所有中间件
    cache = [];
    // middlewares:缓存当前所用实例的`handle`方法调用时之前所有中间件
    middlewares = [];
    use(fn) {
        this.cache.push(fn);
        // 可以支持链式调用,即:
        // mw.use(f1).use(f2)...use(fn).handle()
        return this;
    }
    next() {
        if (!this.middlewares.length) return;
        const fn = this.middlewares.shift();
        // bind(this):需要给每个中间件中的`next`方法绑定实例上下文
        // 因为中间件中调用的`next`方法是`this.next`方法
        fn.call(this, this.next.bind(this));
    }
    handle() {
        this.middlewares = this.cache.map(item => item);
        this.next();
    }
}

现在我们来简单测试一下:

function submit() {
    console.log('提交数据');
}

function validate() {
    console.log('验证数据');
}

function report() {
    console.log('上报数据');
}

function goBack() {
    console.log('返回首页');
}

const mw = new MiddleWare();

mw.use(next => {
    submit();
    next();
});


mw.use(next => {
    validate();
    next();
});


mw.use(next => {
    report();
    next();
});


mw.use(next => {
    goBack();
    next();
});

mw.handle();
// 【执行结果】
// 提交数据
// 验证数据
// 上报数据
// 返回首页

现在我们来测试一下链式调用以及异步处理:

const submit = next => {
    console.log('提交数据');
    next();
}

const validate = next => {
    console.log('验证数据');
    next();
}

// 这是一个异步任务的中间件
const report = next => {
    console.log('上报数据');
    setTimeout(() => {
        console.log('上报成功');
        next();
    }, 3000);
}

const goBack = next => {
    console.log('返回首页');
    // 最后一个中间件的`next`可以不用调用
    next();
}

const mw = new MiddleWare();
mw.use(submit).use(validate).use(report).use(goBack).handle();
// 【执行结果】
// 提交数据
// 验证数据
// 上报数据
// 【3秒后输出】
// 上报成功
// 返回首页

因此通过以上代码实现了业务的隔离,满足每个业务所需的数据,又能很好控制业务下发执行的权利,可以说是链式调用实现的补充以及增强版。其实这也是express框架中的中间件设计思想,当然这里的代码只是雏形。

3. 洋葱圈实现

在说明洋葱圈模型之前,我们先来一栗子:

class MiddleWare {
    // middlewares:缓存当前所用实例的所有中间件
    middlewares = [];
    use(fn) {
        this.middlewares.push(fn);
        // 可以支持链式调用,即:
        // mw.use(f1).use(f2)...use(fn).handle()
        return this;
    }
    handle() {
        const dispath = i => {
            const fn = this.middlewares[i];
            // 执行到数组最后,已经超出了`middlewares`数组的边界,
            // 返回`undefined`,并且退出执行
            if (!fn) return;
            // 通过`next`方法将执行权提交给下一个中间件元素
            const next = () => dispath(i + 1);
            fn(next);
        }
        dispath(0);
    }
}

const f1 = next => {
    console.log(111);
    next();
}

const f2 = next => {
    console.log(222);
    next();
}

const f3 = next => {
    console.log(333);
    next();
}


const mw = new MiddleWare();
mw.use(f1).use(f2).use(f3).handle();
// 【执行结果】
// 111
// 222
// 333

mw.handle方法中,dispath其实是一个递归函数,该函数的作用就是遍历执行middlewares数组的每个中间件元素,并且通过该函数代码块中定义的next方法作为参数传递给当前要执行的中间件,通过这个中间件中业务逻辑决定是否将执行权提交给下一个中间件元素。
我们执行后发现,其从执行效果上来讲与前一小节实现的MiddleWare并没有什么差别。但是我们改造一下:

class MiddleWare {
    middlewares = [];
    use(fn) {
        this.middlewares.push(fn);
        return this;
    }
    handle() {
        const dispath = i => {
            const fn = this.middlewares[i];
            if (!fn) return Promise.resolve();
            const next = () => dispath(i + 1);
            return Promise.resolve(fn(next));
        }
        dispath(0);
    }
}

在原来的基础上结合了 ES6 的 Promise 对象的语法,从语法表现形式上来说天生支持了异步任务处理,那我们现在来测试一下:

const submit = async next => {
    console.log('提交数据');
    await next();
    console.log(111);
}

const validate = async next => {
    console.log('验证数据');
    await next();
    console.log(222);
}

const report = async next => {
    console.log('上报数据');
    await delay();
    await next();
    console.log(333);
}

const goBack = async next => {
    console.log('返回首页');
    await next();
    console.log(444);
}

const delay = () => {
    return new Promise(resolve => {
        setTimeout(() => {
            console.log('上报成功');
            resolve();
        }, 3000)
    })
}

const mw = new MiddleWare();
mw.use(submit).use(validate).use(report).use(goBack).handle();
// 【执行结果】
// 验证数据
// 上报数据
// 【3秒后输出】
// 上报成功
// 返回首页
// 444     
// 333     
// 222     
// 111

以上代码我们在调用的时候使用了 ES2017 规范中的 asyncawait命令符关键字,它们俩在配合使用的时候使 async 函数的返回值是 Promise 对象,而在 MiddleWare 类的handle方法中也包裹了一层又一层的 Promise 对象,也就是每一次递归都会将当前中间件放入事件队列中,等待轮到包含它的事件循环的到来再放入调用栈中执行,这样一层一层的执行就形成了一个类似洋葱圈的模型。我们也可以观察一下它的执行结果。

aop3

asyncawait关键字既然在洋葱圈模型中起到了非常关键的作用,那么我们把这两个命令符关键字给去掉,观察以下执行结果,我们会发现与上一小节的MiddleWare类实现方式的执行结果一致:

const submit = next => {
    console.log('提交数据');
    next();
    console.log(111);
}

const validate = next => {
    console.log('验证数据');
    next();
    console.log(222);
}

const report = async next => {
    console.log('上报数据');
    await delay();
    next();
    console.log(333);
}

const goBack = next => {
    console.log('返回首页');
    next();
    console.log(444);
}

const delay = () => {
    return new Promise(resolve => {
        setTimeout(() => {
            console.log('上报成功');
            resolve();
        }, 3000)
    })
}

const mw = new MiddleWare();
mw.use(submit).use(validate).use(report).use(goBack).handle();
// 【执行结果】
// 验证数据
// 上报数据
// 222     
// 111 
// 【3秒后输出】
// 上报成功
// 返回首页
// 444     
// 333

洋葱圈模式也是Koa框架所采用的设计思想,因为它在设计上天生就支持了异步模式,而且也非常符合Node本身的单线程非阻塞的平台特点,使其足够轻量又高效的应用于业务中。

四、高阶函数的应用

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()时,才利用前面保存的所有参数,真正开始进行求值计算。

通过上述代码所示和理解,我可以理解函数柯里化大概有三个常见作用:

  • 参数复用
  • 提前返回
  • 延迟计算

再次通过一个简单的ES6语法来实现多个参数相加:

function currying(fn) {
    const args = [];
    const combineArgs = (...innerArgs) => {
        if (innerArgs.length === 0) {
            return fn(...args);
        }
        args.push(...innerArgs);
        return combineArgs;
    }
    return combineArgs;
}

const add = (...args) => {
    let result = null;
    for (const item of args) {
        result += item;
    }
    return result;
}

const result = currying(add);

// 传递一个参数 
result(100); // 合并参数 [100]
// 传递多个参数
result(200, 300); // 合并参数 [100,200,300]
// 返回函数也可以传递参数
result(400)(100, 200, 300, 400); // 合并参数 [100,200,300,400,100,200,300,400]
// 将所有参数求和
console.log(result()); // 输出: 2000
Result(100, 200, 300, 400);
// 将所有参数求和
console.log(result()); // 输出: 3000

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

7. 函数组合

函数组合(function compose),是将一系列函数作为参数传递到一个主函数中,在这个主函数执行时,这些函数参数按照一定的顺序分步执行,并将各自执行时所得到的值交给下一个这些函数参数使用(按照我自己的理解,我瞎编的)。

现在我有实现两个数相加的函数 add,然后想将所求和的值进行平方运算,函数为 squ:

const add = (x, y) => x + y;
const squ = z => z * z;
// 我可能会这样来写
const squResult = squ(add(100, 200));
// 我现在想将上面运算的值进行一次减法运算
const sub = num => num - 3;
// 我如果还是这样写
const subResult = sub(squ(add(100, 200)));
// 要是后面再接着来...,感觉自己好晕

我们可以将上面的代码 add、squ 函数当成参数来进行抽离一个简化 compose 函数:

const compose = function(fn1,fn2){
    return function(){
        const result = fn2.apply(this, arguments);
        return fn1(result);
    }
}

compose(add, squ)(1, 2); // 输出:9 

但是如果将 sub 函数传进去这里明显就行不通,但是我们可以借用上面的思维将 add、squ、sub 作为函数 compose 的参数来参与计算,然后借用 subResult 函数的执行方式将 add 函数的执行结果交给 squ 函数执行,然后再将 squ 函数的执行结果交给 sub 函数执行,最终的得到我们想要的结果,这不就是遍历所有的函数参数执行吗?

const result = compose(add, squ, sub);
result(1, 2); // 输出:6
function compose(...fns) {
    const [first, ...others] = fns;
    const innerFn = (...args) => {
        let result = first(...args);
        for (const fnItem of others) {
            result = fnItem(result);
        }
        return result;
    }
    return innerFn;
}

上述 compose 函数的思想在在 reduxcreateStore 方法的设计中主要也是运用这种思想,当然远远要比这复杂得多。

综上所述,我们可以得出以下结论:

  • 第一个函数参数可以接收多个参数
  • 后面的函数参数只接收一个参数
  • 函数参数执行是有序的
  • 函数参数必须是同步的

五、总结

  无论任何语言,高阶函数都是coder走向高级的必修课程,它为我们在日常编码中也提供很多新的思路和解决方案,也为工程设计模式提供了依据铺垫。同时函数柯里化、节流函数、防抖函数、分时函数、惰性加载函数等在工程中使用也非常普遍。

  本文大部分来至《JavaScript设计模式与开发实践》这本书,在这里要特别感谢该书的作者曾探,为此我还特意买了这本书。这篇文章经过自己学习和总结,整个过程真是让我受益匪浅、受益良多。

六、参考文档

posted @ 2020-07-02 01:59  Feesir  阅读(452)  评论(1编辑  收藏  举报