Skip to content

React18-爱彼迎

项目搭建-项目基本配置和目录结构划分

创建项目

  • create-react-app airbnb(创建完成之后删掉不需要的文件)

项目配置

  • 配置项目的icon(自己配)
  • 配置项目的标题(自己配)
  • 配置jsconfig.json(用于智能提示,会好很多)
json
{
  "compilerOptions": {
    "target": "es5",
    "module": "esnext",
    "baseUrl": "./",
    "moduleResolution": "node",
    "paths": {
      "@/*": ["src/*"]
    },
    "jsx": "preserve",
    "lib": ["esnext", "dom", "dom.iterable", "scripthost"]
  }
}
vscode安装的扩展

项目目录结构划分

vscode安装的扩展

项目搭建-craco配置别名和less样式

通过craco配置别名和less文件:

  • 因为react把配置文件都隐藏起来了

安装craco

  • npm i @craco/craco@alpha -D

项目根目录创建craco.config.js文件

javascript
const path = require("path");

const resolve = (pathname) => path.resolve(__dirname, pathname);
module.exports = {
  webpack: {
    alias: {
      "@": resolve("src"),
      "components": resolve("src/components"),
      "utils": resolve("src/utils"),
    },
  },
};
修改package.json用craco来启动项目

配置less

  • npm i craco-less@2.1.0-alpha.0 -D
javascript
const path = require("path");
const CracoLessPlugin = require('craco-less');

const resolve = (pathname) => path.resolve(__dirname, pathname);
module.exports = {
  // webpack
  webpack: {
    alias: {
      "@": resolve("src"),
      "components": resolve("src/components"),
      "utils": resolve("src/utils"),
    },
  },
  // less
  plugins: [
    {
      plugin: CracoLessPlugin,
    },
  ],
};

项目搭建-css的样式重置

安装重置样式文件

  • npm i normalize.css
  • 在index.js中导入normalize.css
  • 在assets/css中新建reset.less,用于重置我们自己的样式
  • 在assets/css中新建variables.less,用于存储一些less变量
  • 在assets/css中新建index.less,用于导入reset.less,并在index.js中导入index.less
css
@textColor: #484848;
@textColorSecondary: #222;
css
@import "./variables.less";

body,button,form,h1,h2,h3,h4,h5,h6,input,li,p,td,textarea,th,ul {
    padding: 0;
    margin: 0;
}

a {
    color: @textColor;
    text-decoration: none;
}

img {
    vertical-align: top;
}

ul,
li {
    list-style: none;
}
css
@import "./reset.less";
jsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "@/App";

import 'normalize.css'
import '@/assets/css/index.less'
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

项目搭建-项目路由Router的搭建和配置

安装路由

  • npm i react-router-dom

在index.js中导入路由组件

jsx
import React from "react";
import ReactDOM from "react-dom/client";
import { HashRouter } from "react-router-dom";

import App from "@/App";

import "normalize.css";
import "@/assets/css/index.less";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <HashRouter>
      <App />
    </HashRouter>
  </React.StrictMode>
);

在view中创建三个页面

  • home/index.jsx
  • detail/index.jsx
  • entire/index.jsx
jsx
import React, { memo } from 'react'

const Home = memo(() => {
  return (
    <div>Home</div>
  )
})

export default Home
jsx
import React, { memo } from 'react'

const Detail = memo(() => {
  return (
    <div>Detail</div>
  )
})

export default Detail
jsx
import React, { memo } from 'react'

const Entire = memo(() => {
  return (
    <div>Entire</div>
  )
})

export default Entire

在router中创建index.js编写路由映射表

  • Navigate用于路由的重定向,当这个组件出现时,就会执行跳转到对应的to路径中
javascript
import React from "react";
import { Navigate } from "react-router-dom";

const Home = React.lazy(() => import("@/views/home"));
const Entire = React.lazy(() => import("@/views/entire"));
const Detail = React.lazy(() => import("@/views/detail"));

const routes = [
  {
    path: "/",
    element: <Navigate to="/home" />,
  },
  {
    path: "/home",
    element: <Home />,
  },
  {
    path: "/entire",
    element: <Entire />,
  },
  {
    path: "/detail",
    element: <Detail />,
  },
];

export default routes;

编写app.jsx

jsx
import React, { memo } from "react";
import { useRoutes } from "react-router-dom";
import routes from "./router";
const App = memo(() => {
  return (
    <div className="app">
      <div className="header">header</div>
      <div className="page">{useRoutes(routes)}</div>
      <div className="footer">footer</div>
    </div>
  );
});

export default App;

index.js中导入路由组件进行包裹,因为用到了组件懒加载,所以需要从react中导入Suspense组件进行包裹

jsx
import React, { Suspense } from "react";
import ReactDOM from "react-dom/client";
import { HashRouter } from "react-router-dom";

import App from "@/App";

import "normalize.css";
import "@/assets/css/index.less";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <Suspense fallback="loading">
      <HashRouter>
        <App />
      </HashRouter>
    </Suspense>
  </React.StrictMode>
);

项目搭建-项目状态管理redux的配置

Redux状态管理的选择

  • 普通方式:目前项目中依然使用率非常高
  • @reduxjs/toolkit方式:推荐方式,未来的趋势
  • 这两种该项目中都会用到

安装@reduxjs/toolkit工具和react-redux

  • 因为@reduxjs/toolkit已经集成了redux了,所以不需要再安装redux
  • npm i @reduxjs/toolkit react-redux

在store中新建modules用于编写redux模块

  • 在modules里面新建home.js(该模块采用RTK模式)
javascript
import { createSlice } from "@reduxjs/toolkit";

const homeSlice = createSlice({
  name: "home",
  initialState: {},
  reducers: {},
});

export default homeSlice.reducer;
  • 在modules里新建entire文件夹(该模块采用原始模式)
    • 在里面新建createActions.js
    • 在里面新建constants.js(放置常量)
    • 在里面新建reducer.js
javascript
const initialState = {};
function reducer(state = initialState, action) {
  switch (action.type) {
    default:
      return state;
  }
}

export default reducer;
- 在里面新建index.js,导入reducer并导出
javascript
import reducer from "./reducer";
export default reducer;

在store中新建index.js编写redux相关代码

javascript
import { configureStore } from "@reduxjs/toolkit";
import homeReducer from "./modules/home";
import entireReducer from "./modules/entire";

const store = configureStore({
  reducer: {
    home: homeReducer,
    entire: entireReducer,
  },
});

export default store;

在index.js中导入store

jsx
import React, { Suspense } from "react";
import ReactDOM from "react-dom/client";
import { HashRouter } from "react-router-dom";

import { Provider } from "react-redux";

import store from "@/store";

import App from "@/App";

import "normalize.css";
import "@/assets/css/index.less";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <Suspense fallback="loading">
      <Provider store={store}>
        <HashRouter>
          <App />
        </HashRouter>
      </Provider>
    </Suspense>
  </React.StrictMode>
);

项目搭建-网络请求axios的封装和测试

安装axios

  • npm i axios

在services中新建request文件夹

  • 在里面新建index.js,在里面封装一个类,通过这个类对axios进行二次封装
javascript
import axios from "axios";
import { BASE_URL, TIMEOUT } from "./config";

class CJRequest {
  constructor(baseURL, timeout) {
    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" });
  }
}
export default new CJRequest(BASE_URL, TIMEOUT);
  • 在里面新建一个config.js文件,用于编写一些配置信息
javascript
export const BASE_URL = "http://codercba.com:1888/airbnb/api";
export const TIMEOUT = 10000;

在services中新建modules文件夹,每个模块都有自己独立的文件来管理网络请求

在services中创建index.js文件作为service统一出口

javascript
import cjRequest from "./request";
export default cjRequest;

在home页面中测试网络请求

javascript
import React, { memo, useEffect, useState } from "react";
import cjRequest from "@/services";

const Home = memo(() => {
  // 定义状态
  const [highScore, setHighScore] = useState({});

  // 网络请求的代码
  useEffect(() => {
    cjRequest.get({ url: "/home/highscore" }).then((res) => {
      console.log(res);
      setHighScore(res);
    });
  }, []);

  return (
    <div>
      <h2>{highScore.title}</h2>
      <h4>{highScore.subtitle}</h4>
      <ul>
        {highScore.list?.map((item) => {
          return <li key={item.id}>{item.name}</li>;
        })}
      </ul>
    </div>
  );
});

export default Home;

项目-整体App架构和Header不同实现方案

修改package.json用craco来启动项目

制作头部有两种方案

  • 一种是放在APP中共用一个头部
  • 一种是三个页面都分别引入头部
  • 这里采用第一种较为复杂的做法

分析把header封装成一个组件

  • 在components中新建app-header文件夹和app-footer文件夹
  • 在app-header中新建index.jsx
jsx
import React, { memo } from "react";

const AppHeader = memo(() => {
  return <div>AppHeader</div>;
});

export default AppHeader;
  • 在app-footer中新建index.jsx
jsx
import React, { memo } from 'react'

const AppFooter = memo(() => {
  return (
    <div>AppFooter</div>
  )
})

export default AppFooter
  • 在app.jsx中导入app-header和app-footer组件
jsx
import React, { memo } from "react";
import { useRoutes } from "react-router-dom";
import routes from "./router";

import AppHeader from "./components/app-header";
import AppFooter from "./components/app-footer";

const App = memo(() => {
  return (
    <div className="app">
      <AppHeader />
      <div className="page">{useRoutes(routes)}</div>
      <AppFooter />
    </div>
  );
});

export default App;

项目-Header的封装和整体布局

安装样式工具库和插件

  • npm i styled-components
该插件用于在css-in-js中编写css时的提示

创建header组件样式文件并导出用于包裹的样式组件

  • 在components/app-header中新建用于编写样式文件,导出样式组件
  • 在components/app-header/index.jsx中导入样式组件进行

AppHeader组件比较复杂,需要分成左中右三块

  • 在app-header中新建文件夹,用于存放AppHeader的子组件
    • 在c-cpns中新建,header-left,header-center,header-right
    • 在三个文件夹里面index.jsx和style.js
  • 在AppHeader组件中导入三个子组件
  • 编写AppHeader组件样式

总体代码如下

jsx
import styled from "styled-components";

export const LeftWrapper = styled.div`
  flex: 1;
`;
jsx
import React, { memo } from "react";
import { LeftWrapper } from "./style";
const HeaderLeft = memo(() => {
  return <LeftWrapper>HeaderLeft</LeftWrapper>;
});

export default HeaderLeft;
jsx
import styled from "styled-components";

export const CenterWrapper = styled.div`

`;
jsx
import React, { memo } from "react";
import { CenterWrapper } from "./style";
const HeaderCenter = memo(() => {
  return <CenterWrapper>HeaderCenter</CenterWrapper>;
});

export default HeaderCenter;
jsx
import React, { memo } from "react";
import { RightWrapper } from "./style";
const HeaderRight = memo(() => {
  return <RightWrapper>HeaderRight</RightWrapper>;
});

export default HeaderRight;
jsx
import styled from "styled-components";

export const RightWrapper = styled.div`
  flex: 1;
  display: flex;
  justify-content: flex-end;
`;
jsx
import styled from "styled-components";

export const HeaderWrapper = styled.div`
  display: flex;
  align-items: center;
  height: 80px;
  border-bottom: 1px solid #eee;
`;
jsx
import React, { memo } from "react";

import HeaderLeft from "./c-cpns/header-left";
import HeaderCenter from "./c-cpns/header-center";
import HeaderRight from "./c-cpns/header-right";

import { HeaderWrapper } from "./style";

const AppHeader = memo(() => {
  return (
    <HeaderWrapper>
      <HeaderLeft />
      <HeaderCenter />
      <HeaderRight />
    </HeaderWrapper>
  );
});

export default AppHeader;

项目-Logo的svg图片的两种使用方式

svg有两种使用方式

  • 一种是直接使用svg标签
  • 另一种是使用svg图片

在assets中新建svg文件夹,把svg封装成一个组件,再导入到HeaderLeft中

封装一个工具函数styleStrToObject把style字符串转换成对象(因为jsx不支持style属性为字符串)

  • 在assets/svg中创建一个utils文件夹,里面新建一个index.js,编写styleStrToObject函数

这样做的好处是svg一大段代码不会干扰到正常编写HeaderLeft组件

svg组件
javascript
function styleStrToObject(styleStr) {
  const obj = {};
  const s = styleStr
    .toLowerCase()
    .replace(/-(.)/g, function (m, g) {
      return g.toUpperCase();
    })
    .replace(/;\s?$/g, "")
    .split(/:|;/g);
  for (var i = 0; i < s.length; i += 2) {
    obj[s[i].replace(/\s/g, "")] = s[i + 1].replace(/^\s+|\s+$/g, "");
  }

  return obj;
}

export default styleStrToObject;
jsx
import React, { memo } from "react";
import { LeftWrapper } from "./style";
import IconLogo from "@/assets/svg/icon_logo";
const HeaderLeft = memo(() => {
  return (
    <LeftWrapper>
      <IconLogo />
    </LeftWrapper>
  );
});

export default HeaderLeft;

在header-left/style.js中可以修改svg颜色

jsx
import styled from "styled-components";

export const LeftWrapper = styled.div`
  flex: 1;
  color: red;
`;

项目-Logo颜色配置和主题文件配置

在assets文件夹中新建theme,在里面新建index.js编写主题颜色

javascript
const Theme = {
  color: {
    primaryColor: "#ff385c",
    secondaryColor: "#00848A",
  },
};

export default Theme;

在index.js中,从style-components中导入ThemeProvider组件用于提供主题

jsx
import React, { Suspense } from "react";
import ReactDOM from "react-dom/client";
import { HashRouter } from "react-router-dom";

import { Provider } from "react-redux";
import { ThemeProvider } from "styled-components";
import theme from "./assets/theme";

import store from "@/store";

import App from "@/App";

import "normalize.css";
import "@/assets/css/index.less";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <Suspense fallback="loading">
      <Provider store={store}>
        <ThemeProvider theme={theme}>
          <HashRouter>
            <App />
          </HashRouter>
        </ThemeProvider>
      </Provider>
    </Suspense>
  </React.StrictMode>
);

使用主题颜色

jsx
import styled from "styled-components";

export const LeftWrapper = styled.div`
  flex: 1;
  color: red;
  display: flex;
  align-items: center;
  color: ${(props) => props.theme.color.primaryColor};
  .logo {
    margin-left: 24px;
    cursor: pointer;
  }
`;

