本节将详细介绍如何使用 Tailwind CSS 进行移动端适配,包括响应式设计、触摸交互优化、性能优化等方面。

基础配置

视口配置

  1. <!-- public/index.html -->
  2. <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
  3. <!-- 适配刘海屏 -->
  4. <meta name="apple-mobile-web-app-capable" content="yes">
  5. <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">

断点设置

  1. // tailwind.config.js
  2. module.exports = {
  3. theme: {
  4. screens: {
  5. 'xs': '375px',
  6. 'sm': '640px',
  7. 'md': '768px',
  8. 'lg': '1024px',
  9. 'xl': '1280px',
  10. // 自定义断点
  11. 'mobile': '480px',
  12. 'tablet': '768px',
  13. 'laptop': '1024px',
  14. 'desktop': '1280px',
  15. },
  16. extend: {
  17. spacing: {
  18. 'safe-top': 'env(safe-area-inset-top)',
  19. 'safe-bottom': 'env(safe-area-inset-bottom)',
  20. 'safe-left': 'env(safe-area-inset-left)',
  21. 'safe-right': 'env(safe-area-inset-right)',
  22. },
  23. },
  24. },
  25. }

移动端导航

响应式导航组件

  1. // components/MobileNav.tsx
  2. import { useState, useEffect } from 'react';
  3. const MobileNav = () => {
  4. const [isOpen, setIsOpen] = useState(false);
  5. const [scrolled, setScrolled] = useState(false);
  6. useEffect(() => {
  7. const handleScroll = () => {
  8. setScrolled(window.scrollY > 20);
  9. };
  10. window.addEventListener('scroll', handleScroll);
  11. return () => window.removeEventListener('scroll', handleScroll);
  12. }, []);
  13. return (
  14. <>
  15. {/* 固定导航栏 */}
  16. <nav className={`
  17. fixed top-0 left-0 right-0 z-50
  18. transition-colors duration-200
  19. pt-safe-top
  20. ${scrolled ? 'bg-white shadow-md' : 'bg-transparent'}
  21. `}>
  22. <div className="px-4 py-3">
  23. <div className="flex items-center justify-between">
  24. {/* Logo */}
  25. <div className="flex-shrink-0">
  26. <img
  27. className="h-8 w-auto"
  28. src="/logo.svg"
  29. alt="Logo"
  30. />
  31. </div>
  32. {/* 菜单按钮 */}
  33. <button
  34. onClick={() => setIsOpen(!isOpen)}
  35. 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"
  36. >
  37. <span className="sr-only">打开菜单</span>
  38. <svg
  39. className={`${isOpen ? 'hidden' : 'block'} h-6 w-6`}
  40. fill="none"
  41. viewBox="0 0 24 24"
  42. stroke="currentColor"
  43. >
  44. <path
  45. strokeLinecap="round"
  46. strokeLinejoin="round"
  47. strokeWidth={2}
  48. d="M4 6h16M4 12h16M4 18h16"
  49. />
  50. </svg>
  51. <svg
  52. className={`${isOpen ? 'block' : 'hidden'} h-6 w-6`}
  53. fill="none"
  54. viewBox="0 0 24 24"
  55. stroke="currentColor"
  56. >
  57. <path
  58. strokeLinecap="round"
  59. strokeLinejoin="round"
  60. strokeWidth={2}
  61. d="M6 18L18 6M6 6l12 12"
  62. />
  63. </svg>
  64. </button>
  65. </div>
  66. </div>
  67. {/* 移动端菜单 */}
  68. <div
  69. className={`
  70. fixed inset-0 bg-gray-900 bg-opacity-50 transition-opacity duration-300
  71. ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}
  72. `}
  73. onClick={() => setIsOpen(false)}
  74. >
  75. <div
  76. className={`
  77. fixed inset-y-0 right-0 max-w-xs w-full bg-white shadow-xl
  78. transform transition-transform duration-300 ease-in-out
  79. ${isOpen ? 'translate-x-0' : 'translate-x-full'}
  80. `}
  81. onClick={e => e.stopPropagation()}
  82. >
  83. <div className="h-full flex flex-col">
  84. {/* 菜单头部 */}
  85. <div className="px-4 py-6 bg-gray-50">
  86. <div className="flex items-center justify-between">
  87. <h2 className="text-lg font-medium text-gray-900">菜单</h2>
  88. <button
  89. onClick={() => setIsOpen(false)}
  90. className="text-gray-500 hover:text-gray-700"
  91. >
  92. <span className="sr-only">关闭菜单</span>
  93. <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  94. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
  95. </svg>
  96. </button>
  97. </div>
  98. </div>
  99. {/* 菜单内容 */}
  100. <div className="flex-1 overflow-y-auto">
  101. <nav className="px-4 py-2">
  102. <div className="space-y-1">
  103. <a
  104. href="#"
  105. className="block px-3 py-2 rounded-md text-base font-medium text-gray-900 hover:bg-gray-50"
  106. >
  107. 首页
  108. </a>
  109. <a
  110. href="#"
  111. className="block px-3 py-2 rounded-md text-base font-medium text-gray-900 hover:bg-gray-50"
  112. >
  113. 产品
  114. </a>
  115. <a
  116. href="#"
  117. className="block px-3 py-2 rounded-md text-base font-medium text-gray-900 hover:bg-gray-50"
  118. >
  119. 关于
  120. </a>
  121. </div>
  122. </nav>
  123. </div>
  124. </div>
  125. </div>
  126. </div>
  127. </nav>
  128. {/* 占位元素,防止内容被固定导航栏遮挡 */}
  129. <div className="h-[calc(env(safe-area-inset-top)+3.5rem)]" />
  130. </>
  131. );
  132. };

