从零开始 React Hook实现在线壁纸网站

5,024 阅读17分钟

更新

🎉图片现已支持 -多分辨率- 下载(Safari暂不支持)

🎉适配PC、Pad、Phone多种分辨率设备,持续更新中!

前言

前段时间学习了React的相关知识,尝试使用Class ComponentHook两种方式进行项目实践,其中Class Component的使用主要围绕生命周期展开,Hook是比较新的函数式实现方式,弱化了生命周期的存在,可能是React未来主推的方式。

尝试使用了官方提供的create react app和蚂蚁提供的umi进行项目搭建,create react app仅提供最为基础的react项目打包和运行配置(路由等相关配置需自己实现),而umi提供开箱即用的详细配置(包括css预处理的选择、第三方UI框架的引入、自动化路由构建的封装),可根据需求情况灵活选择。

使用了ant design react等UI库,对于中后台项目的搭建非常友好,体验很棒。

选择在线壁纸网站实现,一方面可以体验项目搭建的完整过程,还可以方便大家浏览和获取自己喜欢的壁纸。 (PS: 这样换壁纸比较方便😂)

鉴于本次实现的项目为「在线壁纸网站」,对比相关react ui库,最终选择了semantic ui react

优点如下:

  • 支持自定义组件渲染标签样式
  • 支持丰富的组件样式和配置
  • 支持组件颜色反转,便于实现暗黑模式
  • 组件基于语义化命名,查找使用方便

Tip:

本文主要介绍react hook基础项目的搭建,后端基于Node实现简单的接口转发和处理,本文暂不涉及redux引入和后端实现,后续逐步更新。

壁纸来自360壁纸库,在此表示感谢,仅用作学习交流,切勿用于商业用途。

相关文档地址:

react | create react app | umi | ant design react | semantic ui react

效果展示

qrcode_aotianwinter.github.io.png

开始

下面会通过两方面介绍项目的搭建流程,即项目的初始化工作和项目(组件)的正式开发😁。

在介绍过程中,会首先阐述设计的构思和关注点,再介绍实现细节,最后会附上相关源码实现💪。

文中错误烦请指正,不足之处欢迎提出建议😘。

完成项目初始化

为更好的理解和学习React项目的搭建过程和技巧,这里选择使用官方提供的create react app,在此基础上根据当前项目需求,进行项目初始化配置。

这里项目初始化分为以下步骤:

目录划分及创建-->引入相应依赖包-->初始化全局css样式-->完成路由处理模块

目录划分

  • src
    • api 「api定义及拦截器处理」
    • assets 「图片等静态资源」
    • basicUI 「基础UI组件」
    • components 「自定义封装组件」
    • conifg 「项目配置文件(主题、样式、导航等配置)」
    • layouts 「布局组件」
    • routes 「路由配置」
    • store 「redux相关(此次内容不涉及)」
    • views 「页面组件」

依赖引入

梳理本次项目中使用到的依赖包

PS: 部分依赖是项目开发过程中加入,初始化搭建项目时仅引入已知所需依赖即可。

react 核心依赖

  • react 「react核心依赖」
  • react-dom 「react-dom关联核心依赖」
  • react-scripts 「react开发、运行等相关配置依赖」
  • react-router-config 「提供路由静态配置」
  • react-router-dom 「react-dom的增强,提供基础路由容器组件及路由操作能力」

第三方组件依赖

  • react-lazyload 「懒加载组件」
  • styled-components 「样式组件」
  • semantic-ui-react 「语义化react ui库」
  • react-infinite-scroller 「无限滚动组件」
  • react-transition-group 「切换动画组件」

其他

  • axios 「请求处理」

样式引入

为实现不同浏览器中H5标签拥有相同样式表现,应当统一初始化所有标签样式,这里结合styled-componentscreateGlobalStyle创建全局初始化样式。

src/style.js