项目-Header的右侧布局和内容展示

布局header右侧

  • 官网获取三个图标,方式按照之前的方式,制作成组件
svg组件svg组件
  • 新建主题颜色
jsx
const Theme = {
  color: {
    primaryColor: "#ff385c",
    secondaryColor: "#00848A",
  },
  text: {
    primaryColor: "#484848",
    secondaryColor: "#222",
  },
};

export default Theme;
  • 编写布局
jsx
import React, { memo } from "react";
import { RightWrapper } from "./style";
import IconGlobal from "@/assets/svg/icon_global";
import IconMenu from "@/assets/svg/icon_menu";
import IconAvatar from "@/assets/svg/icon_avatar";
const HeaderRight = memo(() => {
  return (
    <RightWrapper>
      <div className="btns">
        <span className="btn">登录</span>
        <span className="btn">注册</span>
        <span className="btn">
          <IconGlobal />
        </span>
      </div>
      <div className="profile">
        <IconMenu />
        <IconAvatar />
      </div>
    </RightWrapper>
  );
});

export default HeaderRight;
  • 编写样式
jsx
import styled from "styled-components";

export const RightWrapper = styled.div`
  flex: 1;
  display: flex;
  justify-content: flex-end;
  align-items: center;
  color: ${(props) => props.theme.text.primaryColor};
  font-size: 14px;
  font-weight: 600;
  .btns {
    display: flex;
    .btn {
      height: 18px;
      line-height: 18px;
      padding: 12px 15px;
      cursor: pointer;
      border-radius: 22px;

      &:hover {
        background-color: #f5f5f5;
      }
    }
  }
  .profile {
    display: flex;
    justify-content: space-evenly;
    align-items: center;
    width: 77px;
    height: 42px;
    margin-right: 24px;
    box-sizing: border-box;
    border: 1px solid #ccc;
    border-radius: 25px;
    background-color: #fff;
    color: #999;
    cursor: pointer;
  }
`;

项目-box-shadow的动画效果和样式混入

编写通用hover,用混入的特性,其他地方也可以用到

jsx
const Theme = {
  color: {
    primaryColor: "#ff385c",
    secondaryColor: "#00848A",
  },
  text: {
    primaryColor: "#484848",
    secondaryColor: "#222",
  },
  mixin:{
    boxShadow:`
      transition: box-shadow 200ms ease;
      &:hover {
        box-shadow: 0 2px 4px rgba(0,0,0,.18);
      }
    `
  }
};

export default Theme;

HeaderRight组件中使用

svg组件

项目-Header的中间搜索框布局和展示

svg组件

抽取搜索图标svg

编写HeaderCenter结构和样式代码

jsx
import React, { memo } from "react";
import { CenterWrapper } from "./style";
import IconSearchBar from "@/assets/svg/icon_search";
const HeaderCenter = memo(() => {
  return (
    <CenterWrapper>
      <div className="search-bar">
        <div className="text">搜索房源和体验</div>
        <div className="icon">
          <IconSearchBar />
        </div>
      </div>
    </CenterWrapper>
  );
});

export default HeaderCenter;
jsx
import styled from "styled-components";

export const CenterWrapper = styled.div`
  .search-bar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    width: 300px;
    height: 48px;
    box-sizing: border-box;
    padding: 0 8px;
    border: 1px solid #ddd;
    border-radius: 24px;
    cursor: pointer;
    ${(props) => props.theme.mixin.boxShadow};
    .text {
      padding: 0 16px;
      color: #222;
      font-weight: 600;
    }
    .icon {
      display: flex;
      align-items: center;
      justify-content: center;
      width: 32px;
      height: 32px;
      border-radius: 50%;
      color: #fff;
      background-color: ${(props) => props.theme.color.primaryColor};
    }
  }
`;
jsx
body {
    font-size: 14px;
}
jsx
@import "./reset.less";
@import "./common.less";

项目-Profile点击面板切换的效果

编写Profile点击面板

svg组件

编写结构和样式,绑定点击事件

jsx
import React, { memo, useEffect } from "react";
import { RightWrapper } from "./style";
import IconGlobal from "@/assets/svg/icon_global";
import IconMenu from "@/assets/svg/icon_menu";
import IconAvatar from "@/assets/svg/icon_avatar";
import { useState } from "react";
const HeaderRight = memo(() => {
  // 定义组件内部的状态
  const [showPanel, setShowPanel] = useState(false);

  // 副作用代码
  useEffect(() => {
    function windowHandleClick() {
      setShowPanel(false);
    }
    window.addEventListener("click", windowHandleClick, true);
    return () => {
      window.removeEventListener("click", windowHandleClick, true);
    };
  }, []);

  // 事件处理函数
  function profileClickHandle() {
    setShowPanel(true);
  }
  return (
    <RightWrapper>
      <div className="btns">
        <span className="btn">登录</span>
        <span className="btn">注册</span>
        <span className="btn">
          <IconGlobal />
        </span>
      </div>
      <div className="profile" onClick={profileClickHandle}>
        <IconMenu />
        <IconAvatar />
        {showPanel && (
          <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>
    </RightWrapper>
  );
});

export default HeaderRight;
jsx
import styled from "styled-components";

export const RightWrapper = styled.div`
  flex: 1;
  display: flex;
  justify-content: flex-end;
  align-items: center;
  color: ${(props) => props.theme.text.primaryColor};
  font-size: 14px;
  font-weight: 600;
  .btns {
    display: flex;
    .btn {
      height: 18px;
      line-height: 18px;
      padding: 12px 15px;
      cursor: pointer;
      border-radius: 22px;

      &:hover {
        background-color: #f5f5f5;
      }
    }
  }
  .profile {
    position: relative;
    display: flex;
    justify-content: space-evenly;
    align-items: center;
    width: 77px;
    height: 42px;
    margin-right: 24px;
    box-sizing: border-box;
    border: 1px solid #ccc;
    border-radius: 25px;
    background-color: #fff;
    color: #999;
    cursor: pointer;
    ${(props) => props.theme.mixin.boxShadow}

    .panel {
      position: absolute;
      top: 54px;
      right: 0;
      width: 240px;
      background-color: #fff;
      border-radius: 10px;
      box-shadow: 0 0 6px rgba(0, 0, 0, 0.2);
      color: #666;

      .top,
      .bottom {
        padding: 10px 0;
      }
      .item {
        height: 40px;
        line-height: 40px;
        padding: 0 16px;

        &:hover {
          background-color: #f5f5f5;
        }
      }
      .top {
        border-bottom: 1px solid #eee;
      }
    }
  }
`;

项目-首页-顶部轮播图图片的展示

在views/home文件夹中新建c-cpns,用于放置home的子组件

  • 在c-cpns里面新建home-banner文件夹,编写轮播图图片
jsx
import React, { memo } from "react";
import { BannerWrapper } from "./style";

const HomeBanner = memo(() => {
  return <BannerWrapper>HomeBanner</BannerWrapper>;
});

export default HomeBanner;
jsx
import styled from "styled-components";
// import coverImg from "@/assets/img/cover_01.jpg";

export const BannerWrapper = styled.div`
  height: 529px;
  background: url(${require("@/assets/img/cover_01.jpg")}) center/cover;
`;

在Home组件中导入HomeBanner组件

jsx
import React, { memo } from "react";
import HomeBanner from "./c-cpns/home-banner";
import { HomeWrapper } from "./style";

const Home = memo(() => {
  return (
    <HomeWrapper>
      <HomeBanner />
    </HomeWrapper>
  );
});

export default Home;
jsx
import styled from "styled-components";
export const HomeWrapper = styled.div`

`

项目-高性价比数据Redux获取和管理

在redux中请求数据,并展示到home组件中

  • 对每个模块的请求数据操作进行独立封装,在services/modules中新建home.js
jsx
import cjRequest from "..";

export function getHomeGoodPriceData() {
  return cjRequest.get({
    url: "/home/goodprice",
  });
}
  • 编写store/modules/home.js这个模块
    • 导入用于执行异步(请求数据)操作
    • 创建异步操作的action:,并导出
    • 中监听成功时,修改store数据goodPriceInfo
    • 高亮的部分是同步修改的action(可不写)
jsx
import { getHomeGoodPriceData } from "@/services";
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";

export const fetchHomeDataAction = createAsyncThunk("fetchdata", async () => {
  const res = await getHomeGoodPriceData();
  return res;
});

const homeSlice = createSlice({
  name: "home",
  initialState: {
    goodPriceInfo: {},
  },
  reducers: {
    changeGoodPriceInfoAction(state, { payload }) {
      state.goodPriceInfo = payload;
    },
  },
  extraReducers: {
    [fetchHomeDataAction.fulfilled](state, { payload }) {
      state.goodPriceInfo = payload;
    },
  },
});
export const { changeGoodPriceInfoAction } = homeSlice.actions;
export default homeSlice.reducer;
  • home组件中发起派发异步action:获取数据并展示
    • **注意使用可选链防止一开始没有goodPriceInfo.list数据,循环渲染报错 **
jsx
import { fetchHomeDataAction } from "@/store/modules/home";
import React, { memo, useEffect } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import HomeBanner from "./c-cpns/home-banner";
import { HomeWrapper } from "./style";

const Home = memo(() => {
  // 从redux中获取数据
  const { goodPriceInfo } = useSelector(
    (state) => ({
      goodPriceInfo: state.home.goodPriceInfo,
    }),
    shallowEqual
  );

  // 派发异步的事件:发送网络请求
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(fetchHomeDataAction());
  }, [dispatch]);
  return (
    <HomeWrapper>
      <HomeBanner />
      <div className="content">
        <h2>{goodPriceInfo.title}</h2>
        {goodPriceInfo.list?.map((item) => {
          return <li key={item.id}>{item.name}</li>;
        })}
      </div>
    </HomeWrapper>
  );
});

export default Home;

项目-首页-区域的header封装过程

把房子列表展示封装成组件

svg组件

先把头部抽成组件

svg组件
  • 这个组件其他地方可能也会用到,就放到通用组件里面
    • 在components中创建section-header文件夹存在该组件
jsx
import PropTypes from "prop-types";
import React, { memo } from "react";
import { HeaderWrapper } from "./style";

const SectionHeader = memo((props) => {
  const { title, subtitle } = props;
  return (
    <HeaderWrapper>
      <h2 className="title">{title}</h2>
      {subtitle && <div className="subtitle">{subtitle}</div>}
    </HeaderWrapper>
  );
});

SectionHeader.propTypes = {
  title: PropTypes.string,
  subtitle: PropTypes.string,
};

export default SectionHeader;
jsx
import styled from "styled-components";
export const HeaderWrapper = styled.div`
  color: #222;
  .title {
    font-size: 22px;
    font-weight: 700;
    margin-bottom: 16px;
  }
  .subtitle {
    font-size: 16px;
    margin-bottom: 20px;
  }
`;

在Home组件中使用该头部组件

jsx
import { fetchHomeDataAction } from "@/store/modules/home";
import React, { memo, useEffect } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import HomeBanner from "./c-cpns/home-banner";
import SectionHeader from "@/components/section-header";

import { HomeWrapper } from "./style";

const Home = memo(() => {
  // 从redux中获取数据
  const { goodPriceInfo } = useSelector(
    (state) => ({
      goodPriceInfo: state.home.goodPriceInfo,
    }),
    shallowEqual
  );

  // 派发异步的事件:发送网络请求
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(fetchHomeDataAction());
  }, [dispatch]);
  return (
    <HomeWrapper>
      <HomeBanner />
      <div className="content">
        <div className="good-price">
          <SectionHeader title={goodPriceInfo.title} />
          <ul>
            {goodPriceInfo.list?.map((item) => {
              return <li key={item.id}>{item.name}</li>;
            })}
          </ul>
        </div>
      </div>
    </HomeWrapper>
  );
});

export default Home;
jsx
import styled from "styled-components";
export const HomeWrapper = styled.div`
  > .content {
    width: 1032px;
    margin: 0 auto;
  }

  .good-price {
    margin-top: 30px;
  }
`;

项目-首页-房间的item封装和整体布局

foot制作,代码略

把item封装成组件

svg组件
  • 在components中新建room-item文件夹,存放该组件,创建index.jsx和style.js
jsx
import PropTypes from "prop-types";
import React, { memo } from "react";
import { ItemWrapper } from "./style";

const RoomItem = memo((props) => {
  const { itemData } = props;
  return (
    <ItemWrapper>
      <div>{itemData.name}</div>
    </ItemWrapper>
  );
});

RoomItem.propTypes = {
  itemData: PropTypes.object,
};

export default RoomItem;
jsx
import styled from "styled-components";

export const ItemWrapper = styled.div`
box-sizing: border-box;
  width: 25%;
  padding: 8px;
`;

在Home组件中导入RoomItem组件

jsx
import { fetchHomeDataAction } from "@/store/modules/home";
import React, { memo, useEffect } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import HomeBanner from "./c-cpns/home-banner";
import SectionHeader from "@/components/section-header";
import RoomItem from "@/components/room-item";

import { HomeWrapper } from "./style";

const Home = memo(() => {
  // 从redux中获取数据
  const { goodPriceInfo } = useSelector(
    (state) => ({
      goodPriceInfo: state.home.goodPriceInfo,
    }),
    shallowEqual
  );

  // 派发异步的事件:发送网络请求
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(fetchHomeDataAction());
  }, [dispatch]);
  return (
    <HomeWrapper>
      <HomeBanner />
      <div className="content">
        <div className="good-price">
          <SectionHeader title={goodPriceInfo.title} />
          <ul className="room-list">
            {goodPriceInfo.list?.slice(0, 8).map((item) => {
              return <RoomItem itemData={item} key={item.id} />;
            })}
          </ul>
        </div>
      </div>
    </HomeWrapper>
  );
});

export default Home;
jsx
import styled from "styled-components";
export const HomeWrapper = styled.div`
  > .content {
    width: 1032px;
    margin: 0 auto;
  }

  .good-price {
    margin-top: 30px;

    .room-list {
      display: flex;
      flex-wrap: wrap;
      margin: 0 -8px;
    }
  }
`;

项目-首页-房间item的布局的展示过程

完善RoomItem组件

jsx
import PropTypes from "prop-types";
import React, { memo } from "react";
import { ItemWrapper } from "./style";

