更新css
🎉图片现已支持 -多分辨率- 下载(Safari暂不支持)html
🎉适配PC、Pad、Phone多种分辨率设备,持续更新中!前端
前段时间学习了React
的相关知识,尝试使用Class Component
和 Hook
两种方式进行项目实践,其中Class Component
的使用主要围绕生命周期展开,Hook
是比较新的函数式实现方式,弱化了生命周期的存在,多是React
将来主推的方式。react
尝试使用了官方提供的create react app
和蚂蚁提供的umi
进行项目搭建,create react app
仅提供最为基础的react
项目打包和运行配置(路由等相关配置需本身实现),而umi
提供开箱即用的详细配置(包括css预处理的选择、第三方UI框架的引入、自动化路由构建的封装),可根据需求状况灵活选择。ios
使用了ant design react
等UI库,对于中后台项目的搭建很是友好,体验很棒。nginx
选择在线壁纸网站实现,一方面能够体验项目搭建的完整过程,还能够方便你们浏览和获取本身喜欢的壁纸。 (PS: 这样换壁纸比较方便😂)git
鉴于本次实现的项目为「在线壁纸网站」
,对比相关react ui
库,最终选择了semantic ui react
。github
优势以下:redux
暗黑模式
Tip:axios
本文主要介绍react hook
基础项目的搭建,后端基于Node
实现简单的接口转发和处理,本文暂不涉及redux
引入和后端实现,后续逐步更新。
壁纸来自360壁纸库
,在此表示感谢,仅用做学习交流,切勿用于商业用途。
相关文档地址:
react | create react app | umi | ant design react | semantic ui react
下面会经过两方面介绍项目的搭建流程,即项目的初始化工做和项目(组件)的正式开发😁。
在介绍过程当中,会首先阐述设计的构思和关注点,再介绍实现细节,最后会附上相关源码实现💪。
文中错误烦请指正,不足之处欢迎提出建议😘。
为更好的理解和学习React项目
的搭建过程和技巧,这里选择使用官方提供的create react app
,在此基础上根据当前项目需求,进行项目初始化配置。
这里项目初始化分为如下步骤:
目录划分及建立-->引入相应依赖包-->初始化全局css样式-->完成路由处理模块
「api定义及拦截器处理」
「图片等静态资源」
「基础UI组件」
「自定义封装组件」
「项目配置文件(主题、样式、导航等配置)」
「布局组件」
「路由配置」
「redux相关(这次内容不涉及)」
「页面组件」
梳理本次项目中使用到的依赖包
PS: 部分依赖是项目开发过程当中加入,初始化搭建项目时仅引入已知所需依赖便可。
react 核心依赖
「react核心依赖」
「react-dom关联核心依赖」
「react开发、运行等相关配置依赖」
「提供路由静态配置」
「react-dom的加强,提供基础路由容器组件及路由操做能力」
第三方组件依赖
「懒加载组件」
「样式组件」
「语义化react ui库」
「无限滚动组件」
「切换动画组件」
其余
「请求处理」
为实现不一样浏览器中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
进行浅比较,防止非必要的渲染,后文再也不赘述。
/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)
复制代码
这里后续会引入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)
}
]
}
]
}
]
复制代码
为方便后续调整顶部导航栏信息,考虑设计为可灵活扩展的组件。
参考常见顶部导航栏设计,考虑将顶部导航栏分为两种状态:
考虑到顶部导航栏配置信息较多,所以抽离Nav
配置文件及说明至/src/config/nav.js
中。
导航栏配置详情可参考: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
组件中,首先引入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配置
为便于组件的复用、扩展和升级,以及对不一样分辨率设备的兼容,这里考虑拆分为如下功能模块组件。
为进一步明确、细分各组件的功能,借助思惟导图完成对各组件功能逻辑的梳理,以下图:
该组件较为简单,遍历父组件传递的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)
复制代码
PageWallPaper
中会加载图片相关组件,并完成对图片加载、请求等逻辑的控制。
为更加清晰的介绍,这里拆解为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
,积极交流。