אופטימיזציית JavaScript ב-2026: פיצול קוד, Tree Shaking וביצועי רינדור
מבוא
במאמרים הקודמים בסדרה שלנו על ביצועי אתרים, סקרנו לעומק את Core Web Vitals — כולל LCP, INP ו-CLS — והבנו כיצד למדוד ולשפר את חוויית המשתמש. אחרי זה, צללנו לעולם אופטימיזציית תמונות, עם פורמטים מודרניים כמו AVIF ו-WebP, טעינה עצלנית ואסטרטגיות responsive images. ועכשיו? הגיע הזמן להתמודד עם הענק שבחדר: JavaScript.
אני חייב להודות — כשראיתי את הנתונים, גם אני הופתעתי.
על פי HTTP Archive, האתר הממוצע ב-2026 שולח למשתמשים מעל 500KB של JavaScript (לאחר דחיסה!), ובגרסה הלא דחוסה הכמות מגיעה בקלות ל-1.5MB ומעלה. אבל הבעיה האמיתית חמורה אף יותר: מחקרים מראים ש-כ-70% מקוד ה-JavaScript שנשלח אינו בשימוש בטעינה הראשונית של הדף. חשבו על זה — הדפדפן מוריד, מפענח, מקמפל ומריץ כמויות אדירות של קוד שהמשתמש פשוט לא צריך באותו רגע.
ובניגוד לתמונות, שהדפדפן יכול להציג בהדרגה, JavaScript הוא חוסם רינדור (render-blocking) מטבעו. כל קילובייט של JS דורש לא רק הורדה, אלא גם parsing וביצוע על ה-main thread — וזה בדיוק מה שגורם ל-INP גרוע ול-LCP איטי. אז בואו נלמד איך לנתח, לצמצם ולייעל את ה-JavaScript באתר שלכם עם הכלים והטכניקות הכי עדכניים של 2026.
חלק 1: הבנת הבעיה — ניתוח גודל ה-Bundle
לפני שרצים לייעל, חייבים להבין מה בדיוק יש ב-bundle. בלי הבנה ברורה של מה תופס מקום, כל אופטימיזציה היא בסך הכול ניחוש. החדשות הטובות? יש לנו כלים מעולים לזה.
Webpack Bundle Analyzer
webpack-bundle-analyzer הוא אחד הכלים הפופולריים ביותר לוויזואליזציה של תוכן ה-bundle. הוא יוצר מפה אינטראקטיבית (treemap) שמראה בדיוק אילו חבילות תופסות כמה מקום — ולפעמים התוצאות ממש מפתיעות.
// התקנה
npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
openAnalyzer: false,
generateStatsFile: true,
statsFilename: 'bundle-stats.json'
})
]
};
אם אתם עובדים עם Vite, יש תוסף מקביל שעובד מצוין:
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
filename: 'dist/bundle-report.html',
open: true,
gzipSize: true,
brotliSize: true
})
]
});
source-map-explorer
כלי נוסף שאני אישית מאוד אוהב הוא source-map-explorer. הוא מנתח את ה-source maps כדי להראות בדיוק מאיפה מגיע כל בית בקוד הסופי:
npx source-map-explorer dist/assets/index-*.js --html result.html
Chrome DevTools Coverage Tab
ואחד הכלים הכי חזקים כבר מובנה לכם בדפדפן — לשונית ה-Coverage ב-Chrome DevTools. ככה משתמשים בה:
- פתחו את DevTools (F12)
- לחצו Ctrl+Shift+P (או Cmd+Shift+P ב-Mac)
- הקלידו "Coverage" ובחרו "Show Coverage"
- לחצו על כפתור ה-reload כדי לתעד את הכיסוי מתחילת הטעינה
- בדקו את העמודה "Unused Bytes" — כל קובץ עם אחוז גבוה של קוד לא בשימוש הוא מועמד לאופטימיזציה
הכלי מראה בצורה ויזואלית (עם פסים אדומים וירוקים) בדיוק אילו שורות קוד רצו ואילו לא. זו הדרך הטובה ביותר לתפוס את ה"פושעים הגדולים" — ספריות שלמות שנטענות אבל רק חלק זעום מהן באמת בשימוש.
Lighthouse ו-Performance Insights
ברור ש-Lighthouse ממשיך להיות חיוני. ב-Lighthouse 12 (הגרסה הנוכחית), הביקורת "Reduce unused JavaScript" נותנת רשימה מפורטת של סקריפטים עם אחוז השימוש שלהם, ומעריכה כמה זמן טעינה אפשר לחסוך. שילוב של כל הכלים האלה ייתן לכם תמונה מלאה ואמיתית של מצב ה-JavaScript באתר.
חלק 2: Tree Shaking — הסרת קוד מת
Tree shaking (מילולית: "ניעור עץ") היא טכניקה שמסירה קוד מת — קוד שיוצא מ-modules אבל אף פעם לא מיובא. הרעיון פשוט ואלגנטי: דמיינו עץ שמייצג את כל הקוד שלכם, ואתם "מנערים" אותו — כל מה שלא מחובר נופל ונעלם.
איך זה עובד עם ES Modules
Tree shaking עובד הכי טוב עם ES Modules (import/export) כי הם סטטיים — ה-bundler יכול לנתח בזמן build בדיוק מה מיובא ומה לא, בלי להריץ את הקוד. זה בניגוד ל-CommonJS (require/module.exports) שהוא דינמי ופשוט לא ניתן לניתוח סטטי.
// CommonJS - לא ניתן לעשות tree shaking!
const _ = require('lodash');
const result = _.get(data, 'user.name');
// כל lodash נכנס ל-bundle (כ-72KB min+gzip)
// ES Modules - tree shaking עובד מצוין!
import { get } from 'lodash-es';
const result = get(data, 'user.name');
// רק הפונקציה get וה-dependencies שלה נכנסות (כ-2KB)
למה CommonJS שובר tree shaking
ב-CommonJS, אפשר לעשות דברים כמו אלה:
// CommonJS מאפשר imports דינמיים ומותנים
if (someCondition) {
const utils = require('./utils');
}
// או אפילו:
const moduleName = 'lodash';
const lib = require(moduleName);
כיוון שהכלי לא יכול לדעת בזמן build מה יקרה בזמן ריצה, הוא חייב לכלול הכול. לכן, הכלל הראשון של tree shaking: ודאו שאתם משתמשים ב-ES Modules בכל מקום. אין פשרות בנקודה הזו.
הגדרת sideEffects ב-package.json
הנה דבר שהרבה מפתחים מפספסים: השדה sideEffects ב-package.json. הוא מספר ל-bundler אם המודולים בחבילה "טהורים" (pure) — כלומר, אין להם תופעות לוואי כמו שינוי משתנים גלובליים, הוספת polyfills, או הרצת קוד ברגע שהקובץ מיובא:
// package.json
{
"name": "my-library",
"version": "1.0.0",
"sideEffects": false
}
// אפשר גם לציין קבצים ספציפיים עם side effects:
{
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills.js"
]
}
כשה-bundler רואה "sideEffects": false, הוא יודע שאפשר להסיר בבטחה כל export שלא מיובא. בלי ההגדרה הזו, הוא מתנהג בזהירות יתרה ולא מסיר קוד שעלול להיות לו side effects.
Named Imports לעומת Default Imports
סגנון ה-import שלכם משפיע ישירות על tree shaking. תראו את ההבדל:
// גרוע - מייבא הכול (אם הספרייה לא תומכת ב-tree shaking)
import _ from 'lodash';
_.debounce(fn, 300);
// טוב - named import מאפשר tree shaking
import { debounce } from 'lodash-es';
debounce(fn, 300);
// הכי טוב - import ישיר מהקובץ הספציפי
import debounce from 'lodash-es/debounce';
debounce(fn, 300);
דוגמה מעשית: lodash לעומת lodash-es
הנה השוואת גדלים אמיתית שממחישה עד כמה זה דרמטי:
- lodash (CommonJS, import מלא): ~72KB min+gzip
- lodash-es (ES Modules, עם tree shaking, שימוש ב-3 פונקציות): ~3-5KB min+gzip
- חיסכון: ~93% מגודל ה-bundle!
93 אחוז. זה לא שיפור שולי — זה שינוי מהותי. הנה הגדרות מומלצות ל-tree shaking ב-Vite:
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
treeshake: {
moduleSideEffects: false,
propertyReadSideEffects: false,
tryCatchDeoptimization: false
}
}
}
});
חלק 3: פיצול קוד (Code Splitting)
אם tree shaking מסיר קוד שלא נחוץ בכלל, אז code splitting דואג שקוד שנחוץ מאוחר יותר ייטען רק כשצריך אותו. במקום bundle אחד ענקי, אנחנו מפצלים אותו ל-chunks קטנים שנטענים לפי דרישה.
זה כמו לארוז מזוודה לטיול — אתם לא זורקים את כל הבגדים ביחד. אתם שמים את מה שצריך ליום הראשון למעלה.
פיצול לפי Routes
הצורה הנפוצה ביותר של code splitting היא פיצול לפי routes. כל דף באפליקציה הופך ל-chunk נפרד שנטען רק כשהמשתמש מנווט אליו:
// React Router עם lazy loading
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<AdminPanel />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
טעינה עצלנית ברמת Component
לא רק routes — אפשר (וכדאי!) לפצל כל component כבד שלא הכרחי בטעינה הראשונית:
// React - טעינה עצלנית של component כבד
import { lazy, Suspense } from 'react';
const HeavyChart = lazy(() => import('./components/HeavyChart'));
const RichTextEditor = lazy(() => import('./components/RichTextEditor'));
function ArticlePage({ article }) {
const [showEditor, setShowEditor] = useState(false);
return (
<div>
<h1>{article.title}</h1>
{/* הגרף נטען רק כשהוא נכנס ל-viewport */}
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={article.chartData} />
</Suspense>
{/* העורך נטען רק כשהמשתמש לוחץ "ערוך" */}
{showEditor && (
<Suspense fallback={<EditorSkeleton />}>
<RichTextEditor content={article.body} />
</Suspense>
)}
<button onClick={() => setShowEditor(true)}>ערוך</button>
</div>
);
}
Dynamic import() ב-Vanilla JavaScript
לא חייבים framework בשביל code splitting. התחביר import() הדינמי עובד בכל bundler מודרני:
// Vanilla JS - טעינה דינמית
document.getElementById('export-btn').addEventListener('click', async () => {
// הספרייה נטענת רק כשהמשתמש לוחץ על הכפתור
const { exportToPDF } = await import('./utils/pdf-export.js');
await exportToPDF(document.getElementById('report'));
});
// עם טיפול בשגיאות
async function loadModule(modulePath) {
try {
const module = await import(modulePath);
return module;
} catch (error) {
console.error(`Failed to load module: ${modulePath}`, error);
// fallback logic
}
}
דוגמה ב-Vue
// Vue Router עם code splitting
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('./views/Home.vue')
},
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue')
},
{
path: '/admin',
component: () => import('./views/Admin.vue'),
children: [
{
path: 'users',
component: () => import('./views/admin/Users.vue')
}
]
}
]
});
הגדרת Vendor Splitting ו-Manual Chunks
בנוסף לפיצול לפי routes, שווה לפצל גם את ספריות הצד השלישי (vendor code) ל-chunks נפרדים. למה? כי ככה כשאתם מעדכנים את הקוד שלכם, ה-vendor chunk נשאר בקאש של הדפדפן:
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 10
},
// פיצול ספריות גדולות ל-chunks נפרדים
react: {
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
name: 'react-vendor',
chunks: 'all',
priority: 20
},
charts: {
test: /[\\/]node_modules[\\/](chart\.js|d3|recharts)[\\/]/,
name: 'charts-vendor',
chunks: 'all',
priority: 20
}
}
}
}
};
ב-Vite, התצורה המקבילה נראית ככה:
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'ui-library': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
'charts': ['recharts', 'd3-scale', 'd3-shape']
}
}
}
}
});
Prefetching של Chunks לחוויית משתמש טובה יותר
יש נקודה חשובה: code splitting יכול ליצור עיכוב קל כשהמשתמש מנווט לדף חדש, כי ה-chunk צריך להיטען. הפתרון? Prefetch — טעינה מוקדמת ברקע של chunks שסביר שהמשתמש יצטרך:
// React - prefetch כשהעכבר מרחף מעל לינק
function NavLink({ to, children }) {
const prefetchRoute = () => {
switch (to) {
case '/dashboard':
import('./pages/Dashboard');
break;
case '/settings':
import('./pages/Settings');
break;
}
};
return (
<Link to={to} onMouseEnter={prefetchRoute}>
{children}
</Link>
);
}
// או באמצעות webpack magic comments:
const Dashboard = lazy(() =>
import(/* webpackPrefetch: true */ './pages/Dashboard')
);
ההערה webpackPrefetch: true גורמת ל-webpack להוסיף אוטומטית תג <link rel="prefetch"> ב-head של הדף, שאומר לדפדפן לטעון את ה-chunk ברקע כשה-main thread פנוי. פשוט וחכם.
חלק 4: מיניפיקציה ודחיסה
אחרי שצמצמנו את כמות הקוד ב-bundle עם tree shaking ו-code splitting, הצעד הבא הוא לדאוג שהקוד שנשאר יישלח בצורה הכי קומפקטית שאפשר. כאן נכנסים מיניפיקציה ודחיסה.
כלי מיניפיקציה: Terser, esbuild ו-SWC
מיניפיקציה מסירה רווחים, הערות ומקצרת שמות משתנים כדי להקטין את גודל הקובץ. שלושת הכלים המרכזיים:
- Terser: הוותיק והאמין. תומך בכל תכונות JS המודרניות, אבל איטי יחסית.
- esbuild: כתוב ב-Go, מהיר פי 10-100 מ-Terser. ה-minifier ברירת המחדל של Vite.
- SWC: כתוב ב-Rust, ביצועים דומים ל-esbuild. ברירת המחדל ב-Next.js.
// vite.config.js - בחירת minifier
export default defineConfig({
build: {
// ברירת המחדל ב-Vite הוא esbuild
minify: 'esbuild',
// או אם צריכים מיניפיקציה אגרסיבית יותר:
// minify: 'terser',
// terserOptions: {
// compress: {
// drop_console: true,
// drop_debugger: true,
// pure_funcs: ['console.log', 'console.info']
// }
// }
}
});
Brotli לעומת Gzip
לאחר המיניפיקציה, דחיסה מקטינה עוד יותר את גודל הקבצים שנשלחים ברשת. Brotli הוא אלגוריתם הדחיסה המודרני שמכה את Gzip כמעט בכל תרחיש:
- קובץ JS מקורי: 500KB
- לאחר מיניפיקציה: 200KB
- Gzip (רמה 9): 55KB (הפחתה של 72.5%)
- Brotli (רמה 11): 45KB (הפחתה של 77.5%)
Brotli משיג דחיסה טובה ב-15-20% יותר מ-Gzip, בעיקר על טקסט ו-JavaScript. וב-2026, כל הדפדפנים המודרניים תומכים ב-Brotli — אז באמת אין סיבה שלא להשתמש בו.
הגדרת Brotli בשרת Nginx
# nginx.conf
# Brotli compression
brotli on;
brotli_comp_level 6; # 1-11, 6 הוא איזון טוב בין מהירות לדחיסה
brotli_types
text/html
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
application/wasm;
# Pre-compressed files (for static assets)
brotli_static on;
# Fallback to gzip for older clients
gzip on;
gzip_comp_level 6;
gzip_types text/html text/css text/javascript application/javascript application/json;
עבור קבצים סטטיים, הגישה המומלצת היא דחיסה מראש (pre-compression) בזמן ה-build, ככה שהשרת לא צריך לדחוס בזמן אמת:
// vite.config.js
import viteCompression from 'vite-plugin-compression';
export default defineConfig({
plugins: [
// Brotli
viteCompression({
algorithm: 'brotliCompress',
ext: '.br',
threshold: 1024
}),
// Gzip כ-fallback
viteCompression({
algorithm: 'gzip',
ext: '.gz',
threshold: 1024
})
]
});
Module/Nomodule Pattern
טכניקה נוספת לצמצום JavaScript היא ה-module/nomodule pattern. דפדפנים מודרניים תומכים ב-ES Modules, ואפשר לשלוח להם קוד מודרני ללא transpilation מיותרת:
<!-- דפדפנים מודרניים טוענים את זה -->
<script type="module" src="/js/app.modern.js"></script>
<!-- דפדפנים ישנים טוענים את זה (דפדפנים מודרניים מתעלמים) -->
<script nomodule src="/js/app.legacy.js"></script>
הגרסה המודרנית יכולה להיות 20-30% קטנה יותר בלי polyfills ו-transpilation מיותרים. ב-2026, כשרוב המשתמשים בדפדפנים מודרניים, זה פחות קריטי מבעבר — אבל עדיין שווה ליישם אם יש לכם קהל עם דפדפנים ישנים יותר.
חלק 5: ביצועי רינדור ואופטימיזציית Framework
אופטימיזציית bundle מטפלת בכמה JavaScript אנחנו שולחים. אבל חשוב באותה מידה מה ה-JavaScript הזה עושה ברגע שהוא מגיע לדפדפן. ב-2026, ה-frameworks המובילים עברו שיפורים מרשימים בביצועי rendering.
React Compiler — מהפכת ה-Auto-Memoization
React Compiler (שהגיע לגרסה 1.0 באוקטובר 2025) הוא כנראה אחד השינויים הגדולים ביותר ב-React מאז הצגת Hooks. הוא מנתח את הקוד בזמן build ומוסיף אוטומטית memoization במקומות הנכונים — מה שאומר שברוב המקרים אפשר להיפרד מ-useMemo, useCallback ו-React.memo הידניים:
// לפני React Compiler - צריך memoization ידנית
function ProductList({ products, onSelect }) {
const sortedProducts = useMemo(
() => products.sort((a, b) => a.price - b.price),
[products]
);
const handleSelect = useCallback(
(id) => onSelect(id),
[onSelect]
);
return sortedProducts.map(product => (
<MemoizedProductCard
key={product.id}
product={product}
onSelect={handleSelect}
/>
));
}
const MemoizedProductCard = React.memo(ProductCard);
// אחרי React Compiler - כותבים קוד רגיל והוא מטפל בהכול
function ProductList({ products, onSelect }) {
const sortedProducts = products.sort((a, b) => a.price - b.price);
return sortedProducts.map(product => (
<ProductCard
key={product.id}
product={product}
onSelect={(id) => onSelect(id)}
/>
));
}
// React Compiler יוסיף אוטומטית memoization בזמן build!
כדי להפעיל את React Compiler:
// babel.config.js
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
target: '19' // React 19
}]
]
};
בכנות? זה אחד מהדברים שמרגישים כמו קסם. כותבים קוד פשוט ונקי, והקומפיילר דואג לביצועים.
Angular Signals וארכיטקטורת Zoneless
Angular גם עבר מהפכה לא קטנה עם Signals — מודל reactivity חדש שמחליף את Zone.js הוותיק (והכבד). במקום לבדוק את כל עץ הרכיבים בכל שינוי, Signals מאפשרים לעקוב בדיוק אחרי מה השתנה ולעדכן רק מה שצריך:
// Angular עם Signals
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ count() }}</p>
<p>Double: {{ doubleCount() }}</p>
<button (click)="increment()">+1</button>
`
})
export class CounterComponent {
count = signal(0);
doubleCount = computed(() => this.count() * 2);
increment() {
this.count.update(c => c + 1);
// רק מה שתלוי ב-count מתעדכן, לא כל ה-component tree!
}
}
הארכיטקטורה הזו חוסכת את ה-overhead של Zone.js (כ-13KB min+gzip — לא מעט!) ומשפרת משמעותית את ביצועי הרינדור באפליקציות גדולות.
Virtual DOM לעומת Fine-Grained Reactivity
ב-2026, המגמה ברורה: fine-grained reactivity — גישה שמעדכנת את ה-DOM הספציפי שהשתנה, בלי שלב ביניים של Virtual DOM diffing. Frameworks כמו Solid.js, Svelte 5 (עם Runes), ו-Vue Vapor Mode מובילים את המגמה. React Compiler הוא המענה של React לאתגר הזה, אם כי הוא עדיין מבוסס על Virtual DOM.
useTransition ו-useDeferredValue ב-React
React 18+ מספק כלים נהדרים לניהול עדיפויות rendering, שמשפרים ישירות את ה-INP:
import { useState, useTransition, useDeferredValue } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleSearch = (e) => {
const value = e.target.value;
setQuery(value); // עדכון מיידי של שדה החיפוש
startTransition(() => {
// עדכון התוצאות הוא low-priority
// React יכול להפסיק ולתת עדיפות לאינטראקציות אחרות
setSearchResults(filterProducts(value));
});
};
return (
<div>
<input value={query} onChange={handleSearch} />
{isPending ? <Spinner /> : <Results data={searchResults} />}
</div>
);
}
// useDeferredValue - גרסה אלטרנטיבית
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const results = useMemo(
() => filterProducts(deferredQuery),
[deferredQuery]
);
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
{results.map(product => <ProductCard key={product.id} {...product} />)}
</div>
);
}
requestIdleCallback ו-scheduler.yield()
לעבודות שאינן קריטיות (אנליטיקס, טעינה מראש, עדכוני UI משניים), אפשר להשתמש ב-APIs שנותנים לדפדפן לבצע אותן כשה-main thread פנוי:
// requestIdleCallback - להרצת משימות כשהדפדפן פנוי
function loadAnalytics() {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
import('./analytics.js').then(({ init }) => init());
}, { timeout: 5000 }); // timeout כדי להבטיח שזה יתבצע תוך 5 שניות
} else {
// fallback
setTimeout(() => {
import('./analytics.js').then(({ init }) => init());
}, 2000);
}
}
// scheduler.yield() - API חדש שמפנה את ה-main thread
async function processLargeList(items) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
// כל 50 פריטים, מפנים את ה-main thread
if (i % 50 === 0 && navigator.scheduling?.isInputPending()) {
await scheduler.yield();
}
}
}
scheduler.yield() חשוב במיוחד לשיפור INP, כי הוא מאפשר לקוד "לוותר" על ה-main thread כדי שהדפדפן יטפל באינטראקציות של המשתמש. זו גישה הרבה יותר מודרנית ואלגנטית מהטריק הישן עם setTimeout(fn, 0).
חלק 6: Speculation Rules API — ניווט מיידי
אחת הטכנולוגיות שהכי מרגשות אותי בתקופה האחרונה היא Speculation Rules API. ה-API הזה מאפשר לדפדפן לטעון מראש (prefetch) או אפילו לרנדר מראש (prerender) דפים שהמשתמש צפוי לנווט אליהם — והתוצאה היא חוויית ניווט כמעט מיידית.
מה זה ואיך זה שונה מ-prefetch הישן
ה-API הישן (<link rel="prefetch">) היה מוגבל למדי — הוא הוריד את ה-HTML בלבד, ולפעמים הדפדפנים פשוט התעלמו ממנו. Speculation Rules מציעים שתי יכולות חזקות בהרבה:
- Prefetch: מוריד ומפענח את ה-HTML כולל subresources קריטיים
- Prerender: בונה את הדף בשלמותו ב-tab נסתר, כולל הרצת JavaScript — כשהמשתמש לוחץ, הדף מופיע מיידית
יישום עם תג Script
<!-- Speculation Rules - הטמעה בסיסית -->
<script type="speculationrules">
{
"prefetch": [
{
"urls": ["/products", "/about", "/contact"]
}
],
"prerender": [
{
"urls": ["/"]
}
]
}
</script>
<!-- גרסה מתקדמת עם document rules -->
<script type="speculationrules">
{
"prerender": [
{
"where": {
"and": [
{ "href_matches": "/*" },
{ "not": { "href_matches": "/logout" } },
{ "not": { "href_matches": "/api/*" } },
{ "not": { "selector_matches": ".no-prerender" } }
]
},
"eagerness": "moderate"
}
],
"prefetch": [
{
"where": {
"href_matches": "/*"
},
"eagerness": "conservative"
}
]
}
</script>
רמות Eagerness
ל-Speculation Rules יש ארבע רמות של "להיטות" שקובעות מתי ה-speculation יתחיל:
- immediate: מתבצע ברגע שה-rules נטענים (מתאים לדף הבית או הדף הכי פופולרי)
- eager: מתבצע מהר, עם עיכוב קטן אחרי הטעינה
- moderate: מתבצע כשהעכבר מרחף מעל לינק — איזון מצוין בין צריכת משאבים לחוויית משתמש
- conservative: מתבצע רק כשהמשתמש מתחיל ללחוץ (mousedown/pointerdown) — חוסך משאבים, עדיין מהיר יותר מבלעדיו
תוצאות בעולם האמיתי
והתוצאות? מרשימות בטירוף:
- Shopify דיווחו על שיפור של 180ms בממוצע בזמן ניווט בין דפים
- מדידות שדה הראו שיפור של 500ms ב-LCP P95 — כלומר 95% מהמשתמשים חוו LCP מהיר יותר בחצי שנייה
- אתרי e-commerce דיווחו על עליות של 2-5% ב-conversion rate בעקבות prerendering
נקודה חשובה: ה-API חכם מספיק שלא לבזבז משאבים. הדפדפן יתעלם מ-speculation rules כשהמשתמש במצב Data Saver, כשה-CPU עמוס, או כשהזיכרון נמוך. Chrome גם מגביל את מספר ה-prerenders הפעילים כדי לא להכביד על המכשיר.
חלק 7: מדידה ומעקב
כל האופטימיזציות שדיברנו עליהן חסרות ערך אם לא מודדים את ההשפעה שלהן. כמו שאמרנו במאמר הראשון: "מה שלא נמדד, לא משתפר".
Lighthouse CI בצינור הפיתוח
הטמעת Lighthouse CI בצינור ה-CI/CD שלכם מבטיחה שביצועים לא יידרדרו בשקט עם כל commit:
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['http://localhost:3000/', 'http://localhost:3000/products'],
numberOfRuns: 3,
settings: {
preset: 'desktop'
}
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'total-byte-weight': ['warning', { maxNumericValue: 500000 }],
'unused-javascript': ['warning', { maxNumericValue: 50000 }],
'mainthread-work-breakdown': ['warning', { maxNumericValue: 3000 }],
'interactive': ['error', { maxNumericValue: 4000 }]
}
},
upload: {
target: 'lhci',
serverBaseUrl: 'https://your-lhci-server.example.com'
}
}
};
# GitHub Actions workflow
name: Lighthouse CI
on: [push]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci && npm run build
- run: npm run start &
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun
ספריית web-vitals ל-RUM
נתוני מעבדה מ-Lighthouse חשובים, אבל Real User Monitoring (RUM) הוא מה שנותן את התמונה האמיתית. ספריית web-vitals מאפשרת לאסוף את כל ה-Core Web Vitals ממשתמשים אמיתיים:
// src/vitals.js
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good', 'needs-improvement', 'poor'
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
url: window.location.href,
userAgent: navigator.userAgent,
connectionType: navigator.connection?.effectiveType || 'unknown',
deviceMemory: navigator.deviceMemory || 'unknown'
});
// sendBeacon כדי שהנתונים יישלחו גם אם המשתמש עוזב את הדף
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/analytics/vitals', body);
} else {
fetch('/api/analytics/vitals', {
body,
method: 'POST',
keepalive: true
});
}
}
// רישום כל המדדים
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
Performance Budgets
Performance budgets (תקציבי ביצועים) הם אולי הכלי הכי אפקטיבי למניעת דגרדציה. אתם מגדירים מגבלות מראש, וה-build נכשל אם חורגים מהן. פשוט, אבל עובד מעולה:
// webpack.config.js
module.exports = {
performance: {
maxAssetSize: 250000, // 250KB לכל asset בודד
maxEntrypointSize: 300000, // 300KB ל-entrypoint
hints: 'error' // 'warning' או 'error'
}
};
// bundlesize - כלי עצמאי לבדיקת גדלים
// package.json
{
"bundlesize": [
{
"path": "./dist/js/main-*.js",
"maxSize": "150 kB",
"compression": "brotli"
},
{
"path": "./dist/js/vendor-*.js",
"maxSize": "200 kB",
"compression": "brotli"
},
{
"path": "./dist/css/*.css",
"maxSize": "30 kB",
"compression": "brotli"
}
]
}
כלי מעקב מתמשך
מעבר לכלים שהזכרנו, יש כמה שירותים מצוינים למעקב מתמשך:
- SpeedCurve: מעקב ויזואלי עם Synthetic ו-RUM, כולל תקציבי ביצועים ו-filmstrip comparisons. מצוין לצוותים שרוצים לראות את ההשפעה של שינויים לאורך זמן.
- DebugBear: מתמחה ב-Core Web Vitals עם RUM מפורט, ניתוח מעמיק של כל מדד, והתראות אוטומטיות כשמשהו נשבר.
- Calibre: כלי מקיף שמשלב Synthetic monitoring, performance budgets, ו-competitive benchmarking — ככה שאתם תמיד יודעים איפה אתם ביחס למתחרים.
ההמלצה שלי? שלבו לפחות שני סוגי מדידה: Synthetic monitoring (מדידות אוטומטיות קבועות מסביבה מבוקרת) ו-RUM (נתונים ממשתמשים אמיתיים). ה-Synthetic נותן baseline עקבי לזיהוי רגרסיות, וה-RUM נותן את האמת בשטח.
סיכום
אז עברנו מסלול שלם של אופטימיזציית JavaScript — מניתוח הבעיה ועד מדידה מתמשכת. בואו נסכם את הנקודות המרכזיות:
- ניתוח ראשון, אופטימיזציה אחר כך: השתמשו ב-webpack-bundle-analyzer, Coverage tab ו-Lighthouse כדי להבין מה נמצא ב-bundle לפני שמשנים משהו.
- Tree shaking: ודאו שאתם ב-ES Modules, הגדירו
sideEffectsב-package.json, והעדיפו named imports. ההבדל בין lodash ל-lodash-es (72KB לעומת 3-5KB) אומר הכול. - Code splitting: פצלו לפי routes ורכיבים כבדים, השתמשו ב-
React.lazy()ו-import()דינמי, והוסיפו prefetching. - מיניפיקציה ודחיסה: esbuild או SWC למיניפיקציה, ו-Brotli לדחיסה (15-20% יותר טוב מ-Gzip).
- ביצועי rendering: React Compiler לאוטו-memoization,
useTransitionלעדיפויות, ו-scheduler.yield()לשחרור ה-main thread. - Speculation Rules API: prerendering לניווט מיידי — 500ms שיפור ב-LCP P95 מדבר בעד עצמו.
- מדידה מתמשכת: Lighthouse CI, RUM עם web-vitals, ו-performance budgets.
המאמר הזה, ביחד עם המאמרים הקודמים על Core Web Vitals ו-אופטימיזציית תמונות, נותן לכם ארגז כלים מקיף לאופטימיזציית ביצועים. התחילו מניתוח המצב הנוכחי, תפסו את הפירות הנמוכים שיביאו את ההשפעה הכי גדולה, ויישמו בהדרגה.
וזכרו — אופטימיזציית ביצועים היא לא פרויקט חד-פעמי. זה תהליך מתמשך. בנו את הכלים והתהליכים שיבטיחו שהביצועים ימשיכו להיות מצוינים גם כשהאתר גדל ומתפתח.
בהצלחה באופטימיזציה, ונתראה במאמר הבא בסדרה!