import { createGlobalStyle } from 'styled-components'
// 创建全局样式
export const GlobalStyle = createGlobalStyle`
  body, h1, h2, h3, h4, h5, h6, hr, p, blockquote, dl, dt, dd, ul, ol, li, pre, form, fieldset, legend, button, input, textarea, th, td { margin:0; padding:0; }
  body, button, input, select, textarea { font: 100% inherit; }
  h1, h2, h3, h4, h5, h6{ font-size:100%; }
  address, cite, dfn, em, var { font-style:normal; }
  code, kbd, pre, samp { font-family: couriernew, courier, monospace; }
  small{ font-size:12px; }
  ul, ol { list-style:none; }
  a { text-decoration:none; cursor: pointer; }
  a:hover { text-decoration:none; }
  sup { vertical-align:text-top; }
  sub{ vertical-align:text-bottom; }
  legend { color:#000; }
  fieldset, img { border:0; }
  button, input, select, textarea { font-size:100%; }
  table { border-collapse:collapse; border-spacing:0; }
`

App.js中引入该全局样式组件即可。

src/App.js

import React from 'react'
import { GlobalStyle } from './style' // init global css style

function App() {
  return (
    <div className="App">
        <GlobalStyle/>
    </div>
  )
}

export default App

路由搭建

路由搭建前的准备

进行路由配置前,先实现BlankLayoutBasicLayout两个布局组件。

因该项目较为简单,所有组件均使用React.memo进行浅比较,防止非必要的渲染,后文不再赘述。

  • BlankLayout (用于匹配初始路由,直接渲染路由对应的内容)

/src/layouts/BlankLayout.js

import React from 'react'
import { renderRoutes } from 'react-router-config'

const BlankLayout = ({route}) => {
  return (99
    <>{renderRoutes(route.routes)}</>
  )
}

export default React.memo(BlankLayout)
  • BasicLayout(用于创建页面通用布局)

这里后续会引入Sticky组件和createRef(用于Sticky挂载目标元素)来固定Nav(顶部导航栏,下文详细讲解),Footer(自定义页脚信息)组件充当页脚信息,内容区域设置最小高度80vh并渲染匹配的子路由对应页面。

/src/layouts/BasicLayout.js

import React, { createRef } from 'react'
import { renderRoutes } from 'react-router-config'
import Nav from '../components/Nav'
import Footer from '../components/Footer'
import navConfig from '../config/nav'
import { Sticky } from 'semantic-ui-react'

function BasicLayout (props) {
  const contextRef = createRef()
  const { route } = props
  
  return (
    <div ref={contextRef}>
      <Sticky context={contextRef}>
        <Nav data={navConfig}/>
      </Sticky>
      <div style={{ minHeight: '80vh' }}>
        {renderRoutes(route.routes)}
      </div>
      <Footer/>
    </div>
  )
}

export default React.memo(BasicLayout)

路由静态配置

首先引入lazy Suspense实现路由懒加载延迟加载回调,并引入自定义CustomPlaceholder组件(未防止闪屏,这里用占位组件替代全局遮罩Loading)实现路由首次加载效果。

引入Redirect实现根路由重定向,引入BlankLayoutBasicLayout分别对应初始路由和创建页面通用布局。

为后续实现选择壁纸种类后刷新页面可正确显示对应种类壁纸信息,这里采用路由传参方式实现壁纸页面路由。

最后引入404页面捕获当前无法正确匹配的路由。

附: React路由传参对比

src/router/index.js

import React, { lazy, Suspense } from 'react'
import { Redirect } from 'react-router-dom'
import BlankLayout from '../layouts/BlankLayout'
import BasicLayout from '../layouts/BasicLayout'
import CustomPlaceholder from '../basicUI/Placeholder'

// 延迟加载回调
const SuspenseComponent = Component => props => {
  return (
    <Suspense fallback={ <CustomPlaceholder /> }>
      <Component {...props}></Component>
    </Suspense>
  )
}

// 组件懒加载
const PageWallPaper = lazy(() => import('../views/WallPaper'))
const PageAbout = lazy(() => import('../views/About'))
const Page404 = lazy(() => import('../views/404'))

export default [
  {
    component: BlankLayout,
    routes: [
      {
        path: "/",
        component: BasicLayout,
        routes: [
          {
            path: "/",
            exact: true, // 是否精确匹配
            render: () => <Redirect to={"/wallpaper/5"} />
          },
          {
            path: "/wallpaper/:id",
            exact: true,
            component: SuspenseComponent(PageWallPaper)
          },
          // ...等其他页面
          {
            path: "/*",
            exact: true,
            component: SuspenseComponent(Page404)
          }
        ]
      }
    ]
  }
]

