引言
前端开发中一个老生常谈的问题就是'当用户滚动时, 根据滚动的位置适当触发不同的函数/动画, 例如当元素出现在视口时触发该元素的style改变. 通常的做法就是在scrollElement
上附加scoll
事件. 但是我们知道, 当滚动条滚动时scroll
事件触发的是很频繁的, 且不由JS控制(浏览器的事件队列原生提供), 如图(添加事件监听后滚轮三格):
依据系统设置的不同, 一次滚轮触发的scroll
事件大概在10~15次之间. 如果在回调函数中添加大量的DOM操作或者计算的话, 会引起明显的卡顿等性能问题. 那有没有办法去稀释
回调函数的触发操作呢? 这个时候就需要函数节流(throttle)和debounce(去颤抖)来解决了!
2017-02-06更新函数式版本
这个版本运用闭包封装数据, 修正this
指向以加强鲁棒性, 剔除了一开始就显示在视口的元素
talk is cheap, here are the code
// 根据单一元素, throttle函数专门负责事件稀释, 接受两个参数: 要间隔调用的函数, 以及调用间隔.
var throttle = function (fn, interval) {
let start = Date.now()
let first = true
return function (...args) {
let now = Date.now()
// 如果是第一次调用, 则忽略时间间隔
if (first) {
fn.apply(this, args)
first = false
return
}
if (now - start > interval) {
fn.apply(this, args)
start = now
}
}
}
// 显示元素的IIFE
var showElems = (function (selector, func) {
// 预处理, 标识已经显示在视口的元素
let elemCollect = [...document.querySelectorAll(selector)]
let innerHeight = window.innerHeight
let hiddenElems = []
elemCollect.forEach((elem, index) => {
let top = elem.getBoundingClientRect().top
// 不显示在视口才加入判断队列
if (top > innerHeight) {
hiddenElems.push(elem)
}
})
// memory release
elemCollect = null
return function (...args) {
hiddenElems.forEach((elem) => {
let bottom = elem.getBoundingClientRect().bottom
if (bottom < innerHeight) {
func.apply(elem, args)
}
})
}
})('p', function(e){
console.log(this, e, 'showed!')
})
// 组合, throttle函数负责稀释showElems触发的频率, showElems负责元素滚动到视口时的相应动作
var throttledScroll = throttle(showElems, 500)
window.addEventListener('scroll', throttledScroll)
debounce
设想一些用户的频繁操作, 例如滚动, 文本框输入等, 每次触发事件都要调用回调函数; 这样做的代价未免大了点. 而debounce
的作用就是在在用户高频触发事件时, 暂时不调用回调, 在用户停止高频操作(例如停止输入, 停止滚动时), 再调用一次回调.
解决方案有了, 怎样用代码实现呢? 这里我们要用到setTimeout
这个功能来做函数调用的延迟. 具体代码如下(将代码粘贴到console中执行以下, 自己试试看):
var timer;
document.addEventListener('scroll', function(){
clearTimeout(timer); //如果操作时已经有了延迟执行, 则取消该延迟执行
timer = setTimeout(function() { //设定新的延迟执行
callback();
}, 500)
})
(这里我们为了方便说明, 设定了timer
全局变量. 实践中我们可以将timer
附加为函数的属性, 隐藏在闭包中, 或者作为对象的属性等. )
当第一次高频操作触发时, 设定一个timer
, 在500ms后执行; 如果用户在500ms之内没有再次进行该操作(本例中是滚动), 那么我们调用callback
; 然而如果500ms之内用户又触发了滚动(即所谓的高频操作), 那么我们清除上一次设定的timeout
, 设定一个新的, 500ms之后执行的timeout
.
大家思考一下, debounce
的本质就是在用户触发expensive操作时, 不断延期该expensive操作的执行时间(取消和设定timeout
的代价是很小的). 当用户停止操作, 那我们就不再延期, 最后一次设定的timeout
会在500ms后执行expensive operation, 例如dom操作, 计算等.
到这里我们似乎已经有了一个解决方案! 然而还有个小小的问题.....
如果用户不停地操作, 那debounce
就会不断把操作延期, 如果用户没有两次操作的间隔时间大于500ms, 那么我们的callback
永远也得不到执行. 可怜的callback
! 恩, 在这一点上我们当然可以改进...
throttle
throttle
的作用是, 保证一个函数至少间隔一段时间得到一次执行. 不像等待用户停止的debounce
, throttle
即使在用户不停操作时, 也能让callback
在操作期间得到间隔的执行.
那么该怎么做呢? 一种方法是在用户开始操作时记录开始时间
, 同时设定一个flag ifOperationBegin = true
. 之后在每次用户的操作中判断当前时间
, 如果当前时间-开始时间 > 某个值
, 比如500ms, 则执行callback
, 同时设定ifOperationBegin = true
, 以开始下一次的设定开始时间 -> 记录操作时间 -> 判断的循环. 具体到代码实现上:
var scrollBegin = false, scrollStartTime = null; //用户尚未开始操作
document.addEventListener('scroll', function(){
if(!scrollBegin)scrollStartTime = Date.now();//记录开始时间, 前提是callback还没有被触发过
scrollBegin = true;//设定flag
if(Date.now() - scrollStartTime > 500){ //如果操作时间和开始时间间隔大于500ms则
exec(elems, cb); //调用回调
scrollBegin = false; //flag设为false, 以设定新的开始时间
}
})
这样做的效果是, 在用户持续触发scroll
操作时, 保证在用户操作期间callback
至少会每隔500ms触发一次. 如果用户操作在500ms之内结束, 那也木有关系, 下一次用户重新开始操作时我们的scrollStartTime
依然保留着, callback
会被立即触发.
实际运用
那这两种技术可以运用到哪里呢? 请看如下代码栗子:
function detectVisible(selector, cb, interval){ //检测元素是否在视口的函数
var elems = document.querySelectorAll(selector), innerHeight = window.innerHeight;
var exec = function(elems, cb){ //回调函数
Array.prototype.forEach.call(elems, function(elem, index){
if(elem.getBoundingClientRect().top < innerHeight){ //判断元素是否出现在视口
cb.call(elem, elem); //调用传入的回调
}
})
}
document.addEventListener('scroll', function(){ //使用debounce和throttle来稀释scroll事件
clearTimeout(detectVisible.timer);
if(!detectVisible.scrollBegin)detectVisible.scrollStartTime = Date.now();
detectVisible.scrollBegin = true;
if(Date.now() - detectVisible.scrollStartTime > interval){
exec(elems, cb);
console.log('invoked by throttle!')
detectVisible.scrollBegin = false;
}
detectVisible.timer = setTimeout(function() {
exec(elems, cb);
console.log('invoked by debounce!')
}, interval)
})
}
detectVisible('div.elem', function(elem){
this.style.backgroundColor = 'yellow';
}, 500);
这个栗子中我们综合运用了throttle
和debounce
, 达到了如下效果: 用户不停滚动时callback
会至少每500ms触发一次; 用户停止滚动后的500ms判断函数也会触发一次. 大家可以打开console查看callback
何时是被throttle
触发的, 何时是被debounce
触发的.
总结
这一篇文章的主要主题事件的稀释以期性能上的改善, 有两种解决方法:throttle
和debounce
. 前者是通过在用户操作时判断操作时间, 来达到间隔一段时间触发回调的效果; 而后者则是将触发的时间不断延期, 直到用户停止操作再执行回调. 两者各有优缺点, 两相结合, 我们得到了一个用户无论怎样操作(不停操作或者操作时间极短)都可以保证callback
定期得到执行的函数. problem solved!
看完这篇, 如果你有所收获, 请去github给我加个star呗!~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。