Skip to content
Go back

react-photo-view 实战课程大纲

Published:  at  10:22 PM

react-photo-view 实战课程大纲

目标:用 1-2 天时间,从零到一实现一个完整的图片预览组件,特别掌握移动端长图预览功能

前置知识:熟悉 React 基本语法、了解 TypeScript 基础


课程安排

天数主题时长产出
Day 1基础架构与核心组件6-8 小时实现基础图片预览
Day 2手势交互与动画6-8 小时实现完整功能

Day 1:基础架构与核心组件

上午:环境准备与基础概念(2小时)

第1课:项目初始化与环境搭建 [30分钟]

实践任务:初始化项目,安装 react、typescript、less

npx create-react-app my-photo-view --template typescript
# 或
npm create vite@latest my-photo-view -- --template react-ts

第2课:理解组件架构 [30分钟]

核心概念

┌─────────────────────────────────────────────────┐
│                  PhotoProvider                   │
│  ┌─────────────────────────────────────────┐   │
│  │            PhotoContext                  │   │
│  │  - images[]                             │   │
│  │  - visible                              │   │
│  │  - index                                │   │
│  │  - show()/update()/remove()            │   │
│  └─────────────────────────────────────────┘   │
│                         │                        │
│         ┌──────────────┴──────────────┐         │
│         ▼                              ▼         │
│  ┌─────────────┐              ┌─────────────┐    │
│  │  PhotoView  │              │ PhotoSlider │    │
│  │  (单图组件)  │              │  (预览主组件) │    │
│  └─────────────┘              └─────────────┘    │
└─────────────────────────────────────────────────┘

第3课:TypeScript 类型系统实战 [60分钟]

实践任务:定义图片预览组件的完整类型

// 定义资源类型
interface PhotoItem {
  key: string | number;
  src?: string;
  width?: number;
  height?: number;
  render?: (props: RenderProps) => React.ReactNode;
}

// 定义 Provider Props
interface PhotoProviderProps {
  children: React.ReactNode;
  images?: PhotoItem[];
  onIndexChange?: (index: number) => void;
  onVisibleChange?: (visible: boolean) => void;
}

// 定义 Context 类型
interface PhotoContextType {
  visible: boolean;
  index: number;
  images: PhotoItem[];
  show: (index: number) => void;
  hide: () => void;
}

下午:核心组件实现(4-6小时)

第4课:实现 PhotoContext [60分钟]

实践任务:创建 photo-context.ts

import { createContext } from 'react';

export interface PhotoItem {
  key: string | number;
  src?: string;
  width?: number;
  height?: number;
}

export interface PhotoContextType {
  visible: boolean;
  index: number;
  images: PhotoItem[];
  show: (index: number) => void;
  hide: () => void;
}

const PhotoContext = createContext<PhotoContextType | undefined>(undefined!);

export default PhotoContext;

产出:完成 photo-context.ts


第5课:实现 PhotoProvider 状态管理 [90分钟]

实践任务:实现 PhotoProvider.tsx

import { useState, useCallback, useMemo, createContext } from 'react';
import PhotoContext from './photo-context';

export default function PhotoProvider({ children }) {
  const [visible, setVisible] = useState(false);
  const [index, setIndex] = useState(0);
  const [images, setImages] = useState([]);

  const show = useCallback((index: number) => {
    setIndex(index);
    setVisible(true);
  }, []);

  const hide = useCallback(() => {
    setVisible(false);
  }, []);

  const value = useMemo(() => ({
    visible,
    index,
    images,
    show,
    hide,
  }), [visible, index, images, show, hide]);

  return (
    <PhotoContext.Provider value={value}>
      {children}
    </PhotoContext.Provider>
  );
}

产出:完成 PhotoProvider.tsx


第6课:实现 PhotoView 单图组件 [60分钟]

实践任务:实现 PhotoView.tsx

import { useContext, useEffect, useRef } from 'react';
import PhotoContext from './photo-context';

export default function PhotoView({ src, children }) {
  const { show, images, setImages } = useContext(PhotoContext);
  const key = useRef(Symbol('photo')).current;

  useEffect(() => {
    // 注册到 Provider
    setImages((prev) => [...prev, { key, src }]);
    return () => {
      // 清理
      setImages((prev) => prev.filter((img) => img.key !== key));
    };
  }, [src]);

  const handleClick = () => {
    const index = images.findIndex((img) => img.key === key);
    show(index);
  };

  if (children) {
    return cloneElement(children, { onClick: handleClick });
  }
  return null;
}

产出:完成 PhotoView.tsx


第7课:实现基础 PhotoSlider [90分钟]

