仿贝壳app全景看房(React+Mobx+Egg+Three.js入门级全栈实战项目)

28,273 阅读10分钟

背景

前段时间学习了一下React,感觉学起来还是比较愉快的,遂迫不及待的写了个项目牛刀小试了一下;学的时候感觉很多东西就是这么回事,但是真正实践起来确实也碰到了很多意想不到的问题,但是通过强大的搜索引擎还是把这些问题解决掉了。实践出真理,现在就让我介绍一下这个项目。

介绍

模仿贝壳看房app,简单的实现了 app 首页我的页面,使用 three.js 做了一个全景看房模块。

  • 技术栈:React+Mobx+Egg+Three.js+Echarts
  • 使用 React 编写前端页面,Mobx 进行数据的管理, 遵循组件模块化的思想,将可复用的、独立的功能方法封装到一个模块当中,使用 Egg 进行后端搭建,同时进行了路由集中式的管理。
  • 功能实现:使用 Three.js 简单实现全景看房的功能

项目思路

首先就是对贝壳看房app进行分析。进来 app 首页点击下方的 Tabber 标签栏就能实现页面的切换;于是就决定将这五个页面写成五个不同的路由,将这些路由进行集中的管理。每个页面里面的内容进行组件式的封装用不同的路由地址展示组件的内容,再引用到该页面上,大致思路就是这样。

项目预处理

Vite将项目搭建起来之后,在src文件夹里面建立components(存放组件)、pages(项目所有的页面)、routes(路由管理)、store(仓库数据的管理)以及utils(公共工具包)文件夹。看到贝壳看房app里面涉及到很多Icon的使用,故先在components文件夹里面使用react-vant的自定义图标进行Iconfont图标的引入处理,为了在引入文件时方便点在 vite.config.js 里面配置路径的别名。

部分代码如下:

// vite.config.js 配置路径别名
 resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
      'utils': path.resolve(__dirname, 'src/utils')
    }
  },
  
// src/components/myIcon/index.jsx  引入外部图标
import { createFromIconfontCN } from '@react-vant/icons'
export default createFromIconfontCN('//at.alicdn.com/t/c/font_3847257_b9j1g2f7n06.js')

成果

d7772441-70ab-4e3e-9b88-ed68e2668f09.gif

c32aa4aa-979b-465d-b5f6-cf8eae7b3d07.gif

53b51358-818c-4345-9f85-ceac932b1585.gif

前端

  • 组件库:React-vant
  • Icon: Iconfont
  • 脚手架:Vite
  • 地图api:百度地图api

路由配置

这里我把项目的所有路由都配置到这个routes/index.jsx里面,这样集中的配置好路由再将这个路由抛出使用。 所以先维护出一个路由数组 routes=[],再配置对应的路由以及路由页面。 src/routes/index.jsx 部分代码:完整代码点击这里

import { useRoutes } from 'react-router-dom'
import { lazy, Suspense } from 'react'
import { Loading } from 'react-vant';

// 页面组件的懒加载
const Home = lazy(() => import('../pages/Home'))
const Meaasge = lazy(() => import('../pages/Message'))
const Info = lazy(() => import('../pages/Info'))

.......

const routes = [
    {
        path: '/',
        element: <Home />
    },
    {
        path: '/message',
        element: <Meaasge />
    },
   ........
]



function RouterList() {
    let element = useRoutes(routes)  // 读取路由数组
    return element
}

function Router() {
    let style = {    // 菊花图的位置
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
        marginTop: '300px'
    }
    return (
        // 加载菊花图
        <Suspense fallback={<Loading type="spinner" color="#3f45ff" style={style} />}>
            <RouterList />
        </Suspense>
    )
}

export default Router

store仓库配置

我们都知道仓库就是用来存储数据的一些状态,这里使用了MobX状态管理库将项目响应式的数据与页面绑定起来,进而实现数据驱动视图的效果,本项目的store仓库同样也沿用了模块化的思想,将不同页面的数据使用不同的仓库存储起来,最后汇总到RootStore里面,要使用的时候只需要引用RootStore,再使用某个具体的仓库。 部分代码额如下:完整代码点击这里

// src/store/index.js
import { createContext, useContext } from "react";
import { observer } from 'mobx-react-lite';
import homeStore from './homeStore'
import mainStore from "./mainStore";
import userStore from "./userStore"

