前端开发实战
今天用Claude Code从零开始构建一个电商产品展示平台。这个项目会涵盖前端开发的核心要素:响应式UI、状态管理、组件库、性能优化。
项目概述
要做什么
做一个电商产品展示平台,包括:
- 响应式UI,适配各种设备
- 全局状态管理(Context + Hooks)
- 可复用组件库
- 性能优化(代码分割、懒加载)
- 动画和交互体验
- 打包优化
技术栈
- 框架: React 18 + React Router v6
- 状态管理: React Context API + 自定义Hooks
- 样式方案: CSS Modules + Styled Components
- 构建工具: Vite(替代Create React App)
- UI组件: 自建组件库 + Headless UI
- 动画: Framer Motion
- 表单: React Hook Form + Zod验证
- 工具: Claude Code作为主要开发工具
需要多久
- 传统开发: 3-5天
- 用Claude Code: 3-5小时
- 效率提升: 8-10倍
准备工作
环境要求
# 检查Node.js版本(需要18+)
node --version
# 检查npm版本
npm --version
# 检查Git版本
git --version
创建项目
# 使用Vite创建React项目
npm create vite@latest ecommerce-frontend -- --template react
# 进入项目目录
cd ecommerce-frontend
# 初始化Git仓库
git init
git branch -M main
# 安装依赖
npm install
启动Claude Code
# 在项目目录启动Claude Code
claude
第一阶段:项目架构设计
第一轮对话
我要创建一个电商产品展示平台:
1. 产品列表页(过滤、搜索、排序)
2. 产品详情页
3. 购物车
4. 收藏功能
5. 响应式设计
6. 性能优化
技术栈:
- React 18 + Vite
- React Router v6
- 自定义状态管理(Context + Hooks)
- CSS Modules + Tailwind CSS
帮我设计项目结构并创建基础文件。
Claude Code会创建完整的项目结构:
📁 创建项目结构...
✓ 创建 src/components/common/
✓ 创建 src/components/pages/
✓ 创建 src/context/
✓ 创建 src/hooks/
✓ 创建 src/services/
✓ 创建 src/utils/
✓ 创建 src/styles/
✓ 创建 src/assets/
✓ 更新路由配置
项目结构
ecommerce-frontend/
├─ ─ src/
│ ├── components/
│ │ ├── common/
│ │ │ ├── Button/
│ │ │ ├── Card/
│ │ │ ├── Modal/
│ │ │ ├── Input/
│ │ │ ├── Select/
│ │ │ └── LoadingSpinner/
│ │ ├── layout/
│ │ │ ├── Header/
│ │ │ ├── Footer/
│ │ │ ├── Sidebar/
│ │ │ └── Navigation/
│ │ └── features/
│ │ ├── ProductCard/
│ │ ├── ProductList/
│ │ ├── ProductFilter/
│ │ ├── ShoppingCart/
│ │ └── Wishlist/
│ ├── pages/
│ │ ├── Home/
│ │ ├── Products/
│ │ ├── ProductDetail/
│ │ ├── Cart/
│ │ ├── Wishlist/
│ │ └── NotFound/
│ ├── context/
│ │ ├── AuthContext.js
│ │ ├── CartContext.js
│ │ ├── WishlistContext.js
│ │ └── ThemeContext.js
│ ├── hooks/
│ │ ├── useCart.js
│ │ ├── useWishlist.js
│ │ ├── useLocalStorage.js
│ │ ├── useDebounce.js
│ │ └── useIntersectionObserver.js
│ ├── services/
│ │ ├── api.js
│ │ ├── productService.js
│ │ └── analytics.js
│ ├── utils/
│ │ ├── format.js
│ │ ├── validation.js
│ │ └── constants.js
│ ├── styles/
│ │ ├── global.css
│ │ ├── variables.css
│ │ └── animations.css
│ ├── App.jsx
│ └── main.jsx
├── public/
│ └── favicon.svg
├── index.html
├── vite.config.js
├── tailwind.config.js
└── package.json
第二阶段:UI组件开发
创建基础组件库
先创建可复用的基础组件:
1. Button组件(多种样式和尺寸)
2. Card组件
3. Input组件
4. Modal组件
5. LoadingSpinner组件
每个组件都要支持主题切换。
Button组件
src/components/common/Button/Button.jsx:
import React from 'react';
import styles from './Button.module.css';
const Button = ({
children,
variant = 'primary',
size = 'medium',
disabled = false,
loading = false,
fullWidth = false,
icon,
onClick,
type = 'button',
className = '',
...props
}) => {
const buttonClasses = [
styles.button,
styles[variant],
styles[size],
fullWidth && styles.fullWidth,
disabled && styles.disabled,
loading && styles.loading,
className,
]
.filter(Boolean)
.join(' ');
return (
<button
type={type}
className={buttonClasses}
onClick={onClick}
disabled={disabled || loading}
{...props}
>
{loading && <span className={styles.spinner} />}
{icon && !loading && <span className={styles.icon}>{icon}</span>}
<span className={styles.content}>{children}</span>
</button>
);
};
export default Button;
src/components/common/Button/Button.module.css:
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.button:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
/* Variants */
.primary {
background: var(--primary-color);
color: white;
}
.primary:hover:not(:disabled) {
background: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.secondary {
background: white;
color: var(--text-primary);
border: 2px solid var(--border-color);
}
.secondary:hover:not(:disabled) {
border-color: var(--primary-color);
color: var(--primary-color);
}
.outline {
background: transparent;
color: var(--primary-color);
border: 2px solid var(--primary-color);
}
.outline:hover:not(:disabled) {
background: var(--primary-color);
color: white;
}
.ghost {
background: transparent;
color: var(--text-secondary);
}
.ghost:hover:not(:disabled) {
background: var(--bg-gray);
color: var(--text-primary);
}
.danger {
background: var(--danger-color);
color: white;
}
.danger:hover:not(:disabled) {
background: #dc2626;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
}
/* Sizes */
.small {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.medium {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.large {
padding: 1rem 2rem;
font-size: 1.125rem;
}
/* States */
.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading {
pointer-events: none;
}
.fullWidth {
width: 100%;
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
.icon {
display: inline-flex;
align-items: center;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.medium {
padding: 0.625rem 1.25rem;
font-size: 0.9375rem;
}
.large {
padding: 0.875rem 1.75rem;
font-size: 1rem;
}
}
Card组件
src/components/common/Card/Card.jsx:
import React from 'react';
import styles from './Card.module.css';
const Card = ({
children,
variant = 'default',
hoverable = false,
padding = 'medium',
shadow = 'medium',
className = '',
onClick,
...props
}) => {
const cardClasses = [
styles.card,
styles[variant],
styles[padding],
styles[shadow],
hoverable && styles.hoverable,
onClick && styles.clickable,
className,
]
.filter(Boolean)
.join(' ');
return (
<div
className={cardClasses}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
{...props}
>
{children}
</div>
);
};
export default Card;
src/components/common/Card/Card.module.css:
.card {
background: white;
border-radius: 1rem;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
/* Variants */
.default {
border: 1px solid var(--border-color);
}
.bordered {
border: 2px solid var(--border-color);
}
.elevated {
border: none;
}
/* Padding */
.small {
padding: 1rem;
}
.medium {
padding: 1.5rem;
}
.large {
padding: 2rem;
}
/* Shadow */
.none {
box-shadow: none;
}
.small {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.medium {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.large {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
/* Hover effect */
.hoverable:hover {
transform: translateY(-4px);
}
.hoverable.medium:hover {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.clickable {
cursor: pointer;
}
.clickable:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
/* 响应式 */
@media (max-width: 768px) {
.medium {
padding: 1rem;
}
.large {
padding: 1.5rem;
}
}
Modal组件
src/components/common/Modal/Modal.jsx:
import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import styles from './Modal.module.css';
const Modal = ({
isOpen = false,
onClose,
title,
children,
size = 'medium',
closeOnOverlayClick = true,
closeOnEscape = true,
showCloseButton = true,
className = '',
}) => {
const modalRef = useRef(null);
const previousActiveElement = useRef(null);
useEffect(() => {
if (isOpen) {
// 保存当前聚焦元素
previousActiveElement.current = document.activeElement;
// 禁用背景滚动
document.body.style.overflow = 'hidden';
// 聚焦到modal
modalRef.current?.focus();
// 监听ESC键
if (closeOnEscape) {
const handleEscape = (e) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}
} else {
// 恢复背景滚动
document.body.style.overflow = '';
// 恢复焦点
previousActiveElement.current?.focus();
}
}, [isOpen, closeOnEscape, onClose]);
if (!isOpen) return null;
const handleOverlayClick = (e) => {
if (closeOnOverlayClick && e.target === e.currentTarget) {
onClose();
}
};
const modalContent = (
<div
className={styles.overlay}
onClick={handleOverlayClick}
aria-modal="true"
role="dialog"
aria-labelledby={title ? 'modal-title' : undefined}
>
<div
ref={modalRef}
className={`${styles.modal} ${styles[size]} ${className}`}
tabIndex={-1}
>
{(title || showCloseButton) && (
<div className={styles.header}>
{title && <h2 id="modal-title" className={styles.title}>{title}</h2>}
{showCloseButton && (
<button
onClick={onClose}
className={styles.closeButton}
aria-label="关闭"
>
✕
</button>
)}
</div>
)}
<div className={styles.content}>
{children}
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
};
export default Modal;
src/components/common/Modal/Modal.module.css:
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease;
}
.modal {
background: white;
border-radius: 1rem;
max-height: 90vh;
display: flex;
flex-direction: column;
animation: slideUp 0.3s ease;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
/* Sizes */
.small {
width: 100%;
max-width: 400px;
}
.medium {
width: 100%;
max-width: 600px;
}
.large {
width: 100%;
max-width: 800px;
}
.fullscreen {
width: 100%;
height: 100%;
max-width: none;
max-height: 100%;
border-radius: 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.closeButton {
background: transparent;
border: none;
font-size: 1.5rem;
color: var(--text-secondary);
cursor: pointer;
padding: 0.25rem;
line-height: 1;
border-radius: 0.375rem;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.closeButton:hover {
background: var(--bg-gray);
color: var(--text-primary);
}
.content {
padding: 1.5rem;
overflow-y: auto;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* 响应式 */
@media (max-width: 768px) {
.overlay {
padding: 0.5rem;
}
.header,
.content {
padding: 1rem;
}
.small,
.medium,
.large {
max-width: 100%;
}
}
Input组件
src/components/common/Input/Input.jsx:
import React, { forwardRef } from 'react';
import styles from './Input.module.css';
const Input = forwardRef((
{
label,
error,
icon,
helperText,
fullWidth = false,
variant = 'outlined',
size = 'medium',
className = '',
...props
},
ref
) => {
const inputClasses = [
styles.input,
styles[variant],
styles[size],
error && styles.error,
icon && styles.withIcon,
fullWidth && styles.fullWidth,
className,
]
.filter(Boolean)
.join(' ');
const wrapperClasses = [
styles.wrapper,
fullWidth && styles.fullWidth,
]
.filter(Boolean)
.join(' ');
return (
<div className={wrapperClasses}>
{label && <label className={styles.label}>{label}</label>}
<div className={styles.inputContainer}>
{icon && <span className={styles.icon}>{icon}</span>}
<input
ref={ref}
className={inputClasses}
{...props}
/>
</div>
{error && <span className={styles.errorText}>{error}</span>}
{helperText && !error && (
<span className={styles.helperText}>{helperText}</span>
)}
</div>
);
});
Input.displayName = 'Input';
export default Input;
src/components/common/Input/Input.module.css:
.wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.fullWidth {
width: 100%;
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
.inputContainer {
position: relative;
display: flex;
align-items: center;
}
.input {
width: 100%;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
font-size: 1rem;
color: var(--text-primary);
background: white;
transition: all 0.2s ease;
}
.input::placeholder {
color: var(--text-placeholder);
}
.input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
/* Variants */
.outlined {
background: white;
}
.filled {
background: var(--bg-gray);
border-color: transparent;
}
.filled:focus {
background: white;
border-color: var(--primary-color);
}
.underlined {
border: none;
border-bottom: 2px solid var(--border-color);
border-radius: 0;
padding-left: 0;
padding-right: 0;
}
.underlined:focus {
border-color: var(--primary-color);
box-shadow: none;
}
/* Sizes */
.small {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.medium {
padding: 0.75rem 1rem;
font-size: 1rem;
}
.large {
padding: 1rem 1.25rem;
font-size: 1.125rem;
}
/* States */
.error {
border-color: var(--danger-color);
}
.error:focus {
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.withIcon {
padding-left: 2.5rem;
}
.icon {
position: absolute;
left: 0.75rem;
color: var(--text-secondary);
pointer-events: none;
}
.errorText {
font-size: 0.75rem;
color: var(--danger-color);
}
.helperText {
font-size: 0.75rem;
color: var(--text-secondary);
}
/* 响应式 */
@media (max-width: 768px) {
.medium {
padding: 0.625rem 0.875rem;
}
.large {
padding: 0.875rem 1.125rem;
font-size: 1rem;
}
}
第三阶段:全局状态管理
创建Context系统
你: 创建一个基于React Context的状态管理系统,包括:
1. 购物车状态(CartContext)
2. 收藏夹状态(WishlistContext)
3. 认证状态(AuthContext)
4. 主题状态(ThemeContext)
每个Context都要提供对应的自定义Hook,并实现持久化存储。
CartContext
src/context/CartContext.jsx:
import React, { createContext, useContext, useReducer, useEffect } from 'react';
import { loadFromLocalStorage, saveToLocalStorage } from '../utils/storage';
const CartContext = createContext();
const CART_STORAGE_KEY = 'shopping_cart';
// 初始状态
const initialState = {
items: [],
totalItems: 0,
totalPrice: 0,
isLoading: false,
error: null,
};
// Reducer
function cartReducer(state, action) {
switch (action.type) {
case 'CART_LOAD_START':
return {
...state,
isLoading: true,
error: null,
};
case 'CART_LOAD_SUCCESS':
return {
...state,
isLoading: false,
...action.payload,
};
case 'CART_LOAD_ERROR':
return {
...state,
isLoading: false,
error: action.payload,
};
case 'CART_ADD_ITEM': {
const existingItemIndex = state.items.findIndex(
(item) => item.id === action.payload.id
);
let newItems;
if (existingItemIndex > -1) {
// Item already exists, update quantity
newItems = state.items.map((item, index) =>
index === existingItemIndex
? { ...item, quantity: item.quantity + action.payload.quantity }
: item
);
} else {
// Add new item
newItems = [...state.items, { ...action.payload }];
}
const totals = calculateTotals(newItems);
return {
...state,
items: newItems,
...totals,
};
}
case 'CART_REMOVE_ITEM': {
const newItems = state.items.filter((item) => item.id !== action.payload);
const totals = calculateTotals(newItems);
return {
...state,
items: newItems,
...totals,
};
}
case 'CART_UPDATE_QUANTITY': {
const newItems = state.items.map((item) =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
);
const totals = calculateTotals(newItems);
return {
...state,
items: newItems,
...totals,
};
}
case 'CART_CLEAR':
return {
...state,
items: [],
totalItems: 0,
totalPrice: 0,
};
case 'CART_SET_ITEMS': {
const totals = calculateTotals(action.payload);
return {
...state,
items: action.payload,
...totals,
};
};
default:
return state;
}
}
// 计算总数和总价
function calculateTotals(items) {
const totalItems = items.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return {
totalItems,
totalPrice: parseFloat(totalPrice.toFixed(2)),
};
}
// Provider组件
export const CartProvider = ({ children }) => {
const [state, dispatch] = useReducer(cartReducer, initialState);
// 从本地存储加载购物车
useEffect(() => {
const loadCart = () => {
dispatch({ type: 'CART_LOAD_START' });
try {
const savedCart = loadFromLocalStorage(CART_STORAGE_KEY);
if (savedCart) {
dispatch({
type: 'CART_LOAD_SUCCESS',
payload: savedCart,
});
} else {
dispatch({ type: 'CART_LOAD_SUCCESS', payload: initialState });
}
} catch (error) {
dispatch({
type: 'CART_LOAD_ERROR',
payload: '加载购物车失败',
});
}
};
loadCart();
}, []);
// 保存购物车到本地存储
useEffect(() => {
if (!state.isLoading) {
saveToLocalStorage(CART_STORAGE_KEY, {
items: state.items,
totalItems: state.totalItems,
totalPrice: state.totalPrice,
});
}
}, [state.items, state.isLoading]);
// Actions
const addToCart = (product, quantity = 1) => {
dispatch({
type: 'CART_ADD_ITEM',
payload: {
id: product.id,
name: product.name,
price: product.price,
image: product.image,
quantity,
addedAt: new Date().toISOString(),
},
});
};
const removeFromCart = (productId) => {
dispatch({
type: 'CART_REMOVE_ITEM',
payload: productId,
});
};
const updateQuantity = (productId, quantity) => {
if (quantity <= 0) {
removeFromCart(productId);
} else {
dispatch({
type: 'CART_UPDATE_QUANTITY',
payload: { id: productId, quantity },
});
}
};
const clearCart = () => {
dispatch({ type: 'CART_CLEAR' });
};
const isInCart = (productId) => {
return state.items.some((item) => item.id === productId);
};
const getItemQuantity = (productId) => {
const item = state.items.find((item) => item.id === productId);
return item ? item.quantity : 0;
};
const value = {
...state,
addToCart,
removeFromCart,
updateQuantity,
clearCart,
isInCart,
getItemQuantity,
};
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
};
// 自定义Hook
export const useCart = () => {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within CartProvider');
}
return context;
};
WishlistContext
src/context/WishlistContext.jsx:
import React, { createContext, useContext, useState, useEffect } from 'react';
import { loadFromLocalStorage, saveToLocalStorage } from '../utils/storage';
const WishlistContext = createContext();
const WISHLIST_STORAGE_KEY = 'user_wishlist';
export const WishlistProvider = ({ children }) => {
const [items, setItems] = useState([]);
const [isLoading, setIsLoading] = useState(true);
// 加载收藏夹
useEffect(() => {
const loadWishlist = () => {
try {
const savedWishlist = loadFromLocalStorage(WISHLIST_STORAGE_KEY);
setItems(savedWishlist || []);
} catch (error) {
console.error('加载收藏夹失败:', error);
} finally {
setIsLoading(false);
}
};
loadWishlist();
}, []);
// 保存收藏夹
useEffect(() => {
if (!isLoading) {
saveToLocalStorage(WISHLIST_STORAGE_KEY, items);
}
}, [items, isLoading]);
// 添加到收藏夹
const addToWishlist = (product) => {
setItems((prevItems) => {
const exists = prevItems.some((item) => item.id === product.id);
if (exists) {
return prevItems;
}
return [
...prevItems,
{
...product,
addedAt: new Date().toISOString(),
},
];
});
};
// 从收藏夹移除
const removeFromWishlist = (productId) => {
setItems((prevItems) => prevItems.filter((item) => item.id !== productId));
};
// 切换收藏状态
const toggleWishlist = (product) => {
const exists = items.some((item) => item.id === product.id);
if (exists) {
removeFromWishlist(product.id);
} else {
addToWishlist(product);
}
};
// 检查是否已收藏
const isInWishlist = (productId) => {
return items.some((item) => item.id === productId);
};
// 清空收藏夹
const clearWishlist = () => {
setItems([]);
};
const value = {
items,
isLoading,
addToWishlist,
removeFromWishlist,
toggleWishlist,
isInWishlist,
clearWishlist,
itemCount: items.length,
};
return (
<WishlistContext.Provider value={value}>
{children}
</WishlistContext.Provider>
);
};
// 自定义Hook
export const useWishlist = () => {
const context = useContext(WishlistContext);
if (!context) {
throw new Error('useWishlist must be used within WishlistProvider');
}
return context;
};
ThemeContext
src/context/ThemeContext.jsx:
import React, { createContext, useContext, useState, useEffect } from 'react';
import { loadFromLocalStorage, saveToLocalStorage } from '../utils/storage';
const ThemeContext = createContext();
const THEME_STORAGE_KEY = 'app_theme';
export const themes = {
light: 'light',
dark: 'dark',
system: 'system',
};
export const ThemeProvider = ({ children }) => {
const [theme, setThemeState] = useState(themes.system);
const [resolvedTheme, setResolvedTheme] = useState('light');
// 初始化主题
useEffect(() => {
const savedTheme = loadFromLocalStorage(THEME_STORAGE_KEY);
if (savedTheme && Object.values(themes).includes(savedTheme)) {
setThemeState(savedTheme);
}
}, []);
// 应用主题
useEffect(() => {
const applyTheme = () => {
let themeToApply = theme;
if (theme === themes.system) {
// 检测系统主题偏好
const prefersDark = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
themeToApply = prefersDark ? themes.dark : themes.light;
}
setResolvedTheme(themeToApply);
// 更新DOM
document.documentElement.setAttribute('data-theme', themeToApply);
// 更新meta theme-color
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (metaThemeColor) {
metaThemeColor.setAttribute(
'content',
themeToApply === themes.dark ? '#1a1a1a' : '#ffffff'
);
}
};
applyTheme();
// 监听系统主题变化
if (theme === themes.system) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => applyTheme();
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}
}, [theme]);
// 切换主题
const setTheme = (newTheme) => {
if (Object.values(themes).includes(newTheme)) {
setThemeState(newTheme);
saveToLocalStorage(THEME_STORAGE_KEY, newTheme);
}
};
// 切换到下一个主题
const toggleTheme = () => {
const themeOrder = [themes.light, themes.dark, themes.system];
const currentIndex = themeOrder.indexOf(theme);
const nextIndex = (currentIndex + 1) % themeOrder.length;
setTheme(themeOrder[nextIndex]);
};
const value = {
theme,
resolvedTheme,
setTheme,
toggleTheme,
isDark: resolvedTheme === themes.dark,
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};
// 自定义Hook
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
};
第四阶段:自定义Hooks开发
你: 创建实用的自定义Hooks:
1. useLocalStorage - 本地存储同步
2. useDebounce - 防抖处理
3. useIntersectionObserver - 懒加载和无限滚动
4. usePrevious - 获取上一次的值
5. useMediaQuery - 响应式检测
6. useAsync - 异步状态管理
useLocalStorage
src/hooks/useLocalStorage.js:
import { useState, useEffect, useCallback } from 'react';
export function useLocalStorage(key, initialValue) {
// 获取初始值
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error loading ${key} from localStorage:`, error);
return initialValue;
}
});
// 更新本地存储和状态
const setValue = useCallback(
(value) => {
try {
// 支持函数更新
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
// 保存到本地存储
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error saving ${key} to localStorage:`, error);
}
},
[key, storedValue]
);
// 从其他标签页同步变化
useEffect(() => {
const handleStorageChange = (e) => {
if (e.key === key && e.newValue !== null) {
try {
setStoredValue(JSON.parse(e.newValue));
} catch (error) {
console.error('Error parsing storage change:', error);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
return [storedValue, setValue];
}
useDebounce
src/hooks/useDebounce.js:
import { useState, useEffect } from 'react';
export function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// 设置定时器
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// 清除定时器
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
// 使用示例:
// const [searchTerm, setSearchTerm] = useState('');
// const debouncedSearchTerm = useDebounce(searchTerm, 500);
//
// useEffect(() => {
// // 使用防抖后的搜索词进行API调用
// if (debouncedSearchTerm) {
// searchProducts(debouncedSearchTerm);
// }
// }, [debouncedSearchTerm]);
useIntersectionObserver
src/hooks/useIntersectionObserver.js:
import { useState, useEffect, useRef } from 'react';
export function useIntersectionObserver(options = {}) {
const [isIntersecting, setIsIntersecting] = useState(false);
const [hasIntersected, setHasIntersected] = useState(false);
const targetRef = useRef(null);
useEffect(() => {
const target = targetRef.current;
if (!target) return;
const observer = new IntersectionObserver(
([entry]) => {
setIsIntersecting(entry.isIntersecting);
if (entry.isIntersecting && !hasIntersected) {
setHasIntersected(true);
}
},
{
threshold: 0.1,
rootMargin: '0px',
...options,
}
);
observer.observe(target);
return () => {
observer.unobserve(target);
};
}, [options.threshold, options.rootMargin, hasIntersected]);
return [targetRef, isIntersecting, hasIntersected];
}
// 使用示例 - 懒加载图片:
// const [imgRef, isVisible] = useIntersectionObserver();
//
// <img
// ref={imgRef}
// src={isVisible ? imageUrl : placeholderUrl}
// alt="Product"
// />
// 使用示例 - 无限滚动:
// const [loadMoreRef, isVisible] = useIntersectionObserver();
//
// useEffect(() => {
// if (isVisible) {
// loadMoreProducts();
// }
// }, [isVisible]);
useMediaQuery
src/hooks/useMediaQuery.js:
import { useState, useEffect } from 'react';
export function useMediaQuery(query) {
const [matches, setMatches] = useState(() => {
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches;
}
return false;
});
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
// 现代浏览器
mediaQuery.addEventListener('change', handler);
return () => {
mediaQuery.removeEventListener('change', handler);
};
}, [query]);
return matches;
}
// 使用示例:
// const isMobile = useMediaQuery('(max-width: 768px)');
// const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
// const isPrint = useMediaQuery('print');
useAsync
src/hooks/useAsync.js:
import { useState, useEffect, useCallback } from 'react';
export function useAsync(asyncFunction, immediate = true) {
const [status, setStatus] = useState('idle');
const [data, setData] = useState(null);
const [error, setError] = useState(null);
// 执行异步函数
const execute = useCallback(
async (...args) => {
setStatus('pending');
setData(null);
setError(null);
try {
const response = await asyncFunction(...args);
setData(response);
setStatus('success');
return response;
} catch (error) {
setError(error);
setStatus('error');
throw error;
}
},
[asyncFunction]
);
// 立即执行
useEffect(() => {
if (immediate) {
execute();
}
}, [execute, immediate]);
return {
execute,
status,
data,
error,
isLoading: status === 'pending',
isError: status === 'error',
isSuccess: status === 'success',
isIdle: status === 'idle',
};
}
// 使用示例:
// const { data, isLoading, error, execute } = useAsync(fetchProducts);
//
// if (isLoading) return <LoadingSpinner />;
// if (error) return <Error message={error.message} />;
// return <ProductList products={data} />;
第五阶段:功能组件开发
ProductCard组件
src/components/features/ProductCard/ProductCard.jsx:
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import Card from '../../common/Card/Card';
import Button from '../../common/Button/Button';
import { useCart } from '../../../context/CartContext';
import { useWishlist } from '../../../context/WishlistContext';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import styles from './ProductCard.module.css';
const ProductCard = ({ product }) => {
const { addToCart, isInCart } = useCart();
const { toggleWishlist, isInWishlist } = useWishlist();
const [imageLoaded, setImageLoaded] = useState(false);
const [imgRef, isVisible] = useIntersectionObserver({
threshold: 0.01,
triggerOnce: true,
});
const inCart = isInCart(product.id);
const inWishlist = isInWishlist(product.id);
const handleAddToCart = (e) => {
e.preventDefault();
e.stopPropagation();
addToCart(product);
};
const handleToggleWishlist = (e) => {
e.preventDefault();
e.stopPropagation();
toggleWishlist(product);
};
const discount = product.discount || 0;
const originalPrice = product.price;
const discountedPrice = discount > 0
? (originalPrice * (1 - discount / 100)).toFixed(2)
: originalPrice;
return (
<Link to={`/products/${product.id}`} className={styles.link}>
<Card
hoverable
className={styles.card}
>
<div ref={imgRef} className={styles.imageContainer}>
{isVisible && (
<img
src={product.image}
alt={product.name}
className={`${styles.image} ${imageLoaded ? styles.loaded : ''}`}
onLoad={() => setImageLoaded(true)}
loading="lazy"
/>
)}
{!imageLoaded && <div className={styles.imagePlaceholder} />}
{discount > 0 && (
<span className={styles.discountBadge}>
-{discount}%
</span>
)}
<button
className={`${styles.wishlistButton} ${inWishlist ? styles.active : ''}`}
onClick={handleToggleWishlist}
aria-label={inWishlist ? '从收藏移除' : '添加到收藏'}
>
{inWishlist ? '❤️' : '🤍'}
</button>
</div>
<div className={styles.content}>
<div className={styles.category}>{product.category}</div>
<h3 className={styles.name}>{product.name}</h3>
<div className={styles.rating}>
<span className={styles.stars}>
{'★'.repeat(Math.floor(product.rating))}
{'☆'.repeat(5 - Math.floor(product.rating))}
</span>
<span className={styles.reviews}>({product.reviews})</span>
</div>
<div className={styles.priceContainer}>
<div className={styles.prices}>
<span className={styles.currentPrice}>¥{discountedPrice}</span>
{discount > 0 && (
<span className={styles.originalPrice}>¥{originalPrice}</span>
)}
</div>
<Button
size="small"
variant={inCart ? 'outline' : 'primary'}
onClick={handleAddToCart}
className={styles.cartButton}
>
{inCart ? '✓ 已加购' : '+ 加入'}
</Button>
</div>
</div>
</Card>
</Link>
);
};
export default ProductCard;
src/components/features/ProductCard/ProductCard.module.css:
.link {
text-decoration: none;
color: inherit;
display: block;
}
.card {
height: 100%;
display: flex;
flex-direction: column;
}
.imageContainer {
position: relative;
padding-top: 100%; /* 1:1 Aspect Ratio */
overflow: hidden;
background: var(--bg-gray);
}
.image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.3s ease;
}
.image.loaded {
opacity: 1;
}
.imagePlaceholder {
position: absolute;
inset: 0;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.discountBadge {
position: absolute;
top: 0.75rem;
left: 0.75rem;
background: var(--danger-color);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 600;
z-index: 1;
}
.wishlistButton {
position: absolute;
top: 0.75rem;
right: 0.75rem;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: white;
border: none;
font-size: 1.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.wishlistButton:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.wishlistButton.active {
background: #fff5f5;
}
.content {
padding: 1.5rem;
display: flex;
flex-direction: column;
flex: 1;
}
.category {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.name {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.75rem 0;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rating {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.stars {
color: var(--warning-color);
letter-spacing: 0.05em;
}
.reviews {
font-size: 0.875rem;
color: var(--text-secondary);
}
.priceContainer {
margin-top: auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.prices {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
}
.currentPrice {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-color);
}
.originalPrice {
font-size: 1rem;
color: var(--text-secondary);
text-decoration: line-through;
}
.cartButton {
flex-shrink: 0;
}
/* 响应式 */
@media (max-width: 768px) {
.content {
padding: 1rem;
}
.name {
font-size: 1rem;
}
.currentPrice {
font-size: 1.25rem;
}
.priceContainer {
flex-direction: column;
align-items: flex-start;
}
.cartButton {
width: 100%;
}
}
ProductList组件(带无限滚动)
src/components/features/ProductList/ProductList.jsx:
import React, { useState, useEffect, useCallback } from 'react';
import ProductCard from '../ProductCard/ProductCard';
import LoadingSpinner from '../../common/LoadingSpinner/LoadingSpinner';
import { useAsync, useIntersectionObserver } from '../../../hooks';
import styles from './ProductList.module.css';
const ProductList = ({ fetchProducts, filters = {}, pageSize = 12 }) => {
const [products, setProducts] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [allFilters, setAllFilters] = useState(filters);
// 使用useAsync获取数据
const { data: newProducts, isLoading, error, execute } = useAsync(
() => fetchProducts({ ...allFilters, page, pageSize }),
false
);
// 更新过滤条件
useEffect(() => {
setAllFilters(filters);
setProducts([]);
setPage(1);
setHasMore(true);
}, [filters]);
// 加载数据
useEffect(() => {
if (page > 0) {
execute();
}
}, [page, allFilters, execute]);
// 处理新数据
useEffect(() => {
if (newProducts) {
if (page === 1) {
setProducts(newProducts);
} else {
setProducts((prev) => [...prev, ...newProducts]);
}
// 检查是否还有更多数据
if (newProducts.length < pageSize) {
setHasMore(false);
}
}
}, [newProducts, page, pageSize]);
// 无限滚动
const [loadMoreRef, isVisible] = useIntersectionObserver({
threshold: 0.1,
});
useEffect(() => {
if (isVisible && hasMore && !isLoading && page > 0) {
setPage((prev) => prev + 1);
}
}, [isVisible, hasMore, isLoading, page]);
if (error) {
return (
<div className={styles.error}>
<p>加载失败: {error.message}</p>
<button onClick={() => execute()} className={styles.retryButton}>
重试
</button>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.grid}>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{isLoading && products.length === 0 && (
<div className={styles.initialLoading}>
<LoadingSpinner size="large" />
</div>
)}
{isLoading && products.length > 0 && (
<div className={styles.paginationLoading}>
<LoadingSpinner size="medium" />
</div>
)}
{!isLoading && !hasMore && products.length > 0 && (
<div className={styles.endMessage}>
<p>没有更多商品了</p>
</div>
)}
{!isLoading && products.length === 0 && (
<div className={styles.empty}>
<p>暂无商品</p>
</div>
)}
{hasMore && !isLoading && (
<div ref={loadMoreRef} className={styles.loadMoreTrigger} />
)}
</div>
);
};
export default ProductList;
第六阶段:性能优化
图片懒加载和优化
你: 实现图片优化方案:
1. 懒加载(useIntersectionObserver)
2. 响应式图片(srcset)
3. WebP格式支持
4. 图片占位符
5. BlurHash生成模糊预览
src/components/common/OptimizedImage/OptimizedImage.jsx:
import React, { useState, useRef } from 'react';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import styles from './OptimizedImage.module.css';
const OptimizedImage = ({
src,
alt,
width,
height,
sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw',
blurHash,
className = '',
...props
}) => {
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
const [imgRef, isVisible] = useIntersectionObserver({
threshold: 0.01,
triggerOnce: true,
});
const handleError = () => {
setHasError(true);
};
const handleLoad = () => {
setIsLoaded(true);
};
// 生成srcset
const generateSrcSet = (baseSrc) => {
const sizes = [400, 800, 1200, 1600];
return sizes
.map((size) => `${baseSrc}?w=${size} ${size}w`)
.join(', ');
};
return (
<div
ref={imgRef}
className={`${styles.container} ${className}`}
style={{ width, height }}
>
{blurHash && (
<div
className={styles.blurHash}
style={{ backgroundImage: `url(${blurHash})` }}
/>
)}
{hasError ? (
<div className={styles.error}>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
</div>
) : (
isVisible && (
<img
src={src}
alt={alt}
srcSet={generateSrcSet(src)}
sizes={sizes}
loading="lazy"
decoding="async"
onLoad={handleLoad}
onError={handleError}
className={`${styles.image} ${isLoaded ? styles.loaded : ''}`}
{...props}
/>
)
)}
{!isLoaded && !hasError && (
<div className={styles.skeleton} />
)}
</div>
);
};
export default OptimizedImage;
代码分割和懒加载
src/App.jsx:
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import LoadingSpinner from './components/common/LoadingSpinner/LoadingSpinner';
import Layout from './components/layout/Layout/Layout';
// 路由级别代码分割
const Home = lazy(() => import('./pages/Home/Home'));
const Products = lazy(() => import('./pages/Products/Products'));
const ProductDetail = lazy(() => import('./pages/ProductDetail/ProductDetail'));
const Cart = lazy(() => import('./pages/Cart/Cart'));
const Wishlist = lazy(() => import('./pages/Wishlist/Wishlist'));
const NotFound = lazy(() => import('./pages/NotFound/NotFound'));
// 加载中组件
const PageLoader = () => (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<LoadingSpinner size="large" />
</div>
);
function App() {
return (
<BrowserRouter>
<Layout>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/products/:id" element={<ProductDetail />} />
<Route path="/cart" element={<Cart />} />
<Route path="/wishlist" element={<Wishlist />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</Layout>
</BrowserRouter>
);
}
export default App;