得物第三弹🎉 React Hooks
前言
经过一段时间的学习,React 全家桶也算是熟悉了,就用 react + hooks 续写对得物的热情啦,欢迎掘友们点赞关注🤗。
项目介绍
- 使用 Redux 集中管理数据,Mockjs模拟后端数据接口
- 坚守 MVVM 、组件化、模块化思想,纯手写函数式组件编写页面
- 使用 Immutable 持久化数据,优化组件渲染
- 使用 styled-components 样式组件编写样式
- React-Router v6 编写路由,完全 Hooks 编程风格
分类部分
(注:因为数据只传了服装的,所以点击一级菜单的其他项没有正常显示属正常操作,文尾有在线地址)
- 使用
better-scroll
打造基础滑动组件,借鉴了三元大佬的网易云音乐项目,并衍生出横向导航切换和竖屏商品列表的组件。 - 定义
Scroll
所需参数
import PropTypes from "prop-types"
Scroll.defaultProps = {
direction: "vertical",
click: true,
refresh: true,
onScroll:null,
pullUpLoading: false,
pullDownLoading: false,
pullUp: null,
pullDown: null,
bounceTop: true,
bounceBottom: true
};
Scroll.propTypes = {
direction: PropTypes.oneOf(['vertical', 'horizental']), // 滚动的方向
refresh: PropTypes.bool, // 是否刷新
onScroll: PropTypes.func, // 滑动触发的回调函数
pullUp: PropTypes.func, // 上拉加载逻辑
pullDown: PropTypes.func, // 下拉加载逻辑
pullUpLoading: PropTypes.bool, // 是否显示上拉 loading 动画
pullDownLoading: PropTypes.bool, // 是否显示下拉 loading 动画
bounceTop: PropTypes.bool, //是否支持向上吸顶
bounceBottom: PropTypes.bool //是否支持向下吸顶
};
- 处理封装
Scroll
组件
import React, { forwardRef, useState,useEffect, useRef, useImperativeHandle, useMemo } from "react"
import BScroll from "better-scroll"
import { debounce } from "../../utils/uiOptimization";
const [bScroll, setBScroll] = useState()
const scrollContaninerRef = useRef()
const { direction, click, refresh, pullUpLoading, pullDownLoading, bounceTop, bounceBottom } = props
const { pullUp, pullDown, onScroll } = props
// 防抖
let pullUpDebounce = useMemo(() => {
return debounce(pullUp, 500)
}, [pullUp])
let pullDownDebounce = useMemo(() => {
return debounce(pullDown, 500)
}, [pullDown])
// 创建 better-scroll 实例
useEffect(() => {
const scroll = new BScroll(scrollContaninerRef.current, {
scrollX: direction === "horizental",
scrollY: direction === "vertical",
probeType: 3,
click: click,
bounce:{
top: bounceTop,
bottom: bounceBottom
}
});
setBScroll(scroll)
return () => {
setBScroll(null)
}
}, [])
// 实例绑定 scroll 事件
useEffect(() => {
if(!bScroll || !onScroll) return
bScroll.on('scroll', onScroll)
return () => {
bScroll.off('scroll', onScroll)
}
}, [onScroll, bScroll])
// 上拉判断
useEffect(() => {
if(!bScroll || !pullUp) return;
const handlePullUp = () => {
//判断是否滑动到了底部
if(bScroll.y <= bScroll.maxScrollY + 100){
pullUpDebounce()
}
};
bScroll.on('scrollEnd', handlePullUp)
return () => {
bScroll.off('scrollEnd', handlePullUp)
}
}, [pullUp, pullUpDebounce, bScroll])
// 下拉判断
useEffect(() => {
if(!bScroll || !pullDown) return;
const handlePullDown = (pos) => {
//判断用户的下拉动作
if(pos.y > 50) {
pullDownDebounce()
}
};
bScroll.on('touchEnd', handlePullDown)
return () => {
bScroll.off('touchEnd', handlePullDown)
}
}, [pullDown, pullDownDebounce, bScroll])
// 刷新实例 防止无法滑动
useEffect(() => {
if(refresh && bScroll){
bScroll.refresh()
}
})
// 刷新组件
useImperativeHandle(ref, () => ({
// 暴露 refresh 方法
refresh() {
if(bScroll) {
bScroll.refresh();
bScroll.scrollTo(0, 0);
}
},
// 暴露 getBScroll 方法,提供 bs 实例
getBScroll() {
if(bScroll) {
return bScroll;
}
}
}));
- 分类页看起来是一个简单的三级菜单?没错,简单的三级菜单而已。
- 路由搭建
import React, { lazy, Suspense } from 'react'
const KindComponent = lazy(() => import("../pages/kind"))
export default [
{
path: "/",
element: <HomeLayout />,
children: [
...
{
path: "/kind",
element: <Suspense fallback={null}><KindComponent></KindComponent></Suspense>
},
]
}
]
- 样式组件
Lcontent
和NavContainer
以及 UI 组件Column
组成一级菜单。
<Lcontent>
<NavContainer>
<Column list={kindTypes} handleClick={handleUpdateCatetory} oldVal={category}/>
</NavContainer>
</Lcontent>
- 横向二级菜单的 UI 组件,这两个滑块组件具体可查看对应源码
<Horizen2 list={item.title} handleClick={handleUpdetaList} oldVal={category2} />
- 使用
useRef
操作 Dom ,获取每一项的高度或者宽度进行累加初始化父元素的高或宽。
useEffect(() => {
let categoryDOM = Category.current
let tagElems = categoryDOM.querySelectorAll("span")
let totalWidth = 0
Array.from(tagElems).forEach(ele => {
totalWidth += ele.offsetWidth
});
totalWidth += 120
categoryDOM.style.width = `${totalWidth}px`
}, [])
搜索部分
- 路由搭建
import React, { lazy, Suspense } from 'react'
const SearchComponent = lazy(() => import("../pages/search"))
export default [
{
path: "/",
element: <HomeLayout />,
children: [
...
{
path: "/search",
element: <Suspense fallback={null}><SearchComponent></SearchComponent></Suspense>
},
]
}
]
- 搜索盒子 UI 组件
SearchBox
// useRef 监听输入
const queryRef = useRef()
<input ref={queryRef} className="box" placeholder="输入商品名" value={query} onChange={handleChange}/>
// 光标聚焦
useEffect(() => {
queryRef.current.focus()
}, [])
// 防抖 缓存
import { debounce } from '../../utils/uiOptimization'
let handleQueryDebounce = useMemo(() => {
return debounce(handleQuery, 500)
}, [handleQuery]);
useEffect(() => {
handleQueryDebounce(query)
}, [query])
分解 Search
axios
请求准备
export const getHotKeyWordsRequest = () => {
return axiosInstance.get(`/search/hot`);
}
export const getResultList = (query) => {
return axiosInstance.get(`/search/keywords=${query}`)
}
mock
拦截请求
import Mock from "mockjs";
import { hotKeyWords } from './hot';
import shopAPI from './shop';
// 匹配接口 api 拦截请求
Mock.mock(/\/search\/hot/, "get", hotKeyWords);
Mock.mock(/\/search\/keywords=.+/, "get", (options) => {
let keyword = options.url.split('=')[1]
return shopAPI.shops().data.items.filter((item) => {
return item.title.indexOf(keyword) > 0
})
})
redux 层开发
- 初始化
state
const defaultState = ({
hotKeyWords: [], // 热门关键词列表
enterLoading: false,
resultList: []
})
- 定义
constants
export const SET_HOT_KEYWRODS = "search/SET_HOT_KEYWRODS"
export const SET_ENTER_LOADING = 'search/SET_ENTER_LOADING'
export const SET_RESULT_LIST = 'search/SET_RESULT_LIST'
- 定义
reducer
函数
import { produce } from 'immer'
export const searchReducer = produce((state, action) => {
switch(action.type) {
case actionTypes.SET_HOT_KEYWRODS:
state.hotKeyWords = action.data
break;
case actionTypes.SET_ENTER_LOADING:
state.enterLoading = action.data
break;
case actionTypes.SET_RESULT_LIST:
state.resultList = action.data
break;
}
}, defaultState)
- 读者可在
actionCreators.js
文件查看具体的action
函数,这里就不列出。 - 导出相关变量
import { searchReducer } from './reducer'
import * as actionCreators from './actionCreators'
import * as constants from './constants'
export { searchReducer, actionCreators, constants }
reducer
注册全局store
import { combineReducers } from "redux";
import { searchReducer } from '../pages/search/store';
export default combineReducers({
search: searchReducer,
});
- 做好这些准备工作,接下来就可以正式连接
redux
啦!
// index.jsx
import { useDispatch, useSelector } from 'react-redux'
const { hotKeyWords, enterLoading, resultList } = useSelector((state) => ({
hotKeyWords: state.search.hotKeyWords,
enterLoading: state.search.enterLoading,
resultList: state.search.resultList
}))
const dispatch = useDispatch()
const getHotKeyWordsDataDispatch = () => {
dispatch(actionTypes.getHotKeyWords())
}
const getResultListDispatch = (q) => {
dispatch(actionTypes.getResultGoodList(q))
}
- 搜索框为空时,展示热词列表
useEffect (() => {
setShow (true)
if (!hotKeyWords.length) {
getHotKeyWordsDataDispatch()
}
}, [])
- 点击热词,发送请求展示搜索结果
const handleQuery = (q) => {
setQuery (q);
if(!q) return;
dispatch(actionTypes.changeEnterLoading(true));
getResultListDispatch(q);
}
- 这里就不具体展示 UI 搭建了,感兴趣的小伙伴可以查看对应源码
评论部分
- 使用
css @keyframes
规则实现弹出层
export const CommentsContainer = styled.div`
position: fixed;
height: 70vh;
width: 100vw;
bottom: 0;
left: 0;
right: 0;
z-index: 2000;
background: ${style["default-color"]};
animation: Popup .4s;
padding: 60px 0;
@keyframes Popup {
0% {
transform: translate3d(0, 100%, 0);
}
100% {
transform: none;
}
}
...
`
Moment.js + lokijs 实现评论
- 新建
database
文件夹,添加创建数据库、集合
// index.js
import Loki from 'lokijs'
export const db = new Loki('goods', {
autoload: true,
autoloadCallback: databaseInitialize,
autosave: true,
autosaveInterval: 3000,
persistenceMethod: "localStorage"
})
// 创建集合
function databaseInitialize() {
const comments = db.getCollection('comments')
if (comments == null) {
db.addCollection('comments')
}
}
export function loadCollection(collection) {
return new Promise(resolve => {
db.loadDatabase({}, () => {
const _collection = db.getCollection(collection)
resolve(_collection)
})
})
}
- 数据库和集合有了,评论不是右手就行?
import moment from 'moment'
// 需引入moment.locale()文件才能转换日期,完整代码就不列出
const [data, setData] = useState([])
const [query, setQuery] = useState('')
const queryRef = useRef()
// 获取输入框的值
const handleChange = () => {
let newValue = queryRef.current.value
if (newValue.trim() != '') {
setQuery(newValue);
}
}
- 初始化评论列表数据
useEffect(() => {
loadCollection('comments')
.then((collection) => {
const entities = collection.chain()
.find()
.simplesort('$loki', 'isdesc')
.data()
setData(entities)
})
}, [])
- 添加评论插入集合
const createComment = (query) => {
if (query.trim() != '') {
setData([...data, { body: query, meta: {
created: Date.now()
}}])
loadCollection('comments')
.then((collection) => {
collection.insert([
{
body: query
}
])
})
.then(setQuery(''))
}
}
- 评论列表展示
<Scroll direction={"vertical"}>
<div className="comments-box">
{
data.map((item, index) =>
<CommentsBox key={index}>
<div className="comment">
{item.body}
</div>
<div className="time">
{moment(item.meta.created).fromNow()}
</div>
</CommentsBox>)
}
</div>
</Scroll>
难点、亮点
- 引入
prop-types
库对数据流处理时进行类型限定 - 在路由构建时使用
React.lazy
和React.Suspense
进行性能优化,实现代码分割,增强体验 - 使用
React.memo
,useMemo
,useCallback
优化组件渲染,使用immer
持久化数据,优化组件渲染 - 评论实时更新显示
MVVM
const createComment = (query) => {
if (query.trim() != '') {
// 前端数据更新
setData([...data, { body: query, meta: {
created: Date.now()
}}])
// 插入数据库 数据更新
loadCollection('comments')
.then((collection) => {
collection.insert([
{
body: query
}
])
})
.then(setQuery(''))
}
}
better-scroll
原理是父级宽或高定死,子元素超过一屏长则滚动,并且其实例只对第一个子元素生效- 页面跳转时使用
react-transition-group
制作动画衔接
源码
转载自:https://juejin.cn/post/7053682198100049956