從零開始 React Hook 實現在線壁紙網站 前言 效果展示 開始 使用 結語

更新

🎉圖片現已支持 -多分辨率- 下載(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

效果展示

開始

下面會通過兩方面介紹項目的搭建流程,即項目的初始化工作和項目(組件)的正式開發😁。

在介紹過程中,會首先闡述設計的構思和關注點,再介紹實現細節,最後會附上相關源碼實現💪。

文中錯誤煩請指正,不足之處歡迎提出建議😘。

完成項目初始化

爲更好的理解和學習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)

路由靜態配置

首先引入lazySuspense實現路由懶加載延遲加載回調,並引入自定義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配置

使用

  • 本地運行
- git clone
- yarn
- yarn start
  • 部署服務器

完成nginx配置後,結合 從零開始 Node實現前端自動化部署
體驗更佳。

結語

以上就是對此次在線壁紙前端實現的介紹,既可以幫助瞭解React項目的基礎搭建流程,也鞏固了Hook的使用,也在組件設計、拆分的過程中增加自己的理解與思考。

文章中如有疏漏、錯誤,歡迎指出。

項目仍在完善更新中,歡迎大家提出建議和靈感。

🎉該項目已開源至 github 歡迎下載使用 後續會完善更多功能 🎉
源碼及項目說明

Tip: 喜歡的話別忘記 star 哦😘,有疑問🧐歡迎提出 issues ,積極交流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章