class RootStore {
  constructor() {
    this.homeStore = homeStore
    this.mainStore = mainStore
    this.userStore = userStore
  }
}

const rootStore = new RootStore()

const context = createContext(rootStore)
const useStore = () => {
  return useContext(context)
}

export { useStore, observer }

// src/store/homeStore.js
import { makeAutoObservable, runInAction } from "mobx";  // 创建一个响应式仓库

class HomeStore {

    historyArray = []    // 搜索历史记录
    recommendItem = []   // 首页推荐列表
......

    constructor() {
        makeAutoObservable(this);  // 将this放进去将仓库变成响应式的
    }

    addHistory(e) {
        this.historyArray.unshift(e)
    }
.......
}

export default new HomeStore()

首页页面

QQ图片20230117225340.jpg

首页功能预览

20230117230515.png

首页内容

首页页面被分成了五个模块,从上往下依次是:Header(顶部搜索栏与右侧地图)、House(二手房、新房、租房)、More(轮播图)、Deal(贝壳指数与我的房子模块)、Botton(下方推荐模块)。

  • Header:实现的效果是点击搜索栏直接跳转搜索页面,点击地图跳转到地图页面 src/pages/Home/components/Header/index.jsx 部分代码:完整代码点击这里
import MyIcon from '@/components/myIcon'
import React, { useState } from 'react';
import { Search, Toast } from 'react-vant';
import s from './style.module.less'
import { useNavigate } from 'react-router-dom'

export default () => {

  const navigate = useNavigate()

  const goToMap = () => {   // 跳转地图页面
    navigate('/map')
  }
  const goToSearch = () => {  // 跳转搜索页面
    navigate('/search')
    console.log(1);
  }
  return (
    <div className='main'>
      <Search
        onClickInput={() => goToSearch()}
        showAction
        label="南昌"
        actionText={<div onClick={() => goToMap()}>
          <div className={s.map} >
            <MyIcon name='icon-ditu' style={{ fontSize: 25, }} />
          </div>
        </div>}
        placeholder="请输入小区/楼盘/地点"
      />
    </div>
  );
};
  • House:这个模块分为左中右三部分对应着 二手房、新房、租房三个页面,但是进入这些页面的时候也出现了首页下面的Tabbar标签栏,于是在 App.jsx 里面对底部标签栏的出现进行了处理:维护出一个数组用于存储出现Tabbar页面的路径,当切换页面的时候获取到当前页面的路径,如果不在此数组中就将Tabbar隐藏掉。
  • src/pages/Home/components/House/index.jsx 部分代码:完整代码点击这里
// App.jsx 部分代码
function App() {
  const [showNav, setShowNav] = useState(false)
  const needNav = ['/', '/message', '/info', '/recommand', '/user']  // 需要被加载Tabbar的路由
  const { pathname } = useLocation()

  useEffect(() => {
    setShowNav(needNav.includes(pathname))
    console.log(showNav);
  }, [pathname])

  return (
    <ConfigProvider>
      <>
        <Router />
        {/* 让导航栏在底部五个路径的页面显示,其他页面则不显示 */}
        <div className={cs({ [s.hidden]: showNav == false })}>
          <NavBar />
        </div>

      </>

    </ConfigProvider>
  )
}
  • More:轮播图模块并没有做相应的功能,仅仅实现轮播的效果。完整代码点击这里
  • Deal:这个模块如下:

Snipaste_2023-01-18_10-12-43.png

实现了点击上方 贝壳指数我的房子 实现下面内容切换的效果,同时点击贝壳指数以及立即添加按钮实现相应页面的跳转;在贝壳指数里面使用了Echarts实现可视化折线图的效果,在添加页面将数据存储到Mobx中实现内容数据的持久化,同时将数据在我的页面进行展示。

