极客园项目总结

508 阅读10分钟

第一板块

配置路径别名

  1. 安装修改 CRA 配置的包:yarn add -D @craco/craco
  2. 在项目根目录中创建 craco 的配置文件:craco.config.js,并在配置文件中配置路径别名。
const path = require('path')
module.exports = {
    webpack: {
        alias: {
            '@': path.join(__dirname, 'src'),
        },
    },
}
  1. 把 package.json 中的 react-scripts 替换为 craco 命令。
{
    "scripts": {
        "start": "craco start",
        "build": "craco build",
        "test": "craco test",
        "eject": "react-scripts eject"
    }
}
  1. 在代码中,就可以通过 @ 来表示 src 目录的绝对路径。
  2. 重启项目,让配置生效。

@ 别名路径提示

  1. 在项目根目录创建 jsconfig.json 配置文件。
  2. 在配置文件中添加以下配置。
{
    "compilerOptions": {
        "baseUrl": "./",
        "paths": {
            "@/*": ["src/*"]
        }
    }
}

css modules (React 脚手架已集成)

能够避免不同组件导入样式时, 样式全局化的问题

  1. 修改样式的文件名为 index.module.scss
  2. 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 中书写样式. 在组件的根标签上应用根类名, 组件中的其他部分就能够用简写的方式调用局部样式

image.png

  1. 每个组件的根节点使用 CSS Modules 形式的类名(根元素的类名:root)
  2. 其他所有的子节点样式包裹在 root 内,并通过 :global 变成普通的 CSS 类名
  3. :global 中样式不会被翻译

注意

  1. 在入口文件中, 引入的外部样式和自己的样式位置, 要把自己的样式文件放在最下面 (因为一般考虑以自己的样式为主)

第二板块

如何在普通 js 文件中进行路由跳转

由于组件中进行路由跳转可以通过 useHistory() 进行跳转, 但在普通 js 文件中不能用 hooks, 所以为了能够在非组件环境下拿到路由信息,需要通过以下步骤暴露 history 对象并获取

  1. 在 utils/history.js 文件中创建 hisotry 对象并导出。
  2. 在 App.js 中导入 history 对象,并设置为 Router 的 history 属性对应的值。
  3. 通过响应拦截器处理 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 对象跳转与传参 和 参数获取

  1. 这里的 ...rest 能拿到没有解构的所有参数(在这里也就是 path), 然后这里的语法能够支持在中写 {...rest}, 脚手架内部能够将代码转化成 <Route path='home'> 的形式, 也就是传过来多少, 就能够挂载多少
  2. 这里的组件 Layout 传过去之后, 由于 解构出来的 component 是小写, 不能被 react 解析为组件, 所以需要解构并重命名为 Component, 再写入
  3. 这里的 children 是组件标签中写的内容默认为挂载到 children 属性上
  4. 如果要在跳转的时候进行传参, 就需要利用 state 属性, 并在需要获取路径的地方使用 location.state.from 获取路径
  5. 是利用 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

  1. 这里的 onFinish 是 form 组件上的内置方法, 能在回调函数中拿到表单数据
  2. history.replace(目标路径), 能将当前页面的路径历史记录替换为目标路径
  3. 也是利用 replace 的原理进行了路径历史记录替换
  4. 浏览器默认会在每次路由更改时, 放入路由路径队列, 所以才有点击回退箭头进行路由回退的操作, 而 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')
)

补充

  1. 左侧菜单栏高亮保持思路
  • 获取地址栏中路径 (利用 useLocation 获取路径)
  • 存储到本地, 再刷新获取
  1. 首页默认显示二级路由页面思路
  • 重定向
  • 二级路由设置成一级路由一致
  1. <Route /> 路由出口标签中能够写 render 函数, 能够在跳转之前实现更多的逻辑
<Route
    path='/home'
    render={() => {
        const token = getToken()
        if (token) {
            return <Layout />
        } else {
            return <Redirect to='/login' />
        }
    }}
></Route>
  1. <Space></Space> 包裹的元素之间会拉开间隙

  2. <Table >标签中 key 属性处理

  • 如果数据中有 key 属性,那么就不用指定 key 属性。
  • 如果数据中没有 key 属性,必须通过 rowKey 属性明确声明用哪个字段当做 key。