实践任务:实现基础的 PhotoSlider

import { useEffect, usePortal } from 'react';

export default function PhotoSlider({ visible, images, index, onClose }) {
  if (!visible) return null;

  // 使用 Portal 渲染到 body
  return createPortal(
    <div className="photo-slider-overlay" onClick={onClose}>
      <div className="photo-slider-content" onClick={e => e.stopPropagation()}>
        <img src={images[index]?.src} alt="" />

        {/* 关闭按钮 */}
        <button onClick={onClose}>✕</button>

        {/* 切换按钮 */}
        {index > 0 && <button onClick={prev}>◀</button>}
        {index < images.length - 1 && <button onClick={next}>▶</button>}
      </div>
    </div>,
    document.body
  );
}

产出:完成基础版 PhotoSlider.tsx


晚上:回顾与练习(1小时)

第8课:Day1 总结与练习 [60分钟]

检查清单


Day 2:手势交互与动画

上午:触摸事件处理(3小时)

第9课:触摸事件基础 [60分钟]

核心概念

// 触摸开始
const handleTouchStart = (e: TouchEvent) => {
  const touch = e.touches[0];
  const startX = touch.clientX;
  const startY = touch.clientY;
};

// 触摸移动
const handleTouchMove = (e: TouchEvent) => {
  const touch = e.touches[0];
  const deltaX = touch.clientX - startX;
  const deltaY = touch.clientY - startY;
};

// 触摸结束
const handleTouchEnd = (e: TouchEvent) => {
  // 判断滑动方向和距离
};

第10课:实现基础滑动切换 [90分钟]

实践任务:给 PhotoSlider 添加滑动切换

function useSwipe(onSwipeLeft, onSwipeRight) {
  const [startX, setStartX] = useState(0);
  const [startY, setStartY] = useState(0);

  const handleTouchStart = (e) => {
    setStartX(e.touches[0].clientX);
    setStartY(e.touches[0].clientY);
  };

  const handleTouchEnd = (e) => {
    const endX = e.changedTouches[0].clientX;
    const endY = e.changedTouches[0].clientY;
    const deltaX = endX - startX;
    const deltaY = endY - startY;

    // 判断是水平滑动还是垂直滑动
    if (Math.abs(deltaX) > Math.abs(deltaY)) {
      // 水平滑动
      if (Math.abs(deltaX) > 50) {
        // 阈值
        if (deltaX > 0) onSwipeRight();
        else onSwipeLeft();
      }
    }
  };

  return { handleTouchStart, handleTouchEnd };
}

产出:支持滑动切换的 PhotoSlider


第11课:长图预览核心原理 [60分钟] ⭐重点

核心概念

┌────────────────────────────┐
│      视口 (viewport)        │
│  ┌──────────────────────┐  │
│  │                      │  │
│  │     长图内容          │  │  ◀── 可滚动
│  │                      │  │
│  │                      │  │
│  └──────────────────────┘  │
└────────────────────────────┘

长图滚动原理:
- 图片高度 > 视口高度时,允许滚动
- translateY 控制滚动位置
- scrollY = translateY 的绝对值

长图 vs 普通图

特性普通图片长图
尺寸适应屏幕原尺寸显示
交互左右滑动切换上下滚动查看
判断宽高比scrollHeight > 视口高度

下午:完整手势交互实现(4小时)

第12课:实现长图滚动预览 [90分钟] ⭐重点

实践任务:实现长图滚动

function LongImageViewer({ src }) {
  const [translateY, setTranslateY] = useState(0);
  const [startY, setStartY] = useState(0);
  const imgRef = useRef<HTMLImageElement>(null);

  // 检测是否需要滚动
  const needsScroll = imgRef.current?.scrollHeight > window.innerHeight;

  const handleTouchMove = (e) => {
    if (!needsScroll) return; // 不需要滚动时,交还给父组件处理滑动切换

    const deltaY = e.touches[0].clientY - startY;
    const newTranslateY = translateY + deltaY;

    // 边界检查
    const maxTranslate = 0;
    const minTranslate = -(imgRef.current!.scrollHeight - window.innerHeight);

    if (newTranslateY > maxTranslate) {
      // 超出顶部,允许拖动但有阻力
      setTranslateY(newTranslateY * 0.3);
    } else if (newTranslateY < minTranslate) {
      // 超出底部,允许拖动但有阻力
      setTranslateY(minTranslate + (newTranslateY - minTranslate) * 0.3);
    } else {
      setTranslateY(newTranslateY);
    }
  };

  return (
    <img
      ref={imgRef}
      src={src}
      style={{
        transform: `translateY(${translateY}px)`,
        transition: isMoving ? 'none' : 'transform 0.3s'
      }}
    />
  );
}

