超强React项目实战,一学就会,附源码和在线链接

6,679 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

前言

React是Facebook开源的一个用来快速动态构建用户界面的js库,React通过操作虚拟DOM(不总是直接操作DOM)来减少更新的次数,React的高效使其非常受欢迎,大厂都在用,赶紧学起来!希望对跟我一样初学react的同学有所帮助。

页面展示

  • 猫眼电影小程序原页面

猫眼原图.jpg

  • 实现后效果?优化后!(添加搜索列表功能,优化城市选择)在线访问地址在文章末尾

image.png

设计师直呼:我让你模仿没让你超越啊xixix.jpg

分析

react中一切皆组件,开发一个功能(页面),相当于开发一个组件。把这一个大的组件划分成一个个组件,然后逐步搭建起来。从这个页面可以看出

  • 头部 电竞赛事
  • 热门赛事 中 导航切换
  • 城市选择
  • 赛事列表
  • 无赛事时显示的nothing
  • 底部导航

主要功能:

  • loading状态
  • 标签栏切换赛事
  • 搜索框根据列表信息搜索赛事
  • 城市选择,可筛选城市赛事
  • 路由切换页面
  • ...

划分的一个个组件及父子组件,在下图的文件目录架构中清晰可见。

文件.png

简单说明一下

  • src:源码文件,开发目录
  • assets:是专门用于保存各种外部文件的,比如音频、图片、字体等
  • api:存放接口文件
  • components:组件文件夹
  • pages:页面级别组件
  • unit: 存放一些js组件,封装的一些js方法
  • routes: 路由配置

准备工作

  1. 初始化脚手架(vitejs 当前最快的脚手架),npm init @vitejs/app -> 输入项目名 -> 选择react 开发框架 -> 确定用react -> 得到了一个模板项目
  2. 安装相关依赖和UI组件库,本文所使用的是antd-mobile 的UI组件库
  3. 创建目录
  4. npm run dev 启动

实现

在分析完如何写以及搭建好我们的目录架构之后,开始进入正题。

图3

路由配置

使用了路由懒加载,将路由组件分包,在进入其他目标页面时再加载,可提升首页加载速度。为了方便,我这里设置了主界面是体育/赛事Contest,因为要实现的主要页面是在体育/赛事,其他页面后续完善

import { lazy } from 'react'
import { Routes, Route} from 'react-router-dom'

const Contest = lazy(() => import('../pages/Contest'))
const Home = lazy(() => import('../pages/Home'))
const Movie = lazy(() => import('../pages/Movie'))
const Yanchu = lazy(() => import('../pages/Yanchu'))
const Mine = lazy(() => import('../pages/Mine'))

const RoutesConfig = () => {
    return (
        <Routes>
            <Route path="/home" element={<Home />}></Route>
            <Route path="/movie" element={<Movie />}></Route>
            <Route path="/yanchu" element={<Yanchu />}></Route>
            <Route path="/contest" element={<Contest />}></Route>
            <Route path="/" element={<Contest />}></Route>
            <Route path="/mine" element={<Mine />}></Route>
        </Routes>
    )
}

export default RoutesConfig

底部导航栏

1.gif 录制所用的工具不太好,区域限制,见谅

路由跳转实现

<FooterWrapper>
    <Link to="./home" className={classnames({active:pathname == '/home'})}>
        <i className='iconfont icon-shouye'></i>
        <span>首页</span>
    </Link>
    <Link to="./movie" className={classnames({active:pathname == '/movie'})}>
        <i className='iconfont icon-dianyingpiaoiocn'></i>
        <span>电影/影院</span>
    </Link>
    <Link to="./yanchu" className={classnames({active:pathname == '/yanchu'})}>
        <i className='iconfont icon-yanchu'></i>
        <span>演出</span>
    </Link>
    <Link to="./contest" className={classnames({active:pathname == '/contest'})}>
        <i className='iconfont icon-saishi'></i>
        <span>体育赛事</span>
    </Link>
    <Link to="./mine" className={classnames({active:pathname == '/mine'})}>
        <i className='iconfont icon-wode'></i>
        <span>我的</span>
    </Link>
</FooterWrapper>

头部标题和布局

移动端flex布局YYDS,多用flex弹性布局,这个很简单就不细说了,具体的可下载代码查看

标签栏

使用antd-mobile的Tabs,activeKey是当前点击的值,后续获取用来实现切换列表功能,定义一个tabs数组存放标签栏数据,然后map加入每一个

