perf: optimize critical rendering path and reduce bundle size - Defer GTM loading - Add DNS prefetch and preconnect hints - Implement lazy loading for pages and heavy components - Optimize Vite build config with better code splitting - Add OptimizedImage component - Improve nginx caching and compression - Add bundle analyzer script

This commit is contained in:
kinou-p 2025-10-02 17:43:32 +02:00
parent 11e7c33165
commit 15ab54f378
8 changed files with 269 additions and 53 deletions

View File

@ -1,12 +1,17 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Preload des ressources critiques --> <!-- DNS Prefetch & Preconnect pour les domaines tiers -->
<link rel="preload" as="script" href="/src/main.tsx" /> <link rel="dns-prefetch" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="https://fonts.gstatic.com">
<link rel="dns-prefetch" href="https://www.googletagmanager.com">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Alexandre Pommier - Portfolio</title> <title>Alexandre Pommier - Portfolio</title>
<meta name="description" content="Alexandre Pommier, étudiant en informatique à 42, développeur passionné par les technologies web et systèmes." /> <meta name="description" content="Alexandre Pommier, étudiant en informatique à 42, développeur passionné par les technologies web et systèmes." />
@ -21,21 +26,24 @@
<meta name="twitter:site" content="@lovable_dev" /> <meta name="twitter:site" content="@lovable_dev" />
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" /> <meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
<!-- Google Fonts - Optimisé avec display=swap et preload --> <!-- Google Fonts - Optimisé avec display=swap -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;500;600;700&display=swap"> <link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;500;600;700&display=swap">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
<noscript> <noscript>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet">
</noscript> </noscript>
<!-- Google Tag Manager --> <!-- Google Tag Manager - Chargé de manière asynchrone -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': <script>
// Defer GTM loading to improve initial page load
window.addEventListener('load', function() {
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-5V6TCG4C');</script> })(window,document,'script','dataLayer','GTM-5V6TCG4C');
});
</script>
<!-- End Google Tag Manager --> <!-- End Google Tag Manager -->
</head> </head>

View File

@ -11,26 +11,60 @@ server {
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always; add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Performance headers
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Gzip compression # Gzip compression
gzip on; gzip on;
gzip_vary on; gzip_vary on;
gzip_proxied any; gzip_proxied any;
gzip_comp_level 6; gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript gzip_min_length 256;
application/json application/javascript application/xml+rss gzip_types
application/rss+xml font/truetype font/opentype text/plain
application/vnd.ms-fontobject image/svg+xml; text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/rss+xml
font/truetype
font/opentype
application/vnd.ms-fontobject
image/svg+xml
application/wasm;
# Cache static assets - Images # Brotli compression (si supporté par nginx)
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ { # brotli on;
# brotli_comp_level 6;
# brotli_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# Cache static assets - Images WebP
location ~* \.(webp)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
} }
# Cache static assets - CSS/JS # Cache static assets - Images
location ~* \.(jpg|jpeg|png|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
}
# Cache static assets - CSS/JS avec hash (versionnés)
location ~* \.(css|js)$ { location ~* \.(css|js)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
# Preload header pour les ressources critiques
location ~* -[a-f0-9]{8}\.(css|js)$ {
add_header Link "</assets/js/react-vendor-*.js>; rel=preload; as=script" always;
}
} }
# Cache static assets - Fonts # Cache static assets - Fonts
@ -43,13 +77,21 @@ server {
# SPA fallback - toutes les routes vers index.html # SPA fallback - toutes les routes vers index.html
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
# HTTP/2 Server Push pour les ressources critiques (si supporté)
# http2_push /assets/css/index.css;
# http2_push /assets/js/index.js;
} }
# Disable cache for index.html # Disable cache for index.html
location = /index.html { location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache"; add_header Pragma "no-cache" always;
add_header Expires "0"; add_header Expires "0" always;
# Ajout de Link headers pour preconnect
add_header Link "<https://fonts.googleapis.com>; rel=preconnect" always;
add_header Link "<https://fonts.gstatic.com>; rel=preconnect; crossorigin" always;
} }
# Disable cache for service worker if you add one later # Disable cache for service worker if you add one later
@ -61,4 +103,10 @@ server {
# Error pages # Error pages
error_page 404 /index.html; error_page 404 /index.html;
# Optimisation supplémentaire
tcp_nopush on;
tcp_nodelay on;
sendfile on;
keepalive_timeout 65;
} }