实现顶部导航栏

设计构思

为方便后续调整顶部导航栏信息,考虑设计为可灵活扩展的组件。

参考常见顶部导航栏设计,考虑将顶部导航栏分为两种状态:

  • 宽度足够情况下(匹配PC,Pad等大屏设备)横向展示导航菜单信息
    • 网站Icon及标题信息
    • 左侧菜单信息(支持下拉分级菜单)
    • 右侧菜单信息(支持下拉分级菜单)
    • 支持站内导航和外部链接跳转
  • 宽度不足时(匹配Phone等移动设备)下拉展示导航菜单信息
    • 隐藏左、右侧菜单信息,并显示展开菜单图标
    • 提供全屏导航菜单显示
    • 支持站内导航和外部链接跳转

考虑到顶部导航栏配置信息较多,因此抽离Nav配置文件及说明至/src/config/nav.js中。

导航栏配置详情可参考:nav配置

细节实现

Nav组件

在顶部导航栏组件中,首先定义getActiveItemByPathName的方法用来根据路由信息比对菜单项信息,获取当前路由对应激活的菜单项。通过selectActiveItem对其调用后返回activeItem的初始值,这里就实现了激活菜单项的初始化操作。

接着引入useStatehook中定义组件状态)并定义activeItemphoneNavShow两个组件状态,分别对应当前激活的菜单项的key和控制是否显示移动端菜单组件。之后定义监听窗口变化(使用媒体查询函数)方法,并在useEffect中启用监听函数(别忘记销毁时移除该监听函数),至此两种状态的切换逻辑基本完成。

定义了handleMenuClick方法处理菜单子项点击逻辑,分为外链URl站内URL,分别对应打开新窗口和设置激活菜单项、进行路由跳转的逻辑。

最后是menuView完成菜单子项的渲染,及总体布局代码的render实现,主要逻辑为通过phoneNavShow控制渲染大屏状态下的组件还是移动端的PhoneNav组件(下面即将介绍)。别忘记引入withRouter包裹以提供路由跳转支持。

src/components/Nav/index.js

import React, { useState, useEffect } from 'react'
import { Dropdown, Menu } from 'semantic-ui-react'
import { withRouter } from 'react-router-dom'
import PhoneNav from './PhoneNav'

function Nav (props) {
  // 根据path获取activeItem
  const getActiveItemByPathName = (menus, pathname) => {
    let temp = ''
    menus.map((item) => {
      // 存在子菜单项
      if (item.subitems && item.subitems.length > 0) {
        item.subitems.map((i) => {
          if (i.href === pathname) {
            temp = i.key
            return
          }
        })
      }
      if (item.href === pathname) {
        temp = item.key
        return
      }
    })
    return temp
  }

  const selectActiveItem = () => {
    const pathname = props.location.pathname
    const val = getActiveItemByPathName(props.data.leftMenu, pathname)
    return val === '' ? getActiveItemByPathName(props.data.rightMenu, pathname) : val
  }

  const [activeItem, setActiveItem] = useState(selectActiveItem())
  const [phoneNavShow, setPhoneNavShow] = useState(false)

  const x = window.matchMedia('(max-width: 900px)')
  // 监听窗口变化 过窄收起侧边栏 过宽展开侧边栏
  const listenScreenWidth = (x) => {
    if (x.matches) { // 媒体查询
      setPhoneNavShow(false)
    } else {
      setPhoneNavShow(true)
    }
  }

  useEffect(() => {
    listenScreenWidth(x) // 执行时调用的监听函数
    x.addListener(listenScreenWidth) // 状态改变时添加监听器
    return () => {
      x.removeListener(listenScreenWidth) // 销毁时移除监听器
    }
  }, [x])

  const handleMenuClick = (menu) => {
    if (menu.externalLink) {
      window.open(menu.href)
    } else {
      setActiveItem(menu.key)
      props.history.push(menu.href)
    }
  }

  // 根据菜单配置信息遍历生成菜单组
  const menuView = (menus) => {
    return menus.map((item) => {
      return item.subitems && item.subitems.length ?
        (
        <Dropdown key={item.key} item text={item.title} style={{ color: props.data.textColor }}>
          <Dropdown.Menu>
            {
              item.subitems.map((i) => {
                return (
                  <Dropdown.Item onClick={ () => handleMenuClick(i) } key={i.key}>
                    {i.title}
                  </Dropdown.Item>
                )
              })
            }
          </Dropdown.Menu>
        </Dropdown>
      ) :
      (
        <Menu.Item key={item.key}
          active={activeItem === item.key}
          style={{ color: props.data.textColor }}
          onClick={ () => handleMenuClick(item) }
        >
          { item.title }
        </Menu.Item>
      )
    })
  }

  return (
    <Menu size='huge' style={{ padding: '0 4%', background: 'black' }}
      color={props.data.activeColor} pointing secondary
    >
      <Menu.Item header>
        <img style={{ height: '18px', width: '18px' }} src={props.data.titleIcon}/>
        <span style={{ color: 'white', marginLeft: '10px' }}>
          { props.data.titleText }
        </span>
      </Menu.Item>
      { phoneNavShow ? (
        <>
          <Menu.Menu position='left'>
            { menuView(props.data.leftMenu) }
          </Menu.Menu>
          <Menu.Menu position='right'>
            { menuView(props.data.rightMenu) }
          </Menu.Menu>
        </>
      ) : (
        <Menu.Menu position='right'>
          <Menu.Item>
            <PhoneNav data={props.data} handlePhoneNavClick={menu => handleMenuClick(menu)}></PhoneNav>
          </Menu.Item>
        </Menu.Menu>
        )
      }
    </Menu>
  )
}

