得物第三弹🎉 React Hooks

·  阅读 12110
得物第三弹🎉 React Hooks

前言

经过一段时间的学习,React 全家桶也算是熟悉了,就用 react + hooks 续写对得物的热情啦,欢迎掘友们点赞关注🤗。

0.gif

项目介绍

  • 使用 Redux 集中管理数据,Mockjs模拟后端数据接口
  • 坚守 MVVM 、组件化、模块化思想,纯手写函数式组件编写页面
  • 使用 Immutable 持久化数据,优化组件渲染
  • 使用 styled-components 样式组件编写样式
  • React-Router v6 编写路由,完全 Hooks 编程风格

分类部分

0001.gif

(注:因为数据只传了服装的,所以点击一级菜单的其他项没有正常显示属正常操作,文尾有在线地址)

  • 使用 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>
        },
        ]
  }
]
复制代码
  • 样式组件 LcontentNavContainer 以及 UI 组件 Column 组成一级菜单。
<Lcontent>
   <NavContainer>
      <Column list={kindTypes} handleClick={handleUpdateCatetory} oldVal={category}/>
   </NavContainer>
</Lcontent>
复制代码
<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`
  }, [])
复制代码

搜索部分

0003.gif

  • 路由搭建
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);
}
复制代码

评论部分

0002.gif

  • 使用 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.lazyReact.Suspense 进行性能优化,实现代码分割,增强体验
  • 使用 React.memouseMemouseCallback 优化组件渲染,使用 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 制作动画衔接

源码

1.gif

分类:
前端
分类:
前端
收藏成功!
已添加到「」, 点击更改