本文已参与[新人创作礼]活动,一起开启掘金创作之路
本文是小白初步接触react做的小项目的主要知识点及bug处理。部分没什么特别突出特点的功能就没有列出。此文仅用于巩固自身,不喜勿喷。
本文使用包:
css作用域
js
const Login = () => {
return (
<div className={styles.root}>
<Card className="login-container">
<img className="login-logo" src={logo} alt="" />
{/* 登录表单 */}
</Card>
</div>
)
}
src/pages/Login/index.module.scss
.root {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
background: center/cover url(~@/assets/login.png);
:global {
.login-logo {
...
}
}
}
配置redux
下包
yarn add redux react-redux redux-thunk redux-devtools-extension
目录:
├─store # redux根目录
│ ├─actionTypes.js #
│ ├─reducers #
│ │ ├─index.js #
│ │ └─login.js #
│ ├─actions #
│ │ └─login.js #
│ └─index.js # 创建store并导出
store/index.js
使用reduxjs
import { legacy_createStore,applyMiddleware } from "redux";
import thunk from "redux-thunk";
import rootReducer from './reducers/index'
import { composeWithDevTools } from "redux-devtools-extension";
//根据开发环境判断是否开启调试工具
let middlewares
if(process.env.NODE_ENV==='production'){
middlewares=applyMiddleware(thunk)
}else{
//开发环境即挂载thunk中间件也挂载调试工具
middlewares=composeWithDevTools(applyMiddleware(thunk))
}
const store = legacy_createStore(rootReducer,middlewares)
export default store
使用reduxjs/toolkit,这里没添加中间件
import reducer from './reducer'
import { configureStore } from '@reduxjs/toolkit'
export const store = configureStore({
reducer:{
counter:reducer,
}
})
export default store
store/reducers/index.js
import login from './login'
import user from './user'
import { combineReducers } from 'redux'
import channels from './channels.js'
import article from './article'
const rootReducer = combineReducers({
login,
user,
channels,
article
})
export default rootReducer
举例:store/reducers/login.js
const initValue = {
token: ''
}
export default function login(state = initValue, action) {
return state
}
src/index.js
import React from "react";
import ReactDOM from "react-dom/client";
import "@/index.scss";
import App from "@/App";
import { Provider } from "react-redux";
import store from '@/store'
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<Provider store={store}>
<App />
</Provider>
);
Redux-action实现登录
思路
-
用户在组件中 dispatch 异步 action
-
actions 提供封装好的异步 action(涉及actionType):
- 在action中发ajax
- dispatch 普通 action 来发起状态更新
-
reducers 处理状态更新(涉及actionType)
1.业务,pages/Login/Login.jsx
import { login } from '@/store/actions/login.js'
const dispatch = useDispatch()
// 当表单校验通过,就会执行onFinished,并且会携带数据
const onFinish = () => {
const onFinish = (values) => {
console.log('Success:', values)
// 发送请求,进行登录
dispatch(login(values))
}
}
2.异步,store/actions/login.js
import request from '@/utils/request'
import { LOGIN } from '../actionType'
export const login = (payload) => {
return async (dispatch) => {
const res = await request({
method: 'post',
url: '/authorizations',
data: payload
})
dispatch({
type: LOGIN,
payload: res.data.token
})
}
}
3.统一存储action,store/actionTypes.js
export const LOGIN = 'LOGIN'
4. reducers,store/reducers/login.js
import { LOGIN } from '../constants'
const initValue = {
token: ''
}
export default function login(state = initValue, action) {
if (action.type === LOGIN) {
return {
...state,
token: action.payload
}
}
return state
}
redux-thunk发请求路线图
token存储到本地
能够统一处理 token 的持久化相关操作
设置持久处理工具
utils/storage.js分别提供 getToken/setToken/removeToken/hasToken四个工具函数并导出
// 消除 魔法字符串
const TOKEN_KEY = 'itcast_geek_h5'
export function getToken () {
return JSON.parse(localStorage.getItem(TOKEN_KEY) || '{}')
}
export function setToken (val) {
localStorage.setItem(TOKEN_KEY, JSON.stringify(val))
}
export function removeToken () {
localStorage.removeItem(TOKEN_KEY)
}
export function hasToken () {
return !!getToken().token
}
}
登录获取用户信息及存储token
src\store\actions\login.js
import {LOGIN} from '../actionTypes'
import response from '@/utils/request.js'
import { setToken } from '@/utils/storage'
export const login=(payload)=>{
return async(dispatch)=>{
const res =await response({
method:'post',
url:'/authorizations',
data:payload
})
if (res.message === 'OK') {
setToken(res.data)
// console.log(res);
dispatch ({type:LOGIN,payload:res.data})
}
}
}
存储信息到仓库中,及判断是否已持久存储token
src\store\reducers\login.js
import {LOGIN,LOGOUT} from '../actionTypes'
import { getToken } from '@/utils/storage'
const initValue = getToken()||{
token: '123'
}
export default function login(state = initValue, action) {
// console.log(action);
switch (action.type) {
case LOGIN:
return {...state,...action.payload}
case LOGOUT:
return initValue
case 'loginUser':
return {...state,...action.payload}
default:
return state
}
}
loading登录消息提示
const [load,setLoad]=useState(false)
。。。
let onFinish=(values)=>{
setLoad(true)
try{
dispatch(login(values))
// values会是一个对象,键名就是表单项的name,键值就是表单元素的内容
// console.log(values);
message.success('登陆成功',1,()=>{
//提示信息,时间,回调函数
setLoad(false)
dispatch(getUserInfo())
// props.history.push('/layout')
props.history.push((history.location.state&&history.location.state.from)||'/layout')
})
// history.push('/layout')
}catch{
message.error('登陆失败')
}
}
。。。
{/* htmlType原生,检测所有带有name的表单属性 */}
<Button loading={load} type="primary" htmlType="submit" size='large' block>
登录
</Button>
重定向
//初始设置默认二级
<Redirect exact from='/' to='/login'></Redirect>
//主页设置默认二级
<Redirect exact from="/layout" to="/layout/home"></Redirect>
自定义history对象实现跳转
新建src\utils\history.js文件
// 自定义history对象
import { createBrowserHistory } from 'history'
const history = createBrowserHistory()
export default history
引入路由
使用src\utils\request.js
import history from '@/utils/history'
/* history====可以获取非组建中获取路由对象信息 */
history.push('/login')
Menu控制左侧菜单栏
const items = [
{
label: <Link to="/layout/home">数据概览</Link>,
key: '/layout/home',
icon: <HomeOutlined />,
},
{
label: <Link to="/layout/article">内容管理</Link>,
key: '/layout/article',
icon: <DiffOutlined />,
},
{
label: <Link to="/layout/publish">发布文章</Link>,
icon: <EditOutlined />,
key: '/layout/publish',
},
]
<Menu
onClick={onClick}
defaultSelectedKeys={[history.location.pathname]}
//初始选中的菜单项 key 数组
selectedKeys={[pathname]}
//该属性可解决除了点击左侧菜单跳转到其他页面高亮不跟随问题
theme="dark"
//主题颜色
mode="vertical"
//菜单类型,垂直
items={items}
//菜单内容
/>
配置请求拦截器
utils/request.js
// 添加请求拦截器
instance.interceptors.request.use(
function (config) {
// 在发送请求之前做些什么
const token = getToken()
if (token) {
// 添加token
config.headers.Authorization = `Bearer ${token}`
}
return config
},
function (error) {
// 对请求错误做些什么
return Promise.reject(error)
}
)
处理请求异常token失效
utils/request.js
// 添加响应拦截器
instance.interceptors.response.use(
function (response) {
// 对响应数据做点什么
return response
},
function (error) {
console.log('error')
console.dir(error)
if (!error.response) {
message.error('网络异常')
} else if (error.response.status === 401) {
message.error('身份过期,请重新登录')
// 1. 清空信息
store.dispatch(logout())
// 2. 跳转页面 - login
//hash
//window.location.href = '/#/login'
//但是页面会刷新,用户体验不好
window.location.href = '/login'
// useHistory
// history.push('/login')
}
return Promise.reject(error)
}
)
登录权限管理
初始版本
<Route path="/layout"
render={()=>{
return token?<Layout></Layout>:<Redirect to="/login"></Redirect>
}}></Route>
之所以下面提出抽离是因为初始版本,如果每个都需要判断token的话过于麻烦了,就封装了一个组件专门处理,使用时仅需携带路径名与组件名即可
抽离版本
app.js
import AuthRoute from "./utils/authRoute";
<AuthRoute path="/Layout" component={Layout}></AuthRoute>
authRoute.js
import {Route , Redirect} from 'react-router'
import { hasToken } from './storage'
export default function AuthRoute(props){
const Com=props.component
const token=hasToken()
return (
<Route path="/layout"
render={()=>{
return token?<Com></Com>:<Redirect to="/login"></Redirect>
}}></Route>
)
}
// <Route path="/layout"
// render={()=>{
// return token?<Layout></Layout>:<Redirect to="/login"></Redirect>
// }}></Route>
记录退出前页面登录后跳转到该页面
layout记录
login登录
给内容添加滚轮
<Content
className="site-layout-background"
style={{
padding: 24,
margin: 0,
minHeight: 280,
overflow:'auto'
}} >
表单初始化
筛选表单
1.表单绑定数据
2.点击事件种获取表单数据
给表单的筛选按钮注册事件
const onFinish=(value)=>{
// console.log(value);
}
3.创建对象用于存储需要提交的变量名来保存表单数据
const onFinish=(value)=>{
// console.log(value);
const params={}
params.status=value.status
params.channel_id=value.channel_id
if(value.date){
params.begin_paubdate=value.date[0]
.startOf('day')
.format('YYYY-MM-DD HH:mm:ss')
params.end_paubdate=value.date[1]
.endOf('day')
.format('YYYY-MM-DD HH:mm:ss')
}
// console.log(params);
}
4.发送请求重新获取表单数据
dispatch(getArticleList(params))
筛选之后点击分页获取数据不对
分析
筛选条件点击之后获取到数据后再点击分页,导致重新走了一遍函数组件,导致重新定义了一次条件{},使之前的条件清空了,然后再发送请求获取的就是原本的所有数据的当前页数据
解决
使用useRef注册变量,然后使用变量.current储存筛选条件
具体实施
-
删除储存条件的params={}
-
给每个事件添加 const params = ref.current
在表单中封装组件
1.创建组件
src\components\Channel.js
import React from 'react'
import { Select } from 'antd'
import {useChannels} from '@/hooks/useChannel'
export default function Channel(props) {
const channels=useChannels()
return (
<Select
{...props}
style={{ width: 200 }}
allowClear
placeholder="请选择频道"
>
{channels.map((item) => (
<Select.Option value={item.id} key={item.id}>
{item.name}
</Select.Option>
))}
</Select>
)
}
2.在表单中使用组件
注意
可以使用props解构出相应数据,也可以使用{...props}
引入并使用
import Channel from '@/components/Channel'
<Form.Item label="频道" name='channel_id'>
<Channel></Channel>
</Form.Item>
上传图片(需要重点掌握)
基本使用
<Form.Item wrapperCol={{ offset: 4, span: 20 }}>
{/*
action: 上传的地址
name: 上传的文件的名字 默认file
fileList: 显示控制上传的图片, 做回填 */}
<Upload listType="picture-card" fileList={fileList}>
<PlusOutlined />
</Upload>
</Form.Item>
const [fileList, setFileList] = useState([])
form.item的数据可以自行收集,但是,Upload组件中的数据,Form表单不会自动收集,需要我们自行处理
实现上传
-
为 Upload 组件添加 action 属性和name,指定封面图片上传接口地址和参数名 -
创建状态 fileList 存储已上传封面图片地址,并设置为 Upload 组件的 fileList 属性值 -
为 Upload 添加 onChange 属性,监听封面图片上传、删除等操作 -
在 change 事件中拿到当前图片数据,并存储到状态 fileList 中
限制数量
-
自己控制type属性
-
根据type属性控制上传的数量(Upload组件有个maxCount属性)
选项与图片组件间联动
-
创建 ref 对象,用来存储已上传图片
-
如果是单图,只展示第一张图片
-
如果是三图,展示所有图片
图片效验
rules={[
{
validator(_, value) {
if (fileList.length !== value) {
return Promise.reject(new Error(`请上传${value}张图片`))
} else {
return Promise.resolve()
}
}
}
]}
核心代码
// 控制type属性
const [type, setType] = useState(1)
// 上传图片数据
const [fileList, setFileList] = useState([
])
// 储存图片
const refFileList=useRef(fileList)
const formRef=useRef(null)
// 上传图片
const onChange1=(value)=>{
// console.log(value);
// 存入数据
refFileList.current=value.fileList
// console.log(value.fileList,'ref');
// 显示图片
setFileList(value.fileList)
//手动触发表单项的校验
formRef.current.validateFields(['type'])
}
<Radio.Group
value={type}
action={`${BASEURL}upload`}
onChange={e=>{
// console.log(e.target.value);
setType(e.target.value)
if(refFileList) setFileList(refFileList.current.slice(0,e.target.value))
}}>
<Radio value={1}>单图</Radio>
<Radio value={3}>三图</Radio>
<Radio value={0}>无图</Radio>
</Radio.Group>
</Form.Item>
<Form.Item wrapperCol={{offset:4 ,span: 22 }}>
{/*
action: 上传的地址
name: 上传的文件的名字 默认file
fileList: 显示控制上传的图片, 做回填 */}
<Upload
action={`${BASEURL}upload`}
//限制图片数量
maxCount={type}
name="image"
onChange={onChange1}
listType="picture-card" fileList={fileList} className='upload'>
{fileList.length < type&& <PlusOutlined />}
</Upload>
</Form.Item>
表单上传
上传事件
注册
绑定
触发
存入草稿
点击草稿事件
// 存入草稿
const addDraft=async()=>{
//手动触发表单项的校验-----表单.validateFields(['属性名'])
const form=await formRef.current.validateFields()
add(form,true)
}
与表单传统一处理获取数据
注意图片的获取
const add =async(value,draft)=>{
const cover = {
type:value.type,
images:fileList.map(item=>item.response.data.url)
}
const data={
title:value.title,
content:value.content,
channel_id:value.channel_id,
cover
}
try {
await dispatch(saveArticle(data,draft))
message.success(draft?'存入草稿':'发布成功', 1, () => {
history.push('/layout/article')
})
} catch(error) {
message.error("操作失败");
}
console.log(data,'dat');
console.log(fileList,'fileList');
}
代码优化
将数据与提示消息分离成两个函数,便于后期修改
实现编辑效果
1.获取id
publish.js
import { useHistory,useParams } from 'react-router-dom'
const {id}=useParams()
2.使用对象存储并传给form
-
使用useEffect监听id -
注意useEffect第一个参数不可以直接放异步函数 -
发请求 -
使用form绑定的useRef给form进行传值,动态给表单设置数据,需要调用表单的实例方法:setFieldsValue({属性名:属性值,...}) -
因为获取到的图片无法直接渲染,需要先将其存入上传图片的数组的对象中
核心代码
// 编辑
useEffect(()=>{
const getInfo=async(id)=>{
if(id){
const res=await dispatch(getArticleInfo(id) )
// console.log(res,'res');
const useInfo={
type:res.cover.type,
content:res.content,
channel_id:res.channel_id,
title:res.title
}
// console.log(useInfo,'useInfo');
formRef.current.setFieldsValue(useInfo)
// console.log(res,'rs');
//点击编辑时如果图片为3图,删除1个后type为默认,需要重新赋值一边,否则type不对导致fileList长度出错
setType(res.cover.type)
setFileList(res.cover.images.map((img) => ({ url: img })))
}
}
getInfo(id)
},[id,dispatch])
3.提交
获取数据时需要注意编辑过的图片与未编辑过的路径不同
const onFinish=(value)=>{
// console.log(value,'value');
add(value,false) }
const buildAdd =(value)=>{
console.log(fileList);
// 后端解析后处理导致点击编辑获取数据方式不同
const images = fileList.map((item) => {
if (item.url) {
return item.url
} else {
return item.response.data.url
}
})
const data = {
...value,
cover: {
type: type,
images: images
}
}
return data
}
提示信息需要与上传分开
const add =async(value,draft)=>{
const data = buildAdd(value)
if(id){
data.id=id
try {
await dispatch(saveArticle(data,draft))
message.success(draft?'修改草稿成功':'修改文章成功', 1, () => {
history.push('/layout/article')
})
} catch(error) {
message.error("修改失败");
}
}else{
上传
}
发布文章菜单高亮处理
layout.js
1.判断路径来源头部
使用内置api
注意:如果报错可能是信息没获取到,可使用js可选链操作符( 父?.子)如果没有父则返回undefined而不会因为找不到父亲而导致无法进行子的查找而报错
2.需要将选中状态改为pathname
发布文章编辑文章切换有数据残留
layout.js
key用于相同组件间的辨别,key值只要不一样具体值无所谓,只是为了辨别不同路由调用相同组件
<Route path="/layout/publish" key="add" exact component={Publish}></Route>
<Route path="/layout/publish/:id" key='edit' component={Publish} />