模拟京东移动端商详主图楼层实现及解析(React Hook+SSR)

3,597 阅读5分钟

1、功能简介

  • 支持首屏主图轮播
  • 支持点击首屏轮播显示带轮播的大图效果
  • 支持首屏轮播图内嵌视频播放

2、核心功能详解

关键数据说明

  • VideoInfo
interface VideoInfo {
    videoId: string; //视频ID
    imageUrl: string; //视频首图
    duration: number; //视频时长
    playUrl: string; //视频路径
}
  • wareImage
WareImageInfo {
    big: string; //大图
    small: string; //小图
}

2.1 组件入口

  • 组件说明
    • Carousel 首屏轮播组件
    • LargeCarousel 点击首屏轮播图显示的大图组件
  • 功能说明
    • 点击首屏轮播图显示大图,并自动定位到所点击图片相应的大图,通过active状态控制
  • 状态说明
    • showModel 控制显示大图的开关
    • initial 加载大图js的开关(用于懒加载)
    • active 记录当前显示图片的数组下标,用于点击显示的大图轮播和首屏轮播之间的交互以及视频组件也需用到,下文有说明
export default (props: IProps) => {
    //用于点击显示的大图轮播和首屏轮播之间的交互,记录当前显示图片的数组下标
    const [active, setActive] = useState(0);
    //是否显示大图轮播组件
    const [showModel, setShowModel] = useState(false);
    //是否进入客户端,进入客户端后懒加载大图组件
    const [initial, setInitial] = useState(false);
    useEffect(() => {
        if (showModel) setInitial(true);
    }, [showModel]);
    const { wareImage, videos, setImgOpacity } = props;
    let videoForMainImg!: VideoInfo;
    if (videos) {
        videoForMainImg = videos.filter((item) => item.videoType == '1')[0];
    }
    return (
        <section className="jd-photo-album">
            {/* 主图轮播,主图带视频传入视频 */}
            <Carousel
                {...{
                    wareImage,
                    setActive,
                    active,
                    setShowModel,
                    showModel,
                    setImgOpacity,
                }}
                {...(videoForMainImg ? { videoInfo: videoForMainImg } : {})}
            />
            {/* 点击主图后的大图的轮播 */}
            {initial && showModel && (
                <Loading>
                    <LargeCarousel
                        {...{
                            setShowModel,
                            active,
                            wareImage,
                            setActive,
                            showModel,
                        }}
                    />
                </Loading>
            )}
        </section>
    );
};

2.2 首屏主图轮播图组件(Carousel)

  • 组件说明
    • JDPlayer 视频组件
    • Slider react-slick https://github.com/akiran/react-slick
    • Slider API https://react-slick.neostack.com/docs/api
  • 功能说明
    • 如果Video有数据,需要在首屏图片轮播图第一张显示视频播放按钮,点击按钮后自动播放视频,播放视频的同时可滑动轮播图,同时视频暂停,再滑动到第一张图片,视频接着自动播放,有几个关键状态控制
  • 状态说明
    • active 父组件传入,如果带视频active为0(第一张)是显示视频的条件之一
    • showPlayer 控制视频显示的开关 轮播图带视频情况下控制是显示视频还是主图 ,状态为ture是显示视频的条件之一
    • play 控制视频播放的开关
    • clickPause 是否是手动点击视频暂停,如果是,视频暂停后滑动回来依旧是暂停状态,否则自动播放