export default withRouter(React.memo(Nav))

PhoneNav组件

PhoneNav组件中,首先引入useState并声明了activeIndexvisible两个组件状态,分别表示当前需要激活的菜单组展开项、是否显示全局下拉菜单

接着定义showPhoneNavWrapper方法实现对展开菜单按钮的动画实现及控制全局下拉菜单的显示。定义handleMenuClick方法实现对全局下拉菜单子项点击处理,这里通过回调父组件菜单点击方法实现,并隐藏当前全局下拉菜单

最后是menuView完成菜单子项的渲染,及总体布局代码的render实现(整体思路和父组件类似)。

src/components/Nav/index.js

import React, { useState } from 'react'
import { PhoneNavBt, PhoneNavWrapper } from './style'
import { Icon, Menu, Accordion, Transition } from 'semantic-ui-react'
import { withRouter } from 'react-router-dom'

function PhoneNav (props) {
  const [activeIndex, setActiveItem] = useState('')
  const [visible, setVisible] = useState(false)
  const emList = document.getElementsByClassName('phone-nav-em')

  const showPhoneNavWrapper = () => {
    setVisible(!visible)
    if (visible) {
      emList[0].style.transform = ''
      emList[1].style.transition = 'all 0.5s ease 0.2s'
      emList[1].style.opacity = '1'
      emList[2].style.transform = ''
    } else {
      emList[0].style.transform = 'translate(0px,6px) rotate(45deg)'
      emList[1].style.opacity = '0'
      emList[1].style.transition = ''
      emList[2].style.transform = 'translate(0px,-6px) rotate(-45deg)'
    }
  }

  const handleMenuClick = (menu) => {
    props.handlePhoneNavClick(menu)
    setVisible(false)
    emList[0].style.transform = ''
    emList[1].style.transition = 'all 0.5s ease 0.2s'
    emList[1].style.opacity = '1'
    emList[2].style.transform = ''
  }

  const menuView = (menus) => {
    return menus.map((item) => {
      return item.subitems && item.subitems.length ?
        (
          <Accordion key={item.key} styled inverted style={{ background: 'black', width: '100%'}}>
            <Accordion.Title
              as={Menu.Header}
              active={activeIndex === item.key}
              index={0}
              onClick={() => setActiveItem(activeIndex === item.key ? '-1' : item.key)}
            >
              <Icon name='dropdown' />
              { item.title }
            </Accordion.Title>
            {
              item.subitems.map((i) => {
                return (
                  <Accordion.Content style={{padding: '0px'}} key={i.key} active={activeIndex === item.key}>
                    <Menu.Item style={{ paddingLeft: '3rem', color: props.data.textColor, background: '#1B1C1D' }}
                      onClick={() => handleMenuClick(i) }>
                      { i.title }
                    </Menu.Item>
                  </Accordion.Content>
                )
              })
            }
          </Accordion>
        )
      :
      (
        <Menu.Item style={{ color: props.data.textColor }} onClick={() => handleMenuClick(item) } key={item.key}>
          { item.title }
        </Menu.Item>
      )
    })
  }

  return (
    <>
      <PhoneNavBt onClick={ showPhoneNavWrapper }>
        <em className='phone-nav-em'></em>
        <em className='phone-nav-em'></em>
        <em className='phone-nav-em'></em>
      </PhoneNavBt>
      <Transition visible={visible} animation='fade' duration={500}>
        <PhoneNavWrapper>
          <Menu style={{ width: '100%' }} inverted size='huge' vertical>
            { menuView(props.data.leftMenu) }
            { menuView(props.data.rightMenu) }
          </Menu>
        </PhoneNavWrapper>
      </Transition>
    </>
  )
}

