S13-07 React-项目:mr-airbnb
[TOC]
项目介绍
爱彼迎团队:
开发团队国内 200 多个人;
经历过数年版本迭代最终完成的一个产品;
爱彼迎目前的项目
已经不针对国内房东、订房进行维护,依然可以订阅国外的房屋;
爱彼迎已经退出中国市场,不排除有一天国内的网站不能继续访问;
项目本身确实非常美观、好看;
项目核心学习、掌握的知识点:
对于第一个 React 项目,我们的核心是对前面所学知识进行练习、实战;
掌握 React 开发的流程、模式、项目架构,项目中会有很多组件、工具等封装、抽取、复用思想;
最重要的是学习 React 开发的模式和编程的思想,而不是局限于我上课期间所讲的内容,并且大部分样式和布局内容需要大家课程自行完成;
在这个项目过程中,我会尽量将之前所学的所有知识都运用起来,但是我们不会为了用某个知识而用某个知识;
课程中会使用我服务器已经获取到的数据,一是国内的数据更好看,二是担心它数据有一天不再维护,三是我对数据已经进行了大量的整理;
后续我们还会专门学习 React+TypeScript 项目实战的内容,React 本身非常的灵活,对 JavaScript 本身要求也较高,但是最重要的还是练习;
项目规范
- 文件夹、文件名称统一小写、多个单词以连接符(-)连接;
- JavaScript 变量名称采用小驼峰标识,常量全部使用大写字母,组件采用大驼峰;
- CSS 采用普通 CSS 和 styled-components 结合来编写(全局采用普通 CSS、局部采用 styled-components);
- 整个项目不再使用 class 组件,统一使用函数式组件,并且全面拥抱 Hooks;
- 所有的函数式组件,为了避免不必要的渲染,全部使用 memo 进行包裹;
- 组件内部的状态,使用 useState、useReducer;业务数据全部放在 redux 中管理;
- 函数组件内部基本按照如下顺序编写代码:
- 组件内部 state 管理;
- redux 的 hooks 代码;
- 其他 hooks 相关代码(比如自定义 hooks);
- 其他逻辑代码;
- 返回 JSX 代码;
- redux 代码规范如下:
- redux 目前我们学习了两种模式,在项目实战中尽量两个都用起来,都需要掌握;
- 每个模块有自己独立的 reducer 或者 slice,之后合并在一起;
- redux 中会保存共享的状态、从服务器获取到的数据状态;
- 网络请求采用 axios
- 对 axios 进行二次封装;
- 所有的模块请求会放到一个请求文件中单独管理;
- 项目使用 AntDesign、MUI(Material UI)
- 爱彼迎本身的设计风格更多偏向于 Material UI,但是课程中也会尽量讲到 AntDesign 的使用方法;
- 项目中某些 AntDesign、MUI 中的组件会被拿过来使用;
- 但是大部分组件还是自己进行编写、封装、实现;
- 其他规范在项目中根据实际情况决定和编写;
创建 react 项目
1、创建项目:
create-react-app mr-airbnb2、项目配置:
配置项目的 icon
配置项目的标题
配置 jsconfig.json
3、通过 craco 配置别名和 less 文件:
安装:
安装 craco:
npm i @craco/craco@alpha -D安装 less:
npm i craco-less@2.1.0-alpha.0 -D创建文件:
craco.config.js配置别名
jsconst path = require('path') function resolve(pathname) { return path.resolve(__dirname, pathname) } module.exports = { webpack: { // 配置别名 alias: { '@': resolve('src') } } }配置 less
jsconst path = require('path') + const CracoLessPlugin = require('craco-less') function resolve(pathname) { return path.resolve(__dirname, pathname) } module.exports = { // 配置Less + plugins: [ + { + plugin: CracoLessPlugin + } + ], webpack: { // 配置别名 alias: { '@': resolve('src') } } }修改启动脚本
js"scripts": { + "start": "craco start", + "build": "craco build", + "test": "craco test", "eject": "react-scripts eject" },
目录结构
对项目进行目录结构的划分:

CSS 样式的重置
对默认 CSS 样式进行重置:
normalize.css
安装:
npm i normalize.cssreset.css
js@mainColor: #484848; body, button, dd, dl, dt, form, h1, h2, h3, h4, h5, h6, hr, input, li, ol, p, td, textarea, th, ul { padding: 0; margin: 0; } a { color: @mainColor; text-decoration: none; } img { vertical-align: top; } ul, ol, li { list-style-type: none; }
全家桶–Router 配置
1、安装:npm i react-router-dom
2、导入 HashRouter
import { HashRouter } from 'react-router-dom'
root.render(
<HashRouter>
<App />
</HashRouter>
)3、配置路由
import { lazy } from 'react'
import { Navigate } from 'react-router-dom'
import NotFound from '@/views/not-found'
// 路由懒加载
const Home = lazy(() => import('@/views/home'))
const Detail = lazy(() => import('@/views/detail'))
const Entire = lazy(() => import('@/views/entire'))
const routes = [
{ path: '/', element: <Navigate to="/home" /> },
{ path: '/home', element: <Home /> },
{ path: '/entire', element: <Entire /> },
{ path: '/detail', element: <Detail /> },
{ path: '*', element: <NotFound /> }
]
export default routes4、异步加载时使用 Suspense 包裹 App
root.render(
<Suspense fallback={<div>Loading...</div>}>
<HashRouter>
<App />
</HashRouter>
</Suspense>
)5、挂载路由
+ import { useRoutes } from 'react-router-dom'
+ import routes from './router'
const App = memo(() => {
return (
<div>
<div className="header">Header</div>
<div className="pages">
{/* 挂载路由 */}
+ {useRoutes(routes)}
</div>
<div className="footer">Footer</div>
</div>
)
})全家桶–Redux 状态管理
Redux 状态管理的选择:
- 普通方式:目前项目中依然使用率非常高;
- @reduxjs/toolkit 方式:推荐方式, 未来的趋势;
1、安装:
- 安装 RTK:
npm i @reduxjs/toolkit - 安装 react-redux:
npm i react-redux
2、创建 store
import { configureStore } from '@reduxjs/toolkit'
import homeReducer from './features/home'
const store = configureStore({
reducer: {
home: homeReducer
}
})
export default store3、创建 reducer
RTK 方式:
import { createSlice } from '@reduxjs/toolkit'
const homeSlice = createSlice({
name: 'home',
initialState: {
count: 100,
banners: []
},
reducers: {}
})
export default homeSlice.reducer普通方式:
constants.js
jsexport const CHANGE_MSG = 'change_msg'createAction.js
jsimport { CHANGE_MSG } from './constants' export function changeMsg(msg) { return { type: CHANGE_MSG, msg: msg } }reducer.js
jsimport { CHANGE_MSG } from './constants' const initialState = { msg: 'hi' } function reducer(state = initialState, action) { switch (action.type) { case CHANGE_MSG: return { ...state, msg: action.msg } default: return state } } export default reducerindex.js
jsimport detailReducer from './reducer' import { changeMsg } from './createAction' export { detailReducer, changeMsg }
4、导入 store 到组件树
root.render(
+(
<Provider store={store}>
<Suspense fallback={<div>Loading...</div>}>
<HashRouter>
+ <App />
</HashRouter>
</Suspense>
+{' '}
</Provider>
)
)网络请求-axios
1、安装:npm i axios
2、封装 axios
import axios from 'axios'
import { BASE_URL, TIMEOUT } from './config'
class MrRequest {
constructor(baseURL, timeout = 10000) {
this.instance = axios.create({ baseURL, timeout })
this.instance.interceptors.response.use(
(res) => {
return res.data
},
(err) => {
return err
}
)
}
request(config) {
return this.instance.request(config)
}
get(config) {
return this.request({ ...config, method: 'GET' })
}
post(config) {
return this.request({ ...config, method: 'POST' })
}
}
const mrRequest = new MrRequest(BASE_URL, TIMEOUT)
export default mrRequest3、配置
export const BASE_URL = 'http://codercba.com:1888/airbnb/api'
export const TIMEOUT = 100004、测试网络请求
const Home = memo(() => {
const [highScore, setHighScore] = useState({})
// 测试网络请求
useEffect(() => {
+ mrRequest.request({
+ url: '/home/highScore'
+ }).then(res => {
+ setHighScore(res)
+ })
+ }, [])
return (
<div>
<h3>Home</h3>
<div className="high-score">
<div className="title">{highScore.title}</div>
<div className="subtitle">{highScore.subtitle}</div>
<ul className="list">
{
highScore.list?.map(item => {
return (
<li key={item.id}>{item.name}</li>
)
})
}
</ul>
</div>
</div>
)
})集成 UI 组件库
MUI
安装
MUI + styled-components
npm i @mui/material @mui/styled-engine-sc styled-components配置 styled-components
module.exports = {
//...
+ resolve: {
+ alias: {
+ '@mui/styled-engine': '@mui/styled-engine-sc'
+ },
+ },
};使用 MUI


错误:
使用 button 时报错:

解决:
安装@emotion/react 、 @emotion/styled
npm i @mui/material @emotion/react @emotion/styledAntd
安装
npm i antd配置 antd + less
1、引入样式
@import "~antd/dist/antd.less"
// 或者
import 'antd/dist/antd.less'2、配置 webpack

使用 antd


组件
AppHeader

页面布局
const AppHeader = memo((props) => {
return (
<AppHeaderWrapper>
<HeaderLeft />
<HeaderCenter />
<HeaderRight />
</AppHeaderWrapper>
)
})HeaderLeft

const HeaderLeft = memo(() => {
return (
<HeaderLeftWrapper>
+ <img className="logo" src={require('@/assets/img/logo.png')} alt="" />
</HeaderLeftWrapper>
)
})HeaderCenter

const HeaderCenter = memo((props) => {
return (
<HeaderCenterWrapper>
<div className="text">搜索房源和体验</div>
<div className="search">
<IconSearch />
</div>
</HeaderCenterWrapper>
)
})HeaderRight

const HeaderRight = memo((props) => {
return (
<HeaderRightWrapper>
<div className="btns">
<span className="btn">登录</span>
<span className="btn">注册</span>
<span className="btn">
<IconGlobal />
</span>
</div>
<div className="profile">
<span className="icon icon-list">
<IconList />
</span>
<span className="icon icon-profile">
<IconProfile />
</span>
</div>
</HeaderRightWrapper>
)
})阴影动画效果

基本写法
.profile {
transition: box-shadow 200ms ease;
&:hover {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18);
}
}抽取动画效果
const theme = {
mixin: {
boxShadow: `
transition: box-shadow 200ms ease;
&:hover {
box-shadow: 0 2px 4px rgba(0,0,0,.18);
}
`
}
}使用动画效果
.profile {
width: 280px;
padding-left: 15px;
font-weight: 600;
+ ${props => props.theme.mixin.boxShadow}
}Profile 点击面板切换效果

1、布局
<div className="profile" onClick={e => setIsPanelShow(true)}>
<span className='icon icon-list'>
<IconList />
</span>
<span className='icon icon-profile'>
<IconProfile />
</span>
+ {/* 面板 */}
+ <div className="panel">
<div className="top">
<div className="item register">注册</div>
<div className="item login">登录</div>
</div>
<div className="bottom">
<div className="item">出租房源</div>
<div className="item">开展体验</div>
<div className="item">帮助</div>
</div>
</div>
</div>2、点击切换显示和隐藏
const HeaderRight = memo((props) => {
const [isPanelShow, setIsPanelShow] = useState(false)
{/* 面板 */}
+ {isPanelShow && (
<div className="panel">
...
</div>
)}
})3、点击 Profile 显示
<div className="profile" onClick={(e) => setIsPanelShow(true)}>4、监听 window 点击,隐藏面板
注意: 需要在 addEventListener 最后的参数设置 true,表示捕获模式,这样点击 window 时才会隐藏面板
useEffect(() => {
function windowClickHandle() {
setIsPanelShow(false)
}
window.addEventListener('click', windowClickHandle, true)
return () => {
window.removeEventListener('click', windowClickHandle, true)
}
}, [])在网页中嵌入 SVG 图片
1、将 svg 保存到组件中,并导出
import React, { memo } from 'react'
import styleStrToObj from '../utils'
const IconMenu = memo(() => {
return (
<svg
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
role="presentation"
focusable="false"
style={styleStrToObj(
'display: block; fill: none; height: 100%; width: 100%; stroke: currentcolor; stroke-width: 3; overflow: visible;'
)}
>
<g fill="none" fillRule="nonzero">
<path d="m2 16h28"></path>
<path d="m2 24h28"></path>
<path d="m2 8h28"></path>
</g>
</svg>
)
})
export default IconMenu2、修改 svg 中的 style 为 jsx 格式的 style 对象写法
style={styleStrToObj("display: block; fill: none; height: 100%; width: 100%; stroke: currentcolor; stroke-width: 3; overflow: visible;")3、styleStrToObj 方法
function styleStrToObj(styleStr) {
const obj = {}
const s = styleStr
.toLowerCase()
.replace(/-(.)/g, function (m, g) {
return g.toUpperCase()
})
.replace(/;\s?$/g, '')
.split(/:|;/g)
for (let i = 0; i < s.length; i += 2) {
obj[s[i].replace(/\s/g, '')] = s[i + 1].replace(/^\s+|\s+$/g, '')
}
return obj
}
export default styleStrToObj4、修改 svg 颜色:修改 svg 的父元素的颜色
.icon{
display: inline-block;
+ color: ${props => props.theme.color.second};
}5、在页面中使用该组件
<span className="icon icon-profile">
+ <IconProfile />
</span>设置主题色
方式一:CSS 原生的方法


方式二:styled-components 的方式
1、定义 theme
const theme = {
color: {
main: '#484848',
second: '#767676'
}
}
export default theme2、使用styled-components 中的ThemeProvider挂载 theme 到组件树中
root.render(
<Suspense fallback={<div>Loading...</div>}>
<Provider store={store}>
+{' '}
<ThemeProvider theme={theme}>
<HashRouter>
<App />
</HashRouter>
+{' '}
</ThemeProvider>
</Provider>
</Suspense>
)3、在组件中使用 theme
.icon{
display: inline-block;
+ color: ${props => props.theme.color.second};
}AppFooter

页面布局
const AppFooter = memo(() => { return (
<AppFooterWrapper>
<div className="container">
<div className="links">
+ {footerData.map((item, index) => { return (
<dl className="item-group" key="{index}">
<dt className="item-title">{item.name}</dt>
+ {item.list.map((iten, indey) => { return ( +
<dd className="item-cell" key="{indey}">+ {iten} +</dd>
) })}
</dl>
) })}
</div>
<div className="info">
<div className="left">
<div className="logo">
<IconLogo />
</div>
<div className="text">
<div>
<a href="#">京ICP备16017121号</a>
<a href="#">京ICP证 160773号</a>
<img className="icon" src={require('@/assets/img/public_security_bureau_logo.png')} alt="" />
<a href="#">京公网安备 11010502032345号</a>
<span>安彼迎网络(北京)有限公司</span>
<img className="icon" src={require('@/assets/img/electronic_business_license.png')} alt="" />
<a href="#">营业执照</a>
</div>
<div>
<span>© 2023 Airbnb, Inc. All rights reserved.</span>
<a href="#">条款</a>·<a href="#">隐私政策</a>·<a href="#">网站地图</a>·
<span>· 全国旅游投诉渠道 12301</span>
</div>
</div>
</div>
<div className="right">
<a className="tag" href="">
<IconSina />
</a>
<a className="tag" href="">
<IconWeixin />
</a>
</div>
</div>
</div>
</AppFooterWrapper>
) })模拟数据
[
{
"name": "爱彼迎",
"list": ["工作机会", "爱彼迎新闻", "政策", "无障碍设施"]
},
{
"name": "发现",
"list": ["信任与安全", "旅行基金", "商务差旅", "爱彼迎杂志", "Airbnb.org"]
},
{
"name": "出租",
"list": ["为什么要出租?", "房东义务", "开展体验", "资源中心"]
},
{
"name": "客服支持",
"list": ["帮助", "邻里支持"]
}
]SectionHeader
import PropTypes from 'prop-types'
import React, { memo } from 'react'
import { SectionHeaderWrapper } from './style'
const SectionHeader = memo((props) => {
+ const { title, subtitle } = props
return (
<SectionHeaderWrapper>
<h2 className="title">{title}</h2>
+ {subtitle && <div className="subtitle">{subtitle}</div>}
</SectionHeaderWrapper>
)
})
SectionHeader.propTypes = {
+ title: PropTypes.string,
+ subtitle: PropTypes.string
}
export default SectionHeader使用组件
return (
<HomeSectionWrapper>
+ <SectionHeader title={infoData?.title} subtitle={infoData?.subtitle} />
<SectionRooms roomList={infoData?.list?.slice(0, 8)} />
</HomeSectionWrapper>
)SectionRooms
页面布局
import PropTypes from 'prop-types'
import React, { memo } from 'react'
import { SectionRoomsWrapper } from './style'
import RoomItem from '../RoomItem'
const SectionRooms = memo((props) => {
+ const { roomList } = props
return (
<SectionRoomsWrapper>
{
+ roomList?.map(item => {
+ return <RoomItem key={item.id} itemData={item}/>
+ })
}
</SectionRoomsWrapper>
)
})
SectionRooms.propTypes = {
+ roomList: PropTypes.array
}
export default SectionRooms使用组件
return (
<HomeSectionWrapper>
<SectionHeader title={infoData?.title} subtitle={infoData?.subtitle} />
+ <SectionRooms roomList={infoData?.list?.slice(0, 8)} />
</HomeSectionWrapper>
)RoomItem

页面布局
import PropTypes from 'prop-types'
import React, { memo } from 'react'
import { RoomItemWrapper } from './style'
import { Rate } from 'antd'
const RoomItem = memo((props) => {
const { itemData } = props
return (
<RoomItemWrapper>
<div className="inner">
+ {/* 1. 设置长款一样的图片 */}
<div className="cover">
<img src={itemData.picture_url} alt="" />
</div>
<div className="tag">
<span className="text">{itemData.verify_info.messages.join(' · ')}</span>
</div>
<div className="title">{itemData.name}</div>
<div className="price">
<div className="bottom">
+ {/* 2. 引入Antd组件 */}
<Rate
allowHalf
value={itemData.star_rating ?? 5}
style={{ fontSize: '12px', color: itemData.star_rating_color }}
/>
<span className="reviews-count">{itemData.reviews_count}</span>+{' '}
{/* 3. 只有当 bottom_info 有值时才显示comment */}
{itemData.bottom_info && <span className="comment"> · {itemData.bottom_info?.content}</span>}
</div>
</div>
</div>
</RoomItemWrapper>
)
})
RoomItem.propTypes = {
itemData: PropTypes.object
}
export default RoomItem设置长款一样的图片
<div className="cover">
<img src="{itemData.picture_url}" alt="" />
</div> .cover {
position: relative;
+ padding: 66.6666% 5px 5px 5px;
img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}传递样式变量到 css
const theme = {
color: {
main: '#484848',
second: '#767676',
+ third: '#222222'
}
}
export default theme .title {
font-weight: 600;
+ color: ${props => props.theme.color.third};
margin-top: 8px;
}调整 rating 的间距
.ant-rate {
li {
+ margin-inline-end: 0;
}
}使用组件
const SectionRooms = memo((props) => {
const { roomList } = props
return (
<SectionRoomsWrapper>
{
roomList?.map(item => {
+ return <RoomItem key={item.id} itemData={item}/>
})
}
</SectionRoomsWrapper>
)
})Section-tabs
使用组件


页面布局

点击 item,切换 rooms

1、点击事件

2、导入classnames
npm i classnames3、高亮点击的 item


4、暴露内部切换事件
组件外部



组件内部


5、抽取带 tabs 的 HomeSectionV2
见:组件:HomeSectionV2
tabs 滚动效果
见:组件:ScrollView
SectionFooter
使用组件


页面布局
1、显示全部


2、查看更多 xxx 房源


ScrollView



使用组件

页面布局

右边按钮
当 scrollWidth - clientWidth 的值大于 0 时,显示右边按钮

点击[右边按钮],向左滚动 1 个 item 的距离


添加滚动动画

当 totalDistance - newOffsetLeft 的值大于 0 时,显示右边按钮,否则隐藏右边按钮



左边按钮
1、显示、隐藏左边按钮
当 newOffsetLeft > 0 时显示左边按钮
点击右边按钮时

点击左边按钮时

2、抽取点击按钮的处理函数

按钮样式

Home
组件:HomeSection
页面布局
import PropTypes from 'prop-types'
import React, { memo } from 'react'
import { HomeSectionWrapper } from './style'
import SectionHeader from '../SectionHeader'
import SectionRooms from '../SectionRooms'
const HomeSection = memo((props) => {
+ const { infoData } = props
return (
<HomeSectionWrapper>
+ <SectionHeader title={infoData?.title} subtitle={infoData?.subtitle}/>
+ <SectionRooms roomList={infoData?.list?.slice(0, 8)}/>
</HomeSectionWrapper>
)
})
+ HomeSection.propTypes = {
+ infoData: PropTypes.object
+ }
export default HomeSection使用组件
return (
<HomeWrapper>
<HomeBanner />
<div className="content">
+ <HomeSection infoData={goodPrice} />
+ <HomeSection infoData={highScore} />
+ <HomeSection infoData={plus} />
</div>
</HomeWrapper>
)组件:HomeSectionV2
使用组件

页面布局


useState 默认值
由于 useState(initial)的 initial 只有在组件第一次渲染时才有效,但是此时第一次传递的是空字符串''

上面的方法无法提供默认值
解决方法:控制第一次 discountInfo 没有值时,不能渲染


组件:HomeLongfor
使用组件


页面布局


组件:LongforItem


Banner
见:webpack 中引入图片
webpack 中引入图片
1、在 img 的 src 中引入图片

2、在 background 的 url 中引入图片

或者直接在模板字符串中使用 require()函数导入
export const HomeBannerWrapper = styled.div`
height: 529px;
background: url(${require('@/assets/img/cover_01.jpeg')}) center/cover;
`高性价比
API:http://codercba.com:1888/airbnb/api/home/goodprice

页面布局
const Home = memo(() => {
const dispatch = useDispatch()
// 网络请求
useEffect(() => {
dispatch(fetechHomeDataAction())
}, [dispatch])
// 获取redux store
const { goodPrice } = useSelector((state) => ({
goodPrice: state.home.goodPrice
}))
return (
<HomeWrapper>
<HomeBanner />
<div className="content">
<HomeSection infoData={goodPrice} />
</div>
</HomeWrapper>
)
})HomeSection 见:组件:HomeSection
获取数据
1、在 service 中发送网络请求
// 高性价比
export const getHomeGoodprice = () => {
return mrRequest.get({ url: '/home/goodprice' })
}2、redux
export const fetechHomeDataAction = createAsyncThunk('homedata', (payload, { dispatch }) => {
// 高性价比
+ getHomeGoodprice().then((res) => {
+ dispatch(changeGoodPriceAction(res))
})
})
const homeSlice = createSlice({
name: 'home',
initialState: {
goodPrice: {}
},
reducers: {
+ changeGoodPriceAction(state, { payload }) {
+ state.goodPrice = payload
+ }
}
})3、在组件中派发异步事件
const Home = memo(() => {
const dispatch = useDispatch()
// 网络请求
useEffect(() => {
;+dispatch(fetechHomeDataAction())
}, [dispatch])
})4、从 redux 中获取数据到组件中
const Home = memo(() => {
// 获取redux store
+ const { goodPrice } = useSelector(state => ({
+ goodPrice: state.home.goodPrice
+ }), shallowEqual)
return (
<HomeWrapper>
<div className="content">
+ <HomeSection infoData={goodPrice}/>
</div>
</HomeWrapper>
)
})高分房源
API: http://codercba.com:1888/airbnb/api/home/highscore

获取数据
1、services
// 高分房源
export const getHomeHighScore = () => {
return mrRequest.get({ url: '/home/highscore' })
}2、store/modules/home.js
在createAsyncThunk 中同时发送多个异步请求,可以通过dispath 将获得的 res 保存到 state 中
export const fetechHomeDataAction = createAsyncThunk('homedata', (payload, { dispatch }) => {
// 高性价比
getHomeGoodprice().then((res) => {
dispatch(changeGoodPriceAction(res))
})
// 高分房源
+ getHomeHighScore().then((res) => {
+ dispatch(changeHighScoreAction(res))
+ })
})
const homeSlice = createSlice({
name: 'home',
initialState: {
goodPrice: {},
highScore: {}
},
reducers: {
changeGoodPriceAction(state, { payload }) {
state.goodPrice = payload
},
+ changeHighScoreAction(state, { payload }) {
+ state.highScore = payload
+ }
},
})
+ export const { changeGoodPriceAction, changeHighScoreAction } = homeSlice.actions3、在组件中获取 highScore 数据
const Home = memo(() => {
// 获取redux store
const { goodPrice, highScore, plus } = useSelector(state => ({
goodPrice: state.home.goodPrice,
+ highScore: state.home.highScore
}), shallowEqual)
})4、在组件中展示数据
const Home = memo(() => {
return (
<HomeWrapper>
<HomeBanner />
<div className="content">
<HomeSection infoData={goodPrice} />
+ <HomeSection infoData={highScore} />
</div>
</HomeWrapper>
)
})抽取组件
见:Home / 组件:HomeSection
折扣优惠
API: http://codercba.com:1888/airbnb/api/home/discount

获取数据
1、service

2、store


展示页面
1、组件中获取 store 数据

2、展示 discount 数据(先写死)

3、修改 item 宽度
动态传递 item 宽度
HomeSection

SectionRooms

RoomItem

RoomItem 的 style 文件

封装 tabs
见:Section-tabs
热门推荐
API:http://codercba.com:1888/airbnb/api/home/hotrecommenddest


获取数据
1、services

2、redux


3、Home 组件


4、封装 SectionFooter
见:组件:SectionFooter
向往城市
API: http://codercba.com:1888/airbnb/api/home/longfor

获取数据
1、services

2、redux


3、Home 组件
见:Home/组件:HomeLongfor
plus 房源
API: http://codercba.com:1888/airbnb/api/home/plus

获取数据
1、services

2、redux


封装:HomeSectionV3
使用组件
页面布局

Entire


跳转 Entire

点击 logo,回到首页

页面布局

组件:EntireFilter

模拟数据

页面布局

点击选中过滤项
点击过滤项,如果已经存在 selectItems 中,则移除该项,否则就添加该项到 selectItems 中

如果当前项存在于 selectItems 中,则添加 active 类

组件:EntireRooms
获取数据
1、services

2、redux
constants

reducer

actionCreators

在 actionCreators 中发送异步网络请求

index

3、在组件中调用 redux 中的异步请求

4、通过 currentPage 动态获取数据

页面布局
组件:EntirePagination

自定义 UI 组件分页样式

方法一:修改 mui 的主题色

方法二:覆盖 mui 的样式
/* 覆盖MUI样式 */
.MuiPaginationItem-page.Mui-selected {
background-color: #222;
color: #fff;
}
.MuiPaginationItem-page {
margin: 0 10px;
&:hover {
text-decoration: underline;
}
}点击分页,实现分页


优化
优化: 合并 2 次 dispatch
优化: 点击页码后,回到顶部,并添加加载蒙版
1、redux/actionCreators 中生成 changeIsLoadingAction

2、redux/reducer 中修改 isLoading


3、在组件中通过 isLoading 显示/隐藏蒙版



轮播图
页面布局
1、基础使用

2、不显示指示器

3、显示左右控制箭头

4、修改 svg 图片的大小


5、修改 svg 图片的颜色
方法:可以通过修改当前元素父元素的颜色来实现

6、控制箭头样式实现

点击控制箭头
1、点击控制箭头,实现图片切换



指示器
1、结构搭建
const Indicator = memo((props) => {
return +(
<IndicatorCss>
+ <div className="content">+ {props.children}+ </div>+{' '}
</IndicatorCss>
)
})
Indicator.propTypes = {}
export default Indicator2、使用 Indicator
const Test = memo(() => {
const list = ['aaa', 'bbb', 'ccc', 'ddd', 'eee', 'fff', 'ggg', 'hhh', 'iii', 'jjj', 'kkk']
return (
<TestCss>
<div className="control">
<button>上一页</button>
<button>下一页</button>
</div>
<div className="list">
+ <Indicator>
+ {
+ list.map((item, index) => {
+ return <button key={item}>{item}</button>
+ })
+ }
+ </Indicator>
</div>
</TestCss>
)
})3、记录当前选中 index
const Test = memo(() => {
+ const [currIndex, setCurrIndex] = useState(0)
// 事件处理函数
function btnClickHandle(isNext) {
// 计算新索引
+ let newIndex = isNext ? currIndex + 1 : currIndex - 1
+ if(newIndex < 0) newIndex = list.length - 1
+ if(newIndex > list.length - 1) newIndex = 0
+ setCurrIndex(newIndex)
}
<TestCss>
<div className="control">
+ <button onClick={e => btnClickHandle(false)}>上一页</button>
+ <button onClick={e => btnClickHandle(true)}>下一页</button>
...
</div>
</TestCss>
})4、分析滚动位置

5、在组件外部传递选中 index 到组件内部
;+(
<Indicator currIndex={currIndex}>
{list.map((item, index) => {
return <button key={item}>{item}</button>
})}
</Indicator>
)6、在组件内部,根据 index 计算滚动距离,保证每个当前项都在中间位置
const Indicator = memo((props) => {
+ const { currIndex } = props
+ const contentRef = useRef()
// 根据currIndex计算滚动的距离
useEffect(() => {
+ const itemEl = contentRef.current.children[currIndex]
+ const itemLeft = itemEl.offsetLeft
+ const itemWidth = itemEl.clientWidth
+ const contentWidth = contentRef.current.clientWidth
+ const maxDistance = contentRef.current.scrollWidth - contentWidth
// console.log(itemLeft, itemWidth, contentWidth, maxDistance)
// 当前项要滚动的距离
+ let distance = itemLeft + itemWidth * 0.5 - contentWidth * 0.5
// 当前项不移动的特殊情况
+ if(distance < 0) distance = 0
+ if(distance > maxDistance) distance = maxDistance
// 执行移动动画
+ contentRef.current.style.transform = `translateX(${-distance}px)`
+ }, [currIndex])
return (
<IndicatorCss>
+ <div className="content" ref={contentRef}>
{props.children}
</div>
</IndicatorCss>
)
})样式
export const IndicatorCss = styled.div`
overflow: hidden;
.content {
display: flex;
flex-wrap: nowrap;
+ transition: transform 200ms ease;
}
`7、不移动的特殊情况
左边:当 distance 为负时,不需要移动到中间位置
右边:当 distance > scrollWidth - clientWidth 时,不需要移动
// 当前项不移动的特殊情况
+ if(distance < 0) distance = 0
+ if(distance > maxDistance) distance = maxDistance项目中使用指示器
1、页面布局

2、设置样式

3、修改选中索引 index

是否显示轮播图


Detail

跳转 Detail
1、组件内部点击 item

2、传递内部 itemClick 事件到外部


3、在外部 EntireRooms 中定义 itemClick 的事件处理函数

4、跳转时传递参数数据

5、由于 itemData 数据较大,使用 redux 共享的方式传递数据


6、在 EntireRooms 组件中,保存 item 到 redux 的 detailInfo

7、在 Detail 页面中,获取 detailInfo

DetailPictures
Detail 页面

DetailPictures 组件

样式



图片浏览器

页面布局
1、基础使用 PictureBrowser 组件

2、让滚动功能消失

3、点击按钮,显示 PictureBrowser 组件


关闭图片浏览器
1、页面布局


2、监听关闭事件
组件内


组件外

图片展示区
1、页面布局

2、上一个、下一个


3、图片切换动画
第三方包:npm i react-transition-group


动画样式


通过isNext,判断动画方向




图片预览指示器
1、页面布局


2、svg 图片组件

3、添加选中时的样式active

4、动态显示 desc 中的内容

5、点击 item 指示器,切换图片

6、显示/隐藏照片列表,并添加动画
这里不要使用display:block/none,因为它不 支持动画。可以使用height 切换





解决点击时动画方向的问题


Extra
点击 item 阻止冒泡


页面切换滚动到顶部
方法: 在 App 页面监听页面是否发生路由跳转,如果跳转了则滚动到顶部

封装成 Hook


Header 效果
不同页面的 fixed 效果切换
1、方法一:每个页面都有一个自己的<AppHeader /> 实例
2、方法二:3 个页面共享同一个<AppHeader /> 实例【当前使用】
3、redux 公共模块 main


4、设置 headerConfig
home 页面

entire 页面

detail 页面

5、在 AppHeader 中根据 headerConfig 设置样式


6、设置 entire 页面 Filter 组件也为 fixed 定位
问题: AppHeader 没有监听 headerConfig 的改变
原因: 这是由于 home,entire,detail 页面是通过懒加载的方式导入的,在加载的时候Suspense 是在Provider 外部,没有被Provider 包裹,所以就不会监听懒加载页面的更新
root.render(
+(
<Suspense fallback={<div>Loading...</div>}>
+{' '}
<Provider store={store}>
<ThemeProvider theme={theme}>
<HashRouter>
<App />
</HashRouter>
</ThemeProvider>
+{' '}
</Provider>
+{' '}
</Suspense>
)
)解决: 将 Suspense 放在Provider 内部
root.render(
+(
<Provider store={store}>
+{' '}
<Suspense fallback={<div>Loading...</div>}>
<ThemeProvider theme={theme}>
<HashRouter>
<App />
</HashRouter>
</ThemeProvider>
+{' '}
</Suspense>
+{' '}
</Provider>
)
)Header 动画效果


1、页面布局


2、HeaderCenter


样式

3、模拟数据

4、搜索状态的切换动画效果
AppHeader 组件中



HeaderCenter 组件中


5、点击 HeaderCenter,修改 isSearch 状态为 true


组件外修改 isSearch

6、点击蒙版,修改 isSearch 状态为 false

7、动画效果

detail 动画样式

bar 动画样式

8、监听滚动,search 消失
useScrollPosition 的 hook

为了防止监听滚动太频繁,对事件函数进行节流
- 安装:
npm i underscore


使用 useScrollPosition

Header 顶部透明效果

1、

home 页面

其他页面为 false
2、AppHeader 组件中通过 topAlpha 修改透明度



设置样式

其他样式类似
3、当 isAlpha=true 时,isSearch 一定为 true

解决 suspense 时 AppHeader 会打印 2 次的问题
解决: suspense 不要包裹 App,而是包裹 useRoutes(routes)