export default (props: IProps) => {
    const { i18n } = usePublicContext();
    const {wareImage,setActive,active,setShowModel,videoInfo,showModel} = props;
    const slider = useRef<Slider>(null);
    const [play, setPlay] = useState(false);
    const [showPlayer, setShowPlayer] = useState(false);
    const [clickPause, setClickPause] = useState(false);
    //index改变触发滑动并触发视频播放/暂停状态
    useEffect(() => {
        if (slider.current) slider.current.slickGoTo(active);
        if (active != 0 && showPlayer) {
            setPlay(false);
        } else if (active == 0 && !clickPause && showPlayer && !showModel) {
            setPlay(true);
        }
    }, [active, play, clickPause, showPlayer, showModel]);

     const settings = {
        infinite: false, //不循环
        speed: 300, //动画速度,以毫秒为单位
        arrow: false, //不显示箭头
        beforeChange: (oldIndex: number, newIndex: number) => {
            setActive(newIndex);
        },
    };

    const handlePlayVideo = (e: React.MouseEvent) => {
        e.stopPropagation();
        if (!videoAlert) {
         //用户点击播放视频时触发提示,可做wifi情况的验证
        } else {
            setShowPlayer(true);
            setPlay(true);
        }
    };

    return (
        <div className="jd-carousel">
            {(!showPlayer || active !== 0) && (
                <div className="page-nub">
                当前显示的图片位置:1/4
                </div>
            )}
            <Slider {...settings} ref={slider}>
                {wareImage.map((item: any, index: number) => {
                    return (
                        <React.Fragment key={index}>
                            {index == 0 && showPlayer && videoInfo && (
                                <Loading>
                                    <JDPlayer
                                        video={videoInfo}
                                        isPlay={play}
                                        {...{
                                            setShowPlayer,
                                            setClickPause,
                                        }}
                                    />
                                </Loading>
                            )}
                            <div
                                onClick={(e: React.MouseEvent) => {
                                    setShowModel(true);
                                }}
                                style={{
                                    display:
                                        index == 0 && showPlayer && videoInfo
                                            ? 'none'
                                            : 'block',
                                }}
                            >
                                {index === 1 &&
                                    !showPlayer &&
                                    videoInfo &&
                                    videoInfo.videoType === '1' && (
                                        <span
                                            className="video-time"
                                            onClick={handlePlayVideo}
                                        >
                                          视频时间
                                        </span>
                                    )}
                                <img
                                    src={item.big}
                                    onLoad={() => {   
                                        if (index == 0) {
                                           //!!! 下文有说明作用
                                            props.setImgOpacity(0);
                                        }
                                    }}
                                />
                            </div>
                        </React.Fragment>
                    );
                })}
            </Slider>
        </div>
    );
};

2.3 视频组件(JDPlayer)

  • 组件说明
    • 视频组件:video-react https://github.com/video-react/video-react
    • API react-slick https://video-react.js.org/components/player/
  • 功能说明
    • 视频播放的时候滑动轮播图后暂停,再滑到首图视频自动播放,通过isPlay控制视频播放暂停
    • 通过react-slick的subscribeToStateChange方法订阅播放器状态变化,当有错误时显示错误UI,错误UI的开关为showError
    • 开发的时候在组件加载完毕后立刻设置播放器播放,会出问题,最终通过setTimeout解决(略肥猪流),如果有更好的方法还希望各位大佬留言
  export default (props: {
    video: VideoInfo;
    isPlay: boolean;
    setClickPause: (value: React.SetStateAction<boolean>) => void;
    setShowPlayer: (value: React.SetStateAction<boolean>) => void;
}) => {
    const { video, isPlay, setClickPause, setShowPlayer } = props;
    const [screenWidth, setScreenWidth] = useState(0);
    useEffect(() => {
        setScreenWidth(screen.width);
    }, [setScreenWidth]);
    const [jdPlayer, setJdPlayer] = useState<any>(null);
    //控制视频错误时的显示错误UI的状态
    const [showError, setShowError] = useState<boolean>(false);
    useEffect(() => {
        if (jdPlayer) {
            if (isPlay)
                //这里有坑,需要setTimeou
                setTimeout(() => {
                    jdPlayer.play();
                }, 0);
            else jdPlayer.pause();
        }
    }, [isPlay, jdPlayer]);

    //获取最新的state
    const handleStateChange = (state: any) => {
        const { error, currentTime } = state;
        //处理Error
        setShowError(error && error.code);
    };
 
    //订阅播放器状态更改
     useEffect(() => {
        if (jdPlayer)
            jdPlayer.subscribeToStateChange((state: any) =>
                handleStateChange(state),
            );
    }, [jdPlayer]);
    return (
        <div className="jd-player">
            {showError && (
                <div className="error-content">
                    <div className="error-content-warp">
                        <div className="error-image"></div>
                        <div className="error-text">
                            error
                        </div>
                    </div>
                </div>
            )}
            <div
                className="close-video"
                onClick={() => {
                    setShowPlayer(false);
                }}
            >
                close
            </div>
            <div
                onClick={() => {
                    setClickPause(isPlay);
                }}
            >
                <Player
                    ref={(player: any) => setJdPlayer(player)}
                    width={screenWidth}
                    height={screenWidth}
                    fluid={false}
                    videoId={video.videoId}
                    src={video.playUrl}
                >
                    <ControlBar autoHide={false} />
                </Player>
            </div>
        </div>
    );
};