src/pages/Home/components/Deal/index.jsx 部分代码:完整代码点击这里

    <div className={s.main}>
           
           ... ....
           // 点击判断隐藏模块实现内容的切换
            <div className={cx({ [s.hidden]: show !== 'beike' })}>
                <div className={s.botton2}>
                    <div className={s.zhishu}>
                        <div className={s.topWords} onClick={()=>changePage('/cityprice')}>贝壳指数</div>
                        <div className={s.cityBox}>
                            <div className={s.city}>南昌房价</div>
                            <div className={s.arrow}></div>
                        </div>
                    </div>
                    <div className={s.line}></div>
                    <div className={s.info}>
                        <div className={s.leftBox}>
                            <div className={s.topPrice}>
                                <div className={s.num}>12078</div>
                                <div className={s.dollor}>元/平</div>
                            </div>
                            <div className={s.oldHouse}>
                                <div className={s.name}>二手房</div>
                                <div className={s.bigArrow}></div>
                                <div className={s.percent}>0.2%</div>
                            </div>
                        </div>
                        <div className={s.rightBox}>
                            <div className={s.num}>19套</div>
                            <div className={s.yesterday}>昨日成交</div>
                        </div>
                    </div>
                </div>
            </div>

        </div>

Deal模块内部: 点击贝壳指数

Snipaste_2023-01-19_07-22-16.png

则路由跳转到贝克指数页面,如下:

Snipaste_2023-01-19_07-24-02.png

