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