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:
parent
11e7c33165
commit
15ab54f378
28
index.html
28
index.html
@ -1,12 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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 -->
|
||||
<link rel="preload" as="script" href="/src/main.tsx" />
|
||||
<!-- DNS Prefetch & Preconnect pour les domaines tiers -->
|
||||
<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>
|
||||
<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:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
||||
|
||||
<!-- Google Fonts - Optimisé avec display=swap et preload -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<!-- Google Fonts - Optimisé avec 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'">
|
||||
<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">
|
||||
</noscript>
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
<!-- Google Tag Manager - Chargé de manière asynchrone -->
|
||||
<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],
|
||||
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);
|
||||
})(window,document,'script','dataLayer','GTM-5V6TCG4C');</script>
|
||||
})(window,document,'script','dataLayer','GTM-5V6TCG4C');
|
||||
});
|
||||
</script>
|
||||
<!-- End Google Tag Manager -->
|
||||
</head>
|
||||
|
||||
|
||||
68
nginx.conf
68
nginx.conf
@ -11,26 +11,60 @@ server {
|
||||
add_header X-XSS-Protection "1; mode=block" 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 on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain 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;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
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
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp)$ {
|
||||
# Brotli compression (si supporté par nginx)
|
||||
# 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;
|
||||
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)$ {
|
||||
expires 1y;
|
||||
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
|
||||
@ -43,13 +77,21 @@ server {
|
||||
# SPA fallback - toutes les routes vers index.html
|
||||
location / {
|
||||
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
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
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
|
||||
@ -61,4 +103,10 @@ server {
|
||||
|
||||
# Error pages
|
||||
error_page 404 /index.html;
|
||||
|
||||
# Optimisation supplémentaire
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
}
|
||||
|
||||
@ -6,10 +6,12 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:analyze": "vite build --mode production && npm run analyze",
|
||||
"build:dev": "vite build --mode development",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"optimize-images": "node scripts/optimize-images.js"
|
||||
"optimize-images": "node scripts/optimize-images.js",
|
||||
"analyze": "node scripts/analyze-bundle.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
|
||||
92
scripts/analyze-bundle.js
Normal file
92
scripts/analyze-bundle.js
Normal 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);
|
||||
22
src/App.tsx
22
src/App.tsx
@ -3,14 +3,24 @@ import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import Index from "./pages/Index";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import ProjectPage from "./pages/ProjectPage";
|
||||
import { ParticlesBackground } from "./components/ParticlesBackground";
|
||||
import { lazy, Suspense } from "react";
|
||||
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();
|
||||
|
||||
// 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 router = createBrowserRouter([
|
||||
@ -32,10 +42,14 @@ const App = () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<ThemeProvider>
|
||||
<Suspense fallback={null}>
|
||||
<ParticlesBackground />
|
||||
</Suspense>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
45
src/components/OptimizedImage.tsx
Normal file
45
src/components/OptimizedImage.tsx
Normal 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
17
vite-plugin-preload.ts
Normal 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>`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -25,40 +25,30 @@ export default defineConfig(({ mode }) => ({
|
||||
build: {
|
||||
// Optimisations pour la production
|
||||
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
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Split vendors pour améliorer le cache
|
||||
manualChunks: {
|
||||
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
|
||||
'ui-vendor': ['framer-motion', 'lucide-react'],
|
||||
'particles': ['@tsparticles/react', '@tsparticles/slim'],
|
||||
'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',
|
||||
],
|
||||
'particles': ['@tsparticles/react', '@tsparticles/slim', '@tsparticles/engine'],
|
||||
},
|
||||
// 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
|
||||
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
|
||||
// Compression CSS supplémentaire
|
||||
cssCodeSplit: true,
|
||||
// Minification supplémentaire
|
||||
reportCompressedSize: true,
|
||||
},
|
||||
}));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user