Debounce و Throttle در جاوا اسکریپت — به زبان ساده
آیا تاکنون این تجربه را داشتهاید که صفحه وب در زمان اسکرول کردن، بسیار کُند شود؟ زمانی که از انیمیشنهای canvas در محیط داشبورد استفاده میکنیم، مصرف CPU برای نمایش روان انیمیشنها در هر ثانیه بسیار بالا میرود. این وضعیت در صورتی که بخواهید در زمان حرکت ماوس روی این نمودارها یک tooltip نیز نمایش پیدا کند، وخامت بسیار بیشتری پیدا مییابد. یکی از راهحلهای این وضعیت، استفاده از Debounce و Throttle در جاوا اسکریپت است که در این راهنما بررسی میکنیم. اگر با مفاهیم تابعهای ناهمگام و طرز کار HOF آشنا باشید، درک مطالبی که در ادامه میآید بسیار آسانتر خواهد بود.
بررسی یک مثال
فرض کنید میخواهید از طریق یک فراخوانی API به دنبال نام یک کشور بگردید. یک کادر ورودی روی صفحه وجود دارد که میتوانید نام کشور را در آن وارد کنید. زمانی که رشته را در آن وارد کردید، اپلیکیشن اقدام به فراخوانی API کرده و همه کشورهایی که با رشته ورودی تطبیق یابند، بارگذاری میکند.
وقتی کاربران موبایل یا افراد تنبل تلاش کنند این قابلیت را امتحان کنند، به صورت زیر خواهد بود:
عبارت روی صفحه به این نکته اشاره دارد که چه تعداد درخواستهای HTTP به سرور ارسال شده است. توجه کنید که هر بار که یک حرف کیبورد را میزنید، حتی زمانی که کلید بکاسپیس را میزنید عدد این عبارت افزایش مییابد. این وضعیت در اپلیکیشن ساده ما شاید نامناسب به نظر نرسد، اما تصور کنید روی اپلیکیشن بزرگی مانند AirBnb کار میکنید، در این وضعیت ارسال درخواستها در هر بار که کاربر یک کلید کیبورد را میزند یا کار دیگری انجام میدهد، انجام میشود که موجب کندی عملکرد خواهد شد.
Debounce و Throttle
برای درک مفهوم حقیقی Debounce و Throttle بهتر است ابتدا ایده راهحل مشکل فوق را بررسی کنیم. مشکل اصلی این است که درخواستهای http زیادی ارسال میشوند. فرض کنید میخواهید به دنبال عبارت Canada بگردید اما صفحه وب به نوعی به جهت ارسال درخواستهای C، Ca، Can و Cana و غیره فریز شده است. این وضعیت خوبی نیست. بنابراین باید تعداد درخواستها کاهش یابد.
Debounce و Throttle در واقع صرفاً نامهایی برای شیوه کاهش این درخواستها هستند. نکته مشترک Debounce و Throttle یک مفهوم ساده است: زمانی که انگشت روی کلید میرود تا زمانی که تأیید نشده است، درخواستی ارسال نمیشود.
به شکل فوق توجه کنید. در این تصویر میبینید که بسته به حالتهای مختلف چه اتفاقاتی میافتد. در حالت Normal هر بار که کلیدی فشرده شود، یک درخواست ارسال میشود. در حالت Throttle یک درخواست نخستین بار در طی دوره ارسال میشود و دیگر هیچ درخواستی تا پایان دوره ارسال نمیشود. هنگامی که دوره زمانی خاتمه یابد، یک درخواست جدید مجدداً ارسال میشود. در حالت Debounce یک Callback استفاده میشود و در طی چند میلیثانیه فراخوانی میشود و تنها درخواستی را ارسال میکند که در طی دوره زمانی درخواست دیگری اضافه نشده باشد. برای مثال به زمانی که حرف n در کلمه Canada وارد میشود دقت کنید. یک درخواست دیگر اضافه شده است و درخواست قبلی اضافه شده زمانی که a وارد شود نادیده گرفته میشود. با این منطق، اگر همه درخواستها درون بازه زمانی اضافه شوند، تنها درخواست آخر اجرا خواهد شد.
بررسی کد حالت Throttle
اینک نوبت به نوشتن کد رسیده است
import axios from 'axios
1import axios from 'axios';
2const search = async (city) =>
3 await axios.get(`https://restcountries.eu/rest/v2/name/${city}`)
search تابعی است که نام شهر را با استفاده از API به همین نام ارسال میکند. از API واقعی restful برای تست این مثال در عمل استفاده کردهایم. تنها کاری که باید انجام دهیم این است که کادر City را با هر نامی که دوست دارید، پر کنید. ابتدا کد را بررسی میکنیم:
1const throttle = (delay, fn) => {
2 let inThrottle = false;
3
4 return args => {
5 if (inThrottle) {
6 return;
7 }
8
9 inThrottle = true;
10 fn(args);
11 setTimeout(() => {
12 inThrottle = false;
13 }, delay);
14 };
15};
تابع بینام یک HOF (تابع مرتبه بالاتر) است که تابع دیگری را بازگشت میدهد. زمانی که این تابع برای نخستین بار فراخوانی میشود، inThrottle مقدار false میگیرد. سپس زمانی که تابع بازگشتی دوباره فراخوانی میشود inThrottle مقدار true میگیرد و تابع callback یعنی fn اجرا میشود. در ادامه inThrottle دوباره در طی زمان delay میلیثانیه مقدار false میگیرد. در این زمان در عین این که inThrottle مقدار true دارد، هیچ کدام از توابع بازگشتی از throttle نمیتوانند اجرا شوند.
چنان که در تصویر فوق میبینید، حتی اگر انگشت خود را روی دکمه نگه دارید، امکان اجرای تابع را نمیدهد مگر این که inThrottle مقدار false بگیرد. سپس متد جدید برای درخواست کردن به صورت زیر درمیآید:
const sendRequestThrottle = throttle(500, search); <input type="text" onChange={sendRequestThrottle} />
اما یک نکته هست که باید مراقب آن باشیم. متد HOF به نام throttle نباید در کامپوننت React قرار گیرد. زمانی که متغیرهای حالت که در کامپوننت A هستند، تغییر مییابند، A رندر مجدد میشود و. همه چیز از نو انتساب مییابد. اگر throttle هم رندر مجدد شود inThrottle دوباره مقدار false خواهد گرفت.
بررسی کد Debounce
اینک به بررسی Debounce میپردازیم. این کد کاملاً مشابه کد Throttle است و بخش غالب آن را به سادگی درک میکنید.
1const debounce = (delay, fn) => {
2 let inDebounce = null;
3 return args => {
4 clearTimeout(inDebounce);
5 inDebounce = setTimeout(() => fn(args), delay);
6 }
7}
میبینید که بسیار ساده است. تنها تفاوت با Throttle در این است که Debounce بررسی نمیکند، آیا inDebounce مقدار true دارد یا نه. اگر Callback درون دوره زمانی مورد نظر اجرا شده باشد، setTimeout قبلی لغو میشود و بیدرنگ اجرا شده و یک setTimeout جدید ایجاد میکند. بنابراین اگر به فشردن کیبورد به صورت سریع ادامه بدهید، callback هرگز اجرا نخواهد شد
اکنون میتوانید فراخوانیهای Debounce را بهتر درک کنید. تنها کاری که باید انجام دهید این است که callback خود را در DOM ثبت کنید:
const sendRequestDebounce = debounce(500, search); <input type="text" onChange={sendRequestDebounce} />
Throttle و Debounce در Lodash
در اغلب موارد نیازی به ایجاد یک Throttle یا Debounce وجود ندارد، زیرا کتابخانههای سبک زیای وجود دارند که این قابلیتها را عرضه میکنند. یک نمونه از آن کتابخانه Lodash است:
1function debounce(func, wait, options) {
2 let lastArgs,
3 lastThis,
4 maxWait,
5 result,
6 timerId,
7 lastCallTime
8
9 let lastInvokeTime = 0
10 let leading = false
11 let maxing = false
12 let trailing = true
13
14 // Bypass `requestAnimationFrame` by explicitly setting `wait=0`.
15 const useRAF = (!wait && wait !== 0 && typeof root.requestAnimationFrame === 'function')
16
17 if (typeof func !== 'function') {
18 throw new TypeError('Expected a function')
19 }
20 wait = +wait || 0
21 if (isObject(options)) {
22 leading = !!options.leading
23 maxing = 'maxWait' in options
24 maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait
25 trailing = 'trailing' in options ? !!options.trailing : trailing
26 }
27
28 function invokeFunc(time) {
29 const args = lastArgs
30 const thisArg = lastThis
31
32 lastArgs = lastThis = undefined
33 lastInvokeTime = time
34 result = func.apply(thisArg, args)
35 return result
36 }
37
38 function startTimer(pendingFunc, wait) {
39 if (useRAF) {
40 root.cancelAnimationFrame(timerId)
41 return root.requestAnimationFrame(pendingFunc)
42 }
43 return setTimeout(pendingFunc, wait)
44 }
45
46 function cancelTimer(id) {
47 if (useRAF) {
48 return root.cancelAnimationFrame(id)
49 }
50 clearTimeout(id)
51 }
52
53 function leadingEdge(time) {
54 // Reset any `maxWait` timer.
55 lastInvokeTime = time
56 // Start the timer for the trailing edge.
57 timerId = startTimer(timerExpired, wait)
58 // Invoke the leading edge.
59 return leading ? invokeFunc(time) : result
60 }
61
62 function remainingWait(time) {
63 const timeSinceLastCall = time - lastCallTime
64 const timeSinceLastInvoke = time - lastInvokeTime
65 const timeWaiting = wait - timeSinceLastCall
66
67 return maxing
68 ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
69 : timeWaiting
70 }
71
72 function shouldInvoke(time) {
73 const timeSinceLastCall = time - lastCallTime
74 const timeSinceLastInvoke = time - lastInvokeTime
75
76 // Either this is the first call, activity has stopped and we're at the
77 // trailing edge, the system time has gone backwards and we're treating
78 // it as the trailing edge, or we've hit the `maxWait` limit.
79 return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
80 (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait))
81 }
82
83 function timerExpired() {
84 const time = Date.now()
85 if (shouldInvoke(time)) {
86 return trailingEdge(time)
87 }
88 // Restart the timer.
89 timerId = startTimer(timerExpired, remainingWait(time))
90 }
91
92 function trailingEdge(time) {
93 timerId = undefined
94
95 // Only invoke if we have `lastArgs` which means `func` has been
96 // debounced at least once.
97 if (trailing && lastArgs) {
98 return invokeFunc(time)
99 }
100 lastArgs = lastThis = undefined
101 return result
102 }
103
104 function cancel() {
105 if (timerId !== undefined) {
106 cancelTimer(timerId)
107 }
108 lastInvokeTime = 0
109 lastArgs = lastCallTime = lastThis = timerId = undefined
110 }
111
112 function flush() {
113 return timerId === undefined ? result : trailingEdge(Date.now())
114 }
115
116 function pending() {
117 return timerId !== undefined
118 }
119
120 function debounced(...args) {
121 const time = Date.now()
122 const isInvoking = shouldInvoke(time)
123
124 lastArgs = args
125 lastThis = this
126 lastCallTime = time
127
128 if (isInvoking) {
129 if (timerId === undefined) {
130 return leadingEdge(lastCallTime)
131 }
132 if (maxing) {
133 // Handle invocations in a tight loop.
134 timerId = startTimer(timerExpired, wait)
135 return invokeFunc(lastCallTime)
136 }
137 }
138 if (timerId === undefined) {
139 timerId = startTimer(timerExpired, wait)
140 }
141 return result
142 }
143 debounced.cancel = cancel
144 debounced.flush = flush
145 debounced.pending = pending
146 return debounced
147}
کد آن کاملاً طولانی است، اما کافی است به تابع debounced که یک HOF است توجه کنید:
1function debounce() {
2 let lastCallTime;
3 ...
4 function debounced(...args) {
5 ...
6 }
7 return debounced;
8}
Debounce یک تابع جدید به نام debounced بازگشت میدهد که در عمل اجرا خواهد شد. درون این debounced از استفاده میکنیم. بنابراین شاید متوجه شوید که debounce در Lodash اقدام به مقایسه زمان قبلی (که تابع قبلاً فراخوانی شده) با زمان جاری (که تابع فعلاً فراخوانی شده) میکند.
برای این که این راهبرد کمی سادهتر شود، کد میتواند مانند زیر باشد:
1const throttled = function(delay, fn) {
2 let lastCall = 0;
3 return (...args) => {
4 let context = this;
5 let current = new Date().getTime();
6
7 if (current - lastCall < delay) {
8 return;
9 }
10 lastCall = current;
11
12 return fn.apply(context, ...args);
13 };
14};
البته شما میتوانید در صورت علاقه، خودتان این کارکردها را بسازید، اما به خاطر داشته باشید که نکته مهم این است که اجرای تابع را تا نقطه بعدی به تأخیر بیندازید.
سخن پایانی
Debounce و Throttle هر دو به دلیل نیاز به ایجاد تأخیر در اجرای تابع به دلیل کاهش تعداد ارسال درخواستهای HTTP از سوی کاربر ساخته شدهاند. امروزه این دو روشهای مهم برای بهبود وب محسوب میشوند. میتواند از هر کدام از آنها در کارهای مختلف که نیاز به ایجاد تأخیر دارند، مانند رویداد اسکرول کردن استفاده کنید.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای برنامهنویسی
- آموزش JavaScript ES6 (جاوا اسکریپت)
- مجموعه آموزشهای JavaScript (جاوا اسکریپت)
- آموزش جاوا اسکریپت — مجموعه مقالات جامع وبلاگ فرادرس
- طراحی تایمر معکوس با HTML ،CSS و JavaScript — به زبان ساده
==