前端性能優(yōu)化-每一個前端開發(fā)者需要知道的防抖與節(jié)流知識
本文轉載自微信公眾號「編程界」,作者五月君。轉載本文請聯(lián)系編程界公眾號。
防抖和節(jié)流都是應用在高頻事件觸發(fā)場景中,例如 scroll(滾動加載、回到頂部)、input(聯(lián)想輸入) 事件等。防抖和節(jié)流核心實現(xiàn)思想是在事件和函數(shù)之間增加了一個控制層,達到延遲執(zhí)行的功能,目的是防止某一時間內(nèi)頻繁執(zhí)行一些操作,造成資源浪費。
事件與函數(shù)之間的控制層通常有兩種實現(xiàn)方式:一是使用定時器,每次事件觸發(fā)時判斷是否已經(jīng)存在定時器,是本文我們實現(xiàn)的方式。另外一種是記錄上一次事件觸發(fā)的時間戳,每次事件觸發(fā)時判斷當前時間戳距離上次執(zhí)行的時間戳之間的一個差值(deplay - (now - previous)),是否達到了設置的延遲時間。
可視化效果對比
下圖是通過一個可視化工具 debounce_throttle 截取的一個效果圖,展示了移動鼠標事件在常規(guī)操作、防抖處理(debounce)、**節(jié)流處理(throttle)**三種情況下的一個對比。
防抖(debounce)
防抖是在事件觸的指定時間后執(zhí)行回掉函數(shù),如果指定時間內(nèi)再次觸發(fā)事件,按照最后一次重新計時。
生活場景示例:公交車到站點后,師傅不會上一個人就立馬關閉車門起步,會等待最后一個人上去了或車上人已經(jīng)滿了,才會關閉車門起步。
聯(lián)想輸入 - 常規(guī)示例
例如搜索框聯(lián)想提示,當我們輸入數(shù)據(jù)后,可能會請求接口獲取數(shù)據(jù),如果沒有做任何處理,當在輸入開始時就會不斷的觸發(fā)接口請求,這中間必然會造成資源的浪費,如果這樣頻繁操作 DOM 也是不可取的。
- // Bad code
- <html>
- <body>
- <div> search: <input id="search" type="text"> </div>
- <script>
- const searchInput = document.getElementById("search");
- searchInput.addEventListener('input', ajax);
- function ajax(e) { // 模仿數(shù)據(jù)查詢
- console.log(`Search data: ${e.target.value}`);
- }
- </script>
- </body>
- </html>
上面這段代碼我們沒有做過任何優(yōu)化,使用 ajax() 方法模擬數(shù)據(jù)請求,讓我們看下執(zhí)行效果。
常規(guī)聯(lián)想輸入操作.gif
如果是調(diào)用的真實接口,從輸入的那一刻起就會不停掉用服務端接口,浪費不必要的性能,還很容易觸發(fā)接口的限流措施,例如 Github 提供的 API 會有每小時最大請求數(shù)限制。
聯(lián)想輸入 - 防抖處理示例
讓我們實現(xiàn)一個防抖函數(shù)(**debounce****)**優(yōu)化下上面的代碼。**原理是通過標記,判斷指定的時間內(nèi)是否存在多次調(diào)用,當存在多次調(diào)用時清除掉上一次的定時器,重新開始計時,在指定的時間內(nèi)如果沒有再次調(diào)用,就執(zhí)行傳入的回調(diào)函數(shù) ****fn**。
- function debounce(fn, ms) {
- let timerId;
- return (...args) => {
- if (timerId) {
- clearTimeout(timerId);
- }
- timerId = setTimeout(() => {
- fn(...args);
- }, ms);
- }
- }
這對于搜索場景是比較合適的,我們希望以最后一次輸入結果為準,修改最開始的聯(lián)想輸入示例。
- const handleSearchRequest = debounce(ajax, 500)
- searchInput.addEventListener('input', handleSearchRequest);
這次就好多了,當連續(xù)輸入停頓時以最后一次的輸入接口為準請求接口,避免了不停的刷新接口。
聯(lián)想輸入-防抖.gif
適當?shù)臅r候記得要清除事件,例如 React 中,我們在組件掛載時監(jiān)聽 input,同樣的組件卸載時也要清除對應的事件監(jiān)聽器函數(shù)。
- componentDidMount() {
- this.handleSearchRequest = debounce(ajax, 500)
- searchInput.addEventListener('input', this.handleSearchRequest);
- }
- componentWillUnmount() {
- searchInput.removeEventListener('input', this.handleSearchRequest);
- }
節(jié)流(throttle)
節(jié)流是在事件觸發(fā)后,在指定的間隔時間內(nèi)執(zhí)行回調(diào)函數(shù)。
生活場景示例:當我們乘坐地鐵時,列車總是按照指定的間隔時間每 5 分鐘(也許是其它時間)這樣運行,當時間到達之后,列車就要開走了。
滾動到頂部 - 常規(guī)示例
例如,頁面有很多個列表項,當我們向下滾動之后希望出現(xiàn)一個 Top 按鈕 點擊之后能夠回到頂部,這時我們需要獲取滾動位置與頂部的距離判斷是否展示 Top 按鈕。
- <body>
- <div id="container"></div>
- <script>
- const container = document.getElementById('container');
- window.addEventListener('scroll', handleScrollTop);
- function handleScrollTop() {
- console.log('scrollTop: ', document.body.scrollTop);
- if (document.body.scrollTop > 400) {
- // 處理展示按鈕操作
- } else {
- // 處理不展示按鈕操作
- }
- }
- </script>
- </body>
可以看到,如果不加任何處理,滾動一下可能就會觸發(fā)上百次,每次都去做處理,顯然是白白浪費性能的。
滾動未處理節(jié)流.png
滾動到頂部 - 節(jié)流處理示例
實現(xiàn)一個簡單的節(jié)流(throttle)函數(shù),與防抖很相似,區(qū)別的地方是,這里通過標志位判斷是否已經(jīng)被觸發(fā),當已經(jīng)觸發(fā)后,再進來的請求直接結束掉,直到上一次指定的間隔時間到達且回調(diào)函數(shù)執(zhí)行之后,再接受下一個處理。
- function throttle(fn, ms) {
- let flag = false;
- return (...args) => {
- if (flag) return;
- flag = true;
- setTimeout(() => {
- fn(...args)
- flag = false;
- }, ms);
- }
- }
改造下上面的示例,再來看看執(zhí)行結果。
- const handleScrollTop = throttle(() => {
- console.log('scrollTop: ', document.body.scrollTop);
- // todo:
- }, 500);
- window.addEventListener('scroll', handleScrollTop);
與上面 “常規(guī)滾動到頂部示例” 做對比,現(xiàn)在效果已經(jīng)好多了。
滾動到頂部-節(jié)流處理.gif
記得清除事件
以 React 為例,組件掛載時我們監(jiān)聽 window 的 scroll 事件,在組件卸載時記得要移除對應的事件監(jiān)聽器函數(shù)。如果組件卸載時忘記移除,原先 A 頁面引入了 ScrollTop 組件,單頁面應用跳轉到 B 頁面后,雖然 B 頁面沒有引入 ScrollTop 組件,但是也會受到影響,因為該事件已經(jīng)在 window 全局對象注冊了,另外這樣也存在內(nèi)存泄漏。
- class ScrollTop extends PureComponent {
- componentDidMount() {
- this.handleScrollTop = throttle(this.props.updateScrollTopValue, 500);
- window.addEventListener('scroll', this.handleScrollTop);
- }
- componentWillUnmount() {
- window.removeEventListener('scroll', this.handleScrollTop);
- }
- // ...
- }
requestAnimationFrame
requestAnimationFrame 是瀏覽器提供的一個 API,它的應用場景是告訴瀏覽器,我需要運行一個動畫。該方法會要求瀏覽器在下次重繪之前調(diào)用指定的回調(diào)函數(shù)更新動畫。這個 API 在 JavaScript 異步編程指南 - 探索瀏覽器中的事件循環(huán)機制 中有講過。
它會受到瀏覽器的刷新頻率影響,如果是 60fps 那就是每間隔 16.67ms 執(zhí)行一次,如果在 16.67ms 內(nèi)有多次 DOM 操作,也是不會渲染多次的。
當瀏覽器的刷新頻率為 60fps 時等價于 throttle(fn, 16.67)。在使用時需要先處理下,不能讓它立即執(zhí)行,由事件觸發(fā)。
- const handleScrollTop = () => requestAnimationFrame(() => {
- console.log('scrollTop: ', document.body.scrollTop);
- // todo:
- });
- window.addEventListener('scroll', handleScrollTop);
requestAnimationFrame 這個是瀏覽器的 API,在 Node.js 中是不支持的。
社區(qū)工具集支持
社區(qū)中一些 JavaScript 的工具集框架,也都提供了防抖與節(jié)流的支持,例如 underscorejs、lodash。
剛開始有提到,另外一種實現(xiàn)方式是記錄上一次事件觸發(fā)的時間戳,每次事件觸發(fā)時判斷當前時間戳距離上次執(zhí)行的時間戳之間的一個差值,來判斷是否達到了設置的延遲時間,以 underscorejs throttle 實現(xiàn)為例,只保留部分代碼示例,一個關鍵代碼片段是 remaining = wait - (_now - previous)。
- // https://github.com/jashkenas/underscore/blob/master/modules/throttle.js#L23
- export default function throttle(func, wait, options) {
- var timeout, context, args, result;
- var previous = 0;
- var throttled = function() {
- var _now = now();
- if (!previous && options.leading === false) previous = _now;
- var remaining = wait - (_now - previous);
- context = this;
- args = arguments;
- if (remaining <= 0 || remaining > wait) {
- if (timeout) {
- clearTimeout(timeout);
- timeout = null;
- }
- previous = _now;
- result = func.apply(context, args);
- if (!timeout) context = args = null;
- }
- };
- return throttled;
- }
總結
防抖是在事件觸的指定時間后執(zhí)行回掉函數(shù),如果指定時間內(nèi)再次觸發(fā)事件,按照最后一次重新計時。節(jié)流是在事件觸發(fā)后的間隔時間內(nèi)執(zhí)行回調(diào)函數(shù)。這兩個概念在前端開發(fā)中都是會遇到的,選擇合理的方案解決實際問題。
防抖與節(jié)流還不是太理解的,對著文中的示例自己實踐下,有什么疑問在留言區(qū)提問。
Reference
https://jinlong.github.io/2016/04/24/Debouncing-and-Throttling-Explained-Through-Examples/
http://demo.nimius.net/debounce_throttle/
JavaScript 異步編程指南 - 探索瀏覽器中的事件循環(huán)機制