触摸交互优化

可触摸按钮组件

  1. // components/TouchableButton.tsx
  2. interface TouchableButtonProps {
  3. onPress?: () => void;
  4. className?: string;
  5. children: React.ReactNode;
  6. disabled?: boolean;
  7. }
  8. const TouchableButton: React.FC<TouchableButtonProps> = ({
  9. onPress,
  10. className = '',
  11. children,
  12. disabled = false
  13. }) => {
  14. return (
  15. <button
  16. onClick={onPress}
  17. disabled={disabled}
  18. className={`
  19. relative overflow-hidden
  20. active:opacity-70
  21. transition-opacity
  22. touch-manipulation
  23. select-none
  24. ${disabled ? 'opacity-50 cursor-not-allowed' : ''}
  25. ${className}
  26. `}
  27. style={{
  28. WebkitTapHighlightColor: 'transparent',
  29. WebkitTouchCallout: 'none'
  30. }}
  31. >
  32. {children}
  33. {/* 触摸反馈效果 */}
  34. <div className="absolute inset-0 bg-black pointer-events-none opacity-0 active:opacity-10 transition-opacity" />
  35. </button>
  36. );
  37. };

滑动列表组件

  1. // components/SwipeableList.tsx
  2. import { useState, useRef } from 'react';
  3. interface SwipeableListProps<T> {
  4. items: T[];
  5. renderItem: (item: T) => React.ReactNode;
  6. onSwipeLeft?: (item: T) => void;
  7. onSwipeRight?: (item: T) => void;
  8. }
  9. function SwipeableList<T>({
  10. items,
  11. renderItem,
  12. onSwipeLeft,
  13. onSwipeRight
  14. }: SwipeableListProps<T>) {
  15. const [activeIndex, setActiveIndex] = useState<number | null>(null);
  16. const touchStartX = useRef<number>(0);
  17. const currentOffset = useRef<number>(0);
  18. const handleTouchStart = (e: React.TouchEvent, index: number) => {
  19. touchStartX.current = e.touches[0].clientX;
  20. setActiveIndex(index);
  21. };
  22. const handleTouchMove = (e: React.TouchEvent) => {
  23. if (activeIndex === null) return;
  24. const touchX = e.touches[0].clientX;
  25. const diff = touchX - touchStartX.current;
  26. currentOffset.current = diff;
  27. // 更新滑动位置
  28. const element = e.currentTarget as HTMLElement;
  29. element.style.transform = `translateX(${diff}px)`;
  30. };
  31. const handleTouchEnd = (e: React.TouchEvent, item: T) => {
  32. if (activeIndex === null) return;
  33. const element = e.currentTarget as HTMLElement;
  34. const offset = currentOffset.current;
  35. // 判断滑动方向和距离
  36. if (Math.abs(offset) > 100) {
  37. if (offset > 0 && onSwipeRight) {
  38. onSwipeRight(item);
  39. } else if (offset < 0 && onSwipeLeft) {
  40. onSwipeLeft(item);
  41. }
  42. }
  43. // 重置状态
  44. element.style.transform = '';
  45. setActiveIndex(null);
  46. currentOffset.current = 0;
  47. };
  48. return (
  49. <div className="overflow-hidden">
  50. {items.map((item, index) => (
  51. <div
  52. key={index}
  53. className="transform transition-transform touch-pan-y"
  54. onTouchStart={e => handleTouchStart(e, index)}
  55. onTouchMove={handleTouchMove}
  56. onTouchEnd={e => handleTouchEnd(e, item)}
  57. >
  58. {renderItem(item)}
  59. </div>
  60. ))}
  61. </div>
  62. );
  63. }

