מונה באתר (אנימציית מונה) הוא כלי עוצמתי להצגת הישגים, אך היישום שלו גורם לעיתים קרובות לבעיות: מספרים קופצים, האנימציה לא מופעלת בגלילה, או שהעמוד נגרר עקב reflows תכופים. כמהנדסים, אנו פותרים בעיות אלה עם hooks של TypeScript ו-IntersectionObserver.
פעם אחת בתרגול שלנו, לקוח התלונן שמונים בעמוד נחיתה הופעלו רק לאחר טעינת העמוד המלאה, ולא כשהבלוק הופיע. שכתבנו את הלוגיקה באמצעות IntersectionObserver עם סף של 0.5 — והאנימציה התחילה ברגע שהסקשן היה 50% בשדה הראייה. גישה זו קיצרה את זמן התחלת האנימציה ב-60% וביטלה עיכובים חזותיים. ה-hook שלנו ב-TypeScript קומפקטי פי 2 מאלטרנטיבות jQuery. להלן קוד מנוסה בקרב שאנו משתמשים בו בפרויקטים על React 18 ו-Next.js. הזמינו פתרון דומה לפרויקט שלכם — הוא יפחית את עומס הדפדפן וישפר את חוויית המשתמש.
למה requestAnimationFrame במקום setInterval?
setInterval לא מתחשב במעבר בין טאבים ויכול לצבור callbacks, מה שמוביל לאנימציה מקוטעת וחישובים מיותרים. פונקציות ה-easing של Robert Penner מראות ש-requestAnimationFrame נעצרת כשהטאב לא פעיל, ומספקת אנימציה חלקה ללא עומס CPU מיותר. ב-hook שלנו useCounterAnimation אנו משתמשים רק בו, ומוסיפים פונקציות easing לתחושת "גלישה" טבעית של המספרים. בהשוואה ל-setInterval, requestAnimationFrame מספקת FPS יציב פי 3-5 לאנימציות ארוכות.
יישום בסיסי עם requestAnimationFrame
// hooks/useCounterAnimation.ts
import { useEffect, useRef, useState } from 'react'
interface CounterOptions {
start?: number
end: number
duration?: number // мс
easing?: (t: number) => number
decimals?: number
onComplete?: () => void
}
// Стандартные easing-функции
export const easings = {
linear: (t: number) => t,
easeOut: (t: number) => 1 - Math.pow(1 - t, 3),
easeInOut: (t: number) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
easeOutExpo: (t: number) => t === 1 ? 1 : 1 - Math.pow(2, -10 * t),
}
export function useCounterAnimation({
start = 0,
end,
duration = 2000,
easing = easings.easeOut,
decimals = 0,
onComplete,
}: CounterOptions) {
const [value, setValue] = useState(start)
const [isRunning, setIsRunning] = useState(false)
const rafRef = useRef<number | null>(null)
const startTimeRef = useRef<number | null>(null)
const run = () => {
if (isRunning) return
setIsRunning(true)
startTimeRef.current = null
const animate = (timestamp: number) => {
if (!startTimeRef.current) startTimeRef.current = timestamp
const elapsed = timestamp - startTimeRef.current
const progress = Math.min(elapsed / duration, 1)
const easedProgress = easing(progress)
const currentValue = start + (end - start) * easedProgress
setValue(parseFloat(currentValue.toFixed(decimals)))
if (progress < 1) {
rafRef.current = requestAnimationFrame(animate)
} else {
setValue(end)
setIsRunning(false)
onComplete?.()
}
}
rafRef.current = requestAnimationFrame(animate)
}
const reset = () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
setValue(start)
setIsRunning(false)
startTimeRef.current = null
}
useEffect(() => {
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
}
}, [])
return { value, run, reset, isRunning }
} רכיב מונה עם IntersectionObserver
// components/Counter.tsx
import { useEffect, useRef } from 'react'
import { useCounterAnimation, easings } from '../hooks/useCounterAnimation'
interface CounterProps {
end: number
start?: number
duration?: number
decimals?: number
prefix?: string // "$", "~"
suffix?: string // "+", "%", "K"
separator?: string // разделитель тысяч: " " или ","
once?: boolean // анимировать только при первом появлении
className?: string
}
export function Counter({
end,
start = 0,
duration = 2000,
decimals = 0,
prefix = '',
suffix = '',
separator = '',
once = true,
className = '',
}: CounterProps) {
const containerRef = useRef<HTMLSpanElement>(null)
const hasAnimated = useRef(false)
const { value, run } = useCounterAnimation({
start,
end,
duration,
decimals,
easing: easings.easeOutExpo,
})
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
if (once && hasAnimated.current) return
hasAnimated.current = true
run()
if (once) observer.unobserve(el)
}
},
{ threshold: 0.5 }
)
observer.observe(el)
return () => observer.disconnect()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const formatted = separator
? value.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, separator)
: value.toFixed(decimals)
return (
<span ref={containerRef} className={className}>
{prefix}{formatted}{suffix}
</span>
)
} סקשן סטטיסטיקות
// components/StatsSection.tsx
import { Counter } from './Counter'
const stats = [
{
value: 1500,
suffix: '+',
label: 'Клиентов',
duration: 2200,
},
{
value: 99.9,
suffix: '%',
label: 'Uptime',
decimals: 1,
duration: 1800,
},
{
value: 12,
suffix: ' лет',
label: 'На рынке',
duration: 1500,
},
{
value: 47,
prefix: '~',
suffix: ' стран',
label: 'География',
duration: 2000,
},
]
export function StatsSection() {
return (
<section className="py-20 bg-gray-50">
<div className="container mx-auto px-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{stats.map((stat) => (
<div key={stat.label} className="text-center">
<div className="text-5xl font-bold text-blue-600 mb-2">
<Counter
end={stat.value}
suffix={stat.suffix}
prefix={stat.prefix}
decimals={stat.decimals ?? 0}
duration={stat.duration}
separator=" "
/>
</div>
<p className="text-gray-600 font-medium">{stat.label}</p>
</div>
))}
</div>
</div>
</section>
)
} פורמט: מספרים גדולים ולוקליזציה
// utils/format-number.ts
export function formatNumber(
value: number,
options: Intl.NumberFormatOptions & { locale?: string } = {}
): string {
const { locale = 'ru-RU', ...intlOptions } = options
return new Intl.NumberFormat(locale, intlOptions).format(value)
}
// Использование в компоненте:
// formatNumber(1500000, { notation: 'compact' }) → "1,5 млн"
// formatNumber(99.9, { minimumFractionDigits: 1 }) → "99,9" איך לבחור פונקציית easing מתאימה?
בחירת פונקציית ה-easing תלויה באפקט הרצוי. easeOut נותן האטה חלקה, easeOutExpo האצה חדה עם דעיכה. למוני סטטיסטיקות, בדרך כלל משתמשים ב-cubic easeOut: זה נראה טבעי. אם רוצים להדגיש צמיחה, השתמשו ב-easeOutExpo. ה-easings ב-hook שלנו מכילים ארבע פונקציות בסיסיות שמכסות 90% מהתרחישים. לכיוונון מדויק, אפשר להעביר פונקציה מותאמת אישית.
איך אנו מייעלים ביצועים: מקרה מבחן
בפרויקט אחד, מונים גרמו ל-Layout Shift (CLS = 0.32) כי מספרים השתנו במהירויות שונות ולמכלים לא היה רוחב קבוע. עטפנו כל מונה ב-// hooks/useCounterAnimation.ts import { useEffect, useRef, useState } from 'react' interface CounterOptions { start?: number end: number duration?: number // мс easing?: (t: number) => number decimals?: number onComplete?: () => void } // Стандартные easing-функции export const easings = { linear: (t: number) => t, easeOut: (t: number) => 1 - Math.pow(1 - t, 3), easeInOut: (t: number) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2, easeOutExpo: (t: number) => t === 1 ? 1 : 1 - Math.pow(2, -10 * t), } export function useCounterAnimation({ start = 0, end, duration = 2000, easing = easings.easeOut, decimals = 0, onComplete, }: CounterOptions) { const [value, setValue] = useState(start) const [isRunning, setIsRunning] = useState(false) const rafRef = useRef<number | null>(null) const startTimeRef = useRef<number | null>(null) const run = () => { if (isRunning) return setIsRunning(true) startTimeRef.current = null const animate = (timestamp: number) => { if (!startTimeRef.current) startTimeRef.current = timestamp const elapsed = timestamp - startTimeRef.current const progress = Math.min(elapsed / duration, 1) const easedProgress = easing(progress) const currentValue = start + (end - start) * easedProgress setValue(parseFloat(currentValue.toFixed(decimals))) if (progress < 1) { rafRef.current = requestAnimationFrame(animate) } else { setValue(end) setIsRunning(false) onComplete?.() } } rafRef.current = requestAnimationFrame(animate) } const reset = () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) setValue(start) setIsRunning(false) startTimeRef.current = null } useEffect(() => { return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) } }, []) return { value, run, reset, isRunning } } עם // components/Counter.tsx import { useEffect, useRef } from 'react' import { useCounterAnimation, easings } from '../hooks/useCounterAnimation' interface CounterProps { end: number start?: number duration?: number decimals?: number prefix?: string // "$", "~" suffix?: string // "+", "%", "K" separator?: string // разделитель тысяч: " " или "," once?: boolean // анимировать только при первом появлении className?: string } export function Counter({ end, start = 0, duration = 2000, decimals = 0, prefix = '', suffix = '', separator = '', once = true, className = '', }: CounterProps) { const containerRef = useRef<HTMLSpanElement>(null) const hasAnimated = useRef(false) const { value, run } = useCounterAnimation({ start, end, duration, decimals, easing: easings.easeOutExpo, }) useEffect(() => { const el = containerRef.current if (!el) return const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { if (once && hasAnimated.current) return hasAnimated.current = true run() if (once) observer.unobserve(el) } }, { threshold: 0.5 } ) observer.observe(el) return () => observer.disconnect() }, []) // eslint-disable-line react-hooks/exhaustive-deps const formatted = separator ? value.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, separator) : value.toFixed(decimals) return ( <span ref={containerRef} className={className}> {prefix}{formatted}{suffix} </span> ) } והוספנו // components/StatsSection.tsx import { Counter } from './Counter' const stats = [ { value: 1500, suffix: '+', label: 'Клиентов', duration: 2200 }, { value: 99.9, suffix: '%', label: 'Uptime', decimals: 1, duration: 1800 }, { value: 12, suffix: ' лет', label: 'На рынке', duration: 1500 }, { value: 47, prefix: '~', suffix: ' стран', label: 'География', duration: 2000 }, ] export function StatsSection() { return ( <section className="py-20 bg-gray-50"> <div className="container mx-auto px-6"> <div className="grid grid-cols-2 md:grid-cols-4 gap-8"> {stats.map((stat) => ( <div key={stat.label} className="text-center"> <div className="text-5xl font-bold text-blue-600 mb-2"> <Counter end={stat.value} suffix={stat.suffix} prefix={stat.prefix} decimals={stat.decimals ?? 0} duration={stat.duration} separator=" " /> </div> <p className="text-gray-600 font-medium">{stat.label}</p> </div> ))} </div> </div> </section> ) } — CLS ירד ל-0.01. באג נפוץ נוסף הוא חוסר התאמה בהידרציה ב-Next.js, כשהשרת מציג 0 והלקוח מציג 1500. פתרון: השתמשו ב-useEffect כדי להתחיל אנימציה, לא בערכי רינדור מהשרת. בסופו של דבר, הלקוח חסך מעל 200 שעות פיתוח על ניסויים עצמיים.
| בעיה | פתרון | תועלת |
|---|---|---|
| Layout Shift (CLS) | רוחב קבוע, will-change | הפחתת CLS מ-0.32 ל-0.01 |
| חוסר התאמה בהידרציה | התחלה רק בצד הלקוח | ביטול שגיאות |
| חישוב מחדש בגלילה | IntersectionObserver עם unobserve | -70% קריאות |
מה כלול בעבודה במפתח
- בדיקת מונים קיימים (אם יש) — מדידת ביצועים, LCP, CLS.
- פיתוח hook מותאם אישית עם תמיכה ב-easing, עשרוניות, פורמט.
- שילוב IntersectionObserver להתחלה עצלה.
- תיעוד וייצוא לחבילת npm לשימוש חוזר.
- בדיקות: unit tests ל-hook (Jest + React Testing Library) ובדיקות E2E (Cypress) במכשירים שונים.
- תמיכה ב-SSR/SSG — רינדור שרת תקין ללא בעיות הידרציה.
איך להימנע מ-Layout Shift במהלך אנימציית מונה?
הגורם העיקרי ל-CLS הוא חוסר מקום שמור למספרים. השתמשו ב-// utils/format-number.ts export function formatNumber( value: number, options: Intl.NumberFormatOptions & { locale?: string } = {} ): string { const { locale = 'ru-RU', ...intlOptions } = options return new Intl.NumberFormat(locale, intlOptions).format(value) } // Использование в компоненте: // formatNumber(1500000, { notation: 'compact' }) → "1,5 млн" // formatNumber(99.9, { minimumFractionDigits: 1 }) → "99,9" עם רוחב קבוע (לדוגמה, <span>) ו-min-width לרמזים לדפדפן. בפרויקטים שלנו, זה מפחית CLS ל-0.01 ודורש מאמץ נוסף. אם סקשן הסטטיסטיקות משתמש באורכי מספרים שונים, הגדירו will-change: contents לפי הערך המקסימלי.
לוחות זמנים משוערים:
| שלב | זמן |
|---|---|
| ניתוח ואב-טיפוס | 2-4 שעות |
| פיתוח hook ורכיב | 4-6 שעות |
| שילוב בפרויקט | 2-4 שעות |
| בדיקות ותיעוד | 2-3 שעות |
| סה"כ | 10 עד 17 שעות |
המחיר מחושב באופן אישי — הוא תלוי במורכבות הסקשן ובצורך באפשרויות נוספות (לדוגמה, אנימציה בלולאה, תמיכה ב-RTL). צרו קשר כדי לקבל ייעוץ ממהנדס עם ניסיון של 10+ שנים ב-React. אנו מבטיחים סביבת בדיקה לפני השילוב.
טעויות נפוצות ואיך להימנע מהן
- המונה לא מתחיל: ודאו שהאלמנט גלוי (לא display: none, לא מחוץ ל-overflow:hidden).
- מספרים קופצים בשינוי גודל: השתמשו ב-observer.unobserve() לאחר הריצה הראשונה כדי למנוע התחלת אנימציה מחדש.
- גרירה במובייל: קצרו את משך האנימציה ל-1500 ms והשתמשו ב-easeOut במקום פונקציות מורכבות.
- הידרציה ב-React: אל תעבירו ערך התחלתי דרך prop — תנו לו להיות 0, ותנו ללקוח להתחיל את האנימציה.
קבלו ייעוץ מהנדס — אנו יכולים לעזור ליישם מונים עם הבטחת ביצועים.