const RoomItem = memo((props) => {
  const { itemData } = props;
  console.log(itemData);
  return (
    <ItemWrapper verifyColor={itemData?.verify_info?.text_color || "#39576a"}>
      <div className="inner">
        <div className="cover">
          <img src={itemData.picture_url} alt="" />
        </div>
        <div className="desc">{itemData.verify_info.messages.join(" · ")}</div>
        <div className="name">{itemData.name}</div>
        <div className="price">¥{itemData.price}/晚</div>
      </div>
    </ItemWrapper>
  );
});

RoomItem.propTypes = {
  itemData: PropTypes.object,
};

export default RoomItem;
jsx
import styled from "styled-components";

export const ItemWrapper = styled.div`
  box-sizing: border-box;
  width: 25%;
  padding: 8px;
  .inner {
    width: 100%;
  }
  .cover {
    position: relative;
    box-sizing: border-box;
    padding: 66.66% 8px 0;
    border-radius: 3px;
    overflow: hidden;

    img {
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
    }
  }

  .desc {
    margin: 10px 0 5px;
    font-size: 12px;
    font-weight: 700;
    color: ${(props) => props.verifyColor};
  }

  .name {
    font-size: 16px;
    font-weight: 700;

    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
  }

  .price {
    margin: 8px 0;
  }
`;
jsx
body {
    font-size: 14px;
    font-family: "Circular", "PingFang-SC", "Hiragino Sans GB", "微软雅黑", "Microsoft YaHei", "Heiti SC";
    color: #484848;
}

项目-首页-房间的item底部内容的展示

项目集成material ui和antd ui,步骤略

使用material ui的打分器,用于房间item底部评分展示

jsx
import PropTypes from "prop-types";
import React, { memo } from "react";
import { ItemWrapper } from "./style";

import Rating from "@mui/material/Rating";

const RoomItem = memo((props) => {
  const { itemData } = props;
  console.log(itemData);
  return (
    <ItemWrapper verifyColor={itemData?.verify_info?.text_color || "#39576a"}>
      <div className="inner">
        <div className="cover">
          <img src={itemData.picture_url} alt="" />
        </div>
        <div className="desc">{itemData.verify_info.messages.join(" · ")}</div>
        <div className="name">{itemData.name}</div>
        <div className="price">¥{itemData.price}/晚</div>

        <div className="bottom">
          <Rating
            value={itemData.star_rating ?? 5}
            precision={0.1}
            readOnly
            sx={{ fontSize: "12px", color: "#008489" }}
          />
          <span className="count">{itemData.reviews_count}</span>
          {
            itemData?.bottom_info && <span className="extra">· {itemData.bottom_info.content}</span>
          }
        </div>
      </div>
    </ItemWrapper>
  );
});

RoomItem.propTypes = {
  itemData: PropTypes.object,
};

export default RoomItem;
jsx
import styled from "styled-components";

export const ItemWrapper = styled.div`
  box-sizing: border-box;
  width: 25%;
  padding: 8px;
  .inner {
    width: 100%;
  }
  .cover {
    position: relative;
    box-sizing: border-box;
    padding: 66.66% 8px 0;
    border-radius: 3px;
    overflow: hidden;

    img {
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
    }
  }

  .desc {
    margin: 10px 0 5px;
    font-size: 12px;
    font-weight: 700;
    color: ${(props) => props.verifyColor};
  }

  .name {
    font-size: 16px;
    font-weight: 700;

    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
  }

  .price {
    margin: 8px 0;
  }

  .bottom {
    display: flex;
    align-items: center;
    font-size: 12px;
    font-weight: 600;
    color: ${(props) => props.theme.text.primaryColor};

    .count {
      margin: 0 2px 0 4px;
    }

    .MuiRating-icon {
      margin-right: -2px;
    }
  }
`;

项目-首页-房间列表的组件代码重构

把整个房间ul列表封装成组件

  • 在components中创建一个section-rooms组件文件夹,把列表展示抽取过去
jsx
import PropTypes from "prop-types";
import React, { memo } from "react";

import RoomItem from "@/components/room-item";
import { RoomsWrap } from "./style";

const SectionRooms = memo((props) => {
  const { roomList = [] } = props;
  return (
    <RoomsWrap>
      {roomList.slice(0, 8).map((item) => {
        return <RoomItem itemData={item} key={item.id} />;
      })}
    </RoomsWrap>
  );
});

SectionRooms.propTypes = {
  roomList: PropTypes.array,
};

export default SectionRooms;
jsx
import styled from "styled-components";

export const RoomsWrap = styled.div`
  display: flex;
  flex-wrap: wrap;
  margin: 0 -8px;
`;

home组件修改

jsx
import { fetchHomeDataAction } from "@/store/modules/home";
import React, { memo, useEffect } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import HomeBanner from "./c-cpns/home-banner";
import SectionHeader from "@/components/section-header";
import SectionRooms from "@/components/section-rooms";

import { HomeWrapper } from "./style";

const Home = memo(() => {
  // 从redux中获取数据
  const { goodPriceInfo } = useSelector(
    (state) => ({
      goodPriceInfo: state.home.goodPriceInfo,
    }),
    shallowEqual
  );

  // 派发异步的事件:发送网络请求
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(fetchHomeDataAction());
  }, [dispatch]);
  return (
    <HomeWrapper>
      <HomeBanner />
      <div className="content">
        <div className="good-price">
          <SectionHeader title={goodPriceInfo.title} />
          <SectionRooms roomList={goodPriceInfo.list} />
        </div>
      </div>
    </HomeWrapper>
  );
});

export default Home;

把原来ul的样式抽走

jsx
import styled from "styled-components";
export const HomeWrapper = styled.div`
  > .content {
    width: 1032px;
    margin: 0 auto;
  }

  .good-price {
    margin-top: 30px;
  }
`;

项目-首页-高评分数据的获取和展示过程

在services/modules/home.js模块中封装请求高分好评房源列表数据

jsx
import cjRequest from "..";

export function getHomeGoodPriceData() {
  return cjRequest.get({
    url: "/home/goodprice",
  });
}

export function getHomeHighScoreData() {
  return cjRequest.get({
    url: "/home/highscore",
  });
}

在store/modules/home.js中编写redux相关代码

  • 把使用promise把reducer换成同步的写法,不用额外的reducer了
jsx
import { getHomeGoodPriceData, getHomeHighScoreData } from "@/services";
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";

export const fetchHomeDataAction = createAsyncThunk(
  "fetchdata",
  async (payload, { dispatch }) => {
    getHomeGoodPriceData().then((res) => {
      dispatch(changeGoodPriceInfoAction(res));
    });
    getHomeHighScoreData().then((res) => {
      dispatch(changeHightScoreInfoAction(res));
    });
  }
);

const homeSlice = createSlice({
  name: "home",
  initialState: {
    goodPriceInfo: {},
    highScoreInfo: {},
  },
  reducers: {
    changeGoodPriceInfoAction(state, { payload }) {
      state.goodPriceInfo = payload;
    },
    changeHightScoreInfoAction(state, { payload }) {
      state.highScoreInfo = payload;
    },
  }
});
export const { 
  changeGoodPriceInfoAction,
  changeHightScoreInfoAction 
} = homeSlice.actions;
export default homeSlice.reducer;

首页展示数据,抽取整个组件

svg组件
  • 在view/home/c-cpns中创建一个文件夹home-section-v1用于存放该组件
jsx
import SectionHeader from "@/components/section-header";
import SectionRooms from "@/components/section-rooms";
import PropTypes from "prop-types";
import React, { memo } from "react";
import { SectionV1Wrapper } from "./style";

const index = memo((props) => {
  const { infoData } = props;
  return (
    <SectionV1Wrapper>
      <SectionHeader title={infoData.title} subtitle={infoData.subtitle} />
      <SectionRooms roomList={infoData.list} />
    </SectionV1Wrapper>
  );
});

index.propTypes = {
  infoData: PropTypes.object,
};

export default index;
jsx
import styled from "styled-components";

export const SectionV1Wrapper = styled.div`
  margin-top: 30px;
`;

修改Home组件

jsx
import { fetchHomeDataAction } from "@/store/modules/home";
import React, { memo, useEffect } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import HomeBanner from "./c-cpns/home-banner";
import HomeSectionV1 from "./c-cpns/home-section-v1";

import { HomeWrapper } from "./style";

const Home = memo(() => {
  // 从redux中获取数据
  const { goodPriceInfo, highScoreInfo } = useSelector(
    (state) => ({
      goodPriceInfo: state.home.goodPriceInfo,
      highScoreInfo: state.home.highScoreInfo,
    }),
    shallowEqual
  );

  // 派发异步的事件:发送网络请求
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(fetchHomeDataAction());
  }, [dispatch]);
  return (
    <HomeWrapper>
      <HomeBanner />
      <div className="content">
        <HomeSectionV1 infoData={goodPriceInfo} />
        <HomeSectionV1 infoData={highScoreInfo} />
      </div>
    </HomeWrapper>
  );
});

export default Home;
jsx
import styled from "styled-components";
export const HomeWrapper = styled.div`
  > .content {
    width: 1032px;
    margin: 0 auto;
  }
`;

项目-首页-折扣数据的分析和获取管理

svg组件

在services/modules/home.js模块中继续封装请求方法

jsx
import cjRequest from "..";

export function getHomeGoodPriceData() {
  return cjRequest.get({
    url: "/home/goodprice",
  });
}

export function getHomeHighScoreData() {
  return cjRequest.get({
    url: "/home/highscore",
  });
}

export function getHomeDiscountData() {
  return cjRequest.get({
    url: "/home/discount",
  });
}

store中编写redux相关代码

jsx
import {
  getHomeDiscountData,
  getHomeGoodPriceData,
  getHomeHighScoreData,
} from "@/services";
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";

export const fetchHomeDataAction = createAsyncThunk(
  "fetchdata",
  async (payload, { dispatch }) => {
    getHomeGoodPriceData().then((res) => {
      dispatch(changeGoodPriceInfoAction(res));
    });
    getHomeHighScoreData().then((res) => {
      dispatch(changeHightScoreInfoAction(res));
    });
    getHomeDiscountData().then((res) => {
      dispatch(changeDiscountInfoAction(res));
    });
  }
);

const homeSlice = createSlice({
  name: "home",
  initialState: {
    goodPriceInfo: {},
    highScoreInfo: {},
    discountInfo: {},
  },
  reducers: {
    changeGoodPriceInfoAction(state, { payload }) {
      state.goodPriceInfo = payload;
    },
    changeHightScoreInfoAction(state, { payload }) {
      state.highScoreInfo = payload;
    },
    changeDiscountInfoAction(state, { payload }) {
      state.discountInfo = payload;
    },
  },
});
export const {
  changeGoodPriceInfoAction,
  changeHightScoreInfoAction,
  changeDiscountInfoAction,
} = homeSlice.actions;
export default homeSlice.reducer;

项目-首页-折扣数据的展示和item动态宽度

更改item的width,让折扣数据可以一行显示3个

jsx
import PropTypes from "prop-types";
import React, { memo } from "react";
import { ItemWrapper } from "./style";

import Rating from "@mui/material/Rating";

const RoomItem = memo((props) => {
  const { itemData, itemWidth = "25%" } = props;
  return (
    <ItemWrapper
      verifyColor={itemData?.verify_info?.text_color || "#39576a"}
      itemWidth={itemWidth}
    >
      {/* ............. */}
    </ItemWrapper>
  );
});

RoomItem.propTypes = {
  itemData: PropTypes.object,
  itemWidth: PropTypes.string,
};

export default RoomItem;
jsx
import styled from "styled-components";

export const ItemWrapper = styled.div`
  box-sizing: border-box;
  /* width: 25%; */
  width: ${(props) => props.itemWidth};
  padding: 8px;
  .inner {
    width: 100%;
  }
 	/* ............ */
`;

在首页中展示折扣数据(暂时写死)

jsx
import { fetchHomeDataAction } from "@/store/modules/home";
import React, { memo, useEffect } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import HomeBanner from "./c-cpns/home-banner";
import HomeSectionV1 from "./c-cpns/home-section-v1";
import SectionHeader from "@/components/section-header";
import SectionRooms from "@/components/section-rooms";

import { HomeWrapper } from "./style";

const Home = memo(() => {
  // 从redux中获取数据
  const { goodPriceInfo, highScoreInfo, discountInfo } = useSelector(
    (state) => ({
      goodPriceInfo: state.home.goodPriceInfo,
      highScoreInfo: state.home.highScoreInfo,
      discountInfo: state.home.discountInfo,
    }),
    shallowEqual
  );

  // 派发异步的事件:发送网络请求
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(fetchHomeDataAction());
  }, [dispatch]);
  return (
    <HomeWrapper>
      <HomeBanner />
      <div className="content">
        <div className="discount">
          <SectionHeader
            title={discountInfo.title}
            subtitle={discountInfo.subtitle}
          />
          <SectionRooms
            roomList={discountInfo.dest_list?.["成都"]}
            itemWidth="33.33%"
          />
        </div>
        <HomeSectionV1 infoData={goodPriceInfo} />
        <HomeSectionV1 infoData={highScoreInfo} />
      </div>
    </HomeWrapper>
  );
});

export default Home;

更新section-rooms和home-section-v1

jsx
import PropTypes from "prop-types";
import React, { memo } from "react";

import RoomItem from "@/components/room-item";
import { RoomsWrap } from "./style";

const SectionRooms = memo((props) => {
  const { roomList = [], itemWidth } = props;
  return (
    <RoomsWrap>
      {roomList.slice(0, 8).map((item) => {
        return <RoomItem itemData={item} key={item.id} itemWidth={itemWidth} />;
      })}
    </RoomsWrap>
  );
});

SectionRooms.propTypes = {
  roomList: PropTypes.array,
  itemWidth: PropTypes.string,
};

export default SectionRooms;
jsx
import SectionHeader from "@/components/section-header";
import SectionRooms from "@/components/section-rooms";
import PropTypes from "prop-types";
import React, { memo } from "react";
import { SectionV1Wrapper } from "./style";

const index = memo((props) => {
  const { infoData } = props;
  return (
    <SectionV1Wrapper>
      <SectionHeader title={infoData.title} subtitle={infoData.subtitle} />
      <SectionRooms roomList={infoData.list} itemWidth="25%" />
    </SectionV1Wrapper>
  );
});

index.propTypes = {
  infoData: PropTypes.object,
};

export default index;

项目-首页-折扣区域tabs的封装和切换

封装选项卡组件

svg组件

在components中新建section-tabs文件夹,在里面新建index.jsx和style.js

  • 安装classnames,npm i classnames
