לאחרונה בפרויקט פורטפוליו WebGL נתקלנו בבעיה: בזמן שהדפדפן טען טקסטורות ומודלים תלת-ממדיים כבדים, המשתמש ראה מסך ריק. שלוש השניות הראשונות במובייל הן קריטיות לשמירת תשומת הלב. יישמנו preloader מונפש עם מעקב אחר התקדמות טעינת המשאבים, וההמרה עלתה ב-15%. הנה איך לבנות preloader כזה עם מעבר חלק לתוכן.
למה preloader הוא לא רק קישוט?
Preloader הוא מסך שמוצג בזמן טעינת הדף. תפקידו: להסתיר תוכן שנטען חלקית, לאפשר לאנימציית פתיחה להתנגן, וליצור תחושה של התחלה מכוונת. preloader גרוע מרגיז. אחד טוב עובר באופן אורגני למסך הראשון. אנו מבטיחים שעם יישום נכון, preloader לא מאריך את זמן הטעינה אלא מסווה אותו.
מתי preloader מוצדק
יש צורך ב-preloader אם: האתר טוען סצנות WebGL או סרטונים כבדים לפני התצוגה, העיצוב דורש אנימציית טקסט/לוגו מסונכרנת בתחילת הדף, או שהמסך הראשון ללא תוכן ייראה לא נכון. עבור אתרי מידע רגילים, preloader הוא עיכוב מיותר. הניסיון שלנו מראה: ב-80% מהמקרים, preloader מוצדק רק לפרויקטים עם תוכן עשיר ויזואלית.
איך לעקוב אחר התקדמות הטעינה?
אנו משתמשים במחלקת Preloader שמקבלת רשימת משאבים (תמונות, סרטונים) וטוענת אותם במקביל, תוך עדכון מונה. אם אין משאבים, אנו מדמים התקדמות במרווחים קבועים. זה נותן תחושת פעילות גם בזמן טעינה מהירה.
class Preloader {
private counter: HTMLElement
private preloader: HTMLElement
private currentCount = 0
private targetCount = 0
private resources: string[]
private loaded = 0
private rafId: number | null = null
constructor(resources: string[] = []) {
this.counter = document.getElementById('preloader-count')!
this.preloader = document.getElementById('preloader')!
this.resources = resources
document.body.classList.add('is-loading')
}
async load(): Promise<void> {
if (this.resources.length === 0) {
// ÐÑли ÑеÑÑÑÑов Ð½ÐµÑ â имиÑиÑÑем пÑогÑеÑÑ
await this.fakeProgress()
} else {
await this.loadResources()
}
await this.hide()
document.body.classList.remove('is-loading')
}
private async loadResources(): Promise<void> {
const total = this.resources.length
const promises = this.resources.map((url) =>
this.loadAsset(url).then(() => {
this.loaded++
this.targetCount = Math.round((this.loaded / total) * 100)
})
)
// ÐнимиÑÑем ÑÑÑÑÑик паÑаллелÑно Ñ Ð·Ð°Ð³ÑÑзкой
this.animateCounter()
await Promise.all(promises)
this.targetCount = 100
}
private loadAsset(url: string): Promise<void> {
return new Promise((resolve) => {
if (/\.(jpg|png|webp|svg|gif)$/i.test(url)) {
const img = new Image()
img.onload = () => resolve()
img.onerror = () => resolve() // не блокиÑоваÑÑ Ð½Ð° оÑибке
img.src = url
} else if (/\.(mp4|webm)$/i.test(url)) {
const video = document.createElement('video')
video.oncanplaythrough = () => resolve()
video.onerror = () => resolve()
video.src = url
video.load()
} else {
fetch(url)
.then(() => resolve())
.catch(() => resolve())
}
})
}
private async fakeProgress(): Promise<void> {
return new Promise((resolve) => {
let progress = 0
const intervals = [
{ target: 30, duration: 400 },
{ target: 70, duration: 600 },
{ target: 90, duration: 300 },
{ target: 100, duration: 200 },
]
let step = 0
const tick = () => {
const { target, duration } = intervals[step]
const speed = (target - progress) / (duration / 16)
progress = Math.min(progress + speed, target)
this.targetCount = Math.round(progress)
if (progress >= target) {
step++
if (step >= intervals.length) {
resolve()
return
}
}
requestAnimationFrame(tick)
}
this.animateCounter()
tick()
})
}
private animateCounter() {
const tick = () => {
if (this.currentCount < this.targetCount) {
this.currentCount = Math.min(
this.currentCount + Math.ceil((this.targetCount - this.currentCount) * 0.1),
this.targetCount
)
this.counter.textContent = String(this.currentCount)
}
if (this.currentCount < 100) {
this.rafId = requestAnimationFrame(tick)
}
}
this.rafId = requestAnimationFrame(tick)
}
private async hide(): Promise<void> {
// ÐдÑм пока ÑÑÑÑÑик дойдÑÑ Ð´Ð¾ 100
await new Promise<void>((resolve) => {
const check = setInterval(() => {
if (this.currentCount >= 100) {
clearInterval(check)
resolve()
}
}, 50)
})
return new Promise((resolve) => {
// ÐнимаÑÐ¸Ñ Ð¸ÑÑÐµÐ·Ð½Ð¾Ð²ÐµÐ½Ð¸Ñ ÑеÑез GSAP
import('gsap').then(({ default: gsap }) => {
const tl = gsap.timeline({
onComplete: () => {
this.preloader.style.display = 'none'
resolve()
}
})
tl.to('.preloader__counter', { opacity: 0, duration: 0.3 })
.to('.preloader', { clipPath: 'inset(0 0 100% 0)', duration: 0.8, ease: 'power3.inOut' })
.from('#app', { opacity: 0, duration: 0.4 }, '-=0.2')
})
})
}
}
// ÐниÑиализаÑиÑ
const preloader = new Preloader([
'/images/hero-bg.webp',
'/images/about-photo.webp',
])
preloader.load().then(() => {
// СÑÑаниÑа гоÑова â запÑÑкаем оÑновнÑе анимаÑии
initHeroAnimation()
})preloader עם מעקב אחר משאבים מדויק פי 2 מהתקדמות מדומה — המשתמשים רואים את מהירות הטעינה האמיתית.
למה להשתמש ב-GSAP לאנימציית ההיעלמות?
GSAP נותן שליטה מלאה על ציר הזמן. בדוגמה למעלה, אנו מסתירים תחילה את המונה, ואז מניעים את class Preloader { private counter: HTMLElement private preloader: HTMLElement private currentCount = 0 private targetCount = 0 private resources: string[] private loaded = 0 private rafId: number | null = null constructor(resources: string[] = []) { this.counter = document.getElementById('preloader-count')! this.preloader = document.getElementById('preloader')! this.resources = resources document.body.classList.add('is-loading') } async load(): Promise<void> { if (this.resources.length === 0) { // Если ресурсов нет — имитируем прогресс await this.fakeProgress() } else { await this.loadResources() } await this.hide() document.body.classList.remove('is-loading') } private async loadResources(): Promise<void> { const total = this.resources.length const promises = this.resources.map((url) => this.loadAsset(url).then(() => { this.loaded++ this.targetCount = Math.round((this.loaded / total) * 100) }) ) // Анимируем счётчик параллельно с загрузкой this.animateCounter() await Promise.all(promises) this.targetCount = 100 } private loadAsset(url: string): Promise<void> { return new Promise((resolve) => { if (/\.(jpg|png|webp|svg|gif)$/i.test(url)) { const img = new Image() img.onload = () => resolve() img.onerror = () => resolve() // не блокировать на ошибке img.src = url } else if (/\.(mp4|webm)$/i.test(url)) { const video = document.createElement('video') video.oncanplaythrough = () => resolve() video.onerror = () => resolve() video.src = url video.load() } else { fetch(url) .then(() => resolve()) .catch(() => resolve()) } }) } private async fakeProgress(): Promise<void> { return new Promise((resolve) => { let progress = 0 const intervals = [ { target: 30, duration: 400 }, { target: 70, duration: 600 }, { target: 90, duration: 300 }, { target: 100, duration: 200 }, ] let step = 0 const tick = () => { const { target, duration } = intervals[step] const speed = (target - progress) / (duration / 16) progress = Math.min(progress + speed, target) this.targetCount = Math.round(progress) if (progress >= target) { step++ if (step >= intervals.length) { resolve() return } } requestAnimationFrame(tick) } this.animateCounter() tick() }) } private animateCounter() { const tick = () => { if (this.currentCount < this.targetCount) { this.currentCount = Math.min( this.currentCount + Math.ceil((this.targetCount - this.currentCount) * 0.1), this.targetCount ) this.counter.textContent = String(this.currentCount) } if (this.currentCount < 100) { this.rafId = requestAnimationFrame(tick) } } this.rafId = requestAnimationFrame(tick) } private async hide(): Promise<void> { // Ждём пока счётчик дойдёт до 100 await new Promise<void>((resolve) => { const check = setInterval(() => { if (this.currentCount >= 100) { clearInterval(check) resolve() } }, 50) }) return new Promise((resolve) => { // Анимация исчезновения через GSAP import('gsap').then(({ default: gsap }) => { const tl = gsap.timeline({ onComplete: () => { this.preloader.style.display = 'none' resolve() }}) tl.to('.preloader__counter', { opacity: 0, duration: 0.3 }) .to('.preloader', { clipPath: 'inset(0 0 100% 0)', duration: 0.8, ease: 'power3.inOut', }) .from('#app', { opacity: 0, duration: 0.4 }, '-=0.2') }) }) } } // Инициализация const preloader = new Preloader([ '/images/hero-bg.webp', '/images/about-photo.webp', ]) preloader.load().then(() => { // Страница готова — запускаем основные анимации initHeroAnimation() }) של ה-preloader, ובמקביל מעמעמים את התוכן הראשי. מעבר כזה בלתי אפשרי עם CSS טהור ללא keyframes מורכבים. יתר על כן, GSAP מבטיח סנכרון בכל המכשירים.
אנימציית לוגו
גישה טיפוסית: לוגו SVG עם אנימציית נתיב דרך clipPath.
.preloader__logo path {
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
animation: draw-logo 1.5s ease forwards;
}
@keyframes draw-logo {
to {
stroke-dashoffset: 0;
}
} // После окончания draw-анимации — fill
gsap.to('.preloader__logo path', {
fill: '#ffffff',
stroke: 'transparent',
duration: 0.4,
delay: 1.6,
}) שמירת מצב — הצגה חד-פעמית
אם ה-preloader צריך להופיע רק בביקור הראשון בסשן:
const PRELOADER_KEY = 'preloader_shown'
function shouldShowPreloader(): boolean {
if (sessionStorage.getItem(PRELOADER_KEY)) return false
sessionStorage.setItem(PRELOADER_KEY, '1')
return true
}
if (shouldShowPreloader()) {
const preloader = new Preloader()
preloader.load()
} else {
document.getElementById('preloader')?.remove()
document.body.classList.remove('is-loading')
} השוואת סוגי preloader
| סוג preloader | זמן יישום | מורכבות | מתאים ל |
|---|---|---|---|
| ספינר CSS | 1–2 שעות | נמוכה | אתרים קלים |
| פס התקדמות עם מעקב | 1–2 ימים | בינונית | אתרים עם תוכן כבד |
| אנימציית מסך מלא עם מעבר | 2–3 ימים | גבוהה | פורטפוליו, דפי נחיתה |
תהליך עבודה עבור preloader
| שלב | משך | תוצאה |
|---|---|---|
| ניתוח דרישות | 1–2 שעות | מפרט אנימציה |
| יצירת SVG/לוגו | 2–4 שעות | לוגו מונפש |
| פיתוח preloader | 1–2 ימים | preloader עובד |
| אינטגרציה עם תוכן | 4–6 שעות | מעבר חלק |
| בדיקות (מובייל, דסקטופ) | 2–4 שעות | דוח |
מה כלול בעבודה
- ניתוח מבנה הדף ורשימת המשאבים הנטענים.
- פיתוח preloader ב-HTML/CSS/JS לפי העיצוב שלך.
- אנימציית לוגו או פס התקדמות.
- אינטגרציה עם התוכן הראשי: מעבר חלק, סנכרון עם אנימציות.
- אופטימיזציית ביצועים: מיניפיקציה, CSS מוטבע, טעינה עצלה של ספריות.
- בדיקות במכשירי מובייל ודסקטופ.
- תיעוד לשימוש ותחזוקה.
השפעת preloader על Core Web Vitals
הסיכון העיקרי — preloader מאריך את LCP (Largest Contentful Paint) מכיוון שהדפדפן מציג אותו לפני התוכן הראשי. אנו מטמיעים את ה-CSS של ה-preloader ישירות לתוך stroke-dashoffset כדי להימנע מחסימת נתיב העיבוד הקריטי. JavaScript מאותחל באופן דחוי דרך .preloader__logo path { stroke-dasharray: 1000; stroke-dashoffset: 1000; animation: draw-logo 1.5s ease forwards; } @keyframes draw-logo { to { stroke-dashoffset: 0; } } , ו-GSAP נטען רק כשהאנימציה של ה-preloader מתחילה להסתתר. גישה זו שומרת על TTFB בשליטה ואינה מוסיפה בקשות רשת מיותרות ברינדור הראשון. בפרויקטים עם WebGL, אנו משעים בנוסף את הרינדור של three.js עד שסיום אנימציית ה-preloader — חוסך עד 40% CPU. FID ו-CLS נשארים באזור הירוק כי ל-preloader יש מיקום // После окончания draw-анимации — fill gsap.to('.preloader__logo path', { fill: '#ffffff', stroke: 'transparent', duration: 0.4, delay: 1.6, }) מחוץ לזרימת המסמך.
לוחות זמנים ועלות
preloader פשוט עם פס התקדמות ודעיכה — 1–2 ימים. אנימציית לוגו, מעקב אחר משאבים אמיתי, מעבר חלק — 2–3 ימים. העלות מחושבת באופן אישי לפי מורכבות האנימציה והאינטגרציה. אנו צוות עם 5 שנות ניסיון בפיתוח ממשקי אינטרנט מורכבים. יש לנו מעל 50 פרויקטים שהושלמו עם אנימציה.
אם אתה צריך preloader לפרויקט שלך, צור קשר. אנו נעריך את המשימה ונציע את הפתרון הטוב ביותר.







