什么是函数节流?

介绍前,先说下背景。在前端开发中,有时会为页面绑定 resize 事件,或者为一个页面元素绑定拖拽事件(其核心就是绑定 mousemove),这种事件有一个特点,就是用户不必特地捣乱,他在一个正常的操作中,都有可能在一个短的时间内触发非常多次事件绑定程序。而大家知道,DOM 操作时很消耗性能的,这个时候,如果你为这些事件绑定一些操作 DOM 节点的操作的话,那就会引发大量的计算,在用户看来,页面可能就一时间没有响应,这个页面一下子变卡了变慢了。甚至在 IE 下,如果你绑定的 resize 事件进行较多 DOM 操作,其高频率可能直接就使得浏览器崩溃。

怎么解决?函数节流就是一种办法。话说第一次接触函数节流 (throttle),还是在看 impress 源代码的时候,impress 在播放的时候,如果窗口大小发生改变 (resize),它会对整体进行缩放 (scale),使得每一帧都完整显示在屏幕上:

impress在resize的时候自动适应

稍微留心,你会发现,当你改变窗体大小的时候,不管你怎么拉,怎么拽,都没有立刻生效,而是在你改变完大小后的一会儿,它的内容才进行缩放适应。看了源代码,它用的就是函数节流的方法。

函数节流,简单地讲,就是让一个函数无法在很短的时间间隔内连续调用,只有当上一次函数执行后过了你规定的时间间隔,才能进行下一次该函数的调用。以 impress 上面的例子讲,就是让缩放内容的操作在你不断改变窗口大小的时候不会执行,只有你停下来一会儿,才会开始执行。

 

函数节流的原理

函数节流的原理挺简单的,估计大家都想到了,那就是定时器。当我触发一个时间时,先 setTimout 让这个事件延迟一会再执行,如果在这个时间间隔内又触发了事件,那我们就 clear 掉原来的定时器,再 setTimeout 一个新的定时器延迟一会执行,就这样。

 

代码实现

明白了原理,那就可以在代码里用上了,但每次都要手动去新建清除定时器毕竟麻烦,于是需要封装。在《JavaScript 高级程序设计》一书有介绍函数节流,里面封装了这样一个函数节流函数:

它把定时器 ID 存为函数的一个属性(= =个人的世界观不喜欢这种写法)。而调用的时候就直接写

这样两次函数调用之间至少间隔 100ms。

而 impress 用的是另一个封装函数:

它使用闭包的方法形成一个私有的作用域来存放定时器变量 timer。而调用方法为

两种方法各有优劣,前一个封装函数的优势在把上下文变量当做函数参数,直接可以定制执行函数的 this 变量;后一个函数优势在于把延迟时间当做变量(当然,前一个函数很容易做这个拓展),而且个人觉得使用闭包代码结构会更优,且易于拓展定制其他私有变量,缺点就是虽然使用 apply 把调用 throttle 时的 this 上下文传给执行函数,但毕竟不够灵活。

 

接下来是?

接下来就讨论怎么更好地封装?这多没意思啊,接下来讨论下怎样拓展深化函数节流。

函数节流让一个函数只有在你不断触发后停下来歇会才开始执行,中间你操作得太快它直接无视你。这样做就有点太绝了。resize 一般还好,但假如你写一个拖拽元素位置的程序,然后直接使用函数节流,那恭喜你,你会发现你拖动时元素是不动的,你拖完了,它直接闪到终点去。

其实函数节流的出发点,就是让一个函数不要执行得太频繁,减少一些过快的调用来节流。当你改变浏览器大小,浏览器触发 resize 事件的时间间隔是多少?我不清楚,个人猜测是 16ms(每秒 64 次),反正跟 mousemove 一样非常太频繁,一个很小的时间段内必定执行,这是浏览器设好的,你无法直接改。而真正的节流应该是在可接受的范围内尽量延长这个调用时间,也就是我们自己控制这个执行频率,让函数减少调用以达到减少计算、提升性能的目的。假如原来是 16ms 执行一次,我们如果发现 resize 时每 50ms 一次也可以接受,那肯定用 50ms 做时间间隔好一点。