jsx
import PropTypes from "prop-types";
import React, { memo, useState } from "react";
import classNames from "classnames";
import { TabsWrapper } from "./style";

const SectionTabs = memo((props) => {
  const { tabNames = [] } = props;
  const [currentIndex, setCurrentIndex] = useState(0);
  function itemClickHandle(index) {
    setCurrentIndex(index);
  }
  return (
    <TabsWrapper>
      {tabNames.map((item, index) => {
        return (
          <div
            className={classNames("item", { active: currentIndex === index })}
            key={index}
            onClick={(e) => itemClickHandle(index)}
          >
            {item}
          </div>
        );
      })}
    </TabsWrapper>
  );
});

SectionTabs.propTypes = {
  tabNames: PropTypes.array,
};

export default SectionTabs;
jsx
import styled from "styled-components";

export const TabsWrapper = styled.div`
  display: flex;

  .item {
    box-sizing: border-box;
    flex-basis: 120px;
    flex-shrink: 0;
    padding: 14px 16px;
    margin-right: 16px;
    border-radius: 3px;
    font-size: 17px;
    text-align: center;
    border: 0.5px solid #d8d8d8;
    white-space: nowrap;
    cursor: pointer;
    ${(props) => props.theme.mixin.boxShadow}
    &:last-child {
      margin-right: 0;
    }
    &.active {
      color: #fff;
      background-color: ${(props) => props.theme.color.secondaryColor};
    }
  }
`;

Home组件导入SectionTabs组件

jsx
import { fetchHomeDataAction } from "@/store/modules/home";
import React, { memo, useEffect, useState } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import HomeBanner from "./c-cpns/home-banner";
import HomeSectionV1 from "./c-cpns/home-section-v1";
import SectionHeader from "@/components/section-header";
import SectionRooms from "@/components/section-rooms";
import SectionTabs from "@/components/section-tabs";

import { HomeWrapper } from "./style";

const Home = memo(() => {
  // 从redux中获取数据
  const { goodPriceInfo, highScoreInfo, discountInfo } = useSelector(
    (state) => ({
      goodPriceInfo: state.home.goodPriceInfo,
      highScoreInfo: state.home.highScoreInfo,
      discountInfo: state.home.discountInfo,
    }),
    shallowEqual
  );

  // 数据的转换
  const tabNames = discountInfo.dest_address?.map((item) => item.name);

  // 派发异步的事件:发送网络请求
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(fetchHomeDataAction());
  }, [dispatch]);

  return (
    <HomeWrapper>
      <HomeBanner />
      <div className="content">
        <div className="discount">
          <SectionHeader
            title={discountInfo.title}
            subtitle={discountInfo.subtitle}
          />
          <SectionTabs tabNames={tabNames} />
          <SectionRooms
            roomList={discountInfo.dest_list?.["成都"]}
            itemWidth="33.33%"
          />
        </div>
        <HomeSectionV1 infoData={goodPriceInfo} />
        <HomeSectionV1 infoData={highScoreInfo} />
      </div>
    </HomeWrapper>
  );
});

export default Home;

项目-首页-tabs的name切换-数据切换

把城市的点击事件传递出去,实现切换数据效果

  • 函数传递给子组件时,可以用useCallback做性能优化
jsx
import PropTypes from "prop-types";
import React, { memo, useState } from "react";
import classNames from "classnames";
import { TabsWrapper } from "./style";

const SectionTabs = memo((props) => {
  const { tabNames = [], tabClick } = props;
  const [currentIndex, setCurrentIndex] = useState(0);
  function itemClickHandle(index, item) {
    setCurrentIndex(index);
    tabClick(index, item);
  }
  return (
    <TabsWrapper>
      {tabNames.map((item, index) => {
        return (
          <div
            className={classNames("item", { active: currentIndex === index })}
            key={index}
            onClick={(e) => itemClickHandle(index, item)}
          >
            {item}
          </div>
        );
      })}
    </TabsWrapper>
  );
});

SectionTabs.propTypes = {
  tabNames: PropTypes.array,
};

export default SectionTabs;
jsx
import { fetchHomeDataAction } from "@/store/modules/home";
import React, { memo, useEffect, useCallback, useState } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import HomeBanner from "./c-cpns/home-banner";
import HomeSectionV1 from "./c-cpns/home-section-v1";
import SectionHeader from "@/components/section-header";
import SectionRooms from "@/components/section-rooms";
import SectionTabs from "@/components/section-tabs";

import { HomeWrapper } from "./style";

const Home = memo(() => {
  // 从redux中获取数据
  const { goodPriceInfo, highScoreInfo, discountInfo } = useSelector(
    (state) => ({
      goodPriceInfo: state.home.goodPriceInfo,
      highScoreInfo: state.home.highScoreInfo,
      discountInfo: state.home.discountInfo,
    }),
    shallowEqual
  );

  // 数据的转换
  const tabNames = discountInfo.dest_address?.map((item) => item.name);

  const [name, setName] = useState("佛山");
  const tabClickHandle = useCallback(function (index, name) {
    setName(name);
  }, []);

  // 派发异步的事件:发送网络请求
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(fetchHomeDataAction());
  }, [dispatch]);

  return (
    <HomeWrapper>
      <HomeBanner />
      <div className="content">
        <div className="discount">
          <SectionHeader
            title={discountInfo.title}
            subtitle={discountInfo.subtitle}
          />
          <SectionTabs tabNames={tabNames} tabClick={tabClickHandle} />
          <SectionRooms
            roomList={discountInfo.dest_list?.[name]}
            itemWidth="33.33%"
          />
        </div>
        <HomeSectionV1 infoData={goodPriceInfo} />
        <HomeSectionV1 infoData={highScoreInfo} />
      </div>
    </HomeWrapper>
  );
});

export default Home;

项目-首页-区域v2的封装和初次渲染的数据

在home/c-cpns中创建home-section-v2文件夹,抽取组件

处理默认name的问题(默认第一列展示佛山,但是不能写死,要从数据里面获取)

  • 两种解决方案
    • 判断discountInfo有值时才进行渲染
      • 封装一个工具函数isEmptyObject用于判断是否是空对象
    • useEffect进行监听,当infoData改变时重新设置name,但是会导致组件渲染三次
      • 第一次是默认组件渲染
      • 第二次是数据改变渲染
      • 第三次是主动设置渲染
jsx
import styled from "styled-components";
export const SectionV2Wrapper = styled.div`
  margin-top: 30px;
`;
jsx
import PropTypes from "prop-types";
import React, { memo, useState, useCallback } from "react";
import SectionHeader from "@/components/section-header";
import SectionRooms from "@/components/section-rooms";
import SectionTabs from "@/components/section-tabs";

import { SectionV2Wrapper } from "./style";
// import { useEffect } from "react";

const HomeSectionV2 = memo((props) => {
  // 从props获取数据
  const { infoData } = props;
  const initialName = Object.keys(infoData.dest_list)[0];

  // 定义内部的state
  const [name, setName] = useState(initialName);

  // 数据的转换
  const tabNames = infoData.dest_address?.map((item) => item.name);

  //   useEffect(() => {
  //     setName(initialName);
  //   }, [initialName]);

  // 事件处理函数
  const tabClickHandle = useCallback(function (index, name) {
    console.log(index, name);
    setName(name);
  }, []);
  return (
    <SectionV2Wrapper>
      <SectionHeader title={infoData.title} subtitle={infoData.subtitle} />
      <SectionTabs tabNames={tabNames} tabClick={tabClickHandle} />
      <SectionRooms roomList={infoData.dest_list?.[name]} itemWidth="33.33%" />
    </SectionV2Wrapper>
  );
});

HomeSectionV2.propTypes = {
  infoData: PropTypes.object,
};

export default HomeSectionV2;
jsx
export function isEmptyO(obj) {
  return !!Object.keys(obj).length;
}
jsx
export * from "./is-empty-object";
jsx
import { fetchHomeDataAction } from "@/store/modules/home";
import React, { memo, useEffect } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import HomeBanner from "./c-cpns/home-banner";
import HomeSectionV1 from "./c-cpns/home-section-v1";
import HomeSectionV2 from "./c-cpns/home-section-v2";

import { isEmptyO } from "@/utils";

import { HomeWrapper } from "./style";

const Home = memo(() => {
  // 从redux中获取数据
  const { goodPriceInfo, highScoreInfo, discountInfo } = useSelector(
    (state) => ({
      goodPriceInfo: state.home.goodPriceInfo,
      highScoreInfo: state.home.highScoreInfo,
      discountInfo: state.home.discountInfo,
    }),
    shallowEqual
  );

  // 派发异步的事件:发送网络请求
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(fetchHomeDataAction());
  }, [dispatch]);

  return (
    <HomeWrapper>
      <HomeBanner />
      <div className="content">
        {isEmptyO(discountInfo) && <HomeSectionV2 infoData={discountInfo} />}
				{isEmptyO(goodPriceInfo) && <HomeSectionV1 infoData={goodPriceInfo} />}
        {isEmptyO(highScoreInfo) && <HomeSectionV1 infoData={highScoreInfo} />}
      </div>
    </HomeWrapper>
  );
});

export default Home;

项目-首页-推荐数据的请求和展示过程

在services/modules/home.js模块中继续封装请求推荐数据的方法

jsx
import cjRequest from "..";

export function getHomeGoodPriceData() {
  return cjRequest.get({
    url: "/home/goodprice",
  });
}

export function getHomeHighScoreData() {
  return cjRequest.get({
    url: "/home/highscore",
  });
}

export function getHomeDiscountData() {
  return cjRequest.get({
    url: "/home/discount",
  });
}

export function getHomeHotRecommendData() {
  return cjRequest.get({
    url: "/home/hotrecommenddest",
  });
}

编写redux操作代码

jsx
import {
  getHomeDiscountData,
  getHomeGoodPriceData,
  getHomeHighScoreData,
  getHomeHotRecommendData,
} from "@/services";
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";

export const fetchHomeDataAction = createAsyncThunk(
  "fetchdata",
  async (payload, { dispatch }) => {
    getHomeGoodPriceData().then((res) => {
      dispatch(changeGoodPriceInfoAction(res));
    });
    getHomeHighScoreData().then((res) => {
      dispatch(changeHightScoreInfoAction(res));
    });
    getHomeDiscountData().then((res) => {
      dispatch(changeDiscountInfoAction(res));
    });
    getHomeHotRecommendData().then((res) => {
      dispatch(changeRecommendInfoAction(res));
    });
  }
);

const homeSlice = createSlice({
  name: "home",
  initialState: {
    goodPriceInfo: {},
    highScoreInfo: {},
    discountInfo: {},
    recommendInfo: {},
  },
  reducers: {
    changeGoodPriceInfoAction(state, { payload }) {
      state.goodPriceInfo = payload;
    },
    changeHightScoreInfoAction(state, { payload }) {
      state.highScoreInfo = payload;
    },
    changeDiscountInfoAction(state, { payload }) {
      state.discountInfo = payload;
    },
    changeHotRecommendInfoAction(state, { payload }) {
      state.recommendInfo = payload;
    },
  },
});
export const {
  changeGoodPriceInfoAction,
  changeHightScoreInfoAction,
  changeDiscountInfoAction,
  changeRecommendInfoAction,
} = homeSlice.actions;
export default homeSlice.reducer;

Home组件展示数据

jsx
import { fetchHomeDataAction } from "@/store/modules/home";
import React, { memo, useEffect } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import HomeBanner from "./c-cpns/home-banner";
import HomeSectionV1 from "./c-cpns/home-section-v1";
import HomeSectionV2 from "./c-cpns/home-section-v2";

import { isEmptyO } from "@/utils";

import { HomeWrapper } from "./style";

const Home = memo(() => {
  // 从redux中获取数据
  const { goodPriceInfo, highScoreInfo, discountInfo, recommendInfo } =
    useSelector(
      (state) => ({
        goodPriceInfo: state.home.goodPriceInfo,
        highScoreInfo: state.home.highScoreInfo,
        discountInfo: state.home.discountInfo,
        recommendInfo: state.home.recommendInfo,
      }),
      shallowEqual
    );

  // 派发异步的事件:发送网络请求
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(fetchHomeDataAction());
  }, [dispatch]);

  return (
    <HomeWrapper>
      <HomeBanner />
      <div className="content">
        {isEmptyO(discountInfo) && <HomeSectionV2 infoData={discountInfo} />}
        {isEmptyO(recommendInfo) && <HomeSectionV2 infoData={recommendInfo} />}
        {isEmptyO(goodPriceInfo) && <HomeSectionV1 infoData={goodPriceInfo} />}
        {isEmptyO(highScoreInfo) && <HomeSectionV1 infoData={highScoreInfo} />}
      </div>
    </HomeWrapper>
  );
});

export default Home;

项目-首页-区域Footer的封装和展示过程

把查看更多封装成独立组件

svg组件

在components中新建section-footer文件夹,存放该组件

  • 抽取箭头svg
  • 样式通过传递一个name属性来判断是 显示全部 还是 查看更多xxx房源
jsx
import styled from "styled-components";

export const FooterWrapper = styled.div`
  display: flex;
  margin-top: 10px;
  .info {
    display: flex;
    align-items: center;
    cursor: pointer;

    font-size: 17px;
    color: ${(props) => props.color};
    font-weight: 700;
    &:hover {
      text-decoration: underline;
    }
    .text {
      margin-right: 6px;
    }
  }
`;
jsx
import PropTypes from "prop-types";
import React, { memo } from "react";
import { FooterWrapper } from "./style";
import IconMoreArrow from "@/assets/svg/icon_more_arrow";
const index = memo((props) => {
  const { name = "" } = props;

  let showMessage = "显示全部";
  if (name) {
    showMessage = `查看更多${name}房源`;
  }

  return (
    <FooterWrapper color={name ? "#00848A" : "#000"}>
      <div className="info">
        <span>{showMessage}</span>
        <IconMoreArrow />
      </div>
    </FooterWrapper>
  );
});

index.propTypes = {
  name: PropTypes.string,
};

export default index;

HomeSectionV1组件中导入SectionFooter组件

jsx
import SectionHeader from "@/components/section-header";
import SectionRooms from "@/components/section-rooms";
import SectionFooter from "@/components/section-footer";
import PropTypes from "prop-types";
import React, { memo } from "react";
import { SectionV1Wrapper } from "./style";