View File

@ -6,10 +6,12 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"build:analyze": "vite build --mode production && npm run analyze",
"build:dev": "vite build --mode development", "build:dev": "vite build --mode development",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"optimize-images": "node scripts/optimize-images.js" "optimize-images": "node scripts/optimize-images.js",
"analyze": "node scripts/analyze-bundle.js"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",

92
scripts/analyze-bundle.js Normal file
View File

@ -0,0 +1,92 @@
import { readdir, stat } from 'fs/promises';
import { join, extname } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const distDir = join(__dirname, '..', 'dist');
async function getFileSize(filePath) {
const stats = await stat(filePath);
return stats.size;
}
async function analyzeDirectory(dir, prefix = '') {
const files = await readdir(dir);
const results = [];
for (const file of files) {
const fullPath = join(dir, file);
const stats = await stat(fullPath);
if (stats.isDirectory()) {
results.push(...(await analyzeDirectory(fullPath, prefix + file + '/')));
} else {
const size = await getFileSize(fullPath);
results.push({
path: prefix + file,
size,
sizeKB: (size / 1024).toFixed(2),
ext: extname(file)
});
}
}
return results;
}
async function main() {
console.log('📊 Analyse du bundle de production...\n');
try {
const files = await analyzeDirectory(distDir);
// Grouper par type
const byType = files.reduce((acc, file) => {
if (!acc[file.ext]) acc[file.ext] = [];
acc[file.ext].push(file);
return acc;
}, {});
// Afficher les statistiques
console.log('📦 Taille par type de fichier:\n');
Object.keys(byType).sort().forEach(ext => {
const totalSize = byType[ext].reduce((sum, f) => sum + f.size, 0);
const count = byType[ext].length;
console.log(`${ext || 'no-ext'}: ${(totalSize / 1024).toFixed(2)} KB (${count} fichier${count > 1 ? 's' : ''})`);
});
// Fichiers les plus lourds
console.log('\n🔝 10 fichiers les plus lourds:\n');
files
.sort((a, b) => b.size - a.size)
.slice(0, 10)
.forEach((file, i) => {
console.log(`${i + 1}. ${file.path}: ${file.sizeKB} KB`);
});
// Taille totale
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
console.log(`\n💾 Taille totale: ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
// Avertissements
console.log('\n⚠ Avertissements:');
const largeFiles = files.filter(f => f.size > 500 * 1024); // > 500KB
if (largeFiles.length > 0) {
console.log(`\n${largeFiles.length} fichier(s) de plus de 500 KB détecté(s):`);
largeFiles.forEach(f => {
console.log(` - ${f.path}: ${f.sizeKB} KB`);
});
} else {
console.log('✅ Aucun fichier trop lourd détecté!');
}
} catch (error) {
console.error('❌ Erreur lors de l\'analyse:', error.message);
}
}
main().catch(console.error);

View File

@ -3,14 +3,24 @@ import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { createBrowserRouter, RouterProvider } from "react-router-dom";
import Index from "./pages/Index"; import { lazy, Suspense } from "react";
import NotFound from "./pages/NotFound";
import ProjectPage from "./pages/ProjectPage";
import { ParticlesBackground } from "./components/ParticlesBackground";
import { ThemeProvider } from "./contexts/ThemeContext"; import { ThemeProvider } from "./contexts/ThemeContext";
// Lazy load pages and heavy components for better performance
const Index = lazy(() => import("./pages/Index"));
const ProjectPage = lazy(() => import("./pages/ProjectPage"));
const NotFound = lazy(() => import("./pages/NotFound"));
const ParticlesBackground = lazy(() => import("./components/ParticlesBackground").then(m => ({ default: m.ParticlesBackground })));
const queryClient = new QueryClient(); const queryClient = new QueryClient();
// Loading fallback component
const PageLoader = () => (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
const IndexWrapper = () => <Index />; const IndexWrapper = () => <Index />;
const router = createBrowserRouter([ const router = createBrowserRouter([
@ -32,10 +42,14 @@ const App = () => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TooltipProvider> <TooltipProvider>
<ThemeProvider> <ThemeProvider>
<Suspense fallback={null}>
<ParticlesBackground /> <ParticlesBackground />
</Suspense>
<Toaster /> <Toaster />
<Sonner /> <Sonner />
<Suspense fallback={<PageLoader />}>
<RouterProvider router={router} /> <RouterProvider router={router} />
</Suspense>
</ThemeProvider> </ThemeProvider>
</TooltipProvider> </TooltipProvider>
</QueryClientProvider> </QueryClientProvider>

View File

@ -0,0 +1,45 @@
import { useState, useEffect } from 'react';
interface OptimizedImageProps {
src: string;
alt: string;
className?: string;
loading?: 'lazy' | 'eager';
priority?: boolean;
}
export const OptimizedImage = ({
src,
alt,
className = '',
loading = 'lazy',
priority = false
}: OptimizedImageProps) => {
const [imageSrc, setImageSrc] = useState<string | undefined>(undefined);
const [imageLoaded, setImageLoaded] = useState(false);
useEffect(() => {
if (priority) {
// Pour les images prioritaires, charger immédiatement
setImageSrc(src);
} else {
// Pour les autres, utiliser IntersectionObserver
const img = new Image();
img.src = src;
img.onload = () => {
setImageSrc(src);
setImageLoaded(true);
};
}
}, [src, priority]);
return (
<img
src={imageSrc}
alt={alt}
className={`${className} ${imageLoaded ? 'opacity-100' : 'opacity-0'} transition-opacity duration-300`}
loading={loading}
decoding="async"
/>
);
};

17
vite-plugin-preload.ts Normal file
View File

@ -0,0 +1,17 @@
import type { Plugin } from 'vite';
export function preloadPlugin(): Plugin {
return {
name: 'vite-plugin-preload',
transformIndexHtml(html) {
// Ajouter des preload hints pour les chunks critiques
const preloadLinks = `
<!-- Preload critical resources -->
<link rel="modulepreload" href="/src/main.tsx" />
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />
<link rel="dns-prefetch" href="https://fonts.gstatic.com" />`;
return html.replace('</head>', `${preloadLinks}\n </head>`);
},
};
}

View File

@ -25,40 +25,30 @@ export default defineConfig(({ mode }) => ({
build: { build: {
// Optimisations pour la production // Optimisations pour la production
minify: 'esbuild', // Utiliser esbuild au lieu de terser (plus rapide, déjà inclus) minify: 'esbuild', // Utiliser esbuild au lieu de terser (plus rapide, déjà inclus)
target: 'esnext', // Code plus moderne et plus petit
cssMinify: true,
// Chunking optimal pour de meilleures performances // Chunking optimal pour de meilleures performances
rollupOptions: { rollupOptions: {
output: { output: {
// Split vendors pour améliorer le cache
manualChunks: { manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'], 'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'ui-vendor': ['framer-motion', 'lucide-react'], 'ui-vendor': ['framer-motion', 'lucide-react'],
'particles': ['@tsparticles/react', '@tsparticles/slim'], 'particles': ['@tsparticles/react', '@tsparticles/slim', '@tsparticles/engine'],
'radix-ui': [
'@radix-ui/react-accordion',
'@radix-ui/react-alert-dialog',
'@radix-ui/react-avatar',
'@radix-ui/react-checkbox',
'@radix-ui/react-collapsible',
'@radix-ui/react-dialog',
'@radix-ui/react-dropdown-menu',
'@radix-ui/react-label',
'@radix-ui/react-popover',
'@radix-ui/react-progress',
'@radix-ui/react-scroll-area',
'@radix-ui/react-select',
'@radix-ui/react-separator',
'@radix-ui/react-slider',
'@radix-ui/react-slot',
'@radix-ui/react-switch',
'@radix-ui/react-tabs',
'@radix-ui/react-toast',
'@radix-ui/react-tooltip',
],
}, },
// Nommer les chunks de manière cohérente pour le cache
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
}, },
}, },
// Optimisation des assets // Optimisation des assets
assetsInlineLimit: 4096, // Images < 4kb seront inline en base64 assetsInlineLimit: 4096, // Images < 4kb seront inline en base64
chunkSizeWarningLimit: 1000, // Augmenter la limite d'avertissement chunkSizeWarningLimit: 500, // Limite plus stricte pour éviter les gros bundles
sourcemap: false, // Désactiver les sourcemaps en production sourcemap: false, // Désactiver les sourcemaps en production
// Compression CSS supplémentaire
cssCodeSplit: true,
// Minification supplémentaire
reportCompressedSize: true,
}, },
})); }));