本节将详细介绍如何使用 Tailwind CSS 进行移动端适配,包括响应式设计、触摸交互优化、性能优化等方面。
基础配置
视口配置
<!-- public/index.html -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<!-- 适配刘海屏 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
断点设置
// tailwind.config.js
module.exports = {
theme: {
screens: {
'xs': '375px',
'sm': '640px',
'md': '768px',
'lg': '1024px',
'xl': '1280px',
// 自定义断点
'mobile': '480px',
'tablet': '768px',
'laptop': '1024px',
'desktop': '1280px',
},
extend: {
spacing: {
'safe-top': 'env(safe-area-inset-top)',
'safe-bottom': 'env(safe-area-inset-bottom)',
'safe-left': 'env(safe-area-inset-left)',
'safe-right': 'env(safe-area-inset-right)',
},
},
},
}
移动端导航
响应式导航组件
// components/MobileNav.tsx
import { useState, useEffect } from 'react';
const MobileNav = () => {
const [isOpen, setIsOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 20);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<>
{/* 固定导航栏 */}
<nav className={`
fixed top-0 left-0 right-0 z-50
transition-colors duration-200
pt-safe-top
${scrolled ? 'bg-white shadow-md' : 'bg-transparent'}
`}>
<div className="px-4 py-3">
<div className="flex items-center justify-between">
{/* Logo */}
<div className="flex-shrink-0">
<img
className="h-8 w-auto"
src="/logo.svg"
alt="Logo"
/>
</div>
{/* 菜单按钮 */}
<button
onClick={() => setIsOpen(!isOpen)}
className="inline-flex items-center justify-center p-2 rounded-md text-gray-700 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
>
<span className="sr-only">打开菜单</span>
<svg
className={`${isOpen ? 'hidden' : 'block'} h-6 w-6`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
<svg
className={`${isOpen ? 'block' : 'hidden'} h-6 w-6`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{/* 移动端菜单 */}
<div
className={`
fixed inset-0 bg-gray-900 bg-opacity-50 transition-opacity duration-300
${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}
`}
onClick={() => setIsOpen(false)}
>
<div
className={`
fixed inset-y-0 right-0 max-w-xs w-full bg-white shadow-xl
transform transition-transform duration-300 ease-in-out
${isOpen ? 'translate-x-0' : 'translate-x-full'}
`}
onClick={e => e.stopPropagation()}
>
<div className="h-full flex flex-col">
{/* 菜单头部 */}
<div className="px-4 py-6 bg-gray-50">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-gray-900">菜单</h2>
<button
onClick={() => setIsOpen(false)}
className="text-gray-500 hover:text-gray-700"
>
<span className="sr-only">关闭菜单</span>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* 菜单内容 */}
<div className="flex-1 overflow-y-auto">
<nav className="px-4 py-2">
<div className="space-y-1">
<a
href="#"
className="block px-3 py-2 rounded-md text-base font-medium text-gray-900 hover:bg-gray-50"
>
首页
</a>
<a
href="#"
className="block px-3 py-2 rounded-md text-base font-medium text-gray-900 hover:bg-gray-50"
>
产品
</a>
<a
href="#"
className="block px-3 py-2 rounded-md text-base font-medium text-gray-900 hover:bg-gray-50"
>
关于
</a>
</div>
</nav>
</div>
</div>
</div>
</div>
</nav>
{/* 占位元素,防止内容被固定导航栏遮挡 */}
<div className="h-[calc(env(safe-area-inset-top)+3.5rem)]" />
</>
);
};
触摸交互优化
可触摸按钮组件
// components/TouchableButton.tsx
interface TouchableButtonProps {
onPress?: () => void;
className?: string;
children: React.ReactNode;
disabled?: boolean;
}
const TouchableButton: React.FC<TouchableButtonProps> = ({
onPress,
className = '',
children,
disabled = false
}) => {
return (
<button
onClick={onPress}
disabled={disabled}
className={`
relative overflow-hidden
active:opacity-70
transition-opacity
touch-manipulation
select-none
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${className}
`}
style={{
WebkitTapHighlightColor: 'transparent',
WebkitTouchCallout: 'none'
}}
>
{children}
{/* 触摸反馈效果 */}
<div className="absolute inset-0 bg-black pointer-events-none opacity-0 active:opacity-10 transition-opacity" />
</button>
);
};
滑动列表组件
// components/SwipeableList.tsx
import { useState, useRef } from 'react';
interface SwipeableListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
onSwipeLeft?: (item: T) => void;
onSwipeRight?: (item: T) => void;
}
function SwipeableList<T>({
items,
renderItem,
onSwipeLeft,
onSwipeRight
}: SwipeableListProps<T>) {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const touchStartX = useRef<number>(0);
const currentOffset = useRef<number>(0);
const handleTouchStart = (e: React.TouchEvent, index: number) => {
touchStartX.current = e.touches[0].clientX;
setActiveIndex(index);
};
const handleTouchMove = (e: React.TouchEvent) => {
if (activeIndex === null) return;
const touchX = e.touches[0].clientX;
const diff = touchX - touchStartX.current;
currentOffset.current = diff;
// 更新滑动位置
const element = e.currentTarget as HTMLElement;
element.style.transform = `translateX(${diff}px)`;
};
const handleTouchEnd = (e: React.TouchEvent, item: T) => {
if (activeIndex === null) return;
const element = e.currentTarget as HTMLElement;
const offset = currentOffset.current;
// 判断滑动方向和距离
if (Math.abs(offset) > 100) {
if (offset > 0 && onSwipeRight) {
onSwipeRight(item);
} else if (offset < 0 && onSwipeLeft) {
onSwipeLeft(item);
}
}
// 重置状态
element.style.transform = '';
setActiveIndex(null);
currentOffset.current = 0;
};
return (
<div className="overflow-hidden">
{items.map((item, index) => (
<div
key={index}
className="transform transition-transform touch-pan-y"
onTouchStart={e => handleTouchStart(e, index)}
onTouchMove={handleTouchMove}
onTouchEnd={e => handleTouchEnd(e, item)}
>
{renderItem(item)}
</div>
))}
</div>
);
}
性能优化
图片优化
// components/OptimizedImage.tsx
interface OptimizedImageProps {
src: string;
alt: string;
sizes?: string;
className?: string;
}
const OptimizedImage: React.FC<OptimizedImageProps> = ({
src,
alt,
sizes = '100vw',
className = ''
}) => {
return (
<picture>
<source
media="(min-width: 1024px)"
srcSet={`${src}?w=1024 1024w, ${src}?w=1280 1280w`}
sizes={sizes}
/>
<source
media="(min-width: 768px)"
srcSet={`${src}?w=768 768w, ${src}?w=1024 1024w`}
sizes={sizes}
/>
<img
src={`${src}?w=375`}
srcSet={`${src}?w=375 375w, ${src}?w=640 640w`}
sizes={sizes}
alt={alt}
className={`w-full h-auto ${className}`}
loading="lazy"
decoding="async"
/>
</picture>
);
};
虚拟列表
// components/VirtualList.tsx
import { useState, useEffect, useRef } from 'react';
interface VirtualListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
itemHeight: number;
containerHeight: number;
overscan?: number;
}
function VirtualList<T>({
items,
renderItem,
itemHeight,
containerHeight,
overscan = 3
}: VirtualListProps<T>) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
// 计算可见范围
const visibleCount = Math.ceil(containerHeight / itemHeight);
const totalHeight = items.length * itemHeight;
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const endIndex = Math.min(
items.length,
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
);
// 可见项目
const visibleItems = items.slice(startIndex, endIndex);
const handleScroll = () => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
}
};
return (
<div
ref={containerRef}
className="overflow-auto"
style={{ height: containerHeight }}
onScroll={handleScroll}
>
<div style={{ height: totalHeight, position: 'relative' }}>
{visibleItems.map((item, index) => (
<div
key={startIndex + index}
style={{
position: 'absolute',
top: (startIndex + index) * itemHeight,
height: itemHeight,
width: '100%'
}}
>
{renderItem(item)}
</div>
))}
</div>
</div>
);
}
手势交互
滑动手势处理
// hooks/useSwipe.ts
interface SwipeOptions {
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
onSwipeUp?: () => void;
onSwipeDown?: () => void;
threshold?: number;
}
export const useSwipe = (options: SwipeOptions = {}) => {
const {
onSwipeLeft,
onSwipeRight,
onSwipeUp,
onSwipeDown,
threshold = 50
} = options;
const touchStart = useRef({ x: 0, y: 0 });
const touchEnd = useRef({ x: 0, y: 0 });
const handleTouchStart = (e: TouchEvent) => {
touchStart.current = {
x: e.touches[0].clientX,
y: e.touches[0].clientY
};
};
const handleTouchEnd = (e: TouchEvent) => {
touchEnd.current = {
x: e.changedTouches[0].clientX,
y: e.changedTouches[0].clientY
};
const deltaX = touchEnd.current.x - touchStart.current.x;
const deltaY = touchEnd.current.y - touchStart.current.y;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
// 水平滑动
if (Math.abs(deltaX) > threshold) {
if (deltaX > 0) {
onSwipeRight?.();
} else {
onSwipeLeft?.();
}
}
} else {
// 垂直滑动
if (Math.abs(deltaY) > threshold) {
if (deltaY > 0) {
onSwipeDown?.();
} else {
onSwipeUp?.();
}
}
}
};
return {
handleTouchStart,
handleTouchEnd
};
};
下拉刷新组件
// components/PullToRefresh.tsx
interface PullToRefreshProps {
onRefresh: () => Promise<void>;
children: React.ReactNode;
}
const PullToRefresh: React.FC<PullToRefreshProps> = ({
onRefresh,
children
}) => {
const [refreshing, setRefreshing] = useState(false);
const [pullDistance, setPullDistance] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const touchStart = useRef(0);
const pulling = useRef(false);
const handleTouchStart = (e: React.TouchEvent) => {
if (containerRef.current?.scrollTop === 0) {
touchStart.current = e.touches[0].clientY;
pulling.current = true;
}
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!pulling.current) return;
const touch = e.touches[0].clientY;
const distance = touch - touchStart.current;
if (distance > 0) {
e.preventDefault();
setPullDistance(Math.min(distance * 0.5, 100));
}
};
const handleTouchEnd = async () => {
if (!pulling.current) return;
pulling.current = false;
if (pullDistance > 60 && !refreshing) {
setRefreshing(true);
try {
await onRefresh();
} finally {
setRefreshing(false);
}
}
setPullDistance(0);
};
return (
<div
ref={containerRef}
className="overflow-auto touch-pan-y"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* 刷新指示器 */}
<div
className="flex items-center justify-center transition-transform"
style={{
transform: `translateY(${pullDistance}px)`,
height: refreshing ? '50px' : '0'
}}
>
{refreshing ? (
<div className="animate-spin rounded-full h-6 w-6 border-2 border-gray-900 border-t-transparent" />
) : (
<div className="h-6 w-6 transition-transform" style={{
transform: `rotate(${Math.min(pullDistance * 3.6, 360)}deg)`
}}>
↓
</div>
)}
</div>
{/* 内容区域 */}
<div style={{
transform: `translateY(${pullDistance}px)`,
transition: pulling.current ? 'none' : 'transform 0.2s'
}}>
{children}
</div>
</div>
);
};
自适应布局
媒体查询工具
// hooks/useMediaQuery.ts
export const useMediaQuery = (query: string) => {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
const updateMatch = (e: MediaQueryListEvent) => {
setMatches(e.matches);
};
setMatches(media.matches);
media.addListener(updateMatch);
return () => media.removeListener(updateMatch);
}, [query]);
return matches;
};
// 使用示例
const isMobile = useMediaQuery('(max-width: 768px)');
const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)');
const isDesktop = useMediaQuery('(min-width: 1025px)');
自适应容器
// components/AdaptiveContainer.tsx
interface AdaptiveContainerProps {
children: React.ReactNode;
className?: string;
}
const AdaptiveContainer: React.FC<AdaptiveContainerProps> = ({
children,
className = ''
}) => {
return (
<div className={`
w-full px-4 mx-auto
sm:max-w-screen-sm sm:px-6
md:max-w-screen-md
lg:max-w-screen-lg lg:px-8
xl:max-w-screen-xl
${className}
`}>
{children}
</div>
);
};
调试工具
设备模拟器
// components/DeviceEmulator.tsx
interface DeviceEmulatorProps {
children: React.ReactNode;
device?: 'iphone' | 'ipad' | 'android' | 'pixel';
}
const deviceSpecs = {
iphone: {
width: '375px',
height: '812px',
safeAreaTop: '44px',
safeAreaBottom: '34px'
},
ipad: {
width: '768px',
height: '1024px',
safeAreaTop: '20px',
safeAreaBottom: '20px'
},
// ... 其他设备规格
};
const DeviceEmulator: React.FC<DeviceEmulatorProps> = ({
children,
device = 'iphone'
}) => {
const specs = deviceSpecs[device];
return (
<div
className="relative bg-black rounded-[3rem] p-4"
style={{
width: `calc(${specs.width} + 2rem)`,
height: `calc(${specs.height} + 2rem)`
}}
>
<div
className="overflow-hidden rounded-[2.5rem] bg-white"
style={{
width: specs.width,
height: specs.height,
paddingTop: specs.safeAreaTop,
paddingBottom: specs.safeAreaBottom
}}
>
{children}
</div>
</div>
);
};
开发者工具
// utils/mobileDebugger.ts
export const initMobileDebugger = () => {
if (process.env.NODE_ENV === 'development') {
// 显示点击区域
document.addEventListener('touchstart', (e) => {
const touch = e.touches[0];
const dot = document.createElement('div');
dot.style.cssText = `
position: fixed;
z-index: 9999;
width: 20px;
height: 20px;
background: rgba(255, 0, 0, 0.5);
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
left: ${touch.clientX}px;
top: ${touch.clientY}px;
`;
document.body.appendChild(dot);
setTimeout(() => dot.remove(), 500);
});
// 显示视口信息
const viewport = document.createElement('div');
viewport.style.cssText = `
position: fixed;
z-index: 9999;
bottom: 0;
left: 0;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 4px 8px;
font-size: 12px;
`;
document.body.appendChild(viewport);
const updateViewport = () => {
viewport.textContent = `${window.innerWidth}x${window.innerHeight}`;
};
window.addEventListener('resize', updateViewport);
updateViewport();
}
};
最佳实践
响应式设计
- 移动优先策略
- 合理的断点设置
- 灵活的布局系统
触摸交互
- 适当的点击区域
- 清晰的反馈效果
- 流畅的动画过渡
性能优化
- 图片优化处理
- 延迟加载策略
- 虚拟滚动列表
用户体验
- 合理的字体大小
- 清晰的视觉层级
- 直观的操作反馈