需求

  • 完成前端界面开发
  • 实现列表滚动加载、图片懒加载效果
  • 使用 mock 数据模拟接口

    模块概览

    image.png

    开发

    底部导航

  • 引入ui react-icon

  • antd-mobile TabBar,最新版是5. ,而umi自带的是2,api有所变化,自行进行了修正

    首页开发

    创建 less 工具函数 .flex(),因为实际开发中很多 flex 布局 ```less .flex(@direction:row,@justify:center,@align:center) { display: flex; flex-direction: @direction; justify-content: @justify; align-items: @align; }
  1. <a name="kEb7x"></a>
  2. ### 首页数据
  3. 对首页数据 mock ,方便后期与后端进行联调<br />需要的接口:
  4. - 可选城市
  5. - 热门民宿
  6. **原则:父组件中的各个子组件没有数据交互,那么就将所有数据放到父组件中,通过父组件传递给子组件**
  7. ---
  8. <a name="ViQ0G"></a>
  9. #### 数据mock
  10. 使用之前开发的<br /> `useHttpHook`:<br />参数:
  11. ```javascript
  12. const useHttpHook = ({
  13. url, //请求路径
  14. method = 'post', //请求方式
  15. headers, //请求头
  16. body = {}, //请求体
  17. watch = [], //useEffect 依赖项
  18. }) => {}

返回一个数组,其中 [data, isLoadingFlag]:分别为请求得到的数据和请求发送结束的标志

搜索界面

点击搜索进行跳转

  1. const history = useHistory()
  2. const handleSearchClick = () => {
  3. if (!times.includes('~')) {
  4. Toast.show({
  5. icon: 'fail',
  6. content: '请选择时间',
  7. })
  8. return
  9. }
  10. history.push({
  11. pathname: '/search',
  12. query: {
  13. code: selectedCity,
  14. startTime: times.split('~')[0],
  15. endTime: times.split('~')[1],
  16. },
  17. })
  18. }

数据同样是 mock 的请求 异步加载,加载时显示 spinLoading

分页加载

useHttpHook + 数据监听
监听页面是否滑倒最底部:Intersection Observer

IntersectionObserver接口 (从属于Intersection Observer API) 提供了一种异步观察目标元素与其祖先元素或顶级文档视窗(viewport)交叉状态的方法。祖先元素与视窗(viewport)被称为根(root)。 当一个IntersectionObserver对象被创建时,其被配置为监听根中一段给定比例的可见区域。一旦IntersectionObserver被创建,则无法更改其配置,所以一个给定的观察者对象只能用来监听可见区域的特定变化值;然而,你可以在同一个观察者对象中配置监听多个目标元素。

demo.js

  1. import React, { useEffect } from 'react'
  2. import { useHistory } from 'react-router-dom'
  3. // 因为api 也是消耗性能的,所以只在他需要的时候使用,不需要的时候要停止监听
  4. let observer = undefined
  5. export default function(props) {
  6. const history = useHistory()
  7. useEffect(() => {
  8. // 参数是一个 函数,监听的 DOM 出现或消失在页面中时调用
  9. observer = new IntersectionObserver(entries => {
  10. console.log(entries)
  11. // entries 是一个数组 [IntersectionObserverEntry]
  12. // 每个IntersectionObserverEntry 中有几个属性,常用的如下:
  13. // intersectionRatio:范围0~1 子元素进入范围
  14. // isIntersecting :布尔值 是否可见
  15. })
  16. // 监听 DOM 元素
  17. const listened = document.querySelector('#listened')
  18. observer.observe(listened)
  19. // 离开页面时
  20. return () => {
  21. if (observer) {
  22. // 解除元素绑定
  23. observer.unobserve(listened)
  24. // 停止监听
  25. observer.disconnect()
  26. }
  27. console.log(observer) // 可以自己看看有什么变化
  28. }
  29. }, [])
  30. return (
  31. <div onClick={() => history.push('/')}>
  32. observer
  33. <div
  34. id="listened"
  35. style={{
  36. width: '100px',
  37. height: '100px',
  38. backgroundColor: 'orange',
  39. marginTop: '1000px',
  40. }}
  41. >
  42. loading
  43. </div>
  44. </div>
  45. )
  46. }