而上面介绍的函数节流,它这个频率就不是 50ms 之类的,它就是无穷大,只要你能不间断 resize,刷个几年它也一次都不执行处理函数。我们可以对上面的节流函数做拓展:

在这个拓展后的节流函数升级版,我们可以设置第三个参数,即必然触发执行的时间间隔。如果用下面的方法调用

则意味着,50ms 的间隔内连续触发的调用,后一个调用会把前一个调用的等待处理掉,但每隔 100ms 至少执行一次。原理也很简单,打时间 tag,一开始记录第一次调用的时间戳,然后每次调用函数都去拿最新的时间跟记录时间比,超出给定的时间就执行一次,更新记录时间。

狠击这里查看测试页面

到现在为止呢,当我们在开发中遇到类似的问题,一个函数可能非常频繁地调用,我们有了几个选择:一呢,还是用原来的写法,频繁执行就频繁执行吧,哥的电脑好;二是用原始的函数节流;三则是用函数节流升级版。不是说第一种就不好,这要看实际项目的要求,有些就是对实时性要求高。而如果要求没那么苛刻,我们可以视具体情况使用第二种或第三种方法,理论上第二种方法执行的函数调用最少,性能应该节省最多,而第三种方法则更加地灵活,你可以在性能与体验上探索一个平衡点。

 

你怎么了,性能

(原谅我,写得有点长 = = ,文章主体还剩最后这一节。)

我们经常说我优化了代码了,现在的代码更高效了,但貌似很少有人去测试,性能是否真的提升了,提升了多少。当然,前端性能测试的不完善、不够体系化也是原因之一,但我们也要有一种严谨的态度。上面介绍了三种方法,理论上来说呢,第一种方法执行的运算最多,性能理应最差(运算过多过频,内存、cpu 占用高,页面变卡),而第二种应该是性能最好,第三种就是一种居中的方案。

为了给读者一个更确切的分析,于是我对三种方法做了一次蛋疼的性能测试。。。我选择的是拖拽一个页面元素位置的应用场景,为了让性能优化更明显一点,拖拽的是一个 iframe,iframe 里面加载的是腾讯首页(一般门户网站的首页都够重量级的),这样在拖拽的过程中会不断触发浏览器的重绘。至于怎么看性能,我打开的是 chrome 的调试面板的时间线标签,里面有 memory 监视。对于性能的评价标准,我选的是内存占用。

于是长达两三个小时的性能测试开始了。。。

 

很快我就发现,chrome 的性能优化得太好了,我的第一种测试方案三种方法之间有性能差异,但这个差异实在不明显,而且每一轮的测试都有波动,而且每次测试还很难保证测试的背景条件(如开始时的内存占用情况),第一组测试结果如下:

第一种方法:函数节流-首轮测试-第一种方法

第二种方法:函数节流-首轮测试-第二种方法

第三种方法:函数节流-首轮测试-第三种方法

可以发现,这些小差异很难判定哪种方法更好。

 

于是有了新一轮测试。不够重量化?好吧,我每次 mousemove 的处理函数中,都触发 iframe 的重新加载;测试数据有瞬时波动?这次我一个测试测 60 秒,看一分钟的总体情况;测试条件不够统一?我规定在 60 秒里面 mouse up 6 次,其他时间各种 move。

于是有了第二组图片(其实做了很多组图片,这里只选出比较有代表性的一组,其他几组类似)

第一种方法:函数节流-二轮测试-第一种方法

第二种方法:函数节流-二轮测试-第二种方法

第三种方法:函数节流-二轮测试-第三种方法

看错了?我一开始也这么认为,但测试了几次都发现,第一种方法正如预料中的占资源,第二种方法竟然不是理论上的性能最优,最优的是第三种方法!