export default React.memo(PhoneNav)

Nav配置文件

导航栏配置详情可参考:nav配置

实现主页面加载(核心)

设计构思

为便于组件的复用、扩展和升级,以及对不同分辨率设备的兼容,这里考虑拆分为以下功能模块组件。

  • 壁纸种类导航菜单(壁纸分类及选择)
  • 图片列表布局(管理图片的布局方式,提供懒加载)
  • 图片组件(提供占位图片支持、加载过渡动画)
  • 大图预览组件(提供全屏大图预览功能)
  • 下载组件(提供分辨率选择及图片下载)

为进一步明确、细分各组件的功能,借助思维导图完成对各组件功能逻辑的梳理,如下图:

组件逻辑思维导图

细节实现

壁纸种类导航菜单

该组件较为简单,遍历父组件传递的props.data,渲染对应子菜单内容即可,后续可结合Redux实现主题切换功能。

/src/components/MenuBar/index.js

import React from 'react'
import { Menu } from 'semantic-ui-react'

function MenuBar (props) {
  return (
    <>
      {
        props.data.length ?
          <Menu secondary compact size='mini' style={{ background: 'white', width: '100%', overflow: 'auto' }}>
            {
              props.data.map((item, index) => {
                return (
                  <Menu.Item onClick={() => props.onMenuClick(item)} key={index}>
                    {item.title}
                  </Menu.Item>
                )
              })
            }
          </Menu> : null
      }
    </>
  )
}

export default React.memo(MenuBar)

图片列表组件

这里根据设备宽度计算ImgView组件包裹容器的宽和高(ImgView会自动填充包裹容器),以确保在不同大小的设备下图片显示大小适中。

然后使用LazyLoad懒加载组件,设置在滚动至屏幕可视区域下200px时加载图片,以保证未下拉时仅加载当前窗口下的图片,最后将图片的地址和标签传给ImgView组件。

/src/basicUI/ImgListView/index.js

import React from 'react'
import LazyLoad from 'react-lazyload'
import ImgView from '../ImgView'
import { ImgListViewWrap, ImgViewWrap } from './style'

function ImgListView (props) {
  const imgList = props.data

  const width = (1 / (document.body.clientWidth / 360) * document.body.clientWidth).toFixed(3)
  const height = (width * 0.5625).toFixed(3)

  return (
    <ImgListViewWrap>
      {
        imgList.length > 0 ? imgList.map((item) => {
          return (
            <ImgViewWrap key={item.id} width={ width + 'px' } height={ height + 'px' }>
              <LazyLoad height={'100%'} offset={200} >
                <ImgView
                  key={item.id}
                  onPreviewClick={() => props.handlePreview(item)}
                  onDownloadClick={() => props.handleDownload(item)}
                  url={item.url} tag={ item.utag }
                  />
              </LazyLoad>
            </ImgViewWrap>
          )
        }) : null
      }
    </ImgListViewWrap>
  )
}

export default React.memo(ImgListView)

图片组件

首先通过对url的过滤获取低分辨率图片地址(即缩略图),以减少图片数据请求量。

render中主要包含以下部分:

  • 实现占位图显示
  • 图片加载渐变效果
  • 图片描述、按钮的蒙层

