仿貝殼app全景看房(React+Mobx+Egg+Three.js入門級全棧實戰項目)
背景
前段時間學習了一下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')
```
成果
前端
- 組件庫: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:
function RouterList() { let element = useRoutes(routes) // 讀取路由數組 return element }
function Router() {
let style = { // 菊花圖的位置
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: '300px'
}
return (
// 加載菊花圖
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() ```
首頁頁面
首頁功能預覽
首頁內容
首頁頁面被分成了五個模塊,從上往下依次是: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 (
- 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>
) } ``` - More:輪播圖模塊並沒有做相應的功能,僅僅實現輪播的效果。完整代碼點擊這裏 - Deal:這個模塊如下:
實現了點擊上方 貝殼指數 與 我的房子 實現下面內容切換的效果,同時點擊貝殼指數
以及立即添加
按鈕實現相應頁面的跳轉;在貝殼指數裏面使用了Echarts實現可視化折線圖的效果,在添加頁面將數據存儲到Mobx中實現內容數據的持久化,同時將數據在我的頁面進行展示。
src/pages/Home/components/Deal/index.jsx 部分代碼:完整代碼點擊這裏 ```
... ....
// 點擊判斷隱藏模塊實現內容的切換
<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模塊內部:**
點擊
貝殼指數`:
則路由跳轉到貝克指數頁面,如下:
這個頁面引用了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 圖的容器
``
點擊
立即添加`:
同樣也是將其做成了一個頁面,其功能就是進行個人房屋的添加,在輸入內容後將內容保存到倉庫中,並且可以在我的頁面進行展示,如果未登錄就不進行展示。
- Botton:從後端獲取數據展示,以及實現點擊上方推薦、二手房、新房、租房實現頁面內容的切換。頁面內容封裝成一個組件,獲取數據的時候將數據存儲到倉庫內,在組件頁面引入倉庫獲取數據,渲染到組件上。
src/pages/Home/components/Botton/index.jsx 部分代碼 :完整代碼點擊這裏
我的界面
功能預覽
我的
界面實現登錄時向後端請求賬號信息數據,匹配成功則跳轉我的
頁面,成功登錄則從倉庫獲取用户的頭像與暱稱(這裏後端只返回了賬號與密碼 [賬號:123456 密碼:111111],賬號的暱稱與頭像都是在倉庫裏面寫死的,但是暱稱可以修改),右上角的設置有修改暱稱(已實現)與頭像(未實現)的功能,同時還可以退出當前賬號登錄。下方我的房子則與首頁的添加房子功能一樣,添加成功則房產在下面展示。
這個頁面的效果如下:
點擊暱稱
位置跳轉登錄頁面,如果未登錄就不允許修改暱稱
與頭像
(未實現);將暱稱數據存儲在倉庫中,若修改則修改倉庫中的暱稱就實現了暱稱的修改功能。
部分代碼:完整代碼點擊這裏
```
// 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 實現簡單的全景看房
效果如下:
我將這個寫成了一個組件,實現的原理也是比較好理解的:用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 封裝的非常簡便。當然我也只寫了一點點的數據模擬了一下接口請求。具體代碼看這裏
寫這個項目的時候碰到的問題
- 刷新後底部Tabbar標籤欄上面的選中顏色會重新匹配到首頁的icon上面:
解決方法:在加載當前頁面的時候獲取當前頁面的路由保存下來。 ``` const { pathname } = useLocation()
useEffect(() => { setShowNav(needNav.includes(pathname)) }, [pathname]) // 監聽路由變化,設置Tabbar標籤欄的選中 ``` 2. 跳轉不需要底部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>
)
```
- 在渲染首頁數據的時候發現重複渲染了很多條,後來發現是自己重複的循環了存儲數據的數組,蠢的我!
總結: 經過了一個多星期的 coding ,雖然功能寫的比較拉跨,但是通過這段時間的努力還是有不小的收穫,對於寫項目前的規劃以及在項目中碰到問題如何解決的能力還是有提升的。這個項目讓我在學習 React 的道路上邁出了第一步,雖然中間碰到很多意外bug,但是寫完還是很成就感的。歡迎大佬們點個贊哦,同時也希望大佬們可以提點建議。感激不盡!😎😎😎
附
項目源碼:點擊此處