const index = memo((props) => {
  const { infoData } = props;
  return (
    <SectionV1Wrapper>
      <SectionHeader title={infoData.title} subtitle={infoData.subtitle} />
      <SectionRooms roomList={infoData.list} itemWidth="25%" />
      <SectionFooter />
    </SectionV1Wrapper>
  );
});

index.propTypes = {
  infoData: PropTypes.object,
};

export default index;

HomeSectionV2组件中导入SectionFooter组件

jsx
import PropTypes from "prop-types";
import React, { memo, useState, useCallback } from "react";
import SectionHeader from "@/components/section-header";
import SectionRooms from "@/components/section-rooms";
import SectionTabs from "@/components/section-tabs";
import SectionFooter from "@/components/section-footer";

import { SectionV2Wrapper } from "./style";
// import { useEffect } from "react";

const HomeSectionV2 = memo((props) => {
  // 从props获取数据
  const { infoData } = props;
  const initialName = Object.keys(infoData.dest_list)[0];

  // 定义内部的state
  const [name, setName] = useState(initialName);

  // 数据的转换
  const tabNames = infoData.dest_address?.map((item) => item.name);

  //   useEffect(() => {
  //     setName(initialName);
  //   }, [initialName]);

  // 事件处理函数
  const tabClickHandle = useCallback(function (index, name) {
    setName(name);
  }, []);
  return (
    <SectionV2Wrapper>
      <SectionHeader title={infoData.title} subtitle={infoData.subtitle} />
      <SectionTabs tabNames={tabNames} tabClick={tabClickHandle} />
      <SectionRooms roomList={infoData.dest_list?.[name]} itemWidth="33.33%" />
      <SectionFooter name={name} />
    </SectionV2Wrapper>
  );
});

HomeSectionV2.propTypes = {
  infoData: PropTypes.object,
};

export default HomeSectionV2;

项目-首页-ScrollView组件的封装

封装组件

svg组件
  • 左侧滚动按钮
  • 右侧滚动按钮
  • 实现滚动功能,展示的内容由用户决定

在base-ui文件夹下创建scroll-view文件夹,存放该组件,可以在其他项目中使用

思路分析

  • 使用插槽
  • 当内容超出才显示右侧侧按钮
    • 用一个变量记录按钮是否显示
    • 可滚动宽度大于占据的宽度时右侧按钮显示
jsx
import styled from "styled-components";

export const ViewWrapper = styled.div`
  overflow: hidden;
  .scroll-content {
    display: flex;
  }
`;
jsx
import React, { memo, useState, useEffect, useRef } from "react";
import { ViewWrapper } from "./style";

const ScrollView = memo((props) => {
  // 定义内部的状态
  const [showRight, setShowRight] = useState(false);

  const scrollContentRef = useRef();

  // 组件渲染完毕,判断是否显示右侧的按钮
  useEffect(() => {
    // 一共可以滚动的宽度
    const scrollWidth = scrollContentRef.current.scrollWidth;
    // 本身占据的宽度
    const clientWidth = scrollContentRef.current.clientWidth;
    // 一共可以滚动的距离
    const totalDistance = scrollWidth - clientWidth;
    setShowRight(totalDistance > 0);
  }, [props.children]);
  return (
    <ViewWrapper>
      <button>左边按钮</button>
      {showRight && <button>右边按钮</button>}

      <div className="scroll-content" ref={scrollContentRef}>
        {props.children}
      </div>
    </ViewWrapper>
  );
});

ScrollView.propTypes = {};

export default ScrollView;
jsx
import PropTypes from "prop-types";
import React, { memo, useState } from "react";
import classNames from "classnames";
import { TabsWrapper } from "./style";

import ScrollView from "@/base-ui/scroll-view";

const SectionTabs = memo((props) => {
  const { tabNames = [], tabClick } = props;
  const [currentIndex, setCurrentIndex] = useState(0);
  function itemClickHandle(index, item) {
    setCurrentIndex(index);
    tabClick(index, item);
  }
  return (
    <TabsWrapper>
      <ScrollView>
        {tabNames.map((item, index) => {
          return (
            <div
              className={classNames("item", { active: currentIndex === index })}
              key={index}
              onClick={(e) => itemClickHandle(index, item)}
            >
              {item}
            </div>
          );
        })}
      </ScrollView>
    </TabsWrapper>
  );
});

SectionTabs.propTypes = {
  tabNames: PropTypes.array,
};

export default SectionTabs;

项目-首页-ScrollView点击右边按钮向左滚动

思路:计算滚动区间,有两种计算方法

  • 第一种:前x个itemWidth + margin-right,缺点是越到后面越不好计算
  • 第二种:直接拿到要滚动到的那个元素的offsetLeft偏移量
    • 先拿到要滚动的是第几个item,用一个变量posIndex存储
    • 每次点击右边按钮加1
    • 其次再拿到具体的那个元素设置transform进行滚动
    • 每次滚动完要判断是否继续显示右边按钮
    • 已经滚动距离>可滚动距离就把右边按钮隐藏
    • svg组件
  • 设置position:relative才能获取正确的offsetLeft
jsx
import styled from "styled-components";

export const ViewWrapper = styled.div`
  overflow: hidden;
  .scroll-content {
    position: relative;
    display: flex;

    transition: transform 250ms ease;
  }
`;

使用useRef来存储对应的数据,有好几个性能优化的点

jsx
import React, { memo, useState, useEffect, useRef } from "react";
import { ViewWrapper } from "./style";

const ScrollView = memo((props) => {
  // 定义内部的状态
  const [showRight, setShowRight] = useState(false);
  const [posIndex, setPosIndex] = useState(0);

  const totalDistanceRef = useRef();
  const scrollContentRef = useRef();

  // 组件渲染完毕,判断是否显示右侧的按钮
  useEffect(() => {
    // 一共可以滚动的宽度
    const scrollWidth = scrollContentRef.current.scrollWidth;
    // 本身占据的宽度
    const clientWidth = scrollContentRef.current.clientWidth;
    // 一共可以滚动的距离
    const totalDistance = scrollWidth - clientWidth;
    totalDistanceRef.current = totalDistance;
    setShowRight(totalDistance > 0);
  }, [props.children]);

  // 事件处理逻辑
  function rightClickHandle() {
    const newIndex = posIndex + 1;
    const newEl = scrollContentRef.current.children[newIndex];
    const newOffsetLeft = newEl.offsetLeft;
    scrollContentRef.current.style.transform = `translateX(${-newOffsetLeft}px)`;
    setPosIndex(newIndex);
    // 是否继续显示右侧的按钮
    setShowRight(totalDistanceRef.current > newOffsetLeft);
  }

  return (
    <ViewWrapper>
      <button>左边按钮</button>
      {showRight && <button onClick={rightClickHandle}>右边按钮</button>}

      <div className="scroll-content" ref={scrollContentRef}>
        {props.children}
      </div>
    </ViewWrapper>
  );
});

ScrollView.propTypes = {};

export default ScrollView;

项目-首页-ScrollView左侧按钮和按钮点击实现

实现左侧按钮点击效果,两个按钮的点击事件封装成一个函数

将按钮改成图标(左右箭头图标自行获取封装成组件)

jsx
import styled from "styled-components";

export const ViewWrapper = styled.div`
  position: relative;
  padding: 8px 0;
  .scroll {
    overflow: hidden;
    .scroll-content {
      display: flex;
      transition: transform 250ms ease;
    }
  }

  .control {
    position: absolute;
    z-index: 9;
    display: flex;
    justify-content: center;
    align-items: center;
    width: 28px;
    height: 28px;
    border-radius: 50%;
    text-align: center;
    border-width: 2px;
    border-style: solid;
    border-color: #fff;
    background: #fff;
    box-shadow: 0px 1px 1px 1px rgba(0, 0, 0, 0.14);
    cursor: pointer;

    &.left {
      left: 0;
      top: 50%;
      transform: translate(-50%, -50%);
    }

    &.right {
      right: 0;
      top: 50%;
      transform: translate(50%, -50%);
    }
  }
`;
jsx
import React, { memo, useState, useEffect, useRef } from "react";
import { ViewWrapper } from "./style";

import IconArrowLeft from "@/assets/svg/icon_arrow_left";
import IconArrowRight from "@/assets/svg/icon_arrow_right";

const ScrollView = memo((props) => {
  // 定义内部的状态
  const [showLeft, setShowLeft] = useState(false);
  const [showRight, setShowRight] = useState(false);
  const [posIndex, setPosIndex] = useState(0);

  const totalDistanceRef = useRef();
  const scrollContentRef = useRef();

  // 组件渲染完毕,判断是否显示右侧的按钮
  useEffect(() => {
    // 一共可以滚动的宽度
    const scrollWidth = scrollContentRef.current.scrollWidth;
    // 本身占据的宽度
    const clientWidth = scrollContentRef.current.clientWidth;
    // 一共可以滚动的距离
    const totalDistance = scrollWidth - clientWidth;
    totalDistanceRef.current = totalDistance;
    setShowRight(totalDistance > 0);
  }, [props.children]);

  // 事件处理逻辑
  function controlClickHandle(num) {
    const newIndex = posIndex + num;
    const newEl = scrollContentRef.current.children[newIndex];
    const newOffsetLeft = newEl.offsetLeft;
    scrollContentRef.current.style.transform = `translateX(${-newOffsetLeft}px)`;
    setPosIndex(newIndex);
    // 是否继续显示右侧的按钮
    setShowRight(totalDistanceRef.current > newOffsetLeft);
    setShowLeft(newOffsetLeft > 0);
  }

  return (
    <ViewWrapper>
      {showLeft && (
        <div className="control left" onClick={() => controlClickHandle(-1)}>
          <IconArrowLeft />
        </div>
      )}
      {showRight && (
        <div className="control right" onClick={() => controlClickHandle(1)}>
          <IconArrowRight />
        </div>
      )}

      <div className="scroll">
        <div className="scroll-content" ref={scrollContentRef}>
          {props.children}
        </div>
      </div>
    </ViewWrapper>
  );
});

ScrollView.propTypes = {};

export default ScrollView;

项目-首页-向往数据的请求和滚动展示

services/modules/home.js模块封装请求向往数据方法

jsx
import cjRequest from "..";

export function getHomeGoodPriceData() {
  return cjRequest.get({
    url: "/home/goodprice",
  });
}

export function getHomeHighScoreData() {
  return cjRequest.get({
    url: "/home/highscore",
  });
}

export function getHomeDiscountData() {
  return cjRequest.get({
    url: "/home/discount",
  });
}

export function getHomeHotRecommendData() {
  return cjRequest.get({
    url: "/home/hotrecommenddest",
  });
}

export function getHomeLongforData() {
  return cjRequest.get({
    url: "/home/longfor",
  });
}

编写redux相关代码

jsx
import {
  getHomeDiscountData,
  getHomeGoodPriceData,
  getHomeHighScoreData,
  getHomeHotRecommendData,
  getHomeLongforData,
} from "@/services";
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";

export const fetchHomeDataAction = createAsyncThunk(
  "fetchdata",
  async (payload, { dispatch }) => {
    getHomeGoodPriceData().then((res) => {
      dispatch(changeGoodPriceInfoAction(res));
    });
    getHomeHighScoreData().then((res) => {
      dispatch(changeHightScoreInfoAction(res));
    });
    getHomeDiscountData().then((res) => {
      dispatch(changeDiscountInfoAction(res));
    });
    getHomeHotRecommendData().then((res) => {
      dispatch(changeRecommendInfoAction(res));
    });
    getHomeLongforData().then((res) => {
      dispatch(changeLongforInfoAction(res));
    });
  }
);

const homeSlice = createSlice({
  name: "home",
  initialState: {
    goodPriceInfo: {},
    highScoreInfo: {},
    discountInfo: {},
    recommendInfo: {},
    longforInfo: {},
  },
  reducers: {
    changeGoodPriceInfoAction(state, { payload }) {
      state.goodPriceInfo = payload;
    },
    changeHightScoreInfoAction(state, { payload }) {
      state.highScoreInfo = payload;
    },
    changeDiscountInfoAction(state, { payload }) {
      state.discountInfo = payload;
    },
    changeRecommendInfoAction(state, { payload }) {
      state.recommendInfo = payload;
    },
    changeLongforInfoAction(state, { payload }) {
      state.longforInfo = payload;
    },
  },
});
export const {
  changeGoodPriceInfoAction,
  changeHightScoreInfoAction,
  changeDiscountInfoAction,
  changeRecommendInfoAction,
  changeLongforInfoAction,
} = homeSlice.actions;
export default homeSlice.reducer;

把单个城市item封装成组件,在components中创建longfor-item文件夹存放该组件

svg组件
jsx
import styled from "styled-components";

export const ItemWrapper = styled.div`
  flex-shrink: 0;
  width: 20%;

  .inner {
    padding: 8px;

    .item-info {
      position: relative;
      border-radius: 3px;
      overflow: hidden;
    }
  }

  .cover {
    width: 100%;
  }

  .bg-cover {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: 60%;
    background-image: linear-gradient(
      -180deg,
      rgba(0, 0, 0, 0) 3%,
      rgb(0, 0, 0) 100%
    );
  }

  .info {
    position: absolute;
    left: 8px;
    right: 8px;
    bottom: 0;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    padding: 0 24px 32px;
    color: #fff;

    .city {
      font-size: 18px;
      font-weight: 600;
    }

    .price {
      font-size: 14px;
      margin-top: 5px;
    }
  }
`;
jsx
import PropTypes from "prop-types";
import React, { memo } from "react";
import { ItemWrapper } from "./style";

const LongforItem = memo((props) => {
  const { itemData } = props;
  return (
    <ItemWrapper>
      <div className="inner">
        <div className="item-info">
          <img className="cover" src={itemData.picture_url} alt="" />
          <div className="bg-cover"></div>
          <div className="info">
            <div className="city">{itemData.city}</div>
            <div className="price">均价:{itemData.price}</div>
          </div>
        </div>
      </div>
    </ItemWrapper>
  );
});

LongforItem.propTypes = {
  itemData: PropTypes.object,
};

export default LongforItem;

longfor区域封装成一个组件,在views/home/c-cpns中新建home-longfor文件夹

jsx
import styled from "styled-components";

export const LongforWrapper = styled.div`
  margin-top: 30px;
  .longfor-list {
    display: flex;
    margin: 0 -8px;
  }
`;
jsx
import PropTypes from "prop-types";
import React, { memo } from "react";
import { LongforWrapper } from "./style";