关于占位图片,初始状态时设置占位图片为绝对定位、默认显示,目标图片透明度为0。通过useState声明isLoaded表示目标图片是否加载完成,通过对onLoad事件的监听,修改isLoaded的状态,此时隐藏占位图片,修改目标图片透明度为1,至此完成加载成功后的切换(这里使用useCallback缓存内联函数,防止组件更新重复创建匿名函数)。

图片首次加载通过CSSTransition组件,自定义fade的动画样式,通过透明度的变化实现过度效果。

图片蒙层使用绝对定位至于ImgView下方,其中加入预览、下载按钮的点击回调。

/src/basicUI/ImgView/index.js

import React, { useState, useCallback } from 'react'
import { Image, Icon } from 'semantic-ui-react'
import { CSSTransition } from 'react-transition-group'
import { ImgWrap } from './style'
import loadingImg from './loading.gif'
import './fade.css'

function ImgView (props) {
  const { url, tag } = props

  const [isLoaded, setIsLoaded] = useState(false)
  
  // cache memoized version of inline callback
  const handleLoaded = useCallback(() => {
    setIsLoaded(true)
  }, [])

  const filterUrl = () => {
    const array = url.split('/bdr/__85/')
    // 过滤url为低分辨率图片,防止加载时间较长
    return array.length !== 2 ? url : array[0] + '/bdm/640_360_85/' + array[1]
  }

  // 正式Image未加载之前没有高度信息
  return (
    <ImgWrap>
      <Image hidden={ isLoaded } className='img-placeholder' src={ loadingImg } rounded />
      <CSSTransition
        in={true}
        classNames={'fade'}
        appear={true}
        key={1}
        timeout={300}
        unmountOnExit={true}
        >
        <Image onLoad={() => setIsLoaded(true)} style={{ opacity: isLoaded ? 1 : 0 }}
          src={ filterUrl() } title={ tag } alt={ tag } rounded />
    </CSSTransition>
      <div className='dim__wrap'>
        <span className='tag'>{ tag }</span>
        <Icon onClick={ () => props.onPreviewClick() } name='eye' color='orange' />
        <Icon onClick={ () => props.onDownloadClick() } name='download' color='teal' src={ filterUrl() } />
      </div>
    </ImgWrap>
  )
}

export default React.memo(ImgView)

大图预览组件

预览组件较为简单,在全局遮罩下显示图片和标签信息即可。

/src/basicUI/ImgPreview/index.js

function ImgPreview (props) {
  const { url, utag } = props.previewImg

  return (
    <Dimmer active={ props.visible } onClick={props.handleClick} page>
      <Image style={{ maxHeight: '90vh' }} src={ url } title={ utag } alt={ utag } />
    </Dimmer>
  )
}

下载组件

首先封装了图片下载的工具类,接收图片地址和下载后的文件名称两个参数。通过发送图片地址请求,并设置返回类型为blob,再利用<a>标签进行下载即可。

Tip: 由于Safari的安全机制,无法进行blob的相关读写操作,因此该方法在Safari中无法使用,应在下载组件中判断是否为Safari浏览器,并提醒用户。

/src/basicUI/DownloadModal/download.js

function download (url, fileName) {
  const x = new XMLHttpRequest()
  x.responseType = 'blob'
  x.open('GET', url, true)
  x.send()
  x.onload = () => {
    const downloadElement = document.createElement('a')
    const href = window.URL.createObjectURL(x.response) // create download url
    downloadElement.href = href
    downloadElement.download = fileName // set filename (include suffix)
    document.body.appendChild(downloadElement) // append <a>
    downloadElement.click() // click download
    document.body.removeChild(downloadElement) // remove <a>
    window.URL.revokeObjectURL(href) // revoke blob
  }
}

对于下载组件,根据下载配置文件(src/config/download_options.js)生成下载列表选项,在点击下载后,进行Safari判断和提示,并根据下载配置拼接对应分辨率图片地址进行下载。

下载分辨率配置详情可参考:下载分辨率配置

/src/basicUI/DownloadModal/index.js

