הטמעת אנימציית מונה עם React ו-TypeScript

מונה באתר שלך קופץ, האנימציה מתחילה בזמן לא נכון, או שהעמוד נתקע? אנחנו מפתחים אנימציית מונה חלקה עם React ו-TypeScript, תוך שימוש ב-IntersectionObserver ו-requestAnimationFrame. הצוות שלנו מוביל את הפרויקט מסף עד תום—מהאודיט ועד היישום—ומבטיח ביצועים יציבים ותמיכה מתמשכת.

פיתוח ותחזוקה של כל סוגי האתרים:

אתרי מידע או יישומי אינטרנט
אתרי תדמית, דפי נחיתה, אתרי חברה, קטלוגים מקוונים, חידונים, אתרי קידום, בלוגים, מקורות חדשות, פורטלי מידע, פורומים, אגרגטורים
אתרי מסחר אלקטרוני או יישומי אינטרנט
חנויות מקוונות, פורטלי B2B, שווקים, בורסות מקוונות, אתרי קאשבק, בורסות, פלטפורמות דרופשיפינג, מנתחי מוצרים
יישומי אינטרנט לניהול תהליכים עסקיים
מערכות CRM, מערכות ERP, פורטלים ארגוניים, מערכות ניהול ייצור, מנתחי מידע
אתרי שירות אלקטרוני או יישומי אינטרנט
פלטפורמות מודעות, בתי ספר מקוונים, בתי קולנוע מקוונים, בוני אתרים, פורטלים לשירותים אלקטרוניים, פלטפורמות אירוח וידאו, פורטלים נושאיים

אלה רק חלק מהסוגים הטכניים של אתרים שאנו עובדים איתם, ולכל אחד מהם יכולים להיות מאפיינים ופונקציונליות ספציפיים משלו, וכן ניתן להתאים אותם לצרכים ולמטרות הספציפיים של הלקוח.

השירותים שאנו מציעים
מציג 1 מתוך 1כל 2062 השירותים
הטמעת אנימציית מונה עם React ו-TypeScript
פשוט
~1 יום

הכישורים שלנו:

שאלות נפוצות

העבודות האחרונות

  • פיתוח אפליקציית ווב עבור FEEDME
    פיתוח אפליקציית ווב עבור FEEDME
    1359
  • פיתוח חנות מקוונת לחברת FURNORO
    פיתוח חנות מקוונת לחברת FURNORO
    1330
  • פיתוח אפליקציית ווב עבור Enviok
    פיתוח אפליקציית ווב עבור Enviok
    1077
  • פיתוח CRM עבור Chasseurs
    פיתוח CRM עבור Chasseurs
    1113
  • פיתוח אתר עבור SBH Partners
    פיתוח אתר עבור SBH Partners
    1201
  • פיתוח אתר עבור Red Pear
    פיתוח אתר עבור Red Pear
    619

מונה באתר (אנימציית מונה) הוא כלי עוצמתי להצגת הישגים, אך היישום שלו גורם לעיתים קרובות לבעיות: מספרים קופצים, האנימציה לא מופעלת בגלילה, או שהעמוד נגרר עקב 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% קריאות

מה כלול בעבודה במפתח

  1. בדיקת מונים קיימים (אם יש) — מדידת ביצועים, LCP, CLS.
  2. פיתוח hook מותאם אישית עם תמיכה ב-easing, עשרוניות, פורמט.
  3. שילוב IntersectionObserver להתחלה עצלה.
  4. תיעוד וייצוא לחבילת npm לשימוש חוזר.
  5. בדיקות: unit tests ל-hook (Jest + React Testing Library) ובדיקות E2E (Cypress) במכשירים שונים.
  6. תמיכה ב-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, ותנו ללקוח להתחיל את האנימציה.

קבלו ייעוץ מהנדס — אנו יכולים לעזור ליישם מונים עם הבטחת ביצועים.