然后我们再将其抽离为一个 自定义HookuseObserverHook

分页Hook:useObserverHook
  1. import { useEffect } from 'react'
  2. let observer = undefined
  3. // 传入要监听的DOM元素 ele, 监听元素的回调函数 callback, useEffect 的依赖项
  4. export default function useObserverHook(
  5. selector,
  6. callback,
  7. watch = [],
  8. ) {
  9. useEffect(() => {
  10. const listened = document.querySelector(selector)
  11. if (listened) {
  12. observer = new IntersectionObserver(entries => {
  13. callback && callback(entries)
  14. })
  15. observer.observe(listened)
  16. }
  17. return () => {
  18. if (!observer || !listened) return
  19. observer.unobserve(listened)
  20. observer.disconnect()
  21. }
  22. // eslint-disable-next-line react-hooks/exhaustive-deps
  23. }, watch)
  24. }

回到原先的demo进行调用

  1. import React, { useEffect } from 'react'
  2. import { useHistory } from 'react-router-dom'
  3. import { useObserverHook } from '@/hooks'
  4. // 因为api 也是消耗性能的,所以只在他需要的时候使用,不需要的时候要停止监听
  5. // let observer = undefined
  6. export default function(props) {
  7. const history = useHistory()
  8. // useEffect(() => {
  9. // // 参数是一个 函数,监听的 DOM 出现或消失在页面中时调用
  10. // observer = new IntersectionObserver(entries => {
  11. // console.log(entries)
  12. // // entries 是一个数组 [IntersectionObserverEntry]
  13. // // 每个IntersectionObserverEntry 中有几个属性,常用的如下:
  14. // // intersectionRatio:范围0~1 子元素进入范围
  15. // // isIntersecting :布尔值 是否可见
  16. // })
  17. // // 监听 DOM 元素
  18. // const listened = document.querySelector('#listened')
  19. // observer.observe(listened)
  20. // // 离开页面时
  21. // return () => {
  22. // if (observer) {
  23. // // 解除元素绑定
  24. // observer.unobserve(listened)
  25. // // 停止监听
  26. // observer.disconnect()
  27. // }
  28. // console.log(observer)
  29. // }
  30. // }, [])
  31. // 监听 DOM 元素
  32. // const listened = document.querySelector('#listened')
  33. useObserverHook('#listened', entries => {
  34. console.log('callback--', entries)
  35. })
  36. return (
  37. <div onClick={() => history.push('/')}>
  38. observer
  39. <div
  40. id="listened"
  41. style={{
  42. width: '100px',
  43. height: '100px',
  44. backgroundColor: 'orange',
  45. marginTop: '1000px',
  46. }}
  47. >
  48. loading
  49. </div>
  50. </div>
  51. )
  52. }

