JavaScript 柯里化,节流,防抖,惰性加载,分时函数

2,019 阅读9分钟

一、函数柯里化

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

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

var monthlyCost = 0;
var cost = function(money){
    monthlyCost += money;
};
cost(100); // 第1天开销
cost(200); // 第2天开销
cost(300); // 第3天开销
// cost(800); // 第30天开销

console.log(monthlyCost) // 输出:600

通过这段代码可以看到,每天结束后我们都会记录并计算到今天为止花掉的钱。但我们其实并不太关心每天花掉了多少钱,而只想知道到月底的时候会花掉多少钱。也就是说,实际上只需要在月底计算一次。

如果在每个月的前29天(假设30天为一个月),我们都只是保存好当天的开销,直到第30天才进行求值计算,这样就达到了我们的要求。虽然下面的 cost 函数还不是一个 currying 函数的完整实现,但有助于我们了解其思想:

var cost = (function () {
    var args = [];
    return function () {
        if (arguments.length == 0) {
            var money = 0;
            for (var i = 0; i < args.length; i++) {
                money += args[i]
            };
            return money;
        } else {
            [].push.apply(args, arguments);
        }
    }
})();

cost(100); // 未真正求值
cost(200); // 未真正求值
cost(300); // 未真正求值

console.log(cost()); // 求值并输出600

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

var currying = function (fn) {
    var args = [];
    return function () {
        if (arguments.length === 0) {
            return fn.apply(this, args);
        };
        [].push.apply(args, arguments);
        return arguments.callee;
    }
};

var cost = (function () {
    var money = 0;
    return function () {
        for (var i = 0; i < arguments.length; i++) {
            money += arguments[i]
        };
        return money;
    }
})();

var cost = currying(cost); // 转化成 currying 函数
cost(100); // 未真正求值
cost(200); // 未真正求值
cost(300); // 未真正求值

console.log(cost()); // 求值并输出600

至此,我们完成了一个 currying 函数的编写。当调用 cost() 时,如果明确地带上了一些参数,表示此时并不进行真正的求值计算,而是把这些参数保存起来,此时让 cost 函数返回另外一个函数。只有当我们以不带参数的形式执行 cost() 时,才利用前面保存的所有参数,真正开始进行求值计算。

二、函数节流

JavaScript 中的函数大多数情况下都是由用户主动触发调用的。除非是函数本身的实现不合理,否则我们一般不会遇到跟函数性能相关的问题。但在一些少数情况下,函数的触发不是由用户直接控制的。在这些场景下,函数有可能被非常频繁地调用,而造成大的性能问题。下面看一个例子。

比如我们在 window.onresize 事件中要执行一些操作,在我们通过拖拽来改变窗口大小的时候,执行的操作在1秒钟进行了10次,而我们实际上只需要2次或3次,这就需要我们按时间段来忽略掉一些事件操作请求,比如确保在500ms内只执行一次。关于函数节流的代码实现有许多种,下面我们借助 setTimeout 来完成这件事情,代码如下:

var throttle = function (fn, delay) {
    var timer, // 定时器
        firstFlag = true; // 是否为第一次调用

    return function () {
        var ctx = this, args = arguments;

        if (firstFlag) { // 如果是第一次调用,不需延迟执行
            fn && fn.apply(ctx, args);
            return firstFlag = false;
        }

        if (timer) { // 如果定时器还在,说明前一次延迟执行还没有完成
            return false;
        }

        timer = setTimeout(function () { // 设置延迟执行
            clearTimeout(timer);
            timer = null;
            fn && fn.apply(ctx, args);
        }, delay || 500)
    };
};

window.onresize = throttle(function (e) {
    console.log(e)
}, 1000);

可以看到,上面的 throttle 函数的原理是,将即将被执行的函数用 setTimeout 延迟一段时间执行,如果该次延迟执行还没有完成,则忽略接下来调用该函数的请求。throttle 函数接受2个参数,第一个参数为需要被延迟执行的函数,第二个参数为延迟执行的时间。

三、函数防抖

除了节流函数,防抖也是应用场景比较广泛的函数,比如实时搜索。基于性能考虑,肯定不能用户每输入一个字符就发送一次搜索请求,一种方法就是等待用户停止输入,比如等待了500ms用户没有再输入,那么就发送搜索请求。下面代码也是借助 setTimeout 来完成:

<input id="ipt" placeholder="防抖函数输入">
<script>
    var debounce = function (fn, wait) {
        var timer = null;
        return function () {
            var ctx = this, args = arguments;
            if (timer) clearInterval(timer); // 如果存在延迟执行函数,则清除
            timer = setTimeout(function () {
                fn && fn.apply(ctx, args)
            }, wait || 300);
        }
    };
    
    document.getElementById('ipt').oninput = debounce(function () {
        console.log(this.value)
    }, 500)
</script>

可以看到防抖函数的思路,在规定时间内未触发第二次,则执行。debounce 函数接受2个参数,第一个参数为需要被延迟执行的函数,第二个参数为等待执行的时间。

四、惰性加载函数

在 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 函数里不再存在条件分支语句:

<button id="btn">点我绑定事件</button>
<script>
    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 btn = document.getElementById('btn');

    addEvent(btn, "click", function () {
        console.log(1)
    });

    // 第二次绑定事件的时候, addEvent 函数已被重写,已经是我们期望的函数,不再存在条件分支语句
    addEvent(btn, "click", function () {
        console.log(2)
    });
</script>

五、分时函数

在前面关于函数节流的讨论中,我们提供了一种限制函数被频繁调用的解决方案。下面我们将遇到另外一个问题,某些函数确实是用户主动调用的,但因为一些客观原因,这些函数会严重影响页面性能,下面看一个例子。

假如我们要创建一个 WebQQ 的 QQ 好友列表。列表中通常会有成百上千个好友,如果一个好友用一个节点来表示,当我们在页面中渲染这个列表的时候,可能要一次性往页面中创建成百上千个节点,在短时间内往页面中大量添加 DOM 节点显然也会让浏览器吃不消,我们看到的结果往往就是浏览器的卡顿甚至假死,代码如下:

var ary = [];

for (var i = 1; i <= 1000; i++) {
    ary.push(i); // 假设 ary 装载了1000个好友的数据
};

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

renderFriendList(ary);

这个问题的解决方案之一就是下面的 timeChunk 函数,timeChunk 函数让创建节点的工作分批进行,比如把1秒钟创建1000个节点,改为每隔200毫秒创建8个节点。timeChunk 函数接受3个参数,第一个参数是创建节点时需要用到的数据,第二个参数是封装了创建节点逻辑的函数,第三个参数表示每一批创建的节点数量。代码如下:

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

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

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

下面我们进行一个小测试,假设我们有1000个好友的数据,我们利用 timeChunk 函数,每一批只往页面中创建8个节点:

var ary = [];

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

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

renderFriendList();

这样就实现了我们的期望,每隔200ms创建8个节点,分批进行。

总结

以上内容有摘自《JavaScript 设计模式与开发实践》,内容有删改。在这里做个笔记,给大家做个分享,如有不足,也可指出,希望大家都有所收获。也可以关注作者,以后会不定期分享心得笔记,与君共勉。