2.4 大图轮播组件(LargeCarousel)

  • 组件说明
  • Slider react-slick https://github.com/akiran/react-slick
  • Slider API https://react-slick.neostack.com/docs/api
  • 功能说明
    • 大图轮播,大图下面有一排轮播小图,本来想用两组slider做,但比较麻烦,发现用自定义dots做简单又方便,但需注意当轮播图只有一张的时候react-slick不会显示dot 需处理一下
  • 状态说明
    • active 父组件传入,大图切换时变化
export default (props: {
   wareImage: Array<WareImageInfo>;
   active: number;
   setActive: (value: React.SetStateAction<number>) => void;
   setShowModel: (value: React.SetStateAction<boolean>) => void;
}) => {
   const { wareImage, setActive, active, setShowModel } = props;
   const pageSlider = useRef<Slider>(null);
   const settings = {
       dots: true,
       infinite: false,
       speed: 300, 
       initialSlide: 0,//默认展示第0张
       beforeChange: (oldIndex: number, newIndex: number) => {
           setActive(newIndex);
       },
       appendDots: (dots: any) => (
           <div>
               <ul style={{ margin: '0px' }}>{dots} </ul>
           </div>
       ), //自定义点模板。与customPaging配合使用
       customPaging: (i: number) => <img src={wareImage[i].small}></img>,
   };

   useEffect(() => {
       //根据active设置显示的大图
       if (pageSlider.current) pageSlider.current.slickGoTo(active);
   }, [active]);

   return (
       <div className="jd-carousel-model">
           <div className="jd-page-slider">
               <div className="pg-header">
                   <i
                       className="header-back"
                       onClick={() => {
                           setShowModel(false);
                       }}
                   ></i>
                   <div className="page-bg">
                       <span>{active + 1}</span>
                       <span className="nub-bg">/</span>
                       <span>{wareImage.length}</span>
                   </div>
               </div>

               <Slider ref={pageSlider} {...settings}>
                   {wareImage.map((item: any, index: number) => {
                       return (
                           <div
                               key={index}
                               onClick={() => {
                                   setShowModel(false);
                               }}
                           >
                               <img
                                   src={item.big}
                                   style={{
                                       width: '100%',
                                   }}
                               />
                           </div>
                       );
                   })}
               </Slider>             
           {/* 当轮播图至于一张的时候手动加dot */}
               {wareImage.length == 1 && (
                   <div className="slick-dots">
                       <ul>
                           <li className="slick-active">
                               <img src={wareImage[0].small} />
                           </li>
                       </ul>
                   </div>
               )}
           </div>
       </div>
   );
};

3、写在最后

这次是SSR做的首屏渲染,加入轮播图组件打包后发现包比预期的大,后续又做以下优化: 首屏渲染时在轮播图位置放一张压缩后的图片做背景图,在进入客户端后懒加载真正的轮播图组件,轮播图组件第一张图片加载完成(`onLoad`)后,设置背景图为透明,在减小JS首屏包的大小的同时也优化了用户体验,做完感觉有点打通任督二脉的感觉,感觉真好。

最后:冲鸭!