Single Page Application: Koncept och struktur
En Single Page Application (SPA) laddar appen en gång och uppdaterar sedan innehållet i webbläsaren vid navigering. Här fokuserar vi på koncept, trade‑offs och enkel struktur.
SPA vs Traditionella Webbapplikationer
graph TB subgraph "Traditional Multi-Page App" A1[Browser] -->|"Request page1.html"| B1[Server] B1 -->|"Full HTML page"| A1 A1 -->|"Request page2.html"| B1 B1 -->|"Full HTML page"| A1 A1 -->|"Request page3.html"| B1 B1 -->|"Full HTML page"| A1 end subgraph "Single Page Application" A2[Browser] -->|"Initial request"| B2[Server] B2 -->|"HTML + JS Bundle"| A2 A2 -->|"API request"| B2 B2 -->|"JSON data"| A2 A2 -.->|"Client-side routing"| A2 end style B1 fill:#ffa07a style A2 fill:#98fb98 style B2 fill:#87ceeb
Fördelar med SPA (varför)
Användarupplevelse:
- Snabbare navigering efter initial laddning
- Smidigare övergångar mellan vyer
- Möjlighet för offline-funktionalitet
- App-liknande känsla
Utveckling:
- Tydlig separation mellan frontend och backend
- Återanvändbar API för flera klienter
- Modern utvecklingsworkflow med hot reloading
Nackdelar med SPA (varför inte alltid)
Initial Prestanda:
- Större initial JavaScript-bundle
- Längre tid till första visning
- Komplicerad state management
SEO och Tillgänglighet:
- Kräver Server-Side Rendering (SSR) för SEO
- Komplexare routing-implementation
- Potential för minnesläckor
Enkel struktur för en liten SPA
Håll mappstrukturen enkel i början:
src/
components/ # Återanvändbara UI-byggstenar
pages/ # Sidor för routing
services/ # API-anrop (fetch)
hooks/ # Egen logik (valfritt)
App.jsx # Router och layout
main.jsx # Entrypoint
Layout-komponent med Navigation
// components/Layout.js
import { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext';
function Layout({ children }) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const { user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const location = useLocation();
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
navigate('/');
setMobileMenuOpen(false);
};
const isActive = (path) => location.pathname === path;
return (
<div className={`app ${theme}`}>
<header className="header">
<div className="container">
<Link to="/" className="logo">
<h1>Min SPA</h1>
</Link>
{/* Desktop Navigation */}
<nav className="desktop-nav">
<Link
to="/"
className={isActive('/') ? 'active' : ''}
>
Hem
</Link>
<Link
to="/products"
className={isActive('/products') ? 'active' : ''}
>
Produkter
</Link>
<Link
to="/about"
className={isActive('/about') ? 'active' : ''}
>
Om oss
</Link>
</nav>
{/* User Actions */}
<div className="user-actions">
<button onClick={toggleTheme} className="theme-toggle">
{theme === 'light' ? '🌙' : '☀️'}
</button>
{user ? (
<div className="user-menu">
<Link to="/dashboard">Dashboard</Link>
<button onClick={handleLogout}>Logga ut</button>
</div>
) : (
<Link to="/login" className="login-btn">
Logga in
</Link>
)}
{/* Mobile Menu Toggle */}
<button
className="mobile-menu-toggle"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
☰
</button>
</div>
</div>
{/* Mobile Navigation */}
{mobileMenuOpen && (
<nav className="mobile-nav">
<Link to="/" onClick={() => setMobileMenuOpen(false)}>Hem</Link>
<Link to="/products" onClick={() => setMobileMenuOpen(false)}>Produkter</Link>
<Link to="/about" onClick={() => setMobileMenuOpen(false)}>Om oss</Link>
{user && (
<Link to="/dashboard" onClick={() => setMobileMenuOpen(false)}>
Dashboard
</Link>
)}
</nav>
)}
</header>
<main className="main">
{children}
</main>
<footer className="footer">
<div className="container">
<p>© 2024 Min SPA. Alla rättigheter förbehållna.</p>
</div>
</footer>
</div>
);
}
export default Layout;
Context API för Global State
// contexts/AppContext.js - Kombinerad state management
import { createContext, useContext, useReducer, useEffect } from 'react';
const AppContext = createContext();
// Action types
const ACTIONS = {
SET_LOADING: 'SET_LOADING',
SET_ERROR: 'SET_ERROR',
CLEAR_ERROR: 'CLEAR_ERROR',
SET_USER: 'SET_USER',
SET_PRODUCTS: 'SET_PRODUCTS',
ADD_TO_CART: 'ADD_TO_CART',
REMOVE_FROM_CART: 'REMOVE_FROM_CART',
CLEAR_CART: 'CLEAR_CART',
SET_THEME: 'SET_THEME'
};
// Initial state
const initialState = {
loading: false,
error: null,
user: null,
products: [],
cart: [],
theme: 'light'
};
// Reducer function
function appReducer(state, action) {
switch (action.type) {
case ACTIONS.SET_LOADING:
return { ...state, loading: action.payload };
case ACTIONS.SET_ERROR:
return { ...state, error: action.payload, loading: false };
case ACTIONS.CLEAR_ERROR:
return { ...state, error: null };
case ACTIONS.SET_USER:
return { ...state, user: action.payload };
case ACTIONS.SET_PRODUCTS:
return { ...state, products: action.payload };
case ACTIONS.ADD_TO_CART:
const existingItem = state.cart.find(item => item.id === action.payload.id);
if (existingItem) {
return {
...state,
cart: state.cart.map(item =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
)
};
}
return {
...state,
cart: [...state.cart, { ...action.payload, quantity: 1 }]
};
case ACTIONS.REMOVE_FROM_CART:
return {
...state,
cart: state.cart.filter(item => item.id !== action.payload)
};
case ACTIONS.CLEAR_CART:
return { ...state, cart: [] };
case ACTIONS.SET_THEME:
return { ...state, theme: action.payload };
default:
return state;
}
}
// Provider component
export function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState);
// Load initial data
useEffect(() => {
const loadInitialData = async () => {
try {
dispatch({ type: ACTIONS.SET_LOADING, payload: true });
// Load user from localStorage
const savedUser = localStorage.getItem('user');
if (savedUser) {
dispatch({ type: ACTIONS.SET_USER, payload: JSON.parse(savedUser) });
}
// Load theme from localStorage
const savedTheme = localStorage.getItem('theme') || 'light';
dispatch({ type: ACTIONS.SET_THEME, payload: savedTheme });
// Load products
const response = await fetch('/api/products');
const products = await response.json();
dispatch({ type: ACTIONS.SET_PRODUCTS, payload: products });
} catch (error) {
dispatch({ type: ACTIONS.SET_ERROR, payload: error.message });
} finally {
dispatch({ type: ACTIONS.SET_LOADING, payload: false });
}
};
loadInitialData();
}, []);
// Save user to localStorage when it changes
useEffect(() => {
if (state.user) {
localStorage.setItem('user', JSON.stringify(state.user));
} else {
localStorage.removeItem('user');
}
}, [state.user]);
// Save theme to localStorage when it changes
useEffect(() => {
localStorage.setItem('theme', state.theme);
document.documentElement.setAttribute('data-theme', state.theme);
}, [state.theme]);
// Action creators
const actions = {
setLoading: (loading) => dispatch({ type: ACTIONS.SET_LOADING, payload: loading }),
setError: (error) => dispatch({ type: ACTIONS.SET_ERROR, payload: error }),
clearError: () => dispatch({ type: ACTIONS.CLEAR_ERROR }),
setUser: (user) => dispatch({ type: ACTIONS.SET_USER, payload: user }),
addToCart: (product) => dispatch({ type: ACTIONS.ADD_TO_CART, payload: product }),
removeFromCart: (productId) => dispatch({ type: ACTIONS.REMOVE_FROM_CART, payload: productId }),
clearCart: () => dispatch({ type: ACTIONS.CLEAR_CART }),
toggleTheme: () => {
const newTheme = state.theme === 'light' ? 'dark' : 'light';
dispatch({ type: ACTIONS.SET_THEME, payload: newTheme });
}
};
return (
<AppContext.Provider value={{ state, actions }}>
{children}
</AppContext.Provider>
);
}
// Custom hook
export function useApp() {
const context = useContext(AppContext);
if (!context) {
throw new Error('useApp must be used within AppProvider');
}
return context;
}
Code Splitting och Lazy Loading
// Lazy load components
import { lazy, Suspense } from 'react';
import LoadingSpinner from './components/LoadingSpinner';
// Lazy loaded pages
const Dashboard = lazy(() => import('./pages/Dashboard'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
const Reports = lazy(() => import('./pages/Reports'));
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route
path="/dashboard"
element={
<Suspense fallback={<LoadingSpinner />}>
<Dashboard />
</Suspense>
}
/>
<Route
path="/admin"
element={
<Suspense fallback={<LoadingSpinner />}>
<AdminPanel />
</Suspense>
}
/>
<Route
path="/reports"
element={
<Suspense fallback={<LoadingSpinner />}>
<Reports />
</Suspense>
}
/>
</Routes>
</Router>
);
}
Memoization och Optimization
import { memo, useMemo, useCallback } from 'react';
// Memoized list component
const ProductList = memo(function ProductList({ products, onAddToCart }) {
const sortedProducts = useMemo(() => {
// Undvik att mutera original-arrayen
return [...products].sort((a, b) => a.name.localeCompare(b.name));
}, [products]);
return (
<div className="product-grid">
{sortedProducts.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={onAddToCart}
/>
))}
</div>
);
});
// Memoized product card
const ProductCard = memo(function ProductCard({ product, onAddToCart }) {
const handleAddToCart = useCallback(() => {
onAddToCart(product);
}, [product, onAddToCart]);
return (
<div className="product-card">
<img src={product.image} alt={product.name} loading="lazy" />
<h3>{product.name}</h3>
<p>{product.price} kr</p>
<button onClick={handleAddToCart}>
Lägg i kundvagn
</button>
</div>
);
});
Virtual Scrolling för Stora Listor
import { FixedSizeList as List } from 'react-window';
function VirtualizedProductList({ products }) {
const Row = ({ index, style }) => {
const product = products[index];
return (
<div style={style} className="virtual-list-item">
<ProductCard product={product} />
</div>
);
};
return (
<List
height={600}
itemCount={products.length}
itemSize={200}
className="virtual-list"
>
{Row}
</List>
);
}
Build och deployment (översikt)
Optimering för Produktion
# Build för produktion
npm run build
# Analysera bundle size
npm install --save-dev webpack-bundle-analyzer
npx webpack-bundle-analyzer build/static/js/*.js
Miljövariabler
# .env.local
REACT_APP_API_URL=https://api.example.com
REACT_APP_GOOGLE_ANALYTICS_ID=GA_TRACKING_ID
REACT_APP_VERSION=$npm_package_version
// config/env.js
const config = {
apiUrl: process.env.REACT_APP_API_URL || 'http://localhost:3001',
googleAnalyticsId: process.env.REACT_APP_GOOGLE_ANALYTICS_ID,
version: process.env.REACT_APP_VERSION || '1.0.0',
isDevelopment: process.env.NODE_ENV === 'development',
isProduction: process.env.NODE_ENV === 'production'
};
export default config;
Vite miljövariabler
# .env
VITE_API_URL=https://api.example.com
VITE_GOOGLE_ANALYTICS_ID=GA_TRACKING_ID
// src/config/env.js (Vite)
const config = {
apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:3001',
googleAnalyticsId: import.meta.env.VITE_GOOGLE_ANALYTICS_ID,
isDevelopment: import.meta.env.DEV,
isProduction: import.meta.env.PROD
};
export default config;
När SSR/SSG?
- SEO-kritiska sidor, snabb First Contentful Paint, delning av länkar med preview.
- Överväg Next.js för Server-Side Rendering (SSR) eller Static Site Generation (SSG) när det passar behovet.
// public/sw.js
const CACHE_NAME = 'spa-cache-v1';
const urlsToCache = [
'/',
'/static/js/bundle.js',
'/static/css/main.css',
'/manifest.json'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// Return cached version or fetch from network
return response || fetch(event.request);
})
);
});
Deployment till olika plattformar
# Netlify
npm run build
# Dra och släpp build-mappen till Netlify
# Vercel
npm install -g vercel
vercel --prod
# GitHub Pages
npm install --save-dev gh-pages
# Lägg till i package.json:
# "homepage": "https://username.github.io/repository-name",
# "predeploy": "npm run build",
# "deploy": "gh-pages -d build"
npm run deploy
Mikro‑övning
Bygg en liten SPA med tre sidor (Hem, Om, Kontakt) och en sida som hämtar data (Produkter).
- Navigera mellan sidor med React Router.
- På Produkter: hämta en lista via Fetch och visa
loading
/error
/tom‑state. - Lägg till en detaljsida med
:id
.
Klar‑kriterier:
- Ingen sidladdning vid navigering.
- Tydliga laddnings‑ och felmeddelanden.
- Detaljsidan visar id från URL:en.
Error Tracking
// utils/errorTracking.js
class ErrorTracker {
static init() {
if (process.env.NODE_ENV === 'production') {
window.addEventListener('error', this.handleError);
window.addEventListener('unhandledrejection', this.handlePromiseRejection);
}
}
static handleError(event) {
console.error('Global error:', event.error);
// Skicka till error tracking service
this.sendToService({
type: 'javascript_error',
message: event.error.message,
stack: event.error.stack,
url: window.location.href,
userAgent: navigator.userAgent
});
}
static handlePromiseRejection(event) {
console.error('Unhandled promise rejection:', event.reason);
this.sendToService({
type: 'promise_rejection',
message: event.reason.toString(),
url: window.location.href
});
}
static sendToService(errorData) {
fetch('/api/errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(errorData)
}).catch(err => console.error('Failed to send error:', err));
}
}
export default ErrorTracker;
Sammanfattning
Single Page Applications erbjuder många fördelar för moderna webbapplikationer:
- Smidig användarupplevelse med snabb navigering
- Tydlig separation mellan frontend och backend
- Modern arkitektur som skalrar bra
- Rich interaktivitet som desktop-applikationer
Viktiga aspekter att tänka på:
- Performance optimization med code splitting
- Proper state management för komplex data
- Build-optimering för produktionsmiljö
- Error tracking och monitoring
- SEO-considerations för offentliga applikationer
I nästa avsnitt lär vi oss implementera routing med React Router för navigation.