import SectionHeader from "@/components/section-header";
import ScrollView from "@/base-ui/scroll-view";
import LongforItem from "@/components/longfor-item";

const HomeLongfor = memo((props) => {
  const { infoData } = props;
  return (
    <LongforWrapper>
      <SectionHeader title={infoData.title} subtitle={infoData.subtitle} />
      <div className="longfor-list">
        <ScrollView>
          {infoData.list.map((item) => {
            return <LongforItem itemData={item} key={item.city} />;
          })}
        </ScrollView>
      </div>
    </LongforWrapper>
  );
});

HomeLongfor.propTypes = {
  infoData: PropTypes.object,
};

export default HomeLongfor;

在Home组件中进行展示

jsx
import { fetchHomeDataAction } from "@/store/modules/home";
import React, { memo, useEffect } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import HomeBanner from "./c-cpns/home-banner";
import HomeSectionV1 from "./c-cpns/home-section-v1";
import HomeSectionV2 from "./c-cpns/home-section-v2";
import HomeLongfor from "./c-cpns/home-longfor";

import { isEmptyO } from "@/utils";

import { HomeWrapper } from "./style";

const Home = memo(() => {
  // 从redux中获取数据
  const {
    goodPriceInfo,
    highScoreInfo,
    discountInfo,
    recommendInfo,
    longforInfo,
  } = useSelector(
    (state) => ({
      goodPriceInfo: state.home.goodPriceInfo,
      highScoreInfo: state.home.highScoreInfo,
      discountInfo: state.home.discountInfo,
      recommendInfo: state.home.recommendInfo,
      longforInfo: state.home.longforInfo,
    }),
    shallowEqual
  );

  // 派发异步的事件:发送网络请求
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(fetchHomeDataAction());
  }, [dispatch]);

  return (
    <HomeWrapper>
      <HomeBanner />
      <div className="content">
        {isEmptyO(discountInfo) && <HomeSectionV2 infoData={discountInfo} />}
        {isEmptyO(recommendInfo) && <HomeSectionV2 infoData={recommendInfo} />}
        {isEmptyO(longforInfo) && <HomeLongfor infoData={longforInfo} />}
        {isEmptyO(goodPriceInfo) && <HomeSectionV1 infoData={goodPriceInfo} />}
        {isEmptyO(highScoreInfo) && <HomeSectionV1 infoData={highScoreInfo} />}
      </div>
    </HomeWrapper>
  );
});

export default Home;

项目-首页-plus数据的请求和展示过程

services/modules/home.js模块封装请求plus数据方法

jsx
import cjRequest from "..";

export function getHomeGoodPriceData() {
  return cjRequest.get({
    url: "/home/goodprice",
  });
}

export function getHomeHighScoreData() {
  return cjRequest.get({
    url: "/home/highscore",
  });
}

export function getHomeDiscountData() {
  return cjRequest.get({
    url: "/home/discount",
  });
}

export function getHomeHotRecommendData() {
  return cjRequest.get({
    url: "/home/hotrecommenddest",
  });
}

export function getHomeLongforData() {
  return cjRequest.get({
    url: "/home/longfor",
  });
}

export function getHomePlusData() {
  return cjRequest.get({
    url: "/home/plus",
  });
}

编写redux相关代码

jsx
import {
  getHomeDiscountData,
  getHomeGoodPriceData,
  getHomeHighScoreData,
  getHomeHotRecommendData,
  getHomeLongforData,
  getHomePlusData,
} from "@/services";
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";

export const fetchHomeDataAction = createAsyncThunk(
  "fetchdata",
  async (payload, { dispatch }) => {
    getHomeGoodPriceData().then((res) => {
      dispatch(changeGoodPriceInfoAction(res));
    });
    getHomeHighScoreData().then((res) => {
      dispatch(changeHightScoreInfoAction(res));
    });
    getHomeDiscountData().then((res) => {
      dispatch(changeDiscountInfoAction(res));
    });
    getHomeHotRecommendData().then((res) => {
      dispatch(changeRecommendInfoAction(res));
    });
    getHomeLongforData().then((res) => {
      dispatch(changeLongforInfoAction(res));
    });
    getHomePlusData().then((res) => {
      dispatch(changePlusInfoAction(res));
    });
  }
);

const homeSlice = createSlice({
  name: "home",
  initialState: {
    goodPriceInfo: {},
    highScoreInfo: {},
    discountInfo: {},
    recommendInfo: {},
    longforInfo: {},
    plusInfo: {},
  },
  reducers: {
    changeGoodPriceInfoAction(state, { payload }) {
      state.goodPriceInfo = payload;
    },
    changeHightScoreInfoAction(state, { payload }) {
      state.highScoreInfo = payload;
    },
    changeDiscountInfoAction(state, { payload }) {
      state.discountInfo = payload;
    },
    changeRecommendInfoAction(state, { payload }) {
      state.recommendInfo = payload;
    },
    changeLongforInfoAction(state, { payload }) {
      state.longforInfo = payload;
    },
    changePlusInfoAction(state, { payload }) {
      state.plusInfo = payload;
    },
  },
});
export const {
  changeGoodPriceInfoAction,
  changeHightScoreInfoAction,
  changeDiscountInfoAction,
  changeRecommendInfoAction,
  changeLongforInfoAction,
  changePlusInfoAction,
} = homeSlice.actions;
export default homeSlice.reducer;

希望用这种风格来展示数据

svg组件

封装一个新组件,在views/home/c-cpns中创建一个home-section-v3用来存放该组件

jsx
import styled from "styled-components";
export const SectionV3Wrapper = styled.div``;
jsx
import React, { memo } from "react";
import PropTypes from "prop-types";
import ScrollView from "@/base-ui/scroll-view";
import SectionHeader from "@/components/section-header";
import SectionFooter from "@/components/section-footer";
import RoomItem from "@/components/room-item";

import { SectionV3Wrapper } from "./style";

const HomeSectionV3 = memo((props) => {
  const { infoData } = props;
  return (
    <SectionV3Wrapper>
      <SectionHeader title={infoData.title} subtitle={infoData.subtitle} />
      <div className="room-list">
        <ScrollView>
          {infoData.list.map((item) => {
            return <RoomItem itemData={item} key={item.id} itemWidth="20%" />;
          })}
        </ScrollView>
      </div>
      <SectionFooter name="plus" />
    </SectionV3Wrapper>
  );
});

HomeSectionV3.propTypes = {
  infoData: PropTypes.object,
};

export default HomeSectionV3;

设置RoomItem组件不被压缩

jsx
import styled from "styled-components";

export const ItemWrapper = styled.div`
  box-sizing: border-box;
  /* width: 25%; */
  width: ${(props) => props.itemWidth};
  padding: 8px;
  flex-shrink: 0;
  .inner {
    width: 100%;
  }
  //............
`;

Home组件统一设置margin-top,不用每个区域都设置

jsx
import styled from "styled-components";
export const HomeWrapper = styled.div`
  > .content {
    width: 1032px;
    margin: 0 auto;

    > div {
      margin-top: 30px;
    }
  }
`;

首页使用HomeSectionV3

jsx
import { fetchHomeDataAction } from "@/store/modules/home";
import React, { memo, useEffect } from "react";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import HomeBanner from "./c-cpns/home-banner";
import HomeSectionV1 from "./c-cpns/home-section-v1";
import HomeSectionV2 from "./c-cpns/home-section-v2";
import HomeLongfor from "./c-cpns/home-longfor";
import HomeSectionV3 from "./c-cpns/home-section-v3";

import { isEmptyO } from "@/utils";

import { HomeWrapper } from "./style";

const Home = memo(() => {
  // 从redux中获取数据
  const {
    goodPriceInfo,
    highScoreInfo,
    discountInfo,
    recommendInfo,
    longforInfo,
    plusInfo,
  } = useSelector(
    (state) => ({
      goodPriceInfo: state.home.goodPriceInfo,
      highScoreInfo: state.home.highScoreInfo,
      discountInfo: state.home.discountInfo,
      recommendInfo: state.home.recommendInfo,
      longforInfo: state.home.longforInfo,
      plusInfo: state.home.plusInfo,
    }),
    shallowEqual
  );

  // 派发异步的事件:发送网络请求
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(fetchHomeDataAction());
  }, [dispatch]);

  return (
    <HomeWrapper>
      <HomeBanner />
      <div className="content">
        {isEmptyO(discountInfo) && <HomeSectionV2 infoData={discountInfo} />}
        {isEmptyO(recommendInfo) && <HomeSectionV2 infoData={recommendInfo} />}
        {isEmptyO(longforInfo) && <HomeLongfor infoData={longforInfo} />}
        {isEmptyO(goodPriceInfo) && <HomeSectionV1 infoData={goodPriceInfo} />}
        {isEmptyO(highScoreInfo) && <HomeSectionV1 infoData={highScoreInfo} />}
        {isEmptyO(plusInfo) && <HomeSectionV3 infoData={plusInfo} />}
      </div>
    </HomeWrapper>
  );
});

export default Home;

项目-全部-全部页面的跳转和整体思路

SectionFooter添加点击事件跳转到全部页面查看更多房源

jsx
import PropTypes from "prop-types";
import React, { memo } from "react";
import { FooterWrapper } from "./style";
import IconMoreArrow from "@/assets/svg/icon_more_arrow";
import { useNavigate } from "react-router-dom";
const index = memo((props) => {
  const { name = "" } = props;

  let showMessage = "显示全部";
  if (name) {
    showMessage = `查看更多${name}房源`;
  }

  // 事件处理
  const navigate = useNavigate();
  function moreClickHandle() {
    navigate("/entire");
  }
  return (
    <FooterWrapper color={name ? "#00848A" : "#000"}>
      <div className="info" onClick={moreClickHandle}>
        <span>{showMessage}</span>
        <IconMoreArrow />
      </div>
    </FooterWrapper>
  );
});

index.propTypes = {
  name: PropTypes.string,
};

export default index;

搭建全部页面结构

jsx
import styled from "styled-components";
export const EntireWrapper = styled.div``;
jsx
import React, { memo } from "react";
import { EntireWrapper } from "./style";

const Entire = memo(() => {
  return (
    <EntireWrapper>
      <div className="filter">filter-section</div>
      <div className="rooms">room-section</div>
      <div className="pagination">pagination-section</div>
    </EntireWrapper>
  );
});

export default Entire;

项目-全部-全部页面的过滤条件展示和选中

在views/entire中创建c-cpns文件夹用于存放子组件

  • 新建三个组件entire-filter/entire-rooms/entire-pagination,代码略

模拟filter数据

jsx
[
  "人数",
  "可免费取消",
  "房源类型",
  "价格",
  "位置区域",
  "闪定",
  "卧室/床数",
  "促销/优惠",
  "更多筛选条件"
]

编写EntireFilter组件,实现点击高亮功能

jsx
import styled from "styled-components";

export const FilterWrapper = styled.div`
  position: fixed;
  z-index: 99;
  left: 0;
  right: 0;
  top: 80px;

  display: flex;
  align-items: center;
  height: 48px;
  padding-left: 16px;
  border-bottom: 1px solid #f2f2f2;
  background-color: #fff;

  .filter {
    display: flex;

    .item {
      margin: 0 4px 0 8px;
      padding: 6px 12px;
      border: 1px solid #dce0e0;
      border-radius: 4px;
      color: #484848;
      cursor: pointer;

      &.active {
        background: #008489;
        border: 1px solid #008486;
        color: #ffffff;
      }
    }
  }
`;
jsx
import React, { memo, useState } from "react";
import { FilterWrapper } from "./style";
import filterData from "@/assets/data/filter_data.json";
import classNames from "classnames";

const EntireFilter = memo((props) => {
  const [selectItems, setSelectItems] = useState([]);
  function itemClickHandle(item) {
    let newItems = [...selectItems];
    if (newItems.includes(item)) { // 移除操作
      const itemIndex = newItems.findIndex((filterItem) => filterItem === item);
      newItems.splice(itemIndex, 1);
    } else { // 添加操作
      newItems.push(item);
    }
    setSelectItems(newItems);
  }
  return (
    <FilterWrapper>
      <div className="filter">
        {filterData.map((item) => {
          return (
            <div
              className={classNames("item", {
                active: selectItems.includes(item),
              })}
              key={item}
              onClick={(e) => itemClickHandle(item)}
            >
              {item}
            </div>
          );
        })}
      </div>
    </FilterWrapper>
  );
});

export default EntireFilter;

Entire组件导入三个子组件

jsx
import React, { memo } from "react";
import EntireFilter from "./c-cpns/entire-filter";
import EntirePagination from "./c-cpns/entire-pagination";
import EntireRooms from "./c-cpns/entire-rooms";
import { EntireWrapper } from "./style";

const Entire = memo(() => {
  return (
    <EntireWrapper>
      <EntireFilter />
      <EntireRooms />
      <EntirePagination />
    </EntireWrapper>
  );
});

export default Entire;

项目-全部-房间列表数据获取和管理方式

编写redux相关代码(使用传统模式操作redux)

  • 定义常量
jsx
export const CHANGE_CURRENT_PAGE = "entire/change_current_page";
export const CHANGE_ROOM_LIST = "entire/change_room_list";
export const CHANGE_TOTAL_COUNT = "entire/change_total_count";
  • 编写reducer
jsx
import * as actionTypes from "./constants";

const initialState = {
  currentPage: 3, // 当前页码
  roomList: [], // 房间列表
  totalCount: 0, // 总数据个数
};
function reducer(state = initialState, action) {
  switch (action.type) {
    case actionTypes.CHANGE_CURRENT_PAGE:
      return { ...state, currentPage: action.currentPage };
    case actionTypes.CHANGE_ROOM_LIST:
      return { ...state, roomList: action.roomList };
    case actionTypes.CHANGE_TOTAL_COUNT:
      return { ...state, totalCount: action.totalCount };
    default:
      return state;
  }
}

export default reducer;
  • 编写action
jsx
import { getEntireRoomList } from "@/services/modules/entire";
import * as actionTypes from "./constants";

export const changeCurrentPageAction = (currentPage) => ({
  type: actionTypes.CHANGE_CURRENT_PAGE,
  currentPage,
});

export const changeRoomListAction = (roomList) => ({
  type: actionTypes.CHANGE_ROOM_LIST,
  roomList,
});

export const changeTotalCountAction = (totalCount) => ({
  type: actionTypes.CHANGE_TOTAL_COUNT,
  totalCount,
});
  • 获取数据
    • 在services/modules中新建模块entire.js
