第一板块
配置路径别名
- 安装修改 CRA 配置的包:
yarn add -D @craco/craco。 - 在项目根目录中创建 craco 的配置文件:
craco.config.js,并在配置文件中配置路径别名。
const path = require('path')
module.exports = {
webpack: {
alias: {
'@': path.join(__dirname, 'src'),
},
},
}
- 把
package.json中的react-scripts替换为craco命令。
{
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test",
"eject": "react-scripts eject"
}
}
- 在代码中,就可以通过
@来表示 src 目录的绝对路径。 - 重启项目,让配置生效。
@ 别名路径提示
- 在项目根目录创建
jsconfig.json配置文件。 - 在配置文件中添加以下配置。
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
}
}
css modules (React 脚手架已集成)
能够避免不同组件导入样式时, 样式全局化的问题
- 修改样式的文件名为
index.module.scss。 pages/Layout/index.js使用。
import React from 'react'
import styles from './index.module.scss'
export default function Layout() {
return <div className={styles.layout}>Layout</div>
}
react 中 css 模块化最合适的写法
- 因为React 脚手架的存在, 应用了
文件名.module.scss的文件中的样式会编译成唯一的形式, 如果要调用这种唯一的形式, 就需要通过styles.类名引用样式, 但由于这种写法太过麻烦, 所以可以通过将样式写到:global中, 这里面的样式不会被翻译, 但这种方式书写的样式又会在全局生效, 为了使最终的样式同时实现局部生效 + 书写方便, 所以书写方式最终变为在根类名中的:global中书写样式. 在组件的根标签上应用根类名, 组件中的其他部分就能够用简写的方式调用局部样式
- 每个组件的根节点使用 CSS Modules 形式的类名(根元素的类名:root)
- 其他所有的子节点样式包裹在 root 内,并通过 :global 变成普通的 CSS 类名
- :global 中样式不会被翻译
注意
- 在入口文件中, 引入的外部样式和自己的样式位置, 要把自己的样式文件放在最下面 (因为一般考虑以自己的样式为主)
第二板块
如何在普通 js 文件中进行路由跳转
由于组件中进行路由跳转可以通过 useHistory() 进行跳转, 但在普通 js 文件中不能用 hooks, 所以为了能够在非组件环境下拿到路由信息,需要通过以下步骤暴露 history 对象并获取
- 在
utils/history.js文件中创建 hisotry 对象并导出。 - 在 App.js 中导入 history 对象,并设置为 Router 的 history 属性对应的值。
- 通过响应拦截器处理 Token 失效。
- utils/history.js
import { createBrowserHistory } from 'history'
const history = createBrowserHistory()
export default history
- App.js
import { Route, Switch, Redirect, Router } from 'react-router-dom'
import Login from '@/pages/Login'
import Layout from '@/pages/Layout'
import NotFound from '@/pages/NotFound'
import history from '@/utils/history'
// Router + history = BrowserRouter
// Router + hash = HashRouter
export default function App() {
return (
<Router history={history}>
<div className='app'>
<Switch>
<Redirect exact from='/' to='/home' />
<Route path='/login' component={Login}></Route>
<Route path='/home' component={Layout}></Route>
<Route component={NotFound} />
</Switch>
</div>
</Router>
)
}
- utils/request.js
instance.interceptors.response.use(
(response) => {
return response
},
(err) => {
if (err.response.status === 401) {
// #1 提示消息
message.error('登录信息过期', 1)
// #2 清除 Token(本地和 Redux 的)
store.dispatch(logout())
// #3 使用 history 进行跳转
history.push('/login')
}
return Promise.reject(err)
}
)
history 对象跳转与传参 和 参数获取
- 这里的 ...rest 能拿到没有解构的所有参数(在这里也就是 path), 然后这里的语法能够支持在中写 {...rest}, 脚手架内部能够将代码转化成
<Route path='home'>的形式, 也就是传过来多少, 就能够挂载多少- 这里的组件 Layout 传过去之后, 由于 解构出来的 component 是小写, 不能被 react 解析为组件, 所以需要解构并重命名为 Component, 再写入
- 这里的 children 是组件标签中写的内容默认为挂载到 children 属性上
- 如果要在跳转的时候进行传参, 就需要利用 state 属性, 并在需要获取路径的地方使用
location.state.from获取路径- 是利用 replace 的原理进行了路径历史记录替换
<PrivateRoute path='/home' component={Layout} />
import { useLocation } from 'react-router-dom'
import { isAuth } from '@/utils/storage'
import { Route, Redirect } from 'react-router-dom'
export default function PrivateRoute({ children, component: Component, ...rest }) {
const location = useLocation()
return (
<Route
{...rest}
render={() => {
if (isAuth()) {
return children ? children : <Component />
} else {
/* Redirect 跳转默认是 replace 的效果,也可以添加 push 属性变成 push 的效果 */
return (
<Redirect
to={{
// #1 指定跳转地址
pathname: '/login',
// #2 通过 state 来传递额外的参数
state: {
// 把当前的地址作为 from 属性传递了过去
from: location.pathname,
},
}}
/>
)
}
}}
/>
)
}
history.replace() 解决网页回退时 bug
- 这里的 onFinish 是 form 组件上的内置方法, 能在回调函数中拿到表单数据
- history.replace(目标路径), 能将当前页面的路径历史记录替换为目标路径
- 也是利用 replace 的原理进行了路径历史记录替换
- 浏览器默认会在每次路由更改时, 放入路由路径队列, 所以才有点击回退箭头进行路由回退的操作, 而 replace 能够对路由记录进行替换
const onFinish = async (values) => {
setLoading(true)
try {
await dispatch(login(values))
message.success('登录成功', 1, () => {
const from = location.state ? location.state.from : '/home'
// 修复,这儿改成了 replace,表示不把当前页面保留到历史记录(或者用新的页面替换掉当前页面历史)
history.replace(from)
})
} catch (e) {
message.error(e.response.data.message, 1, () => {
setLoading(false)
})
}
}
项目中文化
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { ConfigProvider } from 'antd'
import 'antd/dist/antd.css'
import './index.scss'
import store from './store'
import App from './App'
// #1
import 'moment/locale/zh-cn'
// #2
import locale from 'antd/lib/locale/zh_CN'
ReactDOM.render(
<Provider store={store}>
{/* #3 */}
<ConfigProvider locale={locale}>
<App />
</ConfigProvider>
</Provider>,
document.getElementById('root')
)
补充
- 左侧菜单栏高亮保持思路
- 获取地址栏中路径 (利用 useLocation 获取路径)
- 存储到本地, 再刷新获取
- 首页默认显示二级路由页面思路
- 重定向
- 二级路由设置成一级路由一致
- 在
<Route />路由出口标签中能够写 render 函数, 能够在跳转之前实现更多的逻辑
<Route
path='/home'
render={() => {
const token = getToken()
if (token) {
return <Layout />
} else {
return <Redirect to='/login' />
}
}}
></Route>
-
<Space></Space>包裹的元素之间会拉开间隙 -
<Table >标签中 key 属性处理
- 如果数据中有 key 属性,那么就不用指定 key 属性。
- 如果数据中没有 key 属性,必须通过 rowKey 属性明确声明用哪个字段当做 key。
注意
-
在组件当中获取 redux 数据通过 useSelect, 不在组件中获取 redux, 需要导入 store, 从 store 对象上获取数据
-
window.href 路由跳转会刷新页面
-
history 包是内置对象, 不用另外下载
-
可选操作符
?., 可以避免报错阻断代码执行 -
获取地址栏中传递过来的参数, 如果在组件中, 可以通过 useLocation() 获取, 如果不在组件中, 就需要通过暴露出来的 history 对象获取
-
history 一般配合 push 方法用来进行路由跳转, location 一般配合 pathname 方法用来获取页面路径 ( history 是 location 的父级, 也就是说可以写成 history.location.pathname 的形式获取路径)
-
表格组件中,
<Table rowKey='id' dataSource={list.results} columns={columns} />, columns 数据中的对象可以写 render 属性, 并且如果存在 render 属性, 那么最终页面渲染就渲染 render 的结果, 没有 render 的话就渲染 dataIndex 绑定的值
第三板块
分页组件的用法
- 单独使用时
<Pagination onChange={onChange} total={50} />
- 在 table 组件中使用时(可以写成对象的形式, 并且还可以绑定 onchange 事件)
<Table
rowKey='id'
dataSource={articles.results}
columns={columns}
pagination={{
position: ['bottomCenter'],
total: articles.total_count,
pageSize: articles.per_page,
current: articles.page,
onChange(page, pageSize) {
// #4
params.current.page = page
params.current.per_page = pageSize
dispatch(getArticleList(params.current))
},
}}
/>
自定义 hooks 用法
- 构建 hook 文件
import { getChannelList } from '@/store/actions/article'
import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
export function useChannels() {
const dispatch = useDispatch()
const channels = useSelector((state) => state.article.channels)
useEffect(() => {
dispatch(getChannelList())
}, [dispatch])
return channels
}
- 调用 hook
import { useChannels } from '@/hooks'
export default function Publish() {
const channels = useChannels()
}
图片上传功能
- 根目录新建
.env.development和.env.production文件,指定基准地址方便其他地方读取。
REACT_APP_URL = 'http://geek.itheima.net/v1_0/'
- 为 Upload 组件添加 action 属性和 name,指定封面图片上传接口地址。
<Upload
action={process.env.REACT_APP_URL + 'upload'}
listType='picture-card'
fileList={fileList}
onChange={handleChange}
name='image'
>
<PlusOutlined />
</Upload>
- 创建状态 fileList 存储已上传封面图片地址,并设置为 Upload 组件的 fileList 属性值。
- 为 Upload 添加 onChange 属性,监听封面图片上传、删除等操作。
- 在 change 事件中拿到当前图片数据,并存储到状态 fileList 中。
const [fileList, setFileList] = useState([
{
uid: '-1',
name: 'image.png',
status: 'done',
url: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png',
},
])
const handleChange = ({ fileList }) => {
setFileList(fileList)
}
图片数量切换控制
这里的 fileRef 只是用来存储数据, 不具备用来实时渲染的能力, 在点击不同的单选按钮时, 从 fileRef 数据中获取对应数量的图片并修改 useState 中的数据, 才能实现实时渲染, 也就是说 fileRef 只是作为一个数据的中转
- 创建 ref 对象,用来存储已上传图片。
- onChange 的时候存储当前的 fileList 到 ref 对象。
- onTypeChange 的时候从 ref 对象中截取需要的部分。
import { useState, useRef } from 'react'
import { Form, Input, Card, Breadcrumb, Space, Button, Radio, Upload } from 'antd'
import { Link } from 'react-router-dom'
import { PlusOutlined } from '@ant-design/icons'
import styles from './index.module.scss'
import Channel from '@/components/Channel'
import ReactQuill from 'react-quill'
import 'react-quill/dist/quill.snow.css'
export default function Publish() {
const [fileList, setFileList] = useState([
{
url: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png',
},
])
const [type, setType] = useState(1)
// !#1
const fileRef = useRef(fileList)
const onFinish = (values) => {
console.log(values)
}
const onChange = ({ fileList }) => {
setFileList(fileList)
// !#2 每次上传完之后的最终结果都存一下
fileRef.current = fileList
}
const onTypeChange = (e) => {
const count = e.target.value
// 数量
setType(count)
// !#3
// 从最终的结果里面截取需要的部分
setFileList(fileRef.current.slice(0, count))
}
return (
<div className={styles.root}>
<Card
title={
<Breadcrumb separator='>'>
<Breadcrumb.Item>
<Link to='/home'>首页</Link>
</Breadcrumb.Item>
<Breadcrumb.Item>发布文章</Breadcrumb.Item>
</Breadcrumb>
}
>
<Form
labelCol={{ span: 4 }}
wrapperCol={{ span: 20 }}
size='large'
initialValues={{
content: '',
type,
}}
onFinish={onFinish}
>
<Form.Item label='标题' name='title'>
<Input placeholder='请输入文章的标题' style={{ width: 400 }} />
</Form.Item>
<Form.Item label='频道' name='channel_id'>
<Channel />
</Form.Item>
<Form.Item label='封面' name='type'>
<Radio.Group onChange={onTypeChange}>
<Radio value={1}>单图</Radio>
<Radio value={3}>三图</Radio>
<Radio value={0}>无图</Radio>
</Radio.Group>
</Form.Item>
{/* //!#4 */}
{type > 0 && (
<Form.Item wrapperCol={{ offset: 4 }}>
<Upload listType='picture-card' fileList={fileList} action={`${process.env.REACT_APP_URL}upload`} name='image' onChange={onChange} maxCount={type}>
{fileList.length < type && <PlusOutlined />}
</Upload>
</Form.Item>
)}
<Form.Item label='内容' name='content'>
<ReactQuill />
</Form.Item>
<Form.Item wrapperCol={{ offset: 4, span: 20 }}>
<Space>
<Button type='primary' htmlType='submit'>
发布文章
</Button>
<Button>存入草稿</Button>
</Space>
</Form.Item>
</Form>
</Card>
</div>
)
}
表单中书写逻辑校验
<Form.Item
label='封面'
name='type'
rules={[
{
validator(_, value) {
// value => 表示选中的数量
if (fileList.length !== value) {
return Promise.reject(new Error(`请上传${value}张图片`))
} else {
return Promise.resolve()
}
},
},
]}
>
<Radio.Group onChange={onTypeChange}>
<Radio value={1}>单图</Radio>
<Radio value={3}>三图</Radio>
<Radio value={0}>无图</Radio>
</Radio.Group>
</Form.Item>
补充
- 空值合并操作符
-
lableCol 和 wrapperCol 是 Form 组件上使用的 API (
label标签布局,同<Col>组件,设置spanoffset值,如{span: 3, offset: 12}或sm: {span: 3, offset: 12}) -
通过 ref 获取表单元素, 并只对表单中的某一项进行校验
const formRef = useRef(null)
formRef.current.validateFields(['type'])
<Form
autoComplete='off'
size='large'
onFinish={handleSubmit}
initialValues={{
content: '',
type: 1,
}}
ref={formRef}
></Form>
注意
- useState 和 useRef 区别
- useState() 的数据一般和视图绑定, 数据发生变化, 视图也
会发生变化 - useRef() 的数据是使多次渲染之间的数据共享, 但数据变化的时候视图
不会发生变化
-
当子页面超出父页面显示时, 可以在父页面标签上设置
overflowY:'auto', 使子页面出现滚动条, 而不是超出父页面显示 -
自定义 hook 用来封装状态和处理状态的业务逻辑 (以 use 开头)
-
被设置了
name属性的Form.Item包装的控件,表单控件会自动添加value(或valuePropName指定的其他属性)onChange(或trigger指定的其他属性),数据同步将被 Form 接管 -
表单组件中小组件只有设定了 name 属性之后, 才能通过 name 给小组件的 value 设定初始值 initialValues
initialValues={{
content: '',
type: 1,
}}
-
自定义的 hook 要用 use 开头, 并且写成小驼峰的形式, 这种形式才能被脚手架识别, 才能在自定义 hook 中使用其他 hooks
-
useRef 的作用
- 获取子组件的实例(只有类组件可用)
- 在函数组件中的一个全局变量
第四板块
Redux 中间件优化
能够在项目上线和开发的时候, 控制是否显示 redex 控制台
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import { composeWithDevTools } from 'redux-devtools-extension'
import rootReducer from './reducers'
import { getToken } from '@/utils'
// #1
let middlewares
if (process.env.NODE_ENV === 'production') {
middlewares = applyMiddleware(thunk)
} else {
middlewares = composeWithDevTools(applyMiddleware(thunk))
}
export default createStore(
rootReducer,
{
login: {
token: getToken(),
},
},
// #2
middlewares
)
路由懒加载
- 在 App 组件中,导入 Suspense 组件。
- 在 Router 内部,使用 Suspense 组件包裹组件内容。
- 为 Suspense 组件提供 fallback 属性,指定 loading 占位内容。
- 导入 lazy 函数,并修改为懒加载方式导入路由组件。
import { lazy, Suspense } from 'react'
import { Route, Switch, Redirect, Router } from 'react-router-dom'
import PrivateRoute from './components/PrivateRoute'
import history from '@/utils/history'
// #1
const Login = lazy(() => import('@/pages/Login'))
const Layout = lazy(() => import('@/pages/Layout'))
const NotFound = lazy(() => import('@/pages/NotFound'))
export default function App() {
return (
<Router history={history}>
<div className='app'>
{/* #2 */}
<Suspense
fallback={
<div
style={{
textAlign: 'center',
marginTop: 200,
}}
>
loading...
</div>
}
>
<Switch>
<Redirect exact from='/' to='/home' />
<Route path='/login' component={Login}></Route>
<PrivateRoute path='/home' component={Layout} />
<Route component={NotFound} />
</Switch>
</Suspense>
</div>
</Router>
)
}
打包体积分析
- 安装分析打包体积的包:
yarn add source-map-explorer。 - 在
package.json中的 scripts 标签中,添加分析打包体积的命令。
{
"scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'"
}
}
- 对项目打包:
yarn build(如果已经打过包,可省略这一步)。 - 运行分析命令:
yarn analyze。 - 通过浏览器打开的页面,分析图表中的包体积。
配置 CDN 地址
最近 create-react-app 升级到了 5 版本,需要把
craco.config.js中挂载 CDN 的代码改成match.userOptions.cdn = cdn,或者使用react-scripts@4.0.3的版本。
- craco.config.js
const path = require('path')
const { whenProd, getPlugin, pluginByName } = require('@craco/craco')
module.exports = {
webpack: {
alias: {
'@': path.join(__dirname, 'src'),
},
configure: (webpackConfig) => {
let cdn = {
js: [],
css: [],
}
whenProd(() => {
webpackConfig.externals = {
react: 'React',
'react-dom': 'ReactDOM',
redux: 'Redux',
'react-router-dom': 'ReactRouterDOM',
}
cdn = {
js: [
'https://cdn.bootcdn.net/ajax/libs/react/17.0.2/umd/react.production.min.js',
'https://cdn.bootcdn.net/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js',
'https://cdn.bootcdn.net/ajax/libs/redux/4.1.0/redux.min.js',
'https://cdn.bootcdn.net/ajax/libs/react-router-dom/5.2.0/react-router-dom.min.js',
],
css: [],
}
})
const { isFound, match } = getPlugin(webpackConfig, pluginByName('HtmlWebpackPlugin'))
if (isFound) {
match.options.cdn = cdn
}
return webpackConfig
},
},
}
- public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<% htmlWebpackPlugin.options.cdn.css.forEach(cdnURL=> { %>
<link rel="stylesheet" href="<%= cdnURL %>" />
<% }) %>
</head>
<body>
<div id="root"></div>
<% htmlWebpackPlugin.options.cdn.js.forEach(cdnURL=> { %>
<script src="<%= cdnURL %>"></script>
<% }) %>
</body>
</html>
注意
-
action 中可以在发请求之后直接 return 请求到的数据, 然后在发请求的组件中直接用 await 拿到数据
补充
- 利用表单元素的 setFieldsValue 属性能够对表单进行回填, 其中的属性值为表单组件 name 属性绑定的值
formRef.current.setFieldsValue({
...res,
type: res.cover.type, // !#1 封面单选按钮的回显
})