性能优化

图片优化

  1. // components/OptimizedImage.tsx
  2. interface OptimizedImageProps {
  3. src: string;
  4. alt: string;
  5. sizes?: string;
  6. className?: string;
  7. }
  8. const OptimizedImage: React.FC<OptimizedImageProps> = ({
  9. src,
  10. alt,
  11. sizes = '100vw',
  12. className = ''
  13. }) => {
  14. return (
  15. <picture>
  16. <source
  17. media="(min-width: 1024px)"
  18. srcSet={`${src}?w=1024 1024w, ${src}?w=1280 1280w`}
  19. sizes={sizes}
  20. />
  21. <source
  22. media="(min-width: 768px)"
  23. srcSet={`${src}?w=768 768w, ${src}?w=1024 1024w`}
  24. sizes={sizes}
  25. />
  26. <img
  27. src={`${src}?w=375`}
  28. srcSet={`${src}?w=375 375w, ${src}?w=640 640w`}
  29. sizes={sizes}
  30. alt={alt}
  31. className={`w-full h-auto ${className}`}
  32. loading="lazy"
  33. decoding="async"
  34. />
  35. </picture>
  36. );
  37. };

虚拟列表

  1. // components/VirtualList.tsx
  2. import { useState, useEffect, useRef } from 'react';
  3. interface VirtualListProps<T> {
  4. items: T[];
  5. renderItem: (item: T) => React.ReactNode;
  6. itemHeight: number;
  7. containerHeight: number;
  8. overscan?: number;
  9. }
  10. function VirtualList<T>({
  11. items,
  12. renderItem,
  13. itemHeight,
  14. containerHeight,
  15. overscan = 3
  16. }: VirtualListProps<T>) {
  17. const [scrollTop, setScrollTop] = useState(0);
  18. const containerRef = useRef<HTMLDivElement>(null);
  19. // 计算可见范围
  20. const visibleCount = Math.ceil(containerHeight / itemHeight);
  21. const totalHeight = items.length * itemHeight;
  22. const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
  23. const endIndex = Math.min(
  24. items.length,
  25. Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
  26. );
  27. // 可见项目
  28. const visibleItems = items.slice(startIndex, endIndex);
  29. const handleScroll = () => {
  30. if (containerRef.current) {
  31. setScrollTop(containerRef.current.scrollTop);
  32. }
  33. };
  34. return (
  35. <div
  36. ref={containerRef}
  37. className="overflow-auto"
  38. style={{ height: containerHeight }}
  39. onScroll={handleScroll}
  40. >
  41. <div style={{ height: totalHeight, position: 'relative' }}>
  42. {visibleItems.map((item, index) => (
  43. <div
  44. key={startIndex + index}
  45. style={{
  46. position: 'absolute',
  47. top: (startIndex + index) * itemHeight,
  48. height: itemHeight,
  49. width: '100%'
  50. }}
  51. >
  52. {renderItem(item)}
  53. </div>
  54. ))}
  55. </div>
  56. </div>
  57. );
  58. }

手势交互