jsx
import cjRequest from "..";

export function getEntireRoomList(offset = 0, size = 20) {
  return cjRequest.get({
    url: "entire/list",
    params: {
      offset,
      size,
    },
  });
}
  • 发送请求在actionCreators.js中定义fetchRoomListAction方法
jsx
// ↑.............省略
export const fetchRoomListAction = () => {
  // 新的函数
  return async (dispatch, getState) => {
    // 1.根据页码获取最新的数据
    const currentPage = getState().entire.currentPage;
    const res = await getEntireRoomList(currentPage*20);

    // 2.获取到最新的数据,保存到redux的store中
    const roomList = res.list;
    const totalCount = res.totalCount;
    dispatch(changeRoomListAction(roomList));
    dispatch(changeTotalCountAction(totalCount));
  };
};
  • Entire组件中派发该action
jsx
import { fetchRoomListAction } from "@/store/modules/entire/actionCreators";
import React, { memo, useEffect } from "react";
import { useDispatch } from "react-redux";
import EntireFilter from "./c-cpns/entire-filter";
import EntirePagination from "./c-cpns/entire-pagination";
import EntireRooms from "./c-cpns/entire-rooms";
import { EntireWrapper } from "./style";

const Entire = memo(() => {
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(fetchRoomListAction());
  }, [dispatch]);
  return (
    <EntireWrapper>
      <EntireFilter />
      <EntireRooms />
      <EntirePagination />
    </EntireWrapper>
  );
});

export default Entire;

项目-全部-房间列表数据的展示过程

点击logo跳转回首页

jsx
import React, { memo } from "react";
import { LeftWrapper } from "./style";
import IconLogo from "@/assets/svg/icon_logo";
import { useNavigate } from "react-router-dom";
const HeaderLeft = memo(() => {
  const navigate = useNavigate();
  function logoClickHandle() {
    navigate("/home");
  }
  return (
    <LeftWrapper>
      <div className="logo" onClick={logoClickHandle}>
        <IconLogo />
      </div>
    </LeftWrapper>
  );
});

export default HeaderLeft;

编写EntireRooms组件展示数据

jsx
import styled from "styled-components";

export const RoomsWrapper = styled.div`
  padding: 40px 20px;
  .title {
    font-size: 22px;
    color: #222;
    margin: 0 0 10px 10px;
  }
  .list {
    display: flex;
    flex-wrap: wrap;
  }
`;
jsx
import RoomItem from "@/components/room-item";
import { useSelector } from "react-redux";
import React, { memo } from "react";
import { RoomsWrapper } from "./style";

const EntireRooms = memo((props) => {
  // 从redux中获取roomList数据
  const { roomList, totalCount } = useSelector((state) => ({
    roomList: state.entire.roomList,
    totalCount: state.entire.totalCount,
  }));
  return (
    <RoomsWrapper>
      <h2 className="title">{totalCount}家住宿</h2>
      <div className="list">
        {roomList.map((item) => {
          return <RoomItem itemData={item} itemWidth="20%" key={item.id} />;
        })}
      </div>
    </RoomsWrapper>
  );
});

export default EntireRooms;

项目-全部-实现分页功能、请求数据的蒙版展示和细节调整

分页功能,使用material ui的分页Pagination组件

  • 当分页改变时,传递页码重新请求数据
  • 页码滚动回顶部
jsx
import styled from "styled-components";

export const PaginationWrapper = styled.div`
  display: flex;
  justify-content: center;
  margin-top: 30px;
  .page-info {
    text-align: center;

    .info {
      margin-top: 20px;
    }
  }
  .MuiPaginationItem-icon {
    font-size: 20px;
  }
  .MuiPaginationItem-page {
    margin: 0 9px;
    &:hover {
      text-decoration: underline;
    }
  }
  .MuiPaginationItem-page.Mui-selected {
    background-color: #222;
    color: #fff;
    &:hover {
      background-color: #222;
    }
  }
`;
jsx
import React, { memo } from "react";
import Pagination from "@mui/material/Pagination";

import { PaginationWrapper } from "./style";
import { useDispatch, useSelector } from "react-redux";
import { fetchRoomListAction } from "@/store/modules/entire/actionCreators";

const EntirePagination = memo(() => {
  const { currentPage, totalCount } = useSelector((state) => ({
    currentPage: state.entire.currentPage,
    totalCount: state.entire.totalCount,
  }));

  const count = Math.ceil(totalCount / 20);
  const start = currentPage * 20 + 1;
  const end = (currentPage + 1) * 20;

  const dispatch = useDispatch();
  function pageChangeHandle(event, newPage) {
    window.scrollTo(0, 0);
    dispatch(fetchRoomListAction(newPage - 1));
  }

  return (
    <PaginationWrapper>
      <div className="page-info">
        <Pagination count={count} onChange={pageChangeHandle} />
        <div className="info">
          第 {start} - {end} 个房源, 共超过 {totalCount} 个
        </div>
      </div>
    </PaginationWrapper>
  );
});

export default EntirePagination;

修改action和reducer代码

  • 添加一个loading用于实现请求数据过程中展示蒙版功能
jsx
export const CHANGE_CURRENT_PAGE = "entire/change_current_page";
export const CHANGE_ROOM_LIST = "entire/change_room_list";
export const CHANGE_TOTAL_COUNT = "entire/change_total_count";

export const CHANGE_LOADING = "entire/loading";
jsx
import { getEntireRoomList } from "@/services/modules/entire";
import * as actionTypes from "./constants";

export const changeCurrentPageAction = (currentPage) => ({
  type: actionTypes.CHANGE_CURRENT_PAGE,
  currentPage,
});

export const changeRoomListAction = (roomList) => ({
  type: actionTypes.CHANGE_ROOM_LIST,
  roomList,
});

export const changeTotalCountAction = (totalCount) => ({
  type: actionTypes.CHANGE_TOTAL_COUNT,
  totalCount,
});

export const changeLoadingAction = (isLoading) => ({
  type: actionTypes.CHANGE_LOADING,
  isLoading,
});

export const fetchRoomListAction = (page = 0) => {
  // 新的函数
  return async (dispatch, getState) => {
    // 设置isLoading
    dispatch(changeLoadingAction(true));

    // 1.根据页码获取最新的数据
    const res = await getEntireRoomList(page * 20);

    dispatch(changeLoadingAction(false));
    // 2.获取到最新的数据,保存到redux的store中
    const roomList = res.list;
    const totalCount = res.totalCount;
    dispatch(changeCurrentPageAction(page));
    dispatch(changeRoomListAction(roomList));
    dispatch(changeTotalCountAction(totalCount));
  };
};
jsx
import * as actionTypes from "./constants";

const initialState = {
  currentPage: 3, // 当前页码
  roomList: [], // 房间列表
  totalCount: 0, // 总数据个数
};
function reducer(state = initialState, action) {
  switch (action.type) {
    case actionTypes.CHANGE_LOADING:
      return { ...state, isLoading: action.isLoading };
    case actionTypes.CHANGE_CURRENT_PAGE:
      return { ...state, currentPage: action.currentPage };
    case actionTypes.CHANGE_ROOM_LIST:
      return { ...state, roomList: action.roomList };
    case actionTypes.CHANGE_TOTAL_COUNT:
      return { ...state, totalCount: action.totalCount };
    default:
      return state;
  }
}

export default reducer;

EntireRooms组件编写添加蒙版元素

jsx
import styled from "styled-components";

export const RoomsWrapper = styled.div`
  padding: 40px 20px;
  .title {
    font-size: 22px;
    color: #222;
    margin: 0 0 10px 10px;
  }
  .list {
    display: flex;
    flex-wrap: wrap;
  }
  > .cover {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    background-color: rgba(255, 255, 255, 0.8);
  }
`;
jsx
import RoomItem from "@/components/room-item";
import { shallowEqual, useSelector } from "react-redux";
import React, { memo } from "react";
import { RoomsWrapper } from "./style";

const EntireRooms = memo((props) => {
  // 从redux中获取roomList数据
  const { roomList, totalCount, isLoading } = useSelector(
    (state) => ({
      roomList: state.entire.roomList,
      totalCount: state.entire.totalCount,
      isLoading: state.entire.isLoading,
    }),
    shallowEqual
  );
  return (
    <RoomsWrapper>
      <h2 className="title">{totalCount}家住宿</h2>
      <div className="list">
        {roomList.map((item) => {
          return <RoomItem itemData={item} itemWidth="20%" key={item._id} />;
        })}
      </div>
      {isLoading && <div className="cover"></div>}
    </RoomsWrapper>
  );
});

export default EntireRooms;

项目-全部-RoomItem的轮播图实现效果

对RoomItem组件进行重构,使用antd的轮播图组件

  • 如果是首页则展示一张图片,全部页则展示轮播图组件(判断有没有picture_urls)
  • 实现左右切换
jsx
import PropTypes from "prop-types";
import React, { memo, useRef } from "react";
import { ItemWrapper } from "./style";
import { Carousel } from "antd";
import Rating from "@mui/material/Rating";

import IconArrowLeft from "@/assets/svg/icon_arrow_left";
import IconArrowRight from "@/assets/svg/icon_arrow_right";

const RoomItem = memo((props) => {
  const { itemData, itemWidth = "25%" } = props;

  const sliderRef = useRef();

  // 事件处理的逻辑
  function controlClickHandle(isRight = true) {
    isRight ? sliderRef.current.next() : sliderRef.current.prev();
  }
  return (
    <ItemWrapper
      verifyColor={itemData?.verify_info?.text_color || "#39576a"}
      itemWidth={itemWidth}
    >
      <div className="inner">
        {!itemData.picture_urls ? (
          <div className="cover">
            <img src={itemData.picture_url} alt="" />
          </div>
        ) : (
          <div className="slider">
            <div className="control">
              <div
                className="btn left"
                onClick={(e) => controlClickHandle(false)}
              >
                <IconArrowLeft width="30" height="30" />
              </div>
              <div className="btn right" onClick={(e) => controlClickHandle()}>
                <IconArrowRight width="30" height="30" />
              </div>
            </div>
            <Carousel dots={false} ref={sliderRef}>
              {itemData?.picture_urls?.map((item) => {
                return (
                  <div className="cover" key={item}>
                    <img src={item} alt="" />
                  </div>
                );
              })}
            </Carousel>
          </div>
        )}
        <div className="desc">{itemData.verify_info.messages.join(" · ")}</div>
        <div className="name">{itemData.name}</div>
        <div className="price">¥{itemData.price}/晚</div>

        <div className="bottom">
          <Rating
            value={itemData.star_rating ?? 5}
            precision={0.1}
            readOnly
            sx={{ fontSize: "12px", color: "#008489" }}
          />
          <span className="count">{itemData.reviews_count}</span>
          {itemData?.bottom_info && (
            <span className="extra">· {itemData.bottom_info.content}</span>
          )}
        </div>
      </div>
    </ItemWrapper>
  );
});

RoomItem.propTypes = {
  itemData: PropTypes.object,
  itemWidth: PropTypes.string,
};

export default RoomItem;
jsx
import styled from "styled-components";

export const ItemWrapper = styled.div`
  box-sizing: border-box;
  /* width: 25%; */
  width: ${(props) => props.itemWidth};
  padding: 8px;
  flex-shrink: 0;
  .inner {
    width: 100%;
  }
  .cover {
    position: relative;
    box-sizing: border-box;
    padding: 66.66% 8px 0;
    border-radius: 3px;
    overflow: hidden;

    img {
      object-fit: cover;
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
    }
  }

  .slider {
    position: relative;
    cursor: pointer;
    &:hover {
      .control {
        display: flex;
      }
    }
    .control {
      position: absolute;
      z-index: 1;
      left: 0;
      right: 0;
      top: 0;
      display: none;
      justify-content: space-between;
      bottom: 0;
      color: #fff;
      /* background-color: skyblue; */
      .btn {
        display: flex;
        justify-content: center;
        align-items: center;
        width: 83px;
        height: 100%;
        background: linear-gradient(
          to left,
          transparent 0%,
          rgba(0, 0, 0, 0.25) 100%
        );
        &.right {
          background: linear-gradient(
            to right,
            transparent 0%,
            rgba(0, 0, 0, 0.25) 100%
          );
        }
      }
    }
  }

  .desc {
    margin: 10px 0 5px;
    font-size: 12px;
    font-weight: 700;
    color: ${(props) => props.verifyColor};
  }

  .name {
    font-size: 16px;
    font-weight: 700;

    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
  }

  .price {
    margin: 8px 0;
  }

  .bottom {
    display: flex;
    align-items: center;
    font-size: 12px;
    font-weight: 600;
    color: ${(props) => props.theme.text.primaryColor};

    .count {
      margin: 0 2px 0 4px;
    }

    .MuiRating-icon {
      margin-right: -2px;
    }
  }
`;

项目-Indicator组件封装-结构的搭建

制作指示器组件,在base-ui文件夹中新建indicator文件夹存放指示器组件

jsx
import PropTypes from "prop-types";
import React, { memo } from "react";
import { IndicatorWrapper } from "./style";

const Indicator = memo((props) => {
  return (
    <IndicatorWrapper>
      <div className="i-content">{props.children}</div>
    </IndicatorWrapper>
  );
});

Indicator.propTypes = {};

export default Indicator;
jsx
import styled from "styled-components";

export const IndicatorWrapper = styled.div`
  .i-content {
    display: flex;
    overflow: hidden;
    > * {
      flex-shrink: 0;
    }
  }
`;

项目-Indicator组件封装-滚动位置的实现

svg组件
jsx
import PropTypes from "prop-types";
import React, { memo, useEffect, useRef, useState } from "react";
import { IndicatorWrapper } from "./style";

const Indicator = memo((props) => {
  const { selectIndex = 0 } = props;
  const contentRef = useRef();
  useEffect(() => {
    // 1.获取selectIndex对应的item
    const selectItemEl = contentRef.current.children[selectIndex];
    const itemLeft = selectItemEl.offsetLeft;
    const itemWidth = selectItemEl.clientWidth;
    // 2.content的宽度
    const contentWidth = contentRef.current.clientWidth;
    // 获取selectIndex要滚动的距离
    const distance = itemLeft + itemWidth * 0.5 - contentWidth * 0.5;
    contentRef.current.style.transform = `translate(${-distance}px)`;
  }, [selectIndex]);
  return (
    <IndicatorWrapper>
      <div ref={contentRef} className="i-content">
        {props.children}
      </div>
    </IndicatorWrapper>
  );
});