仔细分析。第一种方法由于不断地 mousemove,不断更新位置的同时重新加载 iframe 的内容,所以内存占用不断增加。第二种方法,即原始的函数节流,可以从截图看出内存占用有多处平坦区域,这是因为在 mousemove 的过程中,由于时间间隔短,不触发处理函数,所以内存也就有一段平滑期,几乎没有增长,但在 mouseup 的时候就出现小高峰。第三种方法呢,由于代码写了每 200ms 必须执行一次,于是就有很明显的高峰周期。

为什么第三种方法会比第二种方法占用内存更小呢?个人认为,这跟内存回收有关,有可能 chrmoe 在这方面真的优化得太多(。。。)。不断地每隔一个小时间段地新建定时器,使得内存一直得不到释放。而使用第三种方法,从代码结构可以看出,当到了指定的 mustRunDelay 必须执行处理函数的时候,是不执行新建定时器的,即是说在立即执行之后,有那么一小段时间空隙,定时器是被 clear 的,只有在下一次进入函数的时候才会重新设置。而 chrome 呢,就趁这段时间间隙回收垃圾,于是每一个小高峰后面都有一段瞬时的 “下坡”。

当然,这只是我的推测,期待读者有更独到的看法。

重度测试页面(个人测试的时候是没有切换器的,每次代码选了一种模式,然后就关闭浏览器,重新打开页面来测试,以保证运行时不受到别的模式的影响。这里提供的测试页面仅供参考)

 

后语

(这是后语,不算正文的小节)

上面就是我对函数节流的认识和探索了,时间有限,探索得不够深也写得不够好。个人建议,在实际项目开发中,如果要用到函数节流来优化代码的话,函数节流升级版更加地灵活,且在一些情况下内存占用具有明显的优势(我只试了 chrome,只试了两三个钟,不敢妄言)。

最后我们可以整合了第二、三种方法,封装成一个函数,其实第二种方法也就是第三种方法的特例而已。还可以以 hash 对象封装参数:执行函数、上下文、延迟、必须执行的时间间隔。这比较简单就不在这里贴出来了。

原创文章转载请注明:

转载自AlloyTeam:http://www.alloyteam.com/2012/11/javascript-throttle/

  1. 又灵 2015 年 12 月 14 日

    难得好贴,赞一个,继续努力啊

  2. Legend 2015 年 12 月 1 日

    在第二种节流函数,为什么 timebr 能保存数据。因为有个 timeout 没执行让内容没有释放局部变量吗

  3. luren 2015 年 4 月 16 日

    function throttle(fn, threshhold, scope) { threshhold || (threshhold = 250); var last, deferTimer; return function () { var context = scope || this; var now = +new Date, args = arguments; if (last && now < last + threshhold) { // hold on to it clearTimeout(deferTimer); deferTimer = setTimeout(function () { last = now; fn.apply(context, args); }, threshhold); } else { last = now; fn.apply(context, args); } }; }

  4. 外贸邮箱 2014 年 12 月 9 日

    外贸企业邮箱– 之向导

  5. 坚壳 2014 年 12 月 5 日

    var last = 0;
    var now = +new Date();

    if (now – last > 50) {
    last = now();
    // do my function;
    }

    50ms 内只处理一次,也能达到节流效果吧

  6. Thunk 2014 年 11 月 11 日

    当我触发一个时间时,文字打错了哦

  7. bumfod 2014 年 10 月 4 日

    有一個拼寫錯誤:methor 應爲 method。

  8. wuya 2013 年 11 月 11 日

    var throttleV2 = function(fn, delay, mustRunDelay){
    var timer = null;
    var t_start;
    return function(){
    var context = this, args = arguments, t_curr = +new Date();
    clearTimeout(timer);
    if(!t_start){
    t_start = t_curr;
    }
    if(t_curr – t_start >= mustRunDelay){
    fn.apply(context, args);
    t_start = t_curr;
    }
    else {
    timer = setTimeout(function(){
    //这里是不是应该 更新下 t_start??
    t_start = t_curr;
    fn.apply(context, args);
    }, delay);
    }
    };
    };

  9. Drumer_Coder 2012 年 11 月 23 日

    这个东西具体都用在什么方面呢?看是看明白了但是应用场景我还不是太明确

  10. 李荣飞 2012 年 11 月 15 日

    第一个问题:
    t_start 定义时就应该初始化, 你在返回函数里初始化, 你仔细看看作用只起了一次, 但是实际却是每次都执行, 浪费了多次判断资源
    第二个问题:
    当执行时间大于必须执行时间时, 首先要做的应该是先重置开始时间, 应为在火狐下, 在执行回调函数的同时, 滚动条依然在滚动, 这样就是短时间大量触发回调函数, 没有起到函数节流作用, 不信, 你 alert 一下
    第三个问题:
    当执行时间大于必须执行时间时, 当重置开始时间后, 紧接着要再 clearTimeout(timer), 因为此时 timer 已经定义了, 你在执行回调函数的同时, 可能滚动已经停止, 造成执行完回调函数后, 又要再次执行定时器中的回调函数
    第四个问题:
    定时器中的回调函数执行完后, 也要重置 t_start, 要不然那就彻底没有起到函数节流思想了

    小小思考, 还望楼主更正, 附上改进后代码
    //改进版的函数节流
    function highThottle(fn,content,musttime,time){
    var start=new Date();
    var musttime=musttime||5000;
    return function(){
    var args=arguments;
    clearTimeout(fn.timer);
    var end=new Date();
    if(end-start>musttime){
    start=new Date();
    clearTimeout(fn.timer);
    fn.apply(content,args);
    }else{
    fn.timer=setTimeout(function(){
    start=new Date();
    fn.apply(content,args);
    },time||200);
    }
    }
    }

  11. K 2012 年 11 月 13 日

    http://jsfiddle.net/aFytc/1/

    把 setTimeout 创建的 func 次数减少。
    这样是每次都会去 clearTimeout,
    其实既然 evt type 一样,就没有必要总是去 setTimeout 了。

  12. tglgx 2012 年 11 月 8 日

    这种不知道和第三种有没有太大差别呢;
    其中 args 因为考虑到需要用最后一次的所以提外了,context 这个个人感觉比较纠结,在内在外应该都差不多,姑且也提到函数外了
    var throttle = function(fn, delay){
    var timer = null;
    var args,context;
    return function(){
    context = this;
    args = arguments;
    if(timer) return;
    timer = setTimeout(function(){
    timer = null;
    fn.apply(context, args);
    }, delay);
    };
    };

  13. Jony 2012 年 11 月 8 日

    虽然 chrome 比较好测试,但是在 chrome 下测试是不能反映出来问题的,特别是 IE 系
    这个应该还是要根据实际需求来吧,还有第二种方法里层的闭包 args = arguments 获取不到吧?

  14. 隐身 2012 年 11 月 8 日

    写得是非常好, 昨天才无意识使用了一函数节流, 今天就看到这个概念,,

    文章中提到的 JavaScript 高级程序设计中的代码,    clearTimeout(methor.tId) 中的 methor 哪来的?

  15. welpher.yu 2012 年 11 月 7 日

    性能这个是怎么测试出来的呢

    • TAT.老教授

      TAT.weber 2012 年 11 月 8 日

      在 chrome 下按 F12,在 TimeLine 标签下就可以监控获取当前页面动作的帧速、memory 占用等

  16. webkou 2012 年 11 月 4 日

    太棒了 ,正好解决我之前碰到的一个难题,非常赞!!

  17. TAT.iAzrael

    TAT.iAzrael 2012 年 11 月 2 日

    嗯, 可以看下 underscore 的 delay 和 debounce,其实两种写法都有各自的用途

  18. 流年 2012 年 11 月 2 日

    学习了,一直没考虑到升级版的做法。

发表评论