第七章:项目实战 - 第四节 - Tailwind CSS 移动端适配实践

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

基础配置

视口配置

html 复制代码
<!-- 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">

断点设置

javascript 复制代码
// 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)',
      },
    },
  },
}

移动端导航

响应式导航组件

typescript 复制代码
// 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)]" />
    </>
  );
};

触摸交互优化

可触摸按钮组件

typescript 复制代码
// 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>
  );
};

滑动列表组件

typescript 复制代码
// 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>
  );
}

性能优化

图片优化

typescript 复制代码
// 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>
  );
};

虚拟列表

typescript 复制代码
// 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>
  );
}

手势交互

滑动手势处理

typescript 复制代码
// 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
  };
};

下拉刷新组件

typescript 复制代码
// 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>
  );
};

自适应布局

媒体查询工具

typescript 复制代码
// 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)');

自适应容器

typescript 复制代码
// 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>
  );
};

调试工具

设备模拟器

typescript 复制代码
// 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>
  );
};

开发者工具

typescript 复制代码
// 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();
  }
};

最佳实践

  1. 响应式设计

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

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

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

    • 合理的字体大小
    • 清晰的视觉层级
    • 直观的操作反馈
相关推荐
乌夷9 分钟前
axios结合AbortController取消文件上传
开发语言·前端·javascript
晓晓莺歌32 分钟前
图片的require问题
前端
码农黛兮_461 小时前
CSS3 基础知识、原理及与CSS的区别
前端·css·css3
水银嘻嘻1 小时前
web 自动化之 Unittest 四大组件
运维·前端·自动化
(((φ(◎ロ◎;)φ)))牵丝戏安1 小时前
根据输入的数据渲染柱形图
前端·css·css3·js
wuyijysx2 小时前
JavaScript grammar
前端·javascript
溪饱鱼2 小时前
第6章: SEO与交互指标
服务器·前端·microsoft
咔_2 小时前
LinkedList详解(源码分析)
前端
逍遥德3 小时前
CSS可以继承的样式汇总
前端·css·ui
读心悦3 小时前
CSS3 选择器完全指南:从基础到高级的元素定位技术
前端·css·css3