Indicator.propTypes = {
  selectIndex: PropTypes.number,
};

export default Indicator;
jsx
import styled from "styled-components";

export const IndicatorWrapper = styled.div`
  overflow: hidden;
  .i-content {
    display: flex;
    position: relative;
    transition: transform 200ms ease;
    > * {
      flex-shrink: 0;
    }
  }
`;

新建一个demo页面组件进行测试

jsx
import Indicator from "@/base-ui/indicator";
import React, { memo, useState } from "react";
import { DemoWrapper } from "./style";

const Demo = memo(() => {
  const names = ["abc", "cba", "nba", "mba", "aaa", "bbb", "ccc"];
  const [selectIndex, setSelectIndex] = useState(0);
  function toggleClickHandle(isNext = true) {
    let newIndex = isNext ? selectIndex + 1 : selectIndex - 1;
    if (newIndex < 0) newIndex = names.length - 1;
    if (newIndex > names.length - 1) newIndex = 0;
    setSelectIndex(newIndex);
  }
  return (
    <DemoWrapper>
      <div className="control">
        <button onClick={(e) => toggleClickHandle(false)}>上一个</button>
        <button onClick={(e) => toggleClickHandle(true)}>下一个</button>
      </div>
      <div className="list">
        <Indicator selectIndex={selectIndex}>
          {names.map((item) => {
            return <button key={item}>{item}</button>;
          })}
        </Indicator>
      </div>
    </DemoWrapper>
  );
});

export default Demo;
jsx
import styled from "styled-components";

export const DemoWrapper = styled.div`
  .list {
    width: 100px;
  }
`;

项目-Indicator组件封装-左右特殊情况

要通过计算判断什么情况下需要居中,什么情况下不需要居中

  • 左边的特殊情况处理:判断如果计算出来的距离为负数就不需要移动
  • 右边的特殊情况处理:最多可以移动的距离
svg组件
jsx
import PropTypes from "prop-types";
import React, { memo, useEffect, useRef, useState } from "react";
import { IndicatorWrapper } from "./style";

const Indicator = memo((props) => {
  const { selectIndex = 0 } = props;
  const contentRef = useRef();
  useEffect(() => {
    // 1.获取selectIndex对应的item
    const selectItemEl = contentRef.current.children[selectIndex];
    const itemLeft = selectItemEl.offsetLeft;
    const itemWidth = selectItemEl.clientWidth;
    // 2.content的宽度
    const contentWidth = contentRef.current.clientWidth;
    const contentScroll = contentRef.current.scrollWidth;
    // 3.获取selectIndex要滚动的距离
    let distance = itemLeft + itemWidth * 0.5 - contentWidth * 0.5;
    // 4.特殊情况的处理
    if (distance < 0) distance = 0; // 左边的特殊情况处理
    const totalDistance = contentScroll - contentWidth;
    if (distance > totalDistance) distance = totalDistance; // 右边的特殊情况处理

    // 5.改变位置即可
    contentRef.current.style.transform = `translate(${-distance}px)`;
  }, [selectIndex]);
  return (
    <IndicatorWrapper>
      <div ref={contentRef} className="i-content">
        {props.children}
      </div>
    </IndicatorWrapper>
  );
});

Indicator.propTypes = {
  selectIndex: PropTypes.number,
};

export default Indicator;

项目-全部-RoomItem的指示器实现

在RoomItem中使用Indicator组件

jsx
import PropTypes from "prop-types";
import React, { memo, useRef, useState } from "react";
import { ItemWrapper } from "./style";
import { Carousel } from "antd";
import Rating from "@mui/material/Rating";

import IconArrowLeft from "@/assets/svg/icon_arrow_left";
import IconArrowRight from "@/assets/svg/icon_arrow_right";
import Indicator from "@/base-ui/indicator";
import classNames from "classnames";

const RoomItem = memo((props) => {
  const { itemData, itemWidth = "25%" } = props;
  const [selectIndex, setSelectIndex] = useState(0);
  const sliderRef = useRef();

  // 事件处理的逻辑
  function controlClickHandle(isRight = true) {
    // 上一个面板/下一个面板
    isRight ? sliderRef.current.next() : sliderRef.current.prev();

    // 最新的索引
    let newIndex = isRight ? selectIndex + 1 : selectIndex - 1;
    const length = itemData.picture_urls.length;
    if (newIndex < 0) newIndex = length - 1;
    if (newIndex > length - 1) newIndex = 0;
    setSelectIndex(newIndex);
  }
  return (
    <ItemWrapper
      verifyColor={itemData?.verify_info?.text_color || "#39576a"}
      itemWidth={itemWidth}
    >
      <div className="inner">
        {!itemData.picture_urls ? (
          <div className="cover">
            <img src={itemData.picture_url} alt="" />
          </div>
        ) : (
          <div className="slider">
            <div className="control">
              <div
                className="btn left"
                onClick={(e) => controlClickHandle(false)}
              >
                <IconArrowLeft width="30" height="30" />
              </div>
              <div className="btn right" onClick={(e) => controlClickHandle()}>
                <IconArrowRight width="30" height="30" />
              </div>
            </div>
            <div className="indicator">
              <Indicator selectIndex={selectIndex}>
                {itemData?.picture_urls?.map((item, index) => {
                  return (
                    <div className="dot-item" key={item}>
                      <span
                        className={classNames("dot", {
                          active: selectIndex === index,
                        })}
                      ></span>
                    </div>
                  );
                })}
              </Indicator>
            </div>
            <Carousel dots={false} ref={sliderRef}>
              {itemData?.picture_urls?.map((item) => {
                return (
                  <div className="cover" key={item}>
                    <img src={item} alt="" />
                  </div>
                );
              })}
            </Carousel>
          </div>
        )}
        <div className="desc">{itemData.verify_info.messages.join(" · ")}</div>
        <div className="name">{itemData.name}</div>
        <div className="price">¥{itemData.price}/晚</div>

        <div className="bottom">
          <Rating
            value={itemData.star_rating ?? 5}
            precision={0.1}
            readOnly
            sx={{ fontSize: "12px", color: "#008489" }}
          />
          <span className="count">{itemData.reviews_count}</span>
          {itemData?.bottom_info && (
            <span className="extra">· {itemData.bottom_info.content}</span>
          )}
        </div>
      </div>
    </ItemWrapper>
  );
});

RoomItem.propTypes = {
  itemData: PropTypes.object,
  itemWidth: PropTypes.string,
};

export default RoomItem;
jsx
import styled from "styled-components";

export const ItemWrapper = styled.div`
  box-sizing: border-box;
  /* width: 25%; */
  width: ${(props) => props.itemWidth};
  padding: 8px;
  flex-shrink: 0;
  .inner {
    width: 100%;
  }
  .cover {
    position: relative;
    box-sizing: border-box;
    padding: 66.66% 8px 0;
    border-radius: 3px;
    overflow: hidden;

    img {
      object-fit: cover;
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
    }
  }

  .slider {
    position: relative;
    cursor: pointer;
    &:hover {
      .control {
        display: flex;
      }
    }
    .control {
      position: absolute;
      z-index: 1;
      left: 0;
      right: 0;
      top: 0;
      display: none;
      justify-content: space-between;
      bottom: 0;
      color: #fff;
      /* background-color: skyblue; */
      .btn {
        display: flex;
        justify-content: center;
        align-items: center;
        width: 83px;
        height: 100%;
        background: linear-gradient(
          to left,
          transparent 0%,
          rgba(0, 0, 0, 0.25) 100%
        );
        &.right {
          background: linear-gradient(
            to right,
            transparent 0%,
            rgba(0, 0, 0, 0.25) 100%
          );
        }
      }
    }
    .indicator {
      position: absolute;
      z-index: 9;
      bottom: 10px;
      left: 0;
      right: 0;
      margin: 0 auto;
      width: 30%;

      .dot-item {
        display: flex;
        justify-content: center;
        align-items: center;
        width: 20%;
        .dot {
          width: 6px;
          height: 6px;
          background-color: #fff;
          border-radius: 50%;
          &.active {
            width: 8px;
            height: 8px;
          }
        }
      }
    }
  }

  .desc {
    margin: 10px 0 5px;
    font-size: 12px;
    font-weight: 700;
    color: ${(props) => props.verifyColor};
  }

  .name {
    font-size: 16px;
    font-weight: 700;

    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
  }

  .price {
    margin: 8px 0;
  }

  .bottom {
    display: flex;
    align-items: center;
    font-size: 12px;
    font-weight: 600;
    color: ${(props) => props.theme.text.primaryColor};

    .count {
      margin: 0 2px 0 4px;
    }

    .MuiRating-icon {
      margin-right: -2px;
    }
  }
`;

项目-Item点击跳转到详情页面

把item点击事件处理函数定义在props

  • 把item数据传递出去
jsx
import PropTypes from "prop-types";
import React, { memo, useRef, useState } from "react";
import { ItemWrapper } from "./style";
import { Carousel } from "antd";
import Rating from "@mui/material/Rating";

import IconArrowLeft from "@/assets/svg/icon_arrow_left";
import IconArrowRight from "@/assets/svg/icon_arrow_right";
import Indicator from "@/base-ui/indicator";
import classNames from "classnames";

const RoomItem = memo((props) => {
  const { itemData, itemWidth = "25%", itemClick } = props;
  const [selectIndex, setSelectIndex] = useState(0);
  const sliderRef = useRef();

  // 事件处理的逻辑
  function controlClickHandle(isRight = true) {
    // 上一个面板/下一个面板
    isRight ? sliderRef.current.next() : sliderRef.current.prev();

    // 最新的索引
    let newIndex = isRight ? selectIndex + 1 : selectIndex - 1;
    const length = itemData.picture_urls.length;
    if (newIndex < 0) newIndex = length - 1;
    if (newIndex > length - 1) newIndex = 0;
    setSelectIndex(newIndex);
  }

  function itemClickHandle() {
    if (itemClick) itemClick(itemData);
  }
  return (
    <ItemWrapper
      verifyColor={itemData?.verify_info?.text_color || "#39576a"}
      itemWidth={itemWidth}
      onClick={itemClickHandle}
    >
      <div className="inner">
        {!itemData.picture_urls ? (
          <div className="cover">
            <img src={itemData.picture_url} alt="" />
          </div>
        ) : (
          <div className="slider">
            <div className="control">
              <div
                className="btn left"
                onClick={(e) => controlClickHandle(false)}
              >
                <IconArrowLeft width="30" height="30" />
              </div>
              <div className="btn right" onClick={(e) => controlClickHandle()}>
                <IconArrowRight width="30" height="30" />
              </div>
            </div>
            <div className="indicator">
              <Indicator selectIndex={selectIndex}>
                {itemData?.picture_urls?.map((item, index) => {
                  return (
                    <div className="dot-item" key={item}>
                      <span
                        className={classNames("dot", {
                          active: selectIndex === index,
                        })}
                      ></span>
                    </div>
                  );
                })}
              </Indicator>
            </div>
            <Carousel dots={false} ref={sliderRef}>
              {itemData?.picture_urls?.map((item) => {
                return (
                  <div className="cover" key={item}>
                    <img src={item} alt="" />
                  </div>
                );
              })}
            </Carousel>
          </div>
        )}
        <div className="desc">{itemData.verify_info.messages.join(" · ")}</div>
        <div className="name">{itemData.name}</div>
        <div className="price">¥{itemData.price}/晚</div>

        <div className="bottom">
          <Rating
            value={itemData.star_rating ?? 5}
            precision={0.1}
            readOnly
            sx={{ fontSize: "12px", color: "#008489" }}
          />
          <span className="count">{itemData.reviews_count}</span>
          {itemData?.bottom_info && (
            <span className="extra">· {itemData.bottom_info.content}</span>
          )}
        </div>
      </div>
    </ItemWrapper>
  );
});

RoomItem.propTypes = {
  itemData: PropTypes.object,
  itemWidth: PropTypes.string,
};

export default RoomItem;

在store/modules中新建一个detail.js模块,编写代码并合并reducer

jsx
import { createSlice } from "@reduxjs/toolkit";

const detailSlice = createSlice({
  name: "detail",
  initialState: {
    detailInfo: {},
  },
  reducers: {
    changeDetailInfoAction(state, { payload }) {
      state.detailInfo = payload;
    },
  },
});

export const { changeDetailInfoAction } = detailSlice.actions;
export default detailSlice.reducer;
jsx
import { configureStore } from "@reduxjs/toolkit";
import homeReducer from "./modules/home";
import entireReducer from "./modules/entire";
import detailReducer from "./modules/detail";

const store = configureStore({
  reducer: {
    home: homeReducer,
    entire: entireReducer,
    detail: detailReducer,
  },
});

export default store;

点击时派发action把item数据保存到redux,并跳转到详情页

jsx
import RoomItem from "@/components/room-item";
import { shallowEqual, useDispatch, useSelector } from "react-redux";
import React, { memo, useCallback } from "react";
import { RoomsWrapper } from "./style";
import { useNavigate } from "react-router-dom";
import { changeDetailInfoAction } from "@/store/modules/detail";

const EntireRooms = memo((props) => {
  // 从redux中获取roomList数据
  const { roomList, totalCount, isLoading } = useSelector(
    (state) => ({
      roomList: state.entire.roomList,
      totalCount: state.entire.totalCount,
      isLoading: state.entire.isLoading,
    }),
    shallowEqual
  );

  // 事件处理
  const navigate = useNavigate();
  const dispatch = useDispatch();
  const itemClickHandle = useCallback(
    (item) => {
      dispatch(changeDetailInfoAction(item));
      navigate("/detail");
    },
    [navigate, dispatch]
  );
  return (
    <RoomsWrapper>
      <h2 className="title">{totalCount}家住宿</h2>
      <div className="list">
        {roomList.map((item) => {
          return (
            <RoomItem
              itemClick={itemClickHandle}
              itemData={item}
              itemWidth="20%"
              key={item._id}
            />
          );
        })}
      </div>
      {isLoading && <div className="cover"></div>}
    </RoomsWrapper>
  );
});

export default EntireRooms;

在detail页面中获取数据

jsx
import React, { memo } from "react";
import { useSelector } from "react-redux";

const Detail = memo(() => {
  const { detailInfo } = useSelector((state) => ({
    detailInfo: state.detail.detailInfo,
  }));
  return <div>{detailInfo.name}</div>;
});

export default Detail;