注意

  1. 在组件当中获取 redux 数据通过 useSelect, 不在组件中获取 redux, 需要导入 store, 从 store 对象上获取数据

  2. window.href 路由跳转会刷新页面

  3. history 包是内置对象, 不用另外下载

  4. 可选操作符 ?., 可以避免报错阻断代码执行

  5. 获取地址栏中传递过来的参数, 如果在组件中, 可以通过 useLocation() 获取, 如果不在组件中, 就需要通过暴露出来的 history 对象获取

  6. history 一般配合 push 方法用来进行路由跳转, location 一般配合 pathname 方法用来获取页面路径 ( history 是 location 的父级, 也就是说可以写成 history.location.pathname 的形式获取路径)

  7. 表格组件中, <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()
}

图片上传功能

  1. 根目录新建 .env.development 和 .env.production 文件,指定基准地址方便其他地方读取。
REACT_APP_URL = 'http://geek.itheima.net/v1_0/' 
  1. 为 Upload 组件添加 action 属性和 name,指定封面图片上传接口地址。
 <Upload 
     action={process.env.REACT_APP_URL + 'upload'} 
     listType='picture-card' 
     fileList={fileList} 
     onChange={handleChange} 
     name='image'
 >
  <PlusOutlined />
</Upload>
  1. 创建状态 fileList 存储已上传封面图片地址,并设置为 Upload 组件的 fileList 属性值。
  2. 为 Upload 添加 onChange 属性,监听封面图片上传、删除等操作。
  3. 在 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 只是作为一个数据的中转

  1. 创建 ref 对象,用来存储已上传图片。
  2. onChange 的时候存储当前的 fileList 到 ref 对象。
  3. 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>

补充

  1. 空值合并操作符

image.png

  1. lableCol 和 wrapperCol 是 Form 组件上使用的 API (label 标签布局,同 <Col> 组件,设置 span offset 值,如 {span: 3, offset: 12} 或 sm: {span: 3, offset: 12})

  2. 通过 ref 获取表单元素, 并只对表单中的某一项进行校验

const formRef = useRef(null)
formRef.current.validateFields(['type'])
<Form
  autoComplete='off'
  size='large'
  onFinish={handleSubmit}
  initialValues={{
    content: '',
    type: 1,
  }}
  ref={formRef}
></Form>

注意

  1. useState 和 useRef 区别
  • useState() 的数据一般和视图绑定, 数据发生变化, 视图也发生变化
  • useRef() 的数据是使多次渲染之间的数据共享, 但数据变化的时候视图不会发生变化
  1. 当子页面超出父页面显示时, 可以在父页面标签上设置 overflowY:'auto', 使子页面出现滚动条, 而不是超出父页面显示

  2. 自定义 hook 用来封装状态和处理状态的业务逻辑 (以 use 开头)

  3. 被设置了 name 属性的 Form.Item 包装的控件,表单控件会自动添加 value(或 valuePropName 指定的其他属性) onChange(或 trigger 指定的其他属性),数据同步将被 Form 接管

  4. 表单组件中小组件只有设定了 name 属性之后, 才能通过 name 给小组件的 value 设定初始值 initialValues

initialValues={{
    content: '',
    type: 1,
}}
  1. 自定义的 hook 要用 use 开头, 并且写成小驼峰的形式, 这种形式才能被脚手架识别, 才能在自定义 hook 中使用其他 hooks

  2. 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
)

路由懒加载

  1. 在 App 组件中,导入 Suspense 组件。
  2. 在 Router 内部,使用 Suspense 组件包裹组件内容。
  3. 为 Suspense 组件提供 fallback 属性,指定 loading 占位内容。
  4. 导入 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>
    )
}

打包体积分析

  1. 安装分析打包体积的包:yarn add source-map-explorer
  2. 在 package.json 中的 scripts 标签中,添加分析打包体积的命令。
{
    "scripts": {
        "analyze": "source-map-explorer 'build/static/js/*.js'"
    }
}
  1. 对项目打包:yarn build(如果已经打过包,可省略这一步)。
  2. 运行分析命令:yarn analyze
  3. 通过浏览器打开的页面,分析图表中的包体积。

配置 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>

注意

  1. action 中可以在发请求之后直接 return 请求到的数据, 然后在发请求的组件中直接用 await 拿到数据

补充

  1. 利用表单元素的 setFieldsValue 属性能够对表单进行回填, 其中的属性值为表单组件 name 属性绑定的值
formRef.current.setFieldsValue({
    ...res,
    type: res.cover.type, // !#1 封面单选按钮的回显
})