Tăng hiệu suất React app với Throttling và Debouncing
Đặt vấn đề Trong quá trình xây dựng các ứng dụng với ReactJS, chúng ta luôn phải đau đầu chú ý tới performance để tránh việc API calls, async requests , DOM updates,... quá nhiều lần qua các React features như shouldComponentUpdate(), React.PureComponent, React.memo hay Hooks(useState(), ...
Đặt vấn đề
Trong quá trình xây dựng các ứng dụng với ReactJS, chúng ta luôn phải đau đầu chú ý tới performance để tránh việc API calls, async requests , DOM updates,... quá nhiều lần qua các React features như shouldComponentUpdate(), React.PureComponent, React.memo hay Hooks(useState(), useMemo(), useContext(), useReducer(), etc.
Code "chạy được" là một câu chuyện, code "xịn" lại là một câu chuyện khác.
Trong bài viết này, chúng mình sẽ cùng xem xét một cách cải thiện hiệu suất của các React app mà không sử dụng bất kỳ các React features nào kể trên, thay vào đó là một kỹ thuật chung không chỉ áp dụng cho React: Throttling và Debouncing.
Bắt đầu thôi nàooooo ????????))
Bắt đầu với ví dụ
Search BoxTa bắt đầu với một ví dụ nhé:
import React from 'react'; import './autocomp.css'; class SearchBox extends React.Component { constructor(props) { super(props); this.state= { results: [] } } handleInput = evt => { const value = evt.target.value fetch(`/api/users`) .then(res => res.json()) .then(result => this.setState({ results: result.users })) } render() { let { results } = this.state; return ( <div className='autocomp_wrapper'> <input placeholder="Enter your search.." onChange={this.handleInput} /> <div> {results.map(item=>{item})} </div> </div> ); } } export default SearchBox;
Trong ví dụ trên, SearchBox, khi bạn gõ một từ khóa nào trong ô input, nó sẽ gửi API request để lấy danh sách users ra và hiển thị. Điều này có nghĩa là cứ sau mỗi ký tự bạn gõ sẽ có 1 request gửi lên, nếu thành công, DOM lại được update sau lời gọi setState().
Như vậy, khi bạn gõ 10 ký tự thì sẽ tương ứng 10 API requests và 10 lần updates DOM. Mà đó là chúng ta đang xét mới chỉ một user thôi đó ????????. Bất chấp cả database lưu dưới local thì việc update DOM sau mỗi ký tự cũng vô cùng "tổn phí" đúng không nào ????????
Use & attachment of events
Một ví dụ khác là việc ta dùng kèm với sự kiện resize || scroll. Đa phần, một trang web được cuộn ~1000 lần/s.
Giả sử ta có đoạn code như sau:
document.body.addEventListener('scroll', ()=> { console.log('Scrolled !!!') })
Hàm này sẽ được gọi ~1000 lần/s ???????? Trường hợp xấu nhất là trình xử lý sự kiện phải thực hiện các tính toán và thao tác DOM nặng nề.
function longOp(ms) { var now = Date.now() var end = now + ms while(now < end) { now = Date.now() } } document.body.addEventListener('scroll', ()=> { // simulating a heavy operation longOp(9000) console.log('Scrolled !!!') })
Như bạn đã thấy, sau 9s hệ thống sẽ log ra Scrolled !!!. Nếu chúng ta cuộn body tới 5000px, sẽ có tới 200+ sự kiện bị gọi. Mỗi sự kiện cần 9s để kết thúc. Vậy là sau 9 * 200 = 1800s để hoàn thành hết 200+ sự kiện. Do đó, mất tới nửa giờ từ lúc bắt đầu cho tới kết thúc. Chắc chắn kết quả sẽ không ngọt ngào rằng brouser sẽ "ổn" đâu, nó có thể bị lag hoặc không phản hồi ???????? Hmmm... nhận ra vấn đề ở đây rồi đúng không nào ????????
Cùng tìm hiểu base throttling & debouncing chút đã nhé ????????
Throttling
Throttling enforces a maximum number of times a function can be called over time
Throttling là việc điều chỉnh thực thi một chức năng nhất định sau khi một khoảng thời gian xác định đã trôi qua.
Ví dụ như “Chỉ thực thi hàm này nhiều nhất 1 lần trong 100ms”. Hay giả sử như mình gọi một hàm với tốc độ 1000 lần/20s. Nếu chúng ta điều tiết để thực thi trong mỗi 500ms, thì trong 20s, chức năng sẽ được thực thi trong 40 lần/20s:
1000 * 20 secs = 20,000ms 20,000ms / 500ms = 40 times
Từ 20000 xuống 40, đáng kể chưaaaaa ???????? Để ứng dụng Throttling trong React, chúng ta sẽ sử dụng underscore, lodash libraries, RxJS & tùy chỉnh riêng.
underscore
Thư viện underscore là một package trên npm, dùng để điều tiết component.
npm i underscore
Ta có thể sử dụng trong component như sau:
// ... import * as _ from underscore; class SearchBox extends React.Component { constructor(props) { super(props); this.state = { results: [] } this.handleInputThrottled = _.throttle(this.handleInput, 100) } handleInput = evt => { const value = evt.target.value const filteredRes = data.filter((item)=> { // algorithm to search through the `data` array }) this.setState({ results: filteredRes }) } render() { let { results } = this.state; return ( <div className='autocomp_wrapper'> <input placeholder="Enter your search.." onChange={this.handleInputThrottled} /> <div> {results.map(result=>{result})} </div> </div> ); } }
Trong đoạn code trên, hàm điều tiết handleInputThrottled() nhận vào một callback là handleInput() (hàm cần được điều tiết) và một timebox.
Trở lại với ví dụ phía trên, giả sử tốc độ gõ bình thường của một kí tự là 200ms, gõ 10 ký tự sẽ tốn 200 x 10 = 2000ms. Hàm handleInput bây giờ sẽ chỉ được gọi 2000 / 1000 = 2 lần thôi, thay vì 10 lần như trước.
lodash
lodash cũng là một thư viện giúp chúng ta xử lý vấn đề này.
npm i lodash
Với ví dụ đầu tiên:
// ... import { throttle } from lodash; class SearchBox extends React.Component { constructor(props) { // ... this.handleInputThrottled = throttle(this.handleInput, 100) } handleInput = evt => { // ... } render() { // ... }
Chả khác gì ngoài thay hàm throttle bên lodash với _.throttle bên underscore cả ????????
RxJS
RxJS là Reactive Extensions in JS cung cấp cho chúng ta các toán tử, trong đó có một toán tử xử lý vấn đề throttling.
npm i rxjs
Ví dụ đầu tiên sẽ được xử lý như sau với RxJS:
// ... import { BehaviorSubject } from 'rxjs'; import { throttle } from 'rxjs/operators'; class SearchBox extends React.Component { constructor(props) { super(props); this.state = { results: [] } this.inputStream = new BehaviorSubject() } componentDidMount() { this.inputStream .pipe( throttle(100) ) .subscribe(v => { const filteredRes = data.filter((item)=> { // algorithm to search through the `data` array }) this.setState({ results: filteredRes }) }) } render() { let { results } = this.state; return ( <div className='autocomp_wrapper'> <input placeholder="Enter your search.." onChange={e => this.inputStream.next(e.target.value)} /> <div> {results.map(result => { result })} </div> </div> ); } }
Chúng ta import throttle & BehaviorSubject từ thư viện RxJS. Đầu tiên, khởi tạo một inputStream property là một BehaviorSubject instance. Khi bắt đầu gõ ký tự vào input là lúc emit giá trị đó vào inputStream.
Trong componentDidMount, cho inputStream đi qua một pipe với throttle(1000) (nghĩa là RxJS sẽ điều tiết inputStream sau mỗi 1000ms), sau đó trả về một Observable, ta subscrible để lấy được giá trị đó.
Tự viết custom implementation
Để hiểu hơn về cơ chế throttling, có lẽ chúng ta nên tự viết throttling implementation
function throttle(fn, ms) { let timeout function exec() { fn.apply() } function clear() { timeout == undefined ? null : clearTimeout(timeout) } if(fn !== undefined && ms !== undefined) { timeout = setTimeout(exec, ms) } else { console.error('callback function and the timeout must be supplied') } // API to clear the timeout throttle.clearTimeout = function() { clear(); } }
Trong component SearchBox ta chỉ cần:
// Trong constructor() this.handleInputThrottled = throttle(this.handleInput, 100)
là được rồi ????????
Debouncing
Debouncing enforces that a function will not be called again until a certain amount of time has passed since its last call.
Trong Debouncing, nó bỏ qua tất cả các lệnh gọi đến một hàm và đợi cho đến khi hàm đó ngừng được gọi trong một khoảng thời gian xác định. Về áp dụng, cú pháp giống y throtting luôn, mình có thể dùng lodash, underscore hay RxJS:
// Case 1 import { debounce } from 'lodash'; ... this.handleInputThrottled = debounce(this.handleInput, 100)
// Case 2 import * as _ from 'underscore'; this.handleInputThrottled = _.debounce(this.handleInput, 100)
// Case 3 import { BehaviorSubject } from 'rxjs'; import { debounce } from 'rxjs/operators'; class SearchBox extends React.Component { constructor(props) { // ... this.inputStream = new BehaviorSubject() } componentDidMount() { this.inputStream .pipe( debounce(100) ) .subscribe(v => { // ... }) }
Bạn cũng có thể tham khảo một demo sử dụng debounce trong lodash với Functional Component tại đây.
Common cases
Các trường hợp hay sử dụng tới throtting hay deboucing ta có thể kể tới như trong các Game, đặc biệt là các game hành động yêu cầu nhấn phím hoặc thực hiện các hành động như bắn súng, tăng tốc,... game thủ có thể bấm một phím thường xuyên (40 lần trong 20 giây tức là 2 lần một giây) nhưng cho dù game thủ nhấn phím bắn bao nhiêu lần thì nó cũng sẽ chỉ bắn một lần (giả sử nói mỗi giây).
Ngoài ra thì trường hợp SearchBox như trên cũng thường được sử dụng throtting hay deboucing khá nhiều để hạn chế các API calls, như một cách để giảm tải cho server chẳng hạn. ????????
Kết
Thay vì phải gọi liên tục gọi các phương thức trong ứng dụng React, Throtting hay Deboucingthực sự là một giải pháp tốt để xử lý, nâng cao hiệu suất, tránh các trường hợp DOM-re-rendering không cần thiết các node.
Cảm ơn các bạn vì đã đọc bài viết của mình, tặng mình một upvote để có thêm động lực cho các chủ đề sắp tới nhaaaaa ^^
Tham khảo thêm các bài viết liên quan tại đây. Nếu có ý kiến bổ sung hay bất kỳ câu hỏi nào liên quan đến vấn đề này, hãy comment phía dưới cho mình nhé !
Chúc bạn một ngày làm việc hiệu quả ????????????
Happy coding !
References: Medium