产出:支持长图滚动的 PhotoSlider


第13课:实现惯性滚动 [90分钟] ⭐重点

实践任务:实现物理滚动效果

function useInertialScroll(translateY, setTranslateY, minY, maxY) {
  const lastY = useRef(0);
  const lastTime = useRef(0);

  const handleTouchEnd = (velocityY) => {
    // 速度超过阈值,触发惯性滚动
    if (Math.abs(velocityY) > 0.5) {
      animate({
        from: translateY,
        to: translateY + velocityY * 1000, // 根据速度计算目标位置
        duration: 500,
        easing: 'cubic-bezier(0.25, 0.8, 0.25, 1)',
        onUpdate: (value) => {
          // 边界限制与回弹
          if (value > maxY) {
            value = maxY + (value - maxY) * 0.3;
          } else if (value < minY) {
            value = minY + (value - minY) * 0.3;
          }
          setTranslateY(value);
        },
      });
    }
  };

  return { handleTouchEnd };
}

产出:带惯性滚动的长图预览


第14课:实现图片缩放 [60分钟]

缩放核心逻辑

function usePinchZoom() {
  const [scale, setScale] = useState(1);
  const [startDistance, setStartDistance] = useState(0);

  const handleTouchMove = (e) => {
    if (e.touches.length === 2) {
      // 计算双指距离
      const distance = Math.hypot(
        e.touches[0].clientX - e.touches[1].clientX,
        e.touches[0].clientY - e.touches[1].clientY,
      );

      if (startDistance === 0) {
        setStartDistance(distance);
      } else {
        const newScale = (distance / startDistance) * scale;
        // 限制缩放范围
        setScale(Math.min(Math.max(newScale, 1), 4));
      }
    }
  };

  return { scale, handleTouchMove };
}

晚上:动画与完善(2小时)

第15课:入场/退场动画 [60分钟]

.photo-slider-overlay {
  animation: fadeIn 0.3s ease-out;
}

.photo-slider-content img {
  animation: scaleIn 0.3s ease-out;
}

@keyframes fadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes scaleIn {
  from {
    opacity: 0;
    transform: scale(0.8);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

第16课:来源动画(FLIP)[60分钟] ⭐进阶

FLIP 核心

F - First:   记录初始位置和尺寸
L - Last:    记录最终位置和尺寸
I - Invert:  计算差异,应用变换
P - Play:    播放动画,移除变换

第17课:Day2 总结与完整整合 [60分钟]

最终检查清单


完整功能清单

完成课程后,你将实现一个功能完整的图片预览组件:

基础功能

手势交互

动画效果

高级功能


项目结构

完成课程后,你的项目结构如下:

src/
├── photo-context.ts       # Context 定义
├── PhotoProvider.tsx       # 状态管理
├── PhotoView.tsx          # 单图组件
├── PhotoSlider.tsx        # 预览主组件
├── hooks/
│   ├── useSwipe.ts        # 滑动切换
│   ├── useScroll.ts       # 滚动处理
│   ├── useZoom.ts         # 缩放处理
│   ├── useInertial.ts     # 惯性滚动
│   └── useAnimation.ts    # 动画控制
├── utils/
│   ├── detectImageType.ts  # 检测图片类型
│   └── math.ts            # 数学计算
└── styles/
    └── PhotoSlider.less   # 样式

学习资源

推荐阅读

实战参考


常见问题

Q1: 如何判断是长图还是普通图?

function detectImageType(imgHeight: number, viewportHeight: number): 'long' | 'normal' {
  return imgHeight > viewportHeight ? 'long' : 'normal';
}

Q2: 缩放和滚动冲突如何处理?

// 缩放时禁用滚动
// 滚动时禁用缩放
// 使用状态控制
const [mode, setMode] = useState<'scroll' | 'zoom' | 'none'>('none');

// 单指移动 → 滚动模式
// 双指移动 → 缩放模式
if (touches.length === 1) setMode('scroll');
if (touches.length === 2) setMode('zoom');

Q3: 性能优化怎么做?


课程总结

核心技能

技能掌握程度
React Context✅ 熟练
TypeScript 类型✅ 熟练
Touch Events✅ 熟练
CSS Transform✅ 熟练
物理滚动算法✅ 掌握
FLIP 动画✅ 了解

下一步

  1. 添加更多功能(旋转、下载)
  2. 优化性能和体验
  3. 编写测试用例
  4. 发布到 npm

课程结束,祝你开发愉快!


Suggest Changes

Next Post
检查项目依赖的开源许可证