function DownloadModal (props) {
  const { url, utag } = props.downloadImg

  const handleDownload = (param) => {
    // Safari Tip
    if (/Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)) {
      alert('抱歉😅!暂不支持Safari下载!请手动保存照片!')
      return
    }
    const array = url.split('/bdr/__85/')
    array.length === 2 ? download(array[0] + param + array[1], utag + '.jpg') : download(url, utag + '.jpg')
  }

  return (
    <Modal basic dimmer={ 'blurring' } open={ props.visible }>
      <Header icon='browser' content='download options' />
      <Modal.Content>
        <List verticalAlign='middle'>
          { downloadOptions.length > 0
            ? downloadOptions.map((item, index) => {
            return (
              <List.Item key={ index }>
                <List.Content floated='right'>
                  <Button onClick={ () => handleDownload(item.filterParam) }
                    basic color='green' icon='download' inverted size='mini' />
                </List.Content>
                <List.Content>
                  <Label>{ item.desc }</Label>
                </List.Content>
              </List.Item>
            )
          }) : null }
        </List>
      </Modal.Content>
      <Modal.Actions>
        <Button onClick={ () => props.onClose() } color='green' inverted>
          OK
        </Button>
      </Modal.Actions>
    </Modal>
  )
}

export default React.memo(DownloadModal)

WallPaper页面

PageWallPaper中会加载图片相关组件,并完成对图片加载、请求等逻辑的控制。

为更加清晰的介绍,这里拆解为render逻辑处理两块进行介绍:

  • render介绍

render方法中,首先使用Sticky组件固定MenuBar组件至导航栏下方,将壁纸种类列表typeList传给该组件,并使用changeImgType完成对点击壁纸种类切换的处理。

然后使用InfiniteScroll包裹ImgListView组件,其中ImgListView处理预览、下载按钮点击事件,并接收图片列表imgList。无限加载组件InfiniteScroll中根据isLoading(是否正在加载)、isFinished(是否全部加载完成)、imgList.length(是否图片列表为空)判断是否需要支持更多信息加载(即是否滚动会触发loadMore回调)。loadMore中实现加载更多图片。

最后根据isLoadingisFinished 控制是否显示正在加载、加载完成等用户提示。 通过引入ImgPreviewDownloadModal实现大图预览和图片下载的支持。

src/views/WallPaper/index.js => render

function PageWallPaper (props) {
  <!-- 这里仅展示render,逻辑处理部分后文介绍 -->
  return (
    <div ref={contextRef}>
      {/* img type menu */}
      <Sticky context={contextRef} offset={48} styleElement={{ zIndex: '10' }}>
        <MenuBar onMenuClick={ changeImgType } data={typeList} />
      </Sticky>
      {/* loading img (infinity) */}
      <InfiniteScroll
        initialLoad
        pageStart={0}
        loadMore={ () => loadMoreImgs() }
        hasMore={ !isLoading && !isFinished && imgList.length !== 0 }
        threshold={50}
      >
        <ImgListView
          handlePreview={ handlePreviewImg }
          handleDownload = { handleDownloadImg }
          data={ imgList }
          />
      </InfiniteScroll>
      { isLoading ? <CustomPlaceholder /> : null }
      { isFinished ? <h1 style={{ textAlign: 'center' }}>所有图片已加载完成!✨</h1> : null }
      {/* img preview */}
      <ImgPreview handleClick={ hideImgPreview } visible={ isPreview } previewImg={ currentImg } />
      {/* download options */}
      <DownloadModal onClose={ hideDownloadModal } visible={ isDownload } downloadImg={ currentImg } />
    </div>
  )
}
  • 图片加载逻辑控制

首先通过useState定义多种组件状态和初始状态,分别有查询条件、图片是否正在加载、是否显示预览、是否显示下载、是否加载完成全部图片、当前选中图片信息、图片列表、种类列表(详情请看代码注释),通过createRef对节点的引用完成sticky组件的挂载点。

接下来使用useEffect完成相关副作用,这里使用两个useEffect实现关注点的分离。

第一个useEffect中,第二个参数为[],即模拟类似componentDidMount生命周期效果,这里通过getTypes()获取壁纸类型。

第二个useEffect中,第二个参数为[queryInfo],即queryInfo发生改变后,调用updateImgList()方法更新图片列表。

对于getTypes()updateImgList()的实现,通过axios发送请求并将正常的结果保存至对应组件状态中。 在updateImgList()中,若返回图片列表为空,则说明所有图片都加载完成,此时设置isFinishedtrue,否则通过Array.concat()合并新旧图片列表并保存至imgList中,最后修改加载状态为fasle