现在回到 搜索页面 在此进行分页加载

  1. 监听 发送请求后返回的loading是否为 true ,确保有数据展示(DOM 节点是否可以看见)
  2. 修改分页数据
  3. 监听分页数据的修改,发送接口请求下一页的数据
  4. 监听 loading变化,拼装数据(请求返回的loading,表示请求是否结束)

    1. 根据 loading状态来进行相应的操作
      1. const [page, setPage] = useState({
      2. pageSize: 6, // 一页展示多少
      3. pageNum: 1, // 当前页码
      4. })
      1. const [houses, loading] = useHttpHook({
      2. url: '/houses/search',
      3. body: {},
      4. watch: [page.pageNum], // 监听 pageNum 的变化
      5. })
      另外在mock方面控制
      1. 'post /api/houses/search': (req, res) => {
      2. let data
      3. if (req.body.pageNum < 4) {
      4. data = [...]
      5. } else {
      6. data = []
      7. }
      8. res.json({
      9. status: 200,
      10. data,
      11. })
      此时 lightHouse 得分 42 变为 56

      图片的懒加载

      懒加载:当图片进入可视区时,才显示真实的图片;否则就只是一个 填充品
  5. 监听图片是否进入可视区域

  6. 进入就将 src 属性的值替换为真实的图片地址data-src,一开始是假的 src : fake-src
  7. 已经替换为真实图片地址了,就停止监听

懒加载 Hook:useImgHook
  1. import { useEffect } from 'react'
  2. /**
  3. *
  4. * @param {DOM元素} ele
  5. * @param {function} callback 回调函数
  6. * @param {数组} watch 监听项
  7. * @returns
  8. */
  9. let observer
  10. const useImgHook = (ele, callback, watch = []) => {
  11. useEffect(() => {
  12. const nodes = document.querySelectorAll(ele)
  13. if (nodes && nodes.length) {
  14. observer = new IntersectionObserver(entries => {
  15. callback && callback(entries)
  16. entries.forEach(item => {
  17. // console.log(item)
  18. if (item.isIntersecting) {
  19. const itemTarget = item.target
  20. const dataSrc = itemTarget.getAttribute('data-src')
  21. // console.log(dataSrc)
  22. itemTarget.setAttribute('src', dataSrc)
  23. observer.unobserve(itemTarget)
  24. }
  25. })
  26. })
  27. nodes.forEach(item => {
  28. observer.observe(item)
  29. })
  30. }
  31. return () => {
  32. if (nodes && nodes.length && observer) {
  33. observer.disconnect()
  34. }
  35. }
  36. // eslint-disable-next-line react-hooks/exhaustive-deps
  37. }, watch)
  38. }
  39. export default useImgHook

优化:提取公共组件 ShowLoading

当其他页面也需要滚动加载…
优化点:

  • 抽离公共部分作为组件
    • 优化样式
    • 优化 id ```jsx import React from ‘react’ import { SpinLoading } from ‘antd-mobile/es’ import PropTypes from ‘prop-types’

import ‘./index.less’

export default function ShowLoading(props) { return (

{props.showLoading ? (
) : (
已经到底部了~
)}
) }

ShowLoading.defaultProps = { showLoading: true, id: ‘zhou-loading’, }

ShowLoading.propTypes = { showLoading: PropTypes.bool, id: PropTypes.string, }

  1. <a name="DzZZA"></a>
  2. #### 优化: 建立 enums 专门存放重复出现多次的值
  3. 如页码、id、
  4. <a name="wYV4P"></a>
  5. #### 优化:utils 常用函数集锦
  6. type.ts : 全面返回某个东东的类型
  7. ```typescript
  8. /**
  9. *
  10. * @param ele {any}元素
  11. * @returns {string} 元素类型的字符串
  12. */
  13. export default function type(ele: any): string {
  14. const toString = Object.prototype.toString,
  15. map: any = {
  16. '[object Boolean]': 'boolean',
  17. '[object Number]': 'number',
  18. '[object String]': 'string',
  19. '[object Function]': 'function',
  20. '[object Array]': 'array',
  21. '[object Date]': 'date',
  22. '[object RegExp]': 'regExp',
  23. '[object Undefined]': 'undefined',
  24. '[object Null]': 'null',
  25. '[object Object]': 'object',
  26. '[object Map]': 'map',
  27. '[object Set]': 'set',
  28. '[object Symbol]': 'symbol',
  29. }
  30. return map[toString.call(ele)]
  31. }

isEmpty.ts : 判断一个东西是否为空对象 | 空数组 | 空字符串

  1. import type from './type'
  2. /**
  3. * 判断空对象,空数组,空字符串
  4. * @param obj 数组或者对象或者字符串
  5. * @returns boolean
  6. */
  7. export default function isEmpty(
  8. obj: Array<any> | Object | string,
  9. ): boolean {
  10. if (!obj) {
  11. return true
  12. }
  13. if (obj === '') {
  14. return true
  15. }
  16. if (type(obj) === 'array') {
  17. // @ts-ignore
  18. if (!obj.length) {
  19. return true
  20. }
  21. }
  22. if (type(obj) === 'object') {
  23. if (JSON.stringify(obj) === '{}') {
  24. return true
  25. }
  26. }
  27. return false
  28. }

详情页

快速构建轮播图 🐶

借助一个 第三方写好的 swiper
yarn add react-awesome-swiper

评论浮窗

之前写好的 Modal组件

评论列表

  • 初次渲染
  • 分页加载
  • 评论功能
    • 列表重置

分页加载

数据流+数据监听

  1. 监听 loading 是否展示
  2. 触发 reload 修改分页
  3. 监听 reload 变化,重新请求接口
  4. 拼装数据

基本与上面的类似

订单页面

包括已支付与未支付订单

ui

  1. const [orders] = useHttpHook({
  2. url: '/order/lists',
  3. body: {
  4. ...page,
  5. },
  6. })
  7. const tabs = [
  8. { title: '未支付', key: 0, orders, type: 0 },
  9. { title: '已支付', key: 1, orders, type: 1 },
  10. ]

不一样的滚动加载

只监听底部 loading ,出现就直接发送请求,而不监听数据
思路:

  1. 页面初始化时候请求接口
  2. 监听loading 是否展示出来
  3. 将pageNum 加一后 重新请求接口
  4. 拼装数据,修改setPage

    优化:骨架屏

    展示真实页面的一些元素
    image.png
    实现思路
  • 通过伪元素实现骨架样式
  • 制作布局组件 添加骨架样式
  • 替换默认Loading样式

image.png
image.png

我的页面

edit

  • 添加用户头像
  • 设置用户电话
  • 设置用户签名

使用 antd-mobile 里面的 ImageUploader组件
需求:需要点击修改按键后上传数据,这里借助一个 第三方依赖 **rc-form**来解决给表单每个input 绑定 onchange 的麻烦事

login

表单用到了rc-form

存储信息到cookie

  1. /**
  2. * 验证是否可以被JSON.parse
  3. * @param ele {any} 元素
  4. * @returns {boolean} boolean
  5. */
  6. export default function isJsonString(ele: any): boolean {
  7. try {
  8. JSON.parse(ele)
  9. } catch (e) {
  10. return false
  11. }
  12. return true
  13. }
  1. import isJsonString from './isJsonString'
  2. interface CONFIG {
  3. hours?: number // 过期时间,单位小时
  4. path?: string // 路径
  5. domain?: string // 域名
  6. secure?: boolean // 安全策略
  7. httpOnly?: boolean // 设置键值对是否可以被 js 访问
  8. sameSite?: 'strict' | 'Strict' | 'lax' | 'Lax' | 'none' | 'None' // 用来限制第三方 Cookie
  9. }
  10. /**
  11. * 操作 cookie
  12. */
  13. const cookie = {
  14. /**
  15. * 判断cookie是否可用
  16. * @returns {boolean} boolean
  17. */
  18. support(): boolean {
  19. if (!(document.cookie || navigator.cookieEnabled)) return false
  20. return true
  21. },
  22. /**
  23. * 添加cookie
  24. * @param name {string} cookie 键
  25. * @param value {string | object} cookie 值
  26. * @param config {object} 可选配置项
  27. *
  • {
  • hours: 过期时间,单位小时,
  • path: 路径,
  • domain: 域名,
  • secure: 安全策略,
  • httpOnly: 设置键值对是否可以被 js 访问,
  • sameSite: 用来限制第三方 Cookie
  • }
  • ``` */ set(name: string, value: string | object, config?: CONFIG): void { if (!this.support()) { console.error( ‘ (Cookie方法不可用):浏览器不支持Cookie,请检查相关设置’, ) return }

    let data = name + ‘=’ + encodeURIComponent(JSON.stringify(value))

    if (config?.hours) { const d = new Date() d.setHours(d.getHours() + config?.hours) data += ‘; expires=’ + d.toUTCString() }

    if (config?.path) { data += ‘; path=’ + config.path }

    if (config?.domain) { data += ‘; domain=’ + config.domain }

    if (config?.secure) { data += ‘; secure=’ + config.secure }

    if (config?.httpOnly) { data += ‘; httpOnly=’ + config.httpOnly }

    if (config?.sameSite) { data += ‘; sameSite=’ + config.sameSite }

    document.cookie = data },

    /**

  • 查询 cookie
  • @param name {string} Cookie 的键;如果参数为空则获取所有的cookie
  • @returns {string | object | null} 有参数获取cookie后返回字符串,没有参数获取cookie返回json;获取不到则返回 null */ get(name?: string): string | object | null { if (!this.support()) { console.error( ‘ (Cookie方法不可用):浏览器不支持Cookie,请检查相关设置’, ) return }

    let cs = document.cookie, arr = [], obj: any = {} arr = cs.split(‘;’)

    if (cs !== ‘’) { for (let i = 0; i < arr.length; i++) { const a = arr[i].split(‘=’) const key = a[0].trim() if (key !== ‘’) {

    1. const val = decodeURIComponent(a[1])
    2. obj[key] = isJsonString ? JSON.parse(val) : val

    } }

    return name ? obj[name] : obj } else { return null } },

    /**

  • 删除 cookie
  • @param name Cookie 的键;如果参数为空,则清理所有的cookie
  • @param path 路径,默认为’’ */ remove(name: string, path?: string): void { if (!this.support()) { console.error( ‘ (Cookie方法不可用):浏览器不支持Cookie,请检查相关设置’, ) return }

    if (arguments.length === 0) { const all = this.get() Object.keys(all).forEach(item => { this.set(item, ‘’, { hours: -1 }) }) } else { this.set(name, path || ‘’, { hours: -1 }) } }, }

export default cookie

  1. <a name="TPF4Q"></a>
  2. #### 未登录时点击我的页面应该跳转到登录页面
  3. 多个页面都需要验证的话就会有重复的代码<br />利用 umi 的运行时配置~<br />`src/app.js`实现修改路由等操作
  4. ```javascript
  5. export function onRouteChange(route) {
  6. console.log(route)
  7. }

auto:true表示需要验证

  1. {
  2. path: '/order',
  3. component: './order/index',
  4. title: '订单',
  5. auth: true,
  6. },

优化

memo

举一个例子

  1. const areaEqual = (preProps, nextProps) => {
  2. console.log(preProps, nextProps)
  3. if (
  4. preProps === nextProps &&
  5. preProps.citysLoading === nextProps.citysLoading
  6. ) {
  7. return true
  8. }
  9. return false
  10. }
  11. export default memo(Search, areaEqual)

memo自动对 两次 props 进行对比,但是只是浅层次的对比,需求复杂时,可以自己写一个方法进行精准的判断,作为memo的第二个参数

开发中遇到的问题

  • 一开始 umi 默认开启 CSS Moudle,不习惯,我把它关了
    1. export default {
    2. disableCSSModules: true,
    3. }
  • umi 中的 useLocation 也有问题,直接从 react-router-dom 中引用就没问题,umi2 bug真多。。
  • 分页加载时候发现 滑倒底部引发重复的请求,需要回到 useHttpHook 中进行限制:

节流:

  1. // 节流
  2. let mark = true
  3. mark &&
  4. setTimeout(() => {
  5. return new Promise((resolve, reject) => {
  6. fetch('/api' + url, params)
  7. .then(res => res.json())
  8. .then(res => {
  9. if (res.status === 200) {
  10. resolve(res.data)
  11. setResult(res.data)
  12. } else {
  13. reject(res.errMsg)
  14. }
  15. })
  16. .catch(err => {
  17. console.log(err)
  18. reject(err)
  19. })
  20. .finally(() => {
  21. setLoading(false)
  22. })
  23. })
  24. }, 10)
  25. mark = false
  • 然后发现每次分页加载后滚动条都会回到顶部
    • 发现是因为每次整个页面都重新更新了——渲染了一个loading组件
      • 渲染页面时要还要判断当前页面的 Lists ,如果有就不要重新渲染了 而是接着下面添加
        1. {!loading || housesLists ? (
        2. housesLists?.map(item => (
        3. <div className="item" key={item.id}>
        4. <img alt="img" src={item.img} />
        5. <div className="item-right">
        6. <div className="title">{item.title}</div>
        7. <div className="price">¥{item.price}</div>
        8. </div>
        9. </div>
        10. ))
        11. ) : (
        12. <div
        13. style={{
        14. margin: '50% auto',
        15. width: '10%',
        16. height: '10px',
        17. }}
        18. >
        19. <SpinLoading color="primary" />
        20. </div>
        21. )}