技术栈:React18,redux/react-redux,react-router-domV6,axios,styled-components,antd mobile移动端组件库。
项目整体结构先进行路由搭建,redux基本模型的搭建,然后再逐一封装组件。
涉及到跨域问题,所以需要配置跨域代理:安装:npm i http-proxy-middleware
。然后创建一个src/setupProxy
文件配置代理信息。
// 所以以/api发送的请求,都会被代理到http://127.0.0.1:7100后台服务器接收处理。测试后可以获取数据
const { createProxyMiddleware } = require('http-proxy-middleware')
module.exports = function (app) {
app.use(
createProxyMiddleware('/api', {
target: 'http://127.0.0.1:7100',
ws: true,
changeOrigin: true,
pathRewrite: { "^/api": "" }
})
)
}
这是一个移动端项目,根据需求使用不同的响应式布局方案,这里采样rem
处理。
下面是一段rem基本实现。然后配合px to rem
vscode插件实现。在插件中设置基准为50px。然后在编写代码的时候写px就会有对应的转换为rem的提示。但是这种方式我在写px的时候都需要手动选择转换为rem,有点麻烦。因此有第二种借助插件方法
(function () {
let HTML = document.documentElement
function remSize() {
// 定义设备的宽度,在iphone6中,宽度为375
let deviceWidth = HTML.clientWidth | window.innerWidth
// 设置宽度的边界值
if (deviceWidth >= 750) {
deviceWidth = 750
}
if (deviceWidth <= 320) {
deviceWidth = 320
}
// 设置浏览器的根字号大小
// 750/7.5=100px 1rem=100px 375/7.5=50px 1rem = 50px
HTML.style.fontSize = (deviceWidth / 7.5) + 'px'
}
remSize()
// 当页面视口发生改变的时候就会调用
window.onresize = function () {
remSize()
}
})()
或如下
(function () {
function reSize() {
let HTML = document.documentElement
let deviceW = HTML.clientWidth
let designW = 750 //设计了一个基准
if (deviceW > 750) {
HTML.style.fontSize = '100px'
return
}
let ratio = deviceW / (designW / 100) //等价与上面的(deviceWidth / 7.5)
HTML.style.fontSize = ratio + 'px'
}
reSize()
window.addEventListener('resize', reSize)
}
)()
需要安装插件支持:npm i lib-flexible postcss-pxtorem
。lib-flexible
是设置px和rem之间的转换比例,功能就是上面的代码,其默认执行设备宽度/10 + ‘px’。postcss-pxtorem
插件的功能就是我们写多少像素的代码就直接写,不需要在写px单位的时候在选择转换为rem的操作。最终所有的px单位都会经过webpack插件处理自动转换,antdmobile组件库也是以750为基准计算。
在webpack打包编译的文件中找到getStyleLoaders
函数,在里面配置具体信息
const px2rem = require("postcss-pxtorem")
const getStyleLoaders = (cssOptions, preProcessor) => {
plugins: !useTailwind
? [
....
px2rem({
// 以750设计稿计算,因为lib-flexible插件一设备:宽度/10 + px计算
//1rem=75px计算所有的大小
rootValue: 75,
// 任何文件中都生效
propList: ['*']
})
]
: [
...
px2rem({
// 以750设计稿计算,因为lib-flexible插件一设备:宽度/10 + px计算
rootValue: 75,
// 任何文件中都生效
propList: ['*']
})
],
}
最后在入口文件中引入import 'lib-flexible'
即可。但是这两种使用插件的方式不支持我们这种css in js
的样式处理方式。因此还需要安装一个插件额外支持,babel-plugin-styled-components-px2rem
,安装完成后再packjson文件中配置该插件。这样子在js中写的css代码中的单位,直接写px即可,最终都可以被转换为rem单位。
"babel":{
"pulgins":[
[
"styled-components-px2rem",
{
"rootValue":75
}
]
]
}
除此之外还需要一个登录界面。收藏界面,编辑个人信息,以及404错误界面。目前需要做的路由处理只有这几个界面。
搭建如图所示基本组件,并统一由App组件管理,最终将App组件导入入口文件中使用。
在搭建路径之前,先安装ui组件库:yarn add antd-mobile
,该组件库也默认实现的按需引入,该组件库中推荐修改兼容性处理
presets: [
[
require.resolve('babel-preset-react-app'),
{
runtime: hasJsxRuntime ? 'automatic' : 'classic',
"targets": {
"chrome": "49",
"ios": "10"
}
},
],
],
// 创建路由表
import Home from '@/views/Home.jsx'
import { lazy } from 'react'
const routes = [
{
path: '/',
name: 'home',
component: Home, //主页不需要懒加载
meta: {
title: '知乎日报' //设置页面标题
}
},
{
path: '/detail/:id', // id为必传项
name: 'detail',
component: lazy(() => import('@/views/Detail.jsx')),
meta: {
title: '知乎日报-详情页'
}
},
{
path: '/login',
name: 'login',
component: lazy(() => import('@/views/Login.jsx')),
meta: {
title: '知乎日报-登录页'
}
},
{
path: '/personal',
name: 'personal',
component: lazy(() => import('@/views/Personal.jsx')),
meta: {
title: '知乎日报-个人中心'
}
},
{
path: '/store',
name: 'store',
component: lazy(() => import('@/views/Store.jsx')),
meta: {
title: '知乎日报-收藏页'
}
},
{
path: '/update',
name: 'update',
component: lazy(() => import('@/views/Update.jsx')),
meta: {
title: '知乎日报-个人信息编辑'
}
},
{
//所有的一级路由没有匹配的直接进入404
path: '*',
name: 'page404',
component: lazy(() => import('@/views/Page404.jsx')),
meta: {
title: '知乎日报-错误'
}
}
]
export default routes
import { Suspense } from "react"
import { Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom"
import routes from '@/router/routes.js'
import { Mask, DotLoading } from "antd-mobile"
const Element = (props) => {
let { component: Component, meta } = props
let location = useLocation()
let navigate = useNavigate()
let param = useParams()
// 设置对应路由的页面标题
let { title = '知乎日报' } = meta || {}//防止meta没传,或title没有
document.title = title
return <Component location={location} navigate={navigate} param={param}></Component>
}
const createRoute = (routes) => {
return <>
{routes.map((item, index) => {
let { children, path, component, meta } = item
return <Route key={index} path={path} element={<Element meta={meta} component={component} />}>
{/* 二级路由嵌套 */}
{Array.isArray(children) ? createRoute(children) : null}
</Route >
})}
</>
}
export const RotuerView = () => {
return <Suspense fallback={<Mask visible={true} opacity='thick' >
<div className="maskLoadingContent">
<DotLoading></DotLoading>加载中
</div>
</Mask>}>
<Routes>
{createRoute(routes)}
</Routes>
</Suspense>
}
在App根组件中导入使用
import { HashRouter } from "react-router-dom"
import { RotuerView } from '@/router/index.js'
const App = () => {
return <HashRouter>
<RotuerView></RotuerView>
</HashRouter>
}
export default App
创建一个全局样式文件,设置样式
// 设置异步组件加载时候的load效果
.adm-mask-content {
.maskLoadingContent {
width: 100%;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
}
在src目录下创建如图所示结构
根据代码中的名字存放在不同的文件中(以下只是基本模型,暂无功能实现)
// 唯一派发标识
export const BASE_INFO = 'BASE_INFO'
// 个人信息存储
import _ from '@/utils/utils.js'
// 初始状态
const initialValue = {
info: null
}
export const baseReducer = (state = initialValue, action) => {
state = _.clone(true,state) //深拷贝
switch (action.type) {
}
return state
}
// 收藏模块
import _ from '@/utils/utils.js'
// 初始状态
const initialValue = {
list: null
}
export const storeReducer = (state = initialValue, action) => {
state = _.clone(true,state) //深拷贝
switch (action.type) {
}
return state
}
合并各个模块的reducer
import { combineReducers } from 'redux'
import { baseReducer } from './baseReducer'
import { storeReducer } from './storeReducer'
export const reducer = combineReducers({
base: baseReducer,
store: storeReducer
})
import * as TYPE from '../actionTypes'
export const baseAction = {
}
import * as TYPE from '../actionTypes'
export const storeAction = {
}
合并各个模块的action
import { baseAction } from '../actions/baseAction'
import { storeAction } from '../actions/storeAction'
export const action = {
base: baseAction,
store: storeAction
}
创建store并导出
import { createStore, applyMiddleware } from 'redux'
import reduxLogger from 'redux-logger'
import reduxThunk from 'redux-thunk'
import reduxPromise from 'redux-promise'
import { reducer } from './reducers/index'
let middleware = [reduxThunk, reduxPromise]
// process.env.NODE_ENV 输出当前环境是生成环境production还是开发环境development
// 根据环境决定logger中间件是否使用
if (process.env.NODE_ENV === 'development') {
middleware.push(reduxLogger)
}
export const store = createStore(reducer, applyMiddleware(...middleware))
创建如图所示结构,
在index文件中创建axios实例,并配置默认配置
import axios from 'axios'
export const http = axios.create({
baseURL: '/api', //所有请求均会带上默认地址
timeout: 5000
})
// 拦截器
// 添加请求拦截器
http.interceptors.request.use(function (config) {
return config;
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error);
});
// 添加响应拦截器
http.interceptors.response.use(function (response) {
return response;
}, function (error) {
return Promise.reject(error);
});
在newsApi中编写新闻相关的接口(测试都能正常获取数据)
import { http } from './index'
// 获取最新新闻列表
export const queryNewsLates = () => {
return http({
url: '/news_latest',
method: 'GET'
})
}
// 获取历史新闻,传入日期,如传入20230726获取前一天新闻
export const queryNewsBefore = (time) => {
return http({
url: '/news_before',
method: 'GET',
params: {
time
}
})
}
// 获取新闻详细信息,传入新闻id
export const queryNewsInfo = (id) => {
return http({
url: '/news_info',
method: 'GET',
params: {
id
}
})
}
// 获取新闻点赞信息
export const queryStoreExtra = (id) => {
return http({
url: '/story_extra',
method: 'GET',
params: {
id
}
})
}
将要复用的代码编写在src下的components文件中
目前只是创建组件,并无具体功能和机结构
// 主页头部区域
import React from "react";
const HomeHeader = () => {
return <div>主页头部区域</div>
}
export default HomeHeader
// 新闻个体封装,用于主页和收藏页复用,都会实现相同功能
import React from "react";
const HomeItem = () => {
return <div>主页新闻个体封装</div>
}
export default HomeItem
// 对ui组件库中的navbar组件进行二次封装
import { NavBar } from 'antd-mobile'
import PropTypes from 'prop-types'
const NavBarAgain = (props) => {
let { title = '个人中心' } = props
const handleBack = () => {
//处理返回逻辑
}
return <NavBar onBack={handleBack}>{title}</NavBar>
}
// 设置校验规则
NavBarAgain.propTypes = {
title: PropTypes.string
}
export default NavBarAgain
import { Skeleton } from 'antd-mobile'
// 设置多个页面的骨架屏复用代码
export const SkeletonAgain = () => {
return <div>
<Skeleton.Title animated />
<Skeleton.Paragraph lineCount={5} animated />
</div>
}
在首部中需要使用到日期,该日期可以在当前头部组件中获取,也可以又Home组件传递给子组件使用,但是后去都会被服务器获取的时间覆盖。这里采样父传子的方式。
// 格式化时间
const formatTime = function formatTime() {
// 首页顶部组件需要用到日期显示,需要传递8位日期:20230101
let DATE = new Date()
let year = DATE.getFullYear()
// 方便判断几位数字,填充
let month = String(DATE.getMonth() + 1)
let date = String(DATE.getDate()) //返回1-31
month = month.length === 1 ? '0' + month : month
date = date.length === 1 ? '0' + date : date
return year + month + date
}
const Home = () => {
let [today, setToday] = useState(formatTime())
return <div className='home-box'>
<HomeHeader today={today}></HomeHeader>
</div>
}
在首页头部组件中代码如下。先对头像进行了引入处理。但是发现,通过使用相对地址引入的图片,最终在页面中并没有显示。引起该原因是在webpack打包编译的时候,整个项目的目录结构是被改变了。
const HomeHeader = (props) => {
let { today } = props
return <div className="homeHeader-box">
<div className="info">
<div className="time">
<span>02</span>
<span>七月</span>
</div>
<h2 className="title">知乎日报</h2>
</div>
<div className="avatar">
<img src="../../images/timg.jpg" alt="" />
</div>
</div>
}
下图是我们在JSX视图中编写的代码,发现地址并没有经过任何转换,因此这个时候在打包后的目录结构中查找该图片就会找不到。
如果在css样式中,我们使用绝对地址,都会经过各种css loader的处理,然后webpack打包的时候会对css中的图片进行打包处理。会根据新的目录结构中的图片地址覆盖原有的css中的图片地址
import timgImageUrl from '../../images/timg.jpg'
<img src={timgImageUrl} alt="" />
处理完图片的显示后,就需要处理首页顶部的样式。
const HomeHeaderBox = styled.div`
padding:0.1rem 0.3rem;
display: flex;
justify-content: space-between;
align-items: center;
height: 1.5rem;
.info {
display: flex;
align-items: center;
.time {
display: flex;
flex-direction: column;
align-items: center;
padding: .1rem 0;
padding-right:.3rem;
span {
&:first-child{
font-size: .6rem;
font-weight: 600;
}
&:last-child {
font-size: 0.3rem;
font-weight: 400;
}
}
}
.title {
height: 1.15rem;
font-size: .72rem;
border-left:0.06rem solid #eee;
padding-left:.3rem;
}
}
.avatar{
width: 1rem;
height: 1rem;
border-radius: 50%;
overflow: hidden;
& img {
width: 100%;
height: 100%;
}
}
`
效果如图,当做完样式后,就可以对日期部分进行处理了,将传入的日期显示到对应的位置。日期需要做特殊处理,日期是根据服务器返回的日期决定,如果时间一直不变,就没必要一直重复渲染,直接使用缓存的即可(包括头像),这就需要借助useMeno函数了。
let time = useMemo(() => {
// ['20230726', '2023', '07', '26']()代表分组,返回的是一个数组,第一个元素默认返回原数据
let [, , month, day,] = today.match(/^(\d{4})(\d{2})(\d{2})$/)
let monthArr = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
// +'07'==> 7
return {
month: monthArr[+month - 1],
day
}
}, [today])
<span>{time.day}</span>
<span>{time.month}</span>
import HomeHeader from '../components/home/HomeHeader'
import { useEffect, useState } from 'react'
import { Swiper } from 'antd-mobile'
import { Link } from 'react-router-dom'
import styled from 'styled-components'
import { queryNewsLates } from '../api/newsApi'
const SwiperBox = styled.div`
position: relative;
width: 100% ;
height:7.5rem;
background-color: #ddd; //图片未加载显示背景板
.adm-swiper,.adm-swiper-slide,.adm-swiper-item{
width: 100%;
height: 100%;
}
.adm-swiper-item{
img {
width: 100%;;
height: 100%;
object-fit: cover;
}
}
.desc {
position: absolute;
padding:0 .3rem;
bottom:.5rem;
h3 {
color:#fff;
font-size: .55rem;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin-bottom:.2rem;
}
.author {
color:rgba(255,255,255,.4);
font-size: .32rem;
}
}
.adm-swiper-indicator {
bottom: .24rem;
/* 因为在定位中,left比right的权重高,top比bottom权重高 解决办法:
在共同的类中把left设置为:left:auto或者是把left属性删除,各自的类设置并无互相影响;*/
left: auto;
right:0.1rem;
}
.adm-page-indicator-dot {
width: .2rem;
height:.2rem;
border-radius: 50%;
transition: all .3s;
&.adm-page-indicator-dot-active {
width: .6rem;
border-radius: .3rem;
background-color: #fff;
}
}
`
// 格式化时间
const formatTime = function formatTime() {
...
}
const Home = () => {
let [today, setToday] = useState(formatTime())
let [banner, setBanner] = useState([])
// 初始化执行
useEffect(() => {
(async () => {
try {
let res = await queryNewsLates()
let { date, stories, top_stories } = res.data
setToday(date)
setBanner(top_stories)
} catch {
console.log('轮播图获取失败');
}
})()
}, [])
return <div className='home-box'>
<HomeHeader today={today}></HomeHeader>
{/* 轮播图部分 */}
<SwiperBox className="swiper-box">
{/* 轮播图存在数据才使用 */}
{banner.length > 0 ? <Swiper autoplay={true} loop={true}>
{banner.map(item => {
let { id, hint, image, title } = item
return <Swiper.Item key={id}>
<Link to={{
pathname: `/detail/${id}`
}}>
<img src={image} alt="" />
<div className="desc">
<h3>{title}</h3>
<p className="author">{hint}</p>
</div>
</Link>
</Swiper.Item>
})}
</Swiper> : null}
</SwiperBox>
</div >
}
export default Home
这样子基本效果就出来了。但是可以在进行一点优化,可以使用提供的Image
组件库,实现懒加载效果,并且会覆盖图片加载失败的一些默认样式。
<Image lazy src={image}></Image> //代替原因img标签
该组件默认提供加载失败时候的样式,可以先指定一个错误的图片地址查看。默认样式如下,可以根据选择器去调
添加样式
.adm-image {
width: 100%;
height: 100%;
.adm-image-tip{
&>svg {
width: 2rem;
height: 2rem;
}
}
}
{/* 新闻列表区域 */}
{/* 在新闻列表没有加载出来之前显示骨架屏 */}
<SkeletonAgain></SkeletonAgain>
{/* 循环创建新闻列表 */}
<div className="news-box">
{/* 每天的日期之间使用分割线隔开 */}
<Divider contentPosition='left'>2023年7月27日</Divider>
<HomeItem></HomeItem>
<HomeItem></HomeItem>
<HomeItem></HomeItem>
<HomeItem></HomeItem>
</div>
<div className="news-box">
{/* 每天的日期之间使用分割线隔开 */}
<Divider contentPosition='left'>2023年7月27日</Divider>
<HomeItem></HomeItem>
<HomeItem></HomeItem>
<HomeItem></HomeItem>
<HomeItem></HomeItem>
</div>
{/* 防止加载更多容器的盒子作为标识,一旦盒子进入可视窗口代表需要加载更多数据 */}
<div className="loading-more">
<DotLoading />加载更多
</div>
可以根据效果图修改部分样式,在这里由于设置了根容器的高度为百分比,即可视窗口的高度,因此超出的部分就会显示hmtl的背景色。
#root {
min-height: 100%; //只需要修改为最低高度
background-color: #fff;
}
给底部加载更多指定动画效果(修改了使用组件库中的加载loading标签)。在styled-component中想使用动画,必须先创建一个keyframes并接受返回的内容,和原生的不同。
const moveAnimation = keyframes`
from {
transform: translateY(0);
}
to {
transform: translateY(-5px);
}
`;
const LoadMoreBox = styled.div`
height: 1.2rem;
display: flex;
background-color: #ddd;
justify-content: center;
align-items: center;
.s {
padding:.1rem ;
height: 80%;
font-size: .68rem;
animation: ${moveAnimation} 1s alternate infinite;
${new Array(7).fill(undefined).map((_, i) => `
&:nth-child(${i + 1}) {
animation-delay: ${i * 0.2}s;
}
`)}
}
`
<LoadMoreBox className="loading-more">
<span className='s'>*</span>
<span className='s'>*</span>
<span className='s'>*</span>
<span className='s'>加</span>
<span className='s'>载</span>
<span className='s'>更</span>
<span className='s'>多</span>
</LoadMoreBox>
修改后如图,所有元素都具有动画效果
在全局样式文件下修改分割线的样式
// 设置分割线的样式
.adm-divider-horizontal::before {
max-width: 0% !important;
}
// 取消分割线的外间距
.adm-divider.adm-divider-horizontal.adm-divider-left {
margin: 0;
}
const HomeItem = () => {
return <HomeItemBox>
<Link>
<div className="content">
<h2 className="title">这是标题这是标题这是标题这是标题这是标题这是标题这是标题标题这是标题这是标题这是标题</h2>
<p className="author">这是作者</p>
</div>
<Image lazy src='https://pic1.zhimg.com/v2-adfe067cbb942e386037a523911b0e28.jpg' />
</Link>
</HomeItemBox>
}
const HomeItemBox = styled.div`
box-sizing: border-box;
padding: .35rem 0;
a {
display: flex;
justify-content: space-between;
height: 100%;
width: 100%;
}
.content {
flex: 1;
margin-right:.3rem;
.title {
line-height: .65rem;
color:#000;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.author {
color:#999;
}
}
.adm-image {
width: 1.85rem;
height:1.85rem;
}
// 对Image组件设置无法显示图片的样式
.adm-image-tip {
svg {
width:.68rem;
height:.68rem;
}
}
`
创建数组接收存放新闻列表,数组中的每一项为对象,存放日期和当前日期的新闻列表。
let [newsList, setNewsList] = useState([])
在初始化的useEffect
继续执行如下代码。然后在函数组件中输出render查看输出次数来确定视图是否更新。
newsList.push({
today,
stories
})
setNewsList(newsList)
结果发现,我们的视图只更新了一次,那就代表,setNewsList执行更新操作并没有更新视图。这是因为newsList本身就是一个数组,存放地址在栈中,执行了堆内存中的数据。执行push操作的时候,本身是向引用地址所指的堆内存中添加数据,并没有创建新的引用地址。因此当我们执行setNewsList更新的时候,newsList地址是没有变的。因此会被优化处理,不对同一个数据进行更新操作。解决方案就是指定一新的引用地址即可。
最简单的方案就是如下两种,当然也可以使用拷贝实现
setNewsList([...newsList])
let arr = [{ today, stories }]
newsList = [...newsList, ...arr]
然后根据新闻列表是否存在数据,针对骨架屏,分割线和新闻数据进行处理
{/* 在新闻列表没有加载出来之前显示骨架屏 循环创建新闻列表 */}
{newsList.length === 0 ?
<SkeletonAgain></SkeletonAgain> :
<>
{
// 如果在newsList.map中间出现红色提示,那么就代表{}中循环创建的元素缺少一个根节点,因此外部添加一个空白节点
newsList.map((item, index) => {
let { today, stories } = item
let [, month, day] = today.match(/^\d{4}(\d){2}(\d{2})$/)
return <div className="news-box" style={{ margin: '.1rem .3rem' }} key={today}>
{/* 每天的日期之间使用分割线隔开(第一个显示的新闻列表不需要分割线,即第一个元素) */}
{index !== 0 ? <Divider contentPosition='left'>{month}月{day}日</Divider> : null}
<div className="list">
{stories.map(newsItem => {
let { id } = newsItem
return <HomeItem key={id} newsItem={newsItem}></HomeItem>
})}
</div>
</div>
})
}
</>
}
在每一个新闻项中,接受传递的属性渲染。对于一个提取封装的组件,同时还需要传入数据,建议使用规则校验。因为该组件可能在不同的地方被错误的调用。
const HomeItem = (props) => {
let { newsItem } = props
let { hint, images, title, id } = newsItem
// images为数组,图片地址存放在数组中的首位,主要是为了防止报错,images[0],如果是一个undefined访问[0]那么一定会报错
if (!Array.isArray(images)) return
return <HomeItemBox>
<Link to={{ pathname: `/detail/${id}` }}>
<div className="content">
<h2 className="title">{title}</h2>
<p className="author">{hint}</p>
</div>
<Image lazy src={images[0]} />
</Link>
</HomeItemBox>
}
HomeItem.propTypes = {
newsItem: PropTypes.object
}
可以额外的添加一个针对已访问的新闻项作出浅灰色处理,代表已访问过。(visited测试,可以通过清除浏览器历史记录刷新)
a {
&:visited {
.title {
color:#999;
}
.author {
color:#aaa;
}
}
}
针对loading加载效果做处理,默认情况下进来只显示骨架屏,当前数据全部获取后,才将loading至于底部。通过获取元素位于视口的位置来判断是否获取更多数据。因此可以修改原来的代码,同时获取ref。
let loadMore = useRef()
// 初始化获取元素,并绑定到标签上
useEffect(() => {
console.log(loadMore.current);
}, [])
可以根据新闻列表是否存在数据,来渲染load标签。但是这个时候获取的loadMore.current
即就是undefined,原因是初始状态下newsList为空,因此不执行任何渲染操作,页面中并无load标签,因此在useEffect函数中无法获取到,且useEffect设置的依赖条件是初始执行一次。因此后续我们想再获取标签还需要想办法。
{newsList.length !== 0 ? <LoadMoreBox className="loading-more" ref={loadMore}>
.....
</LoadMoreBox> : null}
使用display
决定元素的显示和隐藏,这种方法初始渲染时候,load元素一定是存在于页面中的。(因为布局采样的flex,因此这里也使用flex)
<LoadMoreBox className="loading-more" ref={loadMore} style={{
display: newsList.length === 0 ? 'none' : 'flex'
}}>
....
</LoadMoreBox>
当可以获取元素之后,就可以针对元素采用不同的方案监听元素是否出现在视口中。这里采用IntersectionObserver
构造函数实现。react中组件销毁的时候,内部会将合成事件,真实DOM和虚拟DOM等释放,自己基于addEventListener,定时器等实现的,都必须要在组件销毁的时候释放。
// 初始化获取元素
useEffect(() => {
// 获取元素,并设置监听,用于加载更多数据
let ob = new IntersectionObserver(entries => {
console.log(entries);
})
ob.observe(loadMore.current)
return () => {
consoloe.log(loadMore.current)
// 所有自由手动实现的监听,在组件销毁的时候都需要手动释放
ob.unobserve(loadMore.current)
ob = null
}
}, [])
但是直接这么写会出错,出错的地方在销毁的时候执行的语句loadMore.current
,当销毁组件的时候,对应的获取loadMore.current
的值为null,这个时候在对一个null进行取消监听操作,因此会报错。
利用闭包处理,使用变量接收ref获取的标签,在销毁的函数中使用该标签,产生闭包,不会销毁当前变量
let loadMoreDOM = loadMore.current
ob.observe(loadMoreDOM)
return () => {
// 所有自由手动实现的监听,在组件销毁的时候都需要手动释放
ob.unobserve(loadMoreDOM)
ob = null
}
同时entries是监听多个元素组成的数组。但是在这里我们只监听了一个元素,所以取该元素即可。在该元素中,存放了元素的坐标以及是否与视口交叉等信息。如何获取往日新闻列表,需要调用接口传入一个日期,传入什么日期,获取该日期前一天的新闻内容。因此可以将newsList列表中最后一个新闻数据的日期传递过去使用。
加载更多数据代码如下,监听元素是否出现在可视区域,决定是否发送请求。这里需要注意返回的格式中日期的属性名为date,而页面中使用的是today,因此需要重命名
if (isIntersecting) {
// 出现在视口区域中,获取往日数据
let time = newsList[newsList.length - 1]['today']
try {
let res = await queryNewsBefore(time)
let { date: today, stories } = res.data
newsList.push({ today, stories })
setNewsList([...newsList])
} catch {
console.log('获取往日新闻失败');
}
}
当调用详情页接口的时候返回的数据如下,服务器完整的返回了一个结构和样式,因此在前端我们只需要使用即可,这可以理解为服务器端渲染返回。在详情页,只需要搭建底部字体图标区域即可
const TabBar = styled.div`
width: 100%;
height: 1.5rem;
/* 固定定位到底部 */
position: fixed;
bottom:0;
display: flex;
align-items: center;
background-color: rgb(246, 246, 246);
svg {
/* 设置字体图标的大小,也可以使用font-size设置 */
width:.7rem;
height: .7rem;
}
.tab-bar-left {
padding:0 .3rem;
border-right: .02rem solid #ccc;
}
.tab-bar-right {
/* 如果存在剩余空间,也不放大,直接使用剩余空间 */
flex-grow:1;
display: flex;
justify-content: space-between;
padding:0 .3rem;
/* 给徽标设置样式,即小数字 */
.adm-badge-fixed{
right: -10%;
}
.adm-badge {
background: none;
.adm-badge-content{
color:#000;
}
}
/* 给收藏设置一个已选中的样式 */
span {
&.active {
color:orange;
}
}
}
`
const Detail = (props) => {
let { navigate } = props
return <div className="detail-box">
<div className="content"></div>
<TabBar className="tab-bar">
<div className="tab-bar-left">
<LeftOutline onClick={() => navigate(-1)} />
</div>
<div className="tab-bar-right">
<Badge content='5'>
<MessageOutline />
</Badge>
<Badge content='5'>
<LikeOutline />
</Badge>
<span className="active">
<StarOutline />
</span>
<span>
<UploadOutline />
</span>
</div>
</TabBar>
</div >
}
首先依旧是搭建骨架屏,用于数据为返回前显示。
// 引入使用即可
<div className="content">
{/* 使用骨架屏 */}
<SkeletonAgain />
</div>
// 保存新闻详情的状态
let [info, setInfo] = useState(null)
let [extra, setExtra] = useState(null)
然后调用接口分别获取,新闻详情信息和点赞等信息。这两个接口都需要传入当前新闻项的ID,可以在函数组件中使用useParams函数获取,也可以使用在路由中将常见路由信息作为props属性传入使用。
但是在发生请求前,需要了常见的网络请求可以分为并行也可以分为串行,可以根据需求选择,比如前一个请求获取的参数需要作为下一个请求的参数使用,就需要使用串行。
这就是一个串行请求的例子,在同一个函数内部连续发生多个请求。并且还是使用await关键字处理。
useEffect(() => {
(async () => {
try {
let res = await queryNewsInfo(param.id)
setInfo(res.data)
let res2 = await queryStoreExtra(param.id)
setExtra(res2.data)
} catch (error) {
console.log('获取新闻详情失败');
}
})()
}, [])
如下就是一个并行请求,因为useEffect
函数本身就是异步处理的,并且可以多次使用
useEffect(() => {
(async () => {
try {
let res = await queryNewsInfo(param.id)
setInfo(res.data)
} catch (error) {
console.log('获取新闻详情失败');
}
})()
}, [])
useEffect(() => {
(async () => {
try {
let res2 = await queryStoreExtra(param.id)
setExtra(res2.data)
} catch (error) {
console.log('获取新闻评论失败');
}
})()
}, [])
首先将底部徽标区域的数字绑定数据,需要做默认值处理
<Badge content={extra ? extra.comments : 0}>
<MessageOutline />
</Badge>
<Badge content={extra ? extra.popularity : 0}>
<LikeOutline />
</Badge>
然后就是将获取的HTML标签放到页面中的位置。react提供了dangerouslySetInnerHTML
属性渲染HTML结构,(和vue的v-html一样,这种直接渲染结构是危险操作)。结构如下
dangerouslySetInnerHTML={ { __html: 'some raw html
' } }
在页面中使用dangerouslySetInnerHTML
渲染整体内容
{
!info ? <SkeletonAgain /> : <div className="content" dangerouslySetInnerHTML={{ __html: info.body }}></div>
}
使用该样式标签代替原有的容器标签
const ContentBox = styled.div`
overflow-x:hidden ;
`
当结构处理完成后,就需要将服务器返回的样式链接绑定到页面中。因为处理的过程可能会繁琐,因此封装为一个函数使用。
然后就会出现如下代码,本意是视图更新后,状态值会改变,将最新的状态值传递过去使用。实际上着是错误的。因为handleStyle(info)使用的是当前上下文中初始情况下的信息,一直是null。即使使用了flushSync保持视图更新后再操作,也无效。
const handleStyle = (info) => {
console.log(info);
}
useEffect(() => {
(async () => {
try {
let res = await queryNewsInfo(param.id)
setInfo(res.data) // 或flushSync(() => setInfo(res.data))
handleStyle(info)
} catch (error) {
console.log('获取新闻详情失败');
}
})()
}, [])
解决方法如下,设置对info
数据的依赖,一旦依赖改变就会调用函数,在函数中获取的值分别为null和数据,对null进行处理即可。
useEffect(() => {
handleStyle(info)
}, [info])
当然上面这种方法有点麻烦,可以直接在调用函数的时候将参数传递过去即可。handleStyle(res.data)
然后在函数中需要处理css样式部分,处理之前需要了解如何创建一个css样式表的基本知识。如下代码是一段html中引入css样式表的处理。我们需要动态创建link
标签,同时设定ref
属性为层叠样式表,然后通过href
属性指定css文件的位置,可以是在本地获取,也可以向服务器获取
<link rel="stylesheet" href="style.css">
const handleStyle = (info) => {
if (!info) return
let { css } = info
if (!Array.isArray(css)) return
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = css[0]
// link位于head标签内
document.head.appendChild(link)
}
但是这么做完页面就会存在一点问题,第一个问题如图,进入详情页创建link标签,但是当我退出详情页的时候,页面也会保留这些link中的样式信息,严重情况下,这会对其他页面的样式造成冲突,并且渲染的时候也会降低性能。
左图是正常样式,右图是link标签出现后的样式,可以发现已经出现了冲突情况。
因此处理的核心:就是想办法让组件切换的时候销毁link标签即可。将原先代码中的link标签定义设置为全局定义
在任意一个useEffect
函数中返回一个函数用于组件销毁时候执行某些操作。
useEffect(() => {
...
return () => {
document.head.removeChild(link)
}
}, [])
然后就是处理详情页大图展示的情况,这里也采样封装函数处理。如图所示是返回的渲染的样式结构,在该结构中,创建的img标签是需要存放到容器img-place-holder中。因此需要获取DOM元素,但是在这里,这些元素是服务器渲染返回的,因此无法使用useRef函数,只能使用原生js获取。
查看如下代码的问题。发现输出的DOM元素为null,一旦为null就无法进行后续操作。那么该如何解决。
const handleImg = (info) => {
if (!info) return
let imgDom = document.querySelector('.img-place-holder')
console.log(imgDom);
}
useEffect(() => {
....
setInfo(res.data)
handleStyle(res.data)
handleImg(res.data)
....
}, [])
问题原因出现在setInfo
调用这里,该函数是异步执行的,不会影响下面两个函数的执行,因此在执行过程中,标签还没来得及创建,就开始获取DOM元素因此会报错。
解决方法如下,使用flushSync
解决。flushSync
外部是同步执行,而flushSync
函数内部是异步执行。这样子在函数中就能确保获取到了DOM元素
flushSync(() => {
setInfo(res.data)
handleStyle(res.data)
})
handleImg(res.data)
在创建img标签方面采用Image()
构造函数实现,Image()
函数将会创建一个新的HTMLImageElement实例
。它的功能等价于 document.createElement('img')
const handleImg = (info) => {
if (!info) return
let imgDom = document.querySelector('.img-place-holder')
if (!imgDom) return
// 创建一个img标签
let img = new Image()
img.src = info.image
// 图片加载成功做的事情是插入到标签中
img.onload = () => {
imgDom.appendChild(img)
}
// 图片加载失败,可以理解为当前新闻详情不需要大图显示,只显示文章,可以通过父元素将整个图片外部的容器删除
img.onerror = () => {
let parent = imgDom.parentNode
parent.parentNode.removeChild(parent)
}
}
其中删除容器部分可以通过以下图片理解。imgDom容器为img-place-holder
,我们需要将整个headline
容器删除,因此需要获取main-wrap
容器处理。
不带图片的情况
带图片的情况,需要调整样式
return <LoginBox className='login-box'>
{/* 导航栏 */}
<NavBarAgain title='登录/注册'></NavBarAgain>
{/* 表单登录 */}
<Form layout='horizontal' style={{ '--border-top': 'none' }}
initialValues={{ phone: '', code: '' }}
footer={
// Button需要位于From表单内部,且类型为submit才会自动触发表单校验规则
<Button block type='submit' color='primary' size='large'>
提交
</Button>
}
onFinish={submit}
form={formIns}
>
<Form.Item
name='phone'
label='姓名'
rules={[{ required: true, message: '姓名不能为空' }]}
>
<Input placeholder='请输入姓名' />
</Form.Item>
<Form.Item name='code' label='短信验证码' extra={<a onClick={send}>发送验证码</a>}>
<Input placeholder='请输入验证码' />
</Form.Item>
</Form>
</LoginBox >
点击获取验证码按钮,需要自己手动触发校验。需要获取表单实例,然后基于validateFields()
触发。同时在这里,需要校验手机号的格式是否正确。组件库提供的常见校验规则无法满足要求,因此需要自定义校验规则。在rule
配置项中提供了validator
自定义属性,该属性会验证自定义的校验规则,将对应的参数传递,同时根据返回的Promise决定是否验证成功(rule, value) => Promise
let [formIns] = Form.useForm()
// 验证码点击需要校验手机号是否正确
const send = async () => {
// 这里需要基于Form表单实例实现手动校验
try {
await formIns.validateFields(['phone']) //针对某一项或多个进行校验,不写默认全体校验
} catch (error) {
}
}
使用validator
自定义使用如下
// 自定义校验规则
const validate = {
phone(_, value) {
value = value.trim() //去收尾字符串
if (value.length === 0) return Promise.reject(new Error('请填写手机号!'))//空字符串返回
let reg = /^(?:(?:\+|00)86)?1[3-9]\d{9}$/
if (reg.test(value)) {
// 校验成功返回成功的实例
return Promise.resolve()
}
return Promise.reject(new Error('手机号格式错误!'))
},
code(_, value) {
value = value.trim() //去收尾字符串
if (value.length === 0) return Promise.reject(new Error('请填写验证码!'))//空字符串返回
let reg = /^\d{6}$/
if (reg.test(value)) {
return Promise.resolve()
}
return Promise.reject(new Error('验证码格式错误!'))
}
}
然后应用到每一个Form.Item
项中
<Form.Item name='phone' label='手机号' rules={[{ validator: validate.phone }]}>...
<Form.Item name='code' label='短信验证码' rules={[{ validator: validate.code }]}...
当绑定完成功后就可以在对应的函数中查看效果了,这里额外添加了一个失败的验证,基于onFinishFailed
实现监听
const submit = (values) => {
// values=> {name: '', code: ''} 基于自动校验,会自动收集表单项的内容
Toast.show({
icon: 'success',
content: '验证成功',
})
}
const submitFailed = () => {
Toast.show({
icon: 'fail',
content: '验证失败',
})
}
const send = async () => {
// 这里需要基于Form表单实例实现手动校验
try {
await formIns.validateFields(['phone']) //针对某一项或多个进行校验,不写默认全体校验
Toast.show({
icon: 'success',
content: '验证成功',
})
} catch (error) {
let { errorFields } = error
Toast.show({
icon: 'fail',
content: errorFields[0].errors[0],
})
}
}
当然这里的校验规则是针对数字进行的,可以基于自定义校验规则通过正则实现,也可以使用内置提供的正则属性 pattern
实现
当用户点击发送验证码的时候,需要在客户端进行手机号验证处理,防止没必要的请求发送出去。当手机号发送给服务器时,服务器还需要进行二次校验。然后调用第三方接口生成短信验证码。但是需要收费。因此在该案例中模拟该环节,在服务器中存在一个code文件,然后校验的手机号成功,但是不存在与code中,就新增该手机号模拟注册。
在api目录下新建一恶搞userApi,用户存放用户登录相关的接口
export const getPhoneCode = (phone) => {
return http({
url: '/phone_code',
method: 'POST',
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
data: {
phone
}
})
}
然后将原先a标签实现的点击获取验证码按钮修改为Button组件,这里需要loading等效果。用组件库的简单。
在登录页面中,像获取验证码需要loading效果,登录按钮也需要loading效果,因此需要创建对应的状态,并且获取验证码按钮还需要设置内部文字的切换技术提示和禁用效果,因此也需要创建对应的状态。
// 验证码按钮状态
let [sendLoading, setSendLoading] = useState(false)
let [disabled, setDisabled] = useState(false)
let [sendText, setSendText] = useState('获取验证码')
// 登录按钮状态
let [loginLoading, setLoginLoading] = useState(false)
footer={
// Button需要位于From表单内部,且类型为submit才会自动触发表单校验规则
<Button block type='submit' color='primary' size='large' loading={loginLoading}>
登录
</Button>
}
extra={<Button loading={sendLoading} disabled={disabled} color='primary' onClick={send}>{sendText}</Button>}>
在这里可以对Button组件进行二次封装。这里两个按钮在向服务器发送请求的时候都需要设置loading效果,设置loading效果可以设置防抖效果,防止用户一直触发按钮,也可以作为提示使用。
封装一个ButtonAgain组件,内部只有一个Button组件,操作ButtonAgain实际就是操作Button组件。因此传递的任何属性都会出现在Button身上
<ButtonAgain block={true} color='primary' size='large' >登录</ButtonAgain>
<ButtonAgain disabled={disabled} color='primary' onClick={send}>{sendText}</ButtonAgain>
const ButtonAgain = (props) => {
// props中存放传递来的属性,这该组件中统一修改处理,但是基于props传递的属性是冻结的,需要取出后操作
let option = { ...props }
console.log(option);
...
return <Button></Button>
}
部分属性如子节点是直接放在闭合标签内,非放在属性身上,需要删除
let { children} = option
delete option.children
<Button >{children}</Button>
在该组件中,实现统一loading效果,编写函数,而其中send方法,就是调用ButtonAgain组件传递来需要执行的函数
let [loading, setLoading] = useState(false)
let { children, onClick:send } = option //虽然是onClick,但是封装的组件不会去执行函数,只有Button的onClick会去执行
// 部分属性如子节点是直接放在闭合标签内,非放在属性身上,需要删除
delete option.children
delete option.onClick
// 实现通用loading效果
const handleClick = async () => {
setLoading(true)
try {
// 执行传递来的函数,确保每一步都不会控制台报错,因此trycatch是必须的
await send()
} catch (error) { }
setLoading(false)
}
return <Button loading={loading} {...option} onClick={handleClick}>
{children}
</Button>
但是这么写完经过测试,获取验证码的按钮是正常的,但是底部登录按钮就失效了,无法校验规则也无loading效果。这是因为在获取验证码按钮中,我们是基于自己手写函数实现表单校验的。并将校验的函数传递给封装的Button组件使用。因此是可以的。但是底部登录按钮,是基于Form表单footer属性生成的,其校验规则是通过type=submit实现的,并没有手动实现校验函数,因此在封装的函数内部点击按钮的时候实则什么事情都没有去做。换句话说,就是onClick属性传递的函数可能有,也可能没有。因此需要进行判断,在Button中动态生成合成事件
修改代码如下,这里的onClick
就是合成事件标识
if (send) {
option.onClick = handleClick
}
return <Button loading={loading} {...option} >
{children}
</Button>
这样子修改后,如果登录按钮也需要实现校验和loading,就需要自己手动封装函数实现了。不能采样Form表单配合submit属性实现自动校验了。
// 登录校验 手机和验证码
const submit = async (values) => {
try {
await formIns.validateFields()
// values=> {name: '', code: ''} 基于自动校验,会自动收集表单项的内容
Toast.show({
icon: 'success',
content: '验证成功',
})
await delay() // 延时函数模仿服务器请求时间
} catch (error) { }
}
<ButtonAgain block={true} color='primary' size='large' onClick={submit}>登录</ButtonAgain>
这样子操作后,在任何地方使用二次封装的Button组件的时候,只需要传入函数,就可以去执行loading效果。
然后给获取验证码的按钮设置一个倒计时和禁用效果,同时实现获取验证码的功能
// 倒计时函数
let num = 5, timer = null
const countDown = () => {
if (num <= 0) {
clearInterval(timer)
timer = null
setDisabled(false)
setSendText('获取验证码')
return
}
setSendText(`${num--}秒后重发`)
}
const send = async () => {
// 这里需要基于Form表单实例实现手动校验
try {
await formIns.validateFields(['phone']) //针对某一项或多个进行校验,不写默认全体校验
let phone = formIns.getFieldValue('phone') // 获取手机号
let res = await getPhoneCode(phone) //调用接口获取验证码
let { code } = res.data // code为0代表成功
if (+code !== 0) {
Toast.show({
icon: 'fail',
content: '获取失败'
})
}
setDisabled(true) //将按钮禁用,进入倒计时
countDown() //手动调用一次,因为定时器是一秒钟后启动
// 编写倒计时函数
if (!timer) timer = setInterval(countDown, 1000);
} catch (error) {
...
}
}
所有通过手动事件绑定,定时器等操作,都需要再组件销毁的时候释放
useEffect(() => {
return () => {
clearInterval(timer)
timer = null
}
}, [])
封装一个登录接口,需要传入手机号和验证码
export const login = ({ phone, code }) => {
return http({
url: '/login',
method: 'POST',
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
data: {
phone,
code
}
})
}
// 登录校验 手机和验证码
const submit = async (values) => {
try {
await formIns.validateFields()
// values=> {name: '', code: ''} 基于自动校验,会自动收集表单项的内容
let { phone, code } = formIns.getFieldsValue()
let res = await login({ phone, code })
console.log(res.data);
if (res.data.code !== 0) {
Toast.show({
icon: 'fail',
content: '登录失败'
})
// 清楚验证码重新获取
formIns.resetFields(['code'])
return
}
_.storage.set('tk', res.data.token)
} catch (error) { }
}
以下是登录成功后,返回的数据,其中token
字段是最重要的,必须保存,根据需求采用不同的保存方式,这里采用具备有效期的localStorage方式,存储的token字段一定要难以识别,否则控制台一打开就看到出来这是token。编写一段函数如图实现功能
后续跟用户相关的接口都需要带上token字段,才能和服务器通信。因此需要设置一个统一拦截器的效果
首先需要了解请求拦截器中默认提供的config
参数的组成,每次都需要获取config.url
和需要携带token的地址进行匹配,匹配成功就设置token到请求头中,一起跟随请求发送到服务器。
http.interceptors.request.use(function (config) {
console.log(config);
// 可以任何接口都传递token,也可以只针对部分接口带上token
let { url } = config
let needTokenArr = ['/user_info', '/user_update', '/store', '/store_remove', '/store_list']
let token = _.storage.get('tk') // 不存在为null
if (token) { //如果不存在也不需要绑定token了,直接向服务器发送请求,由服务器返回错误信息
let flag = needTokenArr.some(item => {
return item === url
})
// 如果flag为true,代表是数组中的地址,需要带上token
if (flag) config.headers['Authorization'] = token //推荐带上Bearer,这里服务器没处理就没携带了
}
return config;
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error);
});
编写一个获取用户信息的接口,该接口需要携带token才能获取,应该接口验证,可以获取到数据。并且在网络的请求头中携带了token字段
export const queryUserInfo = () => {
return http({
url: '/user_info',
method: 'GET'
})
}