<Tabs activeKey={activeKey} onChange={setActiveKey} 
          activeLineMode='fixed'
          style={{'--fixed-active-line-width': '25px', display: 'flex',justifyContent: 'flex-start', '--title-font-size': '0.1'}}
>
{tabs.map(item => (
<Tabs.Tab key={item.key} title={item.title} />
))} 
</Tabs>

因为数据不多,也可以这样写比较方便

<Tabs>
  <Tabs.Tab title='全部' key='全部' />
  <Tabs.Tab title='电竞赛事' key='电竞赛事' />
  <Tabs.Tab title='体育赛事' key='体育赛事' />
</Tabs>

城市选择

2.gif

使用多个小组件拼成,点击城市,弹出层(Popup组件)出现,内容包括城市搜索框(SearchBar)、城市选择列表(CheckList)。

  • Popup:visible控制弹出层的开关,初始状态设置一开始为flase(不弹出),城市选择被点击时改变状态为true(弹出层出现)。点击非弹出层区域外时onMaskClick={() => {setVisible(false)}}或者CheckList的列表被选择时弹出层关闭。
  • useMemo函数:使用来做缓存的,只有当依赖项改变时(SearchBar的内容)才会发生变化,否则就拿缓存的值,这样就不用在每次渲染的时候再做计算
  • 子组件通过调用父组件传来的函数,来通知父组件城市的改变,用以通过选择后城市来筛选列表

标签栏下的搜索框跟城市选择的搜索框是类似的,这里是直接稍作更改后复用的

export default ({getCity}) => {
  const [visible, setVisible] = useState(false)
  const [selected, setSelected] = useState('城市')
  const [searchText, setSearchText] = useState('')
  const pushCity = () => {
    // console.log('1111111111111111')
    getCity(selected)
  }
  const filteredItems = useMemo(() => { // 根据搜索框内容更新城市列表
    if (searchText) {
      return items.filter(item => item.includes(searchText))
    } else {
      return items
    }
  }, [searchText])
  useEffect(() => {
    pushCity(selected)
  }, [selected])
  return (
    <>
      <div className='city-space'
        onClick={() => {
          setVisible(true)
          setSearchText('') // 为了再次点击时,搜索框被清空
        }}
      >
        <div className='city-choose'>
          <span>{selected?selected:'城市'}</span>
          <i className="iconfont icon-down" />
        </div>
      </div>
      <Popup // 弹出层
        visible={visible}
        onMaskClick={() => {
          setVisible(false)
        }}
        destroyOnClose
      >
        <div className='searchBarContainer'>
          <SearchBar // 搜索框
            placeholder='搜索城市'
            value={searchText}
            onChange={v => {
              setSearchText(v)
            }}
          />
        </div>
        <div className='checkListContainer'>
          <CheckList // 列表选择
            className='myCheckList'
            defaultValue={selected ? [selected] : []}
            onChange={val => {
              setSelected(val[0])
              setVisible(false) // 选择后,弹出层关闭
            }}
          >
            {filteredItems.map(item => (
              <CheckList.Item key={item} value={item}>
                {item}
              </CheckList.Item>
            ))}
          </CheckList>
        </div>
      </Popup>
    </>
  )
}

搜索功能的实现部分

这里说的搜索包括标签栏选择筛选赛事列表、城市选择筛选固定城市赛事、搜索框搜索列表信息关键字。三者的筛选会互相影响但又独立分开,比如选了城市北京,那其他两个搜索只会搜索到该城市的赛事,但又也不会影响其他组件已定状态的改变。

通过useEffect来做赛事数据的更新,把从3个的子组件中接收到的筛选信息activeKey、inputContent、 city交给另一个子组件函数fetchcontents去完成下一步工作。

从这里可以发现,子组件并没有自己去做接口请求数据来筛选列表,而是统一交给父组件,然后由父组件去递交给工具函数,我们要知道这些开发套路

  • 接口都放在api目录下
  • 接口请求在路由级别组件发生, 子组件不要去做
  • 子组件不做数据请求, 由父组件统一并传过来,子组件不做复杂状态
  useEffect(() => {
    setLoading(true) 
    setPlaceholder(`在${activeKey}里搜索`)
    setContents([7])
    fetchcontents({inputContent, activeKey, city})
      .then(data => {
        setContents([...data.result])
        setLoading(false)
      })
  }, [activeKey, inputContent, city])