滑动手势处理

  1. // hooks/useSwipe.ts
  2. interface SwipeOptions {
  3. onSwipeLeft?: () => void;
  4. onSwipeRight?: () => void;
  5. onSwipeUp?: () => void;
  6. onSwipeDown?: () => void;
  7. threshold?: number;
  8. }
  9. export const useSwipe = (options: SwipeOptions = {}) => {
  10. const {
  11. onSwipeLeft,
  12. onSwipeRight,
  13. onSwipeUp,
  14. onSwipeDown,
  15. threshold = 50
  16. } = options;
  17. const touchStart = useRef({ x: 0, y: 0 });
  18. const touchEnd = useRef({ x: 0, y: 0 });
  19. const handleTouchStart = (e: TouchEvent) => {
  20. touchStart.current = {
  21. x: e.touches[0].clientX,
  22. y: e.touches[0].clientY
  23. };
  24. };
  25. const handleTouchEnd = (e: TouchEvent) => {
  26. touchEnd.current = {
  27. x: e.changedTouches[0].clientX,
  28. y: e.changedTouches[0].clientY
  29. };
  30. const deltaX = touchEnd.current.x - touchStart.current.x;
  31. const deltaY = touchEnd.current.y - touchStart.current.y;
  32. if (Math.abs(deltaX) > Math.abs(deltaY)) {
  33. // 水平滑动
  34. if (Math.abs(deltaX) > threshold) {
  35. if (deltaX > 0) {
  36. onSwipeRight?.();
  37. } else {
  38. onSwipeLeft?.();
  39. }
  40. }
  41. } else {
  42. // 垂直滑动
  43. if (Math.abs(deltaY) > threshold) {
  44. if (deltaY > 0) {
  45. onSwipeDown?.();
  46. } else {
  47. onSwipeUp?.();
  48. }
  49. }
  50. }
  51. };
  52. return {
  53. handleTouchStart,
  54. handleTouchEnd
  55. };
  56. };

下拉刷新组件

  1. // components/PullToRefresh.tsx
  2. interface PullToRefreshProps {
  3. onRefresh: () => Promise<void>;
  4. children: React.ReactNode;
  5. }
  6. const PullToRefresh: React.FC<PullToRefreshProps> = ({
  7. onRefresh,
  8. children
  9. }) => {
  10. const [refreshing, setRefreshing] = useState(false);
  11. const [pullDistance, setPullDistance] = useState(0);
  12. const containerRef = useRef<HTMLDivElement>(null);
  13. const touchStart = useRef(0);
  14. const pulling = useRef(false);
  15. const handleTouchStart = (e: React.TouchEvent) => {
  16. if (containerRef.current?.scrollTop === 0) {
  17. touchStart.current = e.touches[0].clientY;
  18. pulling.current = true;
  19. }
  20. };
  21. const handleTouchMove = (e: React.TouchEvent) => {
  22. if (!pulling.current) return;
  23. const touch = e.touches[0].clientY;
  24. const distance = touch - touchStart.current;
  25. if (distance > 0) {
  26. e.preventDefault();
  27. setPullDistance(Math.min(distance * 0.5, 100));
  28. }
  29. };
  30. const handleTouchEnd = async () => {
  31. if (!pulling.current) return;
  32. pulling.current = false;
  33. if (pullDistance > 60 && !refreshing) {
  34. setRefreshing(true);
  35. try {
  36. await onRefresh();
  37. } finally {
  38. setRefreshing(false);
  39. }
  40. }
  41. setPullDistance(0);
  42. };
  43. return (
  44. <div
  45. ref={containerRef}
  46. className="overflow-auto touch-pan-y"
  47. onTouchStart={handleTouchStart}
  48. onTouchMove={handleTouchMove}
  49. onTouchEnd={handleTouchEnd}
  50. >
  51. {/* 刷新指示器 */}
  52. <div
  53. className="flex items-center justify-center transition-transform"
  54. style={{
  55. transform: `translateY(${pullDistance}px)`,
  56. height: refreshing ? '50px' : '0'
  57. }}
  58. >
  59. {refreshing ? (
  60. <div className="animate-spin rounded-full h-6 w-6 border-2 border-gray-900 border-t-transparent" />
  61. ) : (
  62. <div className="h-6 w-6 transition-transform" style={{
  63. transform: `rotate(${Math.min(pullDistance * 3.6, 360)}deg)`
  64. }}>
  65. </div>
  66. )}
  67. </div>
  68. {/* 内容区域 */}
  69. <div style={{
  70. transform: `translateY(${pullDistance}px)`,
  71. transition: pulling.current ? 'none' : 'transform 0.2s'
  72. }}>
  73. {children}
  74. </div>
  75. </div>
  76. );
  77. };

自适应布局

媒体查询工具

  1. // hooks/useMediaQuery.ts
  2. export const useMediaQuery = (query: string) => {
  3. const [matches, setMatches] = useState(false);
  4. useEffect(() => {
  5. const media = window.matchMedia(query);
  6. const updateMatch = (e: MediaQueryListEvent) => {
  7. setMatches(e.matches);
  8. };
  9. setMatches(media.matches);
  10. media.addListener(updateMatch);
  11. return () => media.removeListener(updateMatch);
  12. }, [query]);
  13. return matches;
  14. };
  15. // 使用示例
  16. const isMobile = useMediaQuery('(max-width: 768px)');
  17. const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)');
  18. const isDesktop = useMediaQuery('(min-width: 1025px)');

