更新
🎉圖片現已支持 -多分辨率- 下載(Safari暫不支持)
🎉適配PC、Pad、Phone多種分辨率設備,持續更新中!
前言
前段時間學習了React
的相關知識,嘗試使用Class Component
和 Hook
兩種方式進行項目實踐,其中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
「頁面組件」
- api
依賴引入
梳理本次項目中使用到的依賴包
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-components
的createGlobalStyle
創建全局初始化樣式。
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
路由搭建
路由搭建前的準備
進行路由配置前,先實現BlankLayout
和BasicLayout
兩個佈局組件。
因該項目較爲簡單,所有組件均使用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
實現根路由重定向,引入BlankLayout
、BasicLayout
分別對應初始路由和創建頁面通用佈局。
爲後續實現選擇壁紙種類後刷新頁面可正確顯示對應種類壁紙信息,這裏採用路由傳參方式實現壁紙頁面路由。
最後引入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
的初始值,這裏就實現了激活菜單項的初始化操作。
接着引入useState
(hook
中定義組件狀態)並定義activeItem
和phoneNavShow
兩個組件狀態,分別對應當前激活的菜單項的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
並聲明瞭activeIndex
和visible
兩個組件狀態,分別表示當前需要激活的菜單組展開項、是否顯示全局下拉菜單
。
接着定義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
中實現加載更多圖片。
最後根據isLoading
、isFinished
控制是否顯示正在加載、加載完成等用戶提示。
通過引入ImgPreview
、DownloadModal
實現大圖預覽和圖片下載的支持。
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()
中,若返回圖片列表爲空,則說明所有圖片都加載完成,此時設置isFinished
爲true
,否則通過Array.concat()
合併新舊圖片列表並保存至imgList
中,最後修改加載狀態爲fasle
。
在壁紙種類點擊的回調changeImgType()
中,判斷若不是當前頁面對應的壁紙種類,則進行頁面跳轉(需引入withRouter
支持),然後設置返回頁面頂部,並恢復組件的初始狀態,其中修改查詢對象queryInfo
的type
狀態。
對於滾動列表的加載回調loadMoreImgs
中,設置isLoading
爲true
,並修改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
,積極交流。