在壁纸种类点击的回调changeImgType()中,判断若不是当前页面对应的壁纸种类,则进行页面跳转(需引入withRouter支持),然后设置返回页面顶部,并恢复组件的初始状态,其中修改查询对象queryInfotype状态。

对于滚动列表的加载回调loadMoreImgs中,设置isLoadingtrue,并修改queryInfo的查询参数,此时会出发第二个useEffect的副作用,完成图片列表的更新。

最后是通用useCallback缓存相关内联函数,防止组件更新重复创建匿名函数,以提升性能。

src/views/WallPaper/index.js

import React, { useState, useEffect, createRef, useCallback } from 'react'
import { withRouter } from 'react-router-dom'

import { getCategories, getPictureList } from '../../api/getData'

function PageWallPaper (props) {
  const [queryInfo, setQueryInfo] = useState({type: props.match.params.id || 5, start: 0, count: 30}) // query info
  const [isLoading, setIsLoading] = useState(true) // is loading img
  const [isPreview, setIsPreview] = useState(false) // is preview img
  const [isDownload, setIsDownload] = useState(false) // is download modal show
  const [isFinished, setIsFinished] = useState(false) // is all img loading finished

  const [currentImg, setCurrentImg] = useState({}) // current img info
  const [imgList, setImgList] = useState([])
  const [typeList, setTypeList] = useState([])

  const contextRef = createRef()

  useEffect(() => {
    getTypes()
  }, [])

  useEffect(() => {
    updateImgList()
  }, [queryInfo])

  const getTypes = async () => {
    const res = await getCategories()
    if (res.data) {
      setTypeList(res.data.data)
    }
  }
  // update img list
  const updateImgList = async () => {
    const res = await getPictureList({...queryInfo})
    if (res.data) {
      if (res.data.data.length === 0) {
        setIsFinished(true)
      } else {
        setImgList(imgList.concat(res.data.data))
      }
      setIsLoading(false)
    }
  }

  const changeImgType = (item) => {
    if (item.key !== queryInfo.type) {
      props.history.push('/wallpaper/' + item.key)
    }
    document.body.scrollTop = 0
    document.documentElement.scrollTop = 0
    // init state
    setImgList([])
    setIsLoading(true)
    setIsFinished(false)
    setQueryInfo({...queryInfo, type: item.key })
  }

  const loadMoreImgs = () => {
    setIsLoading(true)
    setQueryInfo({...queryInfo, start: queryInfo.start + queryInfo.count})
  }

  // cache memoized version of inline callback
  // click preview
  const handlePreviewImg = useCallback((img) => {
    setCurrentImg(img)
    setIsPreview(true)
  }, [])

  // click download
  const handleDownloadImg = useCallback((img) => {
    setCurrentImg(img)
    setIsDownload(true)
  }, [])

  // hide ImgPreview
  const hideImgPreview = useCallback(() => {
    setIsPreview(false)
  }, [])
  
  // hide DownloadModal
  const hideDownloadModal = useCallback(() => {
    setIsDownload(false)
  }, [])
}

export default withRouter(React.memo(PageWallPaper))

至此壁纸页面的设计、加载逻辑开发完成,后续会继续优化图片加载效果、逻辑解耦等。

页脚、异常页

最后完成页脚和异常页的开发,页面可根据个人喜好进行设计,主要以样式为主,与hook的关联不多,这里不再赘述。

页脚配置详情可参考:footer配置

结语

以上就是对此次在线壁纸前端实现的介绍,既可以帮助了解React项目的基础搭建流程,也巩固了Hook的使用,也在组件设计、拆分的过程中增加自己的理解与思考。

文章中如有疏漏、错误,欢迎指出。

项目仍在完善更新中,欢迎大家提出建议和灵感。

🎉该项目已开源至 github 欢迎下载使用 后续会完善更多功能 🎉 源码及项目说明

Tip: 喜欢的话别忘记 star 哦😘,有疑问🧐欢迎提出 issues ,积极交流。

其他文章:

从零开始 Node实现前端自动化部署

开源⚡ auto-deploy-app自动化构建部署工具