自适应容器

  1. // components/AdaptiveContainer.tsx
  2. interface AdaptiveContainerProps {
  3. children: React.ReactNode;
  4. className?: string;
  5. }
  6. const AdaptiveContainer: React.FC<AdaptiveContainerProps> = ({
  7. children,
  8. className = ''
  9. }) => {
  10. return (
  11. <div className={`
  12. w-full px-4 mx-auto
  13. sm:max-w-screen-sm sm:px-6
  14. md:max-w-screen-md
  15. lg:max-w-screen-lg lg:px-8
  16. xl:max-w-screen-xl
  17. ${className}
  18. `}>
  19. {children}
  20. </div>
  21. );
  22. };

调试工具

设备模拟器

  1. // components/DeviceEmulator.tsx
  2. interface DeviceEmulatorProps {
  3. children: React.ReactNode;
  4. device?: 'iphone' | 'ipad' | 'android' | 'pixel';
  5. }
  6. const deviceSpecs = {
  7. iphone: {
  8. width: '375px',
  9. height: '812px',
  10. safeAreaTop: '44px',
  11. safeAreaBottom: '34px'
  12. },
  13. ipad: {
  14. width: '768px',
  15. height: '1024px',
  16. safeAreaTop: '20px',
  17. safeAreaBottom: '20px'
  18. },
  19. // ... 其他设备规格
  20. };
  21. const DeviceEmulator: React.FC<DeviceEmulatorProps> = ({
  22. children,
  23. device = 'iphone'
  24. }) => {
  25. const specs = deviceSpecs[device];
  26. return (
  27. <div
  28. className="relative bg-black rounded-[3rem] p-4"
  29. style={{
  30. width: `calc(${specs.width} + 2rem)`,
  31. height: `calc(${specs.height} + 2rem)`
  32. }}
  33. >
  34. <div
  35. className="overflow-hidden rounded-[2.5rem] bg-white"
  36. style={{
  37. width: specs.width,
  38. height: specs.height,
  39. paddingTop: specs.safeAreaTop,
  40. paddingBottom: specs.safeAreaBottom
  41. }}
  42. >
  43. {children}
  44. </div>
  45. </div>
  46. );
  47. };

开发者工具

  1. // utils/mobileDebugger.ts
  2. export const initMobileDebugger = () => {
  3. if (process.env.NODE_ENV === 'development') {
  4. // 显示点击区域
  5. document.addEventListener('touchstart', (e) => {
  6. const touch = e.touches[0];
  7. const dot = document.createElement('div');
  8. dot.style.cssText = `
  9. position: fixed;
  10. z-index: 9999;
  11. width: 20px;
  12. height: 20px;
  13. background: rgba(255, 0, 0, 0.5);
  14. border-radius: 50%;
  15. pointer-events: none;
  16. transform: translate(-50%, -50%);
  17. left: ${touch.clientX}px;
  18. top: ${touch.clientY}px;
  19. `;
  20. document.body.appendChild(dot);
  21. setTimeout(() => dot.remove(), 500);
  22. });
  23. // 显示视口信息
  24. const viewport = document.createElement('div');
  25. viewport.style.cssText = `
  26. position: fixed;
  27. z-index: 9999;
  28. bottom: 0;
  29. left: 0;
  30. background: rgba(0, 0, 0, 0.7);
  31. color: white;
  32. padding: 4px 8px;
  33. font-size: 12px;
  34. `;
  35. document.body.appendChild(viewport);
  36. const updateViewport = () => {
  37. viewport.textContent = `${window.innerWidth}x${window.innerHeight}`;
  38. };
  39. window.addEventListener('resize', updateViewport);
  40. updateViewport();
  41. }
  42. };

最佳实践

  1. 响应式设计

    • 移动优先策略
    • 合理的断点设置
    • 灵活的布局系统
  2. 触摸交互

    • 适当的点击区域
    • 清晰的反馈效果
    • 流畅的动画过渡
  3. 性能优化

    • 图片优化处理
    • 延迟加载策略
    • 虚拟滚动列表
  4. 用户体验

    • 合理的字体大小
    • 清晰的视觉层级
    • 直观的操作反馈