Skip to content

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 = nextConfig
typescript
// 使用外部图片
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 = nextConfig
javascript
// 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.com
typescript
// 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
  • 监控分析 - 跟踪资源加载性能

通过合理使用这些功能,可以显著提升应用的加载速度和用户体验。

本站内容仅供学习和研究使用。