筛选数据的函数,为了下载后更易观看,我把赛事列表写在本地,用delay函数模拟数据请求的延迟,使loading加载中更好显示出来。

const delay = time => new Promise(resolve => setTimeout(resolve, time));
const withDelay = fn => async (...args) => { 
    await delay(1000);
    return fn(...args)
}

export const fetchcontents = withDelay(params => {
    const { inputContent, activeKey, city='' } = params
    let result = contents;
    if (activeKey) {
        switch (activeKey) {
            case "电竞赛事":
                result = result.filter(content => content.type === '电竞赛事')
                break;
            case "体育赛事":
                result = result.filter(content => content.type === '体育赛事')
                break;
            default:
                break
        }
    }
    if (inputContent) {
        result = result.filter(content => (
            content.text+content.date+content.price+content.pos).includes(inputContent))
    }
    if (city && city !== '所有城市'&& city !== '城市') {
        result = result.filter(content => content.pos.includes(city))
    }

    return Promise.resolve({
        activeKey, result
    })
})

loading加载

当数据获取到时,将loading状态设为false,使SpinLoading不显示,Space和SpinLoading来自antd-mobile

const [loading, setLoading] = useState(true);
{
    loading && 
    <Space direction='horizontal' wrap block style={{ '--gap': '16px' }}>
      <SpinLoading className='list-loading' style={{ '--size': '40px' }} />
    </Space>
} 

赛事列表&&loading

将contents数据从父组件中解构出来,使用antd-mobile的List组件快速完成赛事列表

3.gif

const ContentList = ({contents}) => {
    return (
        <div>
            { contents[0] !== 7 &&
                <List style={{"marginBottom": "2.3rem"}}>
                    {contents.map(({ img, text, date, pos, price,id }) => {
                        return (
                            <List.Item key={id}>
                                <ListWrapper>
                                    <img src={img} />
                                    <div className='list-content'>
                                        <p>{text}</p>
                                        <p>{date}</p>
                                        <p>{pos}</p>
                                        <div><span>售票中</span>{price}</div>
                                    </div>
                                </ListWrapper>
                            </List.Item>
                        )
                    })}
                </List>
            }
        </div>
    )
}

虽然看上去写的判断很奇怪,这是我在改完bug后最终代码,为了能控制3个画面(加载中,无赛事,赛事列表)在不同状态的显示与否,点击切换标签栏时,赛事列表先消失,加载该标签栏列表或者显示暂无赛事,关键部分就是在下方代码的setContents([7])

4.gif

  useEffect(() => {
    setLoading(true) 
    setPlaceholder(`在${activeKey}里搜索`)
    setContents([7])
    fetchcontents({inputContent, activeKey, city})
      .then(data => {
        setContents([...data.result])
        setLoading(false)
      })
  }, [activeKey, inputContent, city])

css 方面

reset

很多同学喜欢使用*通配符来做reset,但* 性能不好,不是每一个标签都需要reset设置,推荐使用下面的做法(部分代码)

html,body,div,span,applet,object,iframe,
h1,h2,h3(省略了很多){
  margin: 0;
  padding: 0;
  border: 0;
  outline: 0;
  font-size: 100%;
  vertical-align: baseline;
  background: transparent;
}
body {
  line-height: 1;
}
...

移动端不要用px用rem

移动端为了能适配不同屏幕大小的手机,我们的样式不能用固定的px,要用相对单位rem,引入下面的js文件解决适配和屏幕切换问题

var init = function () {
    var clientWidth = document.documentElement.clientWidth || document.body.clientWidth;
    if (clientWidth >= 640) {
      clientWidth = 640;
    }
    var fontSize = 20 / 375 * clientWidth;
    document.documentElement.style.fontSize = fontSize + "px";
 }
  
 init();
  
 window.addEventListener("resize", init);

css in js

css放哪,可以写在css文件然后引入,我更倾向与使用css in js的方式,styled-components 会生成一个hash类名,绝对不重名,还能嵌套写

import styled from 'styled-components'

export const Wrapper = styled.div`
    里面写样式
`

图标哪里找

  • font-awesome: 方便 缺点是 没有定制性
  • iconfont 网站 可以去找,下载一个代码包

结语

为了缩减篇幅(不是我偷懒),还有很多东西没有拿出来说,可以下载源码后再慢慢看,后续也会持续优化和增加其他页面,点个赞支持一下吧!

线上浏览地址&&项目地址

线上浏览地址(切为手机显示): Vite App (576711977.github.io)

项目地址:react-maoyan: (gitee.com)