这个页面引用了 echarts 的折线图,实现数据的可视化效果。 src/pages/other/CityPrice 部分代码:完整代码点击这里

 useEffect(() => {
    getData()
    var chartDom1 = document.getElementById('echarts1');
    var myChart1 = echarts.init(chartDom1);
    var option1;
    option1={...}   // echarts 图配置
    option1 && myChart1.setOption(option1);
    }
// echarts 图的容器
   <div className={s.map}>
          <div id='echarts1' style={{ width: 300, height: 200 }} ></div>
          <div className={s.botton}>
            <div className={s.one}>数据来源:均价数据来自贝壳成交系统统计</div>
            <div className={s.two}>更新时间:202212月</div>
          </div>
        </div>

点击 立即添加

Snipaste_2023-01-19_07-35-08.png

同样也是将其做成了一个页面,其功能就是进行个人房屋的添加,在输入内容后将内容保存到仓库中,并且可以在我的页面进行展示,如果未登录就不进行展示。

Snipaste_2023-01-19_07-37-47.png

  • Botton:从后端获取数据展示,以及实现点击上方推荐二手房新房租房实现页面内容的切换。页面内容封装成一个组件,获取数据的时候将数据存储到仓库内,在组件页面引入仓库获取数据,渲染到组件上。

src/pages/Home/components/Botton/index.jsx 部分代码 :完整代码点击这里

我的界面

功能预览

QQ图片20230118151844.png

我的界面实现登录时向后端请求账号信息数据,匹配成功则跳转我的页面,成功登录则从仓库获取用户的头像与昵称(这里后端只返回了账号与密码 [账号:123456 密码:111111],账号的昵称与头像都是在仓库里面写死的,但是昵称可以修改),右上角的设置有修改昵称(已实现)与头像(未实现)的功能,同时还可以退出当前账号登录。下方我的房子则与首页的添加房子功能一样,添加成功则房产在下面展示。

这个页面的效果如下:

dfce06a1-50ee-4191-b3fd-52f58646d340.gif

点击昵称位置跳转登录页面,如果未登录就不允许修改昵称头像(未实现);将昵称数据存储在仓库中,若修改则修改仓库中的昵称就实现了昵称的修改功能。 部分代码:完整代码点击这里

// src/pages/User/index.jsx
const User = () => {
  // 解构出userStore仓库
  const { userStore } = useStore()
  const navigate = useNavigate()

  const showSetting = () => {
    navigate('/setting')
  }

  const goToLogin = () => {
    if(userStore.isLogin){
      Toast.info('已登录')
    }else{
      navigate('/login')
    }
  }
  .....
  .....
}
// src/pages/User/components/Setting/index.jsx 右上角设置功能
const Setting = () => {
    const navigate = useNavigate()
    const { userStore } = useStore()

    const changeLoginState = () => {   // 点击退出登录或者点击登录
        if (userStore.isLogin) {
            userStore.changeLogin(false)
            Toast.info('注销成功!')
            navigate('/user')
        } else {
            navigate('/login')
        }
    }

    const changeInfo = () => {
        if (userStore.isLogin) {
                navigate('/userInfo')
        } else {
            Toast.info('请登录')
        }
    }
    const changeName = () => {
        if (userStore.isLogin) {
                navigate('/userName')
        } else {
            Toast.info('请登录')
        }
    }

  
}

Three.js 实现简单的全景看房

效果如下:

c21183ee-4154-4fda-be95-f15560f02c3d.gif

我将这个写成了一个组件,实现的原理也是比较好理解的:用Three.js先生成一个立方体,将照片资源分别放在立方体的六个面上,然后将立方体的外表面往内部翻转,将camera放到立方体里面通过镜头的旋转实现全景看房效果。 部分代码如下: 完整代码点击这里

   // 获取挂载房子容器的dom结构
    const container1 = useRef(null)
   // 创建渲染器
    const renderer1 = new THREE.WebGLRenderer({
   //增加下面两个属性,可以抗锯齿
        antialias: true,
        alpha: true
    })
   renderer1.setSize = (window.innerWidth, window.innerHeight)  // 设置渲染的页面大小
   // 创建镜头
   const camera1 = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
   camera1.position.z = 0.1
   // 创建场景
   const scene1 = new THREE.Scene()
   // 立方体
    const geometry1 = new THREE.BoxGeometry(1, 1, 1)
   let livingRoom = ['4_l', '4_r', '4_u', '4_d', '4_b', '4_f']  // 图片资源
   let boxMaterials1 = []
   livingRoom.forEach((item, index) => {
        // 纹理加载
        let texture = new THREE.TextureLoader().load(`./imgs/living/${item}.jpg`)
        // if (item === '4_u' || item === '4_d') {   // 天花板与地面要设置旋转中心
        if (index === 2 || index === 3) {   // 天花板与地面要设置旋转中心
            texture.rotation = Math.PI
            texture.center = new THREE.Vector2(0.5, 0.5)
            boxMaterials1.push(new THREE.MeshBasicMaterial({ map: texture }))
        } else {
            boxMaterials1.push(new THREE.MeshBasicMaterial({ map: texture }))
        }
    })
   const cube1 = new THREE.Mesh(geometry1, boxMaterials1)
   cube1.geometry.scale(1, 1, -1) // 将几何体的面往内部翻转
   scene1.add(cube1)
    // 渲染场景函数 
   const render1 = () => {
        // console.log(container);
        requestAnimationFrame(render1)  // 让浏览器递归的渲染模型
        renderer1.render(scene1, camera1)
    }
   useEffect(() => {
        const controls1 = new OrbitControls(camera1, container1.current)
        controls1.enableDamping = true  // 增加控制器的阻尼感
        container1.current.appendChild(renderer1.domElement)  // 往dom结构上挂载这个模型
        render1()
    }, [])

后端

基于 Egg.js 搭建

总的来说个人感觉 Egg.js 封装的非常简便。当然我也只写了一点点的数据模拟了一下接口请求。具体代码看这里

写这个项目的时候碰到的问题

  1. 刷新后底部Tabbar标签栏上面的选中颜色会重新匹配到首页的icon上面

    解决方法:在加载当前页面的时候获取当前页面的路由保存下来。

  const { pathname } = useLocation()

  useEffect(() => {
    setShowNav(needNav.includes(pathname))
  }, [pathname]) // 监听路由变化,设置Tabbar标签栏的选中
  1. 跳转不需要底部Tabbar标签栏的页面时也会展示标签栏

    解决方法:维护一个数组用来存储需要展示标签栏的页面的路由地址,在路由地址改变的时候判断标签栏是否显示。

     const needNav = ['/', '/message', '/info', '/recommand', '/user']
     const { pathname } = useLocation()
     useEffect(() => {
    setShowNav(needNav.includes(pathname))
    console.log(showNav);
    }, [pathname])
    
    return (
    <ConfigProvider>
      <>
        <Router />
        {/* 让导航栏在底部五个路径的页面显示,其他页面则不显示 */}
        <div className={cs({ [s.hidden]: showNav == false })}>
          <NavBar />
        </div>
    
      </>
    
    </ConfigProvider>
    )
    
  2. 在渲染首页数据的时候发现重复渲染了很多条,后来发现是自己重复的循环了存储数据的数组,蠢的我!

总结: 经过了一个多星期的 coding ,虽然功能写的比较拉跨,但是通过这段时间的努力还是有不小的收获,对于写项目前的规划以及在项目中碰到问题如何解决的能力还是有提升的。这个项目让我在学习 React 的道路上迈出了第一步,虽然中间碰到很多意外bug,但是写完还是很成就感的。欢迎大佬们点个赞哦,同时也希望大佬们可以提点建议。感激不尽!😎😎😎

项目源码:点击此处