Next.js 静态资源
静态资源是 Web 应用的重要组成部分,包括图片、字体、图标等文件。Next.js 提供了强大的静态资源处理能力,本章将详细介绍如何优化和管理这些资源。
静态资源基础
public 目录
Next.js 使用 public 目录存放静态资源:
public/
├── favicon.ico
├── logo.png
├── images/
│ ├── hero.jpg
│ ├── avatar.png
│ └── gallery/
│ ├── photo1.jpg
│ └── photo2.jpg
├── icons/
│ ├── home.svg
│ └── user.svg
├── fonts/
│ ├── custom-font.woff2
│ └── custom-font.woff
└── documents/
└── manual.pdf访问静态资源
typescript
// 直接使用路径访问
export default function HomePage() {
return (
<div>
<img src="/logo.png" alt="Logo" />
<img src="/images/hero.jpg" alt="Hero" />
<a href="/documents/manual.pdf" download>
下载手册
</a>
</div>
)
}图片优化
Next.js Image 组件
typescript
// components/OptimizedImage.tsx
import Image from 'next/image'
export default function OptimizedImage() {
return (
<div>
{/* 基本使用 */}
<Image
src="/images/hero.jpg"
alt="Hero Image"
width={800}
height={400}
/>
{/* 响应式图片 */}
<Image
src="/images/hero.jpg"
alt="Hero Image"
fill
style={{ objectFit: 'cover' }}
/>
{/* 优先加载 */}
<Image
src="/images/hero.jpg"
alt="Hero Image"
width={800}
height={400}
priority
/>
</div>
)
}外部图片
typescript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ['example.com', 'cdn.example.com'],
// 或使用 remotePatterns (推荐)
remotePatterns: [
{
protocol: 'https',
hostname: 'example.com',
port: '',
pathname: '/images/**',
},
{
protocol: 'https',
hostname: 'cdn.example.com',
},
],
},
}
module.exports = nextConfigtypescript
// 使用外部图片
import Image from 'next/image'
export default function ExternalImage() {
return (
<Image
src="https://example.com/images/photo.jpg"
alt="External Photo"
width={600}
height={400}
/>
)
}动态图片
typescript
// components/DynamicImage.tsx
import Image from 'next/image'
import { useState } from 'react'
interface DynamicImageProps {
src: string
alt: string
width: number
height: number
}
export default function DynamicImage({ src, alt, width, height }: DynamicImageProps) {
const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
return (
<div className="relative">
{isLoading && (
<div className="absolute inset-0 bg-gray-200 animate-pulse rounded" />
)}
<Image
src={src}
alt={alt}
width={width}
height={height}
onLoad={() => setIsLoading(false)}
onError={() => {
setIsLoading(false)
setHasError(true)
}}
className={`transition-opacity duration-300 ${
isLoading ? 'opacity-0' : 'opacity-100'
}`}
/>
{hasError && (
<div className="absolute inset-0 bg-gray-100 flex items-center justify-center">
<span className="text-gray-500">图片加载失败</span>
</div>
)}
</div>
)
}图片画廊
typescript
// components/ImageGallery.tsx
'use client'
import Image from 'next/image'
import { useState } from 'react'
interface ImageItem {
id: string
src: string
alt: string
width: number
height: number
}
interface ImageGalleryProps {
images: ImageItem[]
}
export default function ImageGallery({ images }: ImageGalleryProps) {
const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null)
return (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{images.map((image) => (
<div
key={image.id}
className="cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setSelectedImage(image)}
>
<Image
src={image.src}
alt={image.alt}
width={300}
height={200}
className="rounded-lg object-cover"
/>
</div>
))}
</div>
{/* 模态框 */}
{selectedImage && (
<div
className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50"
onClick={() => setSelectedImage(null)}
>
<div className="relative max-w-4xl max-h-full p-4">
<Image
src={selectedImage.src}
alt={selectedImage.alt}
width={selectedImage.width}
height={selectedImage.height}
className="max-w-full max-h-full object-contain"
/>
<button
className="absolute top-4 right-4 text-white text-2xl"
onClick={() => setSelectedImage(null)}
>
✕
</button>
</div>
</div>
)}
</>
)
}字体优化
Google Fonts
typescript
// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
})
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh" className={inter.className}>
<body>
<div className={robotoMono.className}>
代码字体示例
</div>
{children}
</body>
</html>
)
}本地字体
typescript
// app/layout.tsx
import localFont from 'next/font/local'
const customFont = localFont({
src: [
{
path: '../public/fonts/custom-regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../public/fonts/custom-bold.woff2',
weight: '700',
style: 'normal',
},
],
display: 'swap',
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh" className={customFont.className}>
<body>{children}</body>
</html>
)
}字体变量
typescript
// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
})
const robotoMono = Roboto_Mono({
subsets: ['latin'],
variable: '--font-roboto-mono',
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh" className={`${inter.variable} ${robotoMono.variable}`}>
<body>{children}</body>
</html>
)
}css
/* globals.css */
:root {
--font-inter: 'Inter', sans-serif;
--font-roboto-mono: 'Roboto Mono', monospace;
}
body {
font-family: var(--font-inter);
}
code {
font-family: var(--font-roboto-mono);
}图标管理
SVG 图标组件
typescript
// components/icons/HomeIcon.tsx
export default function HomeIcon({
size = 24,
color = 'currentColor'
}: {
size?: number
color?: string
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 9L12 2L21 9V20C21 20.5304 20.7893 21.0391 20.4142 21.4142C20.0391 21.7893 19.5304 22 19 22H5C4.46957 22 3.96086 21.7893 3.58579 21.4142C3.21071 21.0391 3 20.5304 3 20V9Z"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9 22V12H15V22"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}图标库
typescript
// components/icons/index.tsx
import HomeIcon from './HomeIcon'
import UserIcon from './UserIcon'
import SettingsIcon from './SettingsIcon'
export const icons = {
home: HomeIcon,
user: UserIcon,
settings: SettingsIcon,
} as const
export type IconName = keyof typeof icons
interface IconProps {
name: IconName
size?: number
color?: string
className?: string
}
export default function Icon({ name, size = 24, color = 'currentColor', className }: IconProps) {
const IconComponent = icons[name]
return (
<IconComponent
size={size}
color={color}
className={className}
/>
)
}使用图标
typescript
// components/Navigation.tsx
import Icon from '@/components/icons'
export default function Navigation() {
return (
<nav className="flex space-x-4">
<a href="/" className="flex items-center space-x-2">
<Icon name="home" size={20} />
<span>首页</span>
</a>
<a href="/profile" className="flex items-center space-x-2">
<Icon name="user" size={20} />
<span>个人资料</span>
</a>
<a href="/settings" className="flex items-center space-x-2">
<Icon name="settings" size={20} />
<span>设置</span>
</a>
</nav>
)
}文件下载
下载组件
typescript
// components/DownloadButton.tsx
'use client'
import { useState } from 'react'
interface DownloadButtonProps {
url: string
filename: string
children: React.ReactNode
}
export default function DownloadButton({ url, filename, children }: DownloadButtonProps) {
const [downloading, setDownloading] = useState(false)
const handleDownload = async () => {
setDownloading(true)
try {
const response = await fetch(url)
const blob = await response.blob()
const downloadUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(downloadUrl)
} catch (error) {
console.error('下载失败:', error)
} finally {
setDownloading(false)
}
}
return (
<button
onClick={handleDownload}
disabled={downloading}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
>
{downloading ? '下载中...' : children}
</button>
)
}文件预览
typescript
// components/FilePreview.tsx
'use client'
import { useState } from 'react'
interface FilePreviewProps {
url: string
type: 'image' | 'pdf' | 'video'
name: string
}
export default function FilePreview({ url, type, name }: FilePreviewProps) {
const [isOpen, setIsOpen] = useState(false)
const renderPreview = () => {
switch (type) {
case 'image':
return (
<img
src={url}
alt={name}
className="max-w-full max-h-full object-contain"
/>
)
case 'pdf':
return (
<iframe
src={url}
className="w-full h-full"
title={name}
/>
)
case 'video':
return (
<video
src={url}
controls
className="max-w-full max-h-full"
>
您的浏览器不支持视频播放
</video>
)
default:
return <div>不支持的文件类型</div>
}
}
return (
<>
<button
onClick={() => setIsOpen(true)}
className="text-blue-500 hover:underline"
>
预览 {name}
</button>
{isOpen && (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
<div className="relative w-full h-full max-w-4xl max-h-full p-4">
<button
onClick={() => setIsOpen(false)}
className="absolute top-4 right-4 text-white text-2xl z-10"
>
✕
</button>
<div className="w-full h-full flex items-center justify-center">
{renderPreview()}
</div>
</div>
</div>
)}
</>
)
}资源优化
图片压缩配置
javascript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
formats: ['image/webp', 'image/avif'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
minimumCacheTTL: 60 * 60 * 24 * 365, // 1年
},
}
module.exports = nextConfig懒加载
typescript
// components/LazyImage.tsx
'use client'
import Image from 'next/image'
import { useState, useRef, useEffect } from 'react'
interface LazyImageProps {
src: string
alt: string
width: number
height: number
placeholder?: string
}
export default function LazyImage({
src,
alt,
width,
height,
placeholder = '/placeholder.jpg'
}: LazyImageProps) {
const [isInView, setIsInView] = useState(false)
const imgRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true)
observer.disconnect()
}
},
{ threshold: 0.1 }
)
if (imgRef.current) {
observer.observe(imgRef.current)
}
return () => observer.disconnect()
}, [])
return (
<div ref={imgRef} style={{ width, height }}>
<Image
src={isInView ? src : placeholder}
alt={alt}
width={width}
height={height}
className={`transition-opacity duration-300 ${
isInView ? 'opacity-100' : 'opacity-50'
}`}
/>
</div>
)
}资源预加载
typescript
// components/ResourcePreloader.tsx
'use client'
import { useEffect } from 'react'
interface ResourcePreloaderProps {
images?: string[]
fonts?: string[]
}
export default function ResourcePreloader({ images = [], fonts = [] }: ResourcePreloaderProps) {
useEffect(() => {
// 预加载图片
images.forEach(src => {
const img = new Image()
img.src = src
})
// 预加载字体
fonts.forEach(src => {
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'font'
link.type = 'font/woff2'
link.crossOrigin = 'anonymous'
link.href = src
document.head.appendChild(link)
})
}, [images, fonts])
return null
}静态资源 CDN
配置 CDN
javascript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
assetPrefix: process.env.NODE_ENV === 'production'
? 'https://cdn.example.com'
: '',
images: {
loader: 'custom',
loaderFile: './lib/imageLoader.js',
},
}
module.exports = nextConfigjavascript
// lib/imageLoader.js
export default function imageLoader({ src, width, quality }) {
const params = new URLSearchParams()
params.set('w', width.toString())
if (quality) {
params.set('q', quality.toString())
}
return `https://cdn.example.com${src}?${params}`
}环境变量配置
bash
# .env.local
NEXT_PUBLIC_CDN_URL=https://cdn.example.com
NEXT_PUBLIC_ASSET_PREFIX=https://assets.example.comtypescript
// lib/assets.ts
export function getAssetUrl(path: string): string {
const cdnUrl = process.env.NEXT_PUBLIC_CDN_URL
return cdnUrl ? `${cdnUrl}${path}` : path
}
// 使用示例
import { getAssetUrl } from '@/lib/assets'
export default function MyComponent() {
return (
<img
src={getAssetUrl('/images/logo.png')}
alt="Logo"
/>
)
}性能监控
资源加载监控
typescript
// hooks/useResourceMonitor.ts
'use client'
import { useEffect, useState } from 'react'
interface ResourceTiming {
name: string
duration: number
size?: number
}
export function useResourceMonitor() {
const [resources, setResources] = useState<ResourceTiming[]>([])
useEffect(() => {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries()
const resourceTimings = entries.map(entry => ({
name: entry.name,
duration: entry.duration,
size: (entry as any).transferSize,
}))
setResources(prev => [...prev, ...resourceTimings])
})
observer.observe({ entryTypes: ['resource'] })
return () => observer.disconnect()
}, [])
return resources
}最佳实践
1. 图片优化
typescript
// ✅ 好的做法
<Image
src="/hero.jpg"
alt="Hero Image"
width={800}
height={400}
priority // 首屏图片
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
// ❌ 避免
<img src="/hero.jpg" alt="Hero Image" />2. 字体优化
typescript
// ✅ 好的做法
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // 避免字体闪烁
preload: true,
})
// ❌ 避免
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />3. 资源组织
public/
├── images/
│ ├── common/ # 通用图片
│ ├── pages/ # 页面特定图片
│ └── components/ # 组件图片
├── icons/
│ ├── ui/ # UI 图标
│ └── social/ # 社交图标
└── fonts/
├── display/ # 标题字体
└── body/ # 正文字体总结
Next.js 静态资源管理的关键点:
- Image 组件 - 自动优化图片性能
- 字体优化 - 使用 next/font 避免布局偏移
- 资源组织 - 合理的目录结构
- 性能优化 - 懒加载、预加载、CDN
- 监控分析 - 跟踪资源加载性能
通过合理使用这些功能,可以显著提升应用的加载速度和用户体验。