这一节我将带领大家,来开发博客管理系统的顶部导航功能,其效果最终如下:
一、路由优化
上一节我们介绍了routes的目录结构,其中我们创建了一web.js文件,用来保存路由配置。
/** * web路由配置项 * @author zy * @date 2020/4/5 */ export default { path: '/', name: 'home', component: Home, exact: false, childRoutes: [ {path: 'about', component: About}, {path: '*', component: PageNotFound} ] }
我们每添加一个路由,都需要在这里配置一次,如果路由较多,且存在多层父子关系的情况下,该配置会越来越复杂且结构不够清晰,此外多个人同时开发,每个人都可能去配置路由信息,那样我们就需要解决代码冲突问题。
那么有没有一种简单的方式,我们可以根据views文件夹结构动态生成路由配置信息,当然可以了,我们现在就来介绍。
1、views目录创建
我们的博客管理系统设计主要参考了郭大大这位博主的文章:
首先来看一下我们博客系统的主页,我们可以看到导航栏主要包含首页、归档、分类、关于这几个菜单,每个菜单项都对应着一个路由。
因此,我们在views文件夹下创建个web文件夹,并在该文件夹下创建与这几个对应的文件夹,用于保存每个页面对应的视图组件。最终我们views目录结构如下:
其中menu_config.js用于导出菜单配置,我们后面会根据该配置动态生成路由配置信息以及菜单配置信息。
我们首先来看一些about文件夹:
about.jsx:
import React from 'react'; function About(props) { console.log('About=>', props); returnAbout
; } export default About;
menu_config.js:
import {UserOutlined} from '@ant-design/icons'; import About from './about'; export default { title: '关于', icon: UserOutlined, path: 'about', component: About }
path:指定About组件的路由,由于每个路由实际上都对应这一个菜单项,因此我们通过icon指定菜单图标,title指定菜单名称。
如果一个菜单还有子菜单,比如归档菜单下面还有子菜单github:
archives文件夹下menu_config.js:
import {FolderOutlined} from '@ant-design/icons'; import github from './github/menu_config'; export default { title: '归档', icon: FolderOutlined, path: 'archives', subMenus: [github] }
这里我们配置了归档的子菜单github;
github.jsx:
import React from 'react'; function Github(props) { console.log('Github=>', props); returngithub
; } export default Github;
menu_config.js:
import {GithubOutlined} from '@ant-design/icons'; import Github from './github'; export default { title: 'github', icon: GithubOutlined, path: 'github', component: Github }
home和categories文件夹同上,就不一一介绍,最后我们通过web文件夹下的menu_config导出该菜单结构:
import home from './home/menu_config'; import archives from './archives/menu_config'; import categories from './categories/menu_config'; import about from './about/menu_config'; export default [home, archives, categories, about];
2、获取菜单配置信息
由于我们之前配置的path都是相对路径,因此我们需要将其转换为绝对路径,此外,我们还在菜单配置中加入了404菜单配置项;
@/components/404/menu_config:
import PageNotFound from './index'; export default { title: '404', icon: '', path: '*', component: PageNotFound, invisible: true }
这里配置了invisible指明*路由不需要出现在菜单项中。
utils/get_menus.js:
import _ from 'lodash'; import pageNotFoundMenu from '@/components/404/menu_config'; /** * 解析menu_config 将配置路径由相对路径转为绝对路径 * @author zy * @date 2020/4/8 * @param menus:menu_config配置 * @return contextPath:设置根路径 */ const getMenus = (menus, contextPath) => { const menusCopy = _.cloneDeep(menus); const decodeMenus = (menusCopy, menuContextPath) => { _.forEach(menusCopy, item => { //获取当前菜单路径 let path = item.path ? `${menuContextPath}/${item.path}` : menuContextPath; item.path = path.replace(/\/+/g, '/'); if (item.subMenus) { decodeMenus(item.subMenus, path); } }) //给每个同阶菜单追加一个404 如/* /archives/* /archives/layout/* if (menusCopy) { const menu = _.cloneDeep(pageNotFoundMenu); menu.path = (menuContextPath + '/*').replace(/\/+/g, '/'); menusCopy.push(menu); } } decodeMenus(menusCopy, contextPath); return menusCopy; } export default getMenus;
utils/index.js:
/** * @author zy * @date 2020/4/6 * @Description: 统用函数 */ import getMenusFunctions from './get_menus'; export const getMenus = getMenusFunctions;
3、routes目录
我们修改routes/web.js:
/** * @author zy * @date 2020/4/5 * @Description: web路由 * 不懂的可以参考:https://segmentfault.com/a/1190000020812860 * https://reacttraining.com/react-router/web/api/Route */ import Layout from '@/layout/web'; import menus from '@/views/web/menu_config'; import {getMenus} from '@/utils'; import {WEB_ROOT_PATH} from '@/config'; /** * web路由配置项 * @author zy * @date 2020/4/5 */ //web 菜单配置 export const webMenuConfig = getMenus(menus, WEB_ROOT_PATH); //web route配置 export const webRouteConfig = { title: 'home', path: WEB_ROOT_PATH, component: Layout, //根路径下配置web统一布局样式 subMenus: webMenuConfig }
这里WEB_ROOT_PATH配置为'/'路径:
/** * @author zy * @date 2020/4/6 * @Description: 项目配置文件 */ //web 根路径 export const WEB_ROOT_PATH = '/'; //导航栏博客名称 export const HEADER_BLOG_NAME = '我的博客';
其对应的组件为Layout,该组件是我们的布局组件,其主要包括顶部导航和侧边导航部分。
修改routes/index.js:
/** * @author zy * @date 2020/4/5 * @Description: 路由组件 */ import React from 'react'; import { Switch, Route } from 'react-router-dom'; import { webRouteConfig } from './web'; import _ from 'lodash'; //保存所有路由配置的数组 const routeConfig = [webRouteConfig] /** * 路由配置 * @author zy * @date 2020/4/5 */ export default function () { /** * 生成路由嵌套结构 * @author: zy * @date: 2020-03-05 * @param routeConfig: 路由配置数组 */ const renderRouters = (routeConfig) => { const routes = []; //遍历每一个路由项 _.forEach(routeConfig, item => { //这里使用了嵌套路由 routes.push( <Route key={item.path} path={item.path} component={() =>{item.component &&} exact={item.subMenus ? false : true} /> ); }); return} {item.subMenus && renderRouters(item.subMenus)} {routes} ; }; return renderRouters(routeConfig); }
我们可以输出webRouteConfig:
二、Layout组件开发
上面我们已经说过WEB_ROOT_PATH路由,对应的组件为Layout,其中主要包括顶部导航和侧边导航部分,这里我们将尝试开发顶部导航功能:
我们将顶部导航拆分为两个组件Left,Right;Right组件拆分为Serch、NavBar、UserInfo三个组件;
我们在layout下创建web文件夹,其目录如下:
1、web/index.js
/** * @author zy * @date 2020/4/6 * @Description: web页面布局 */ import React from 'react'; import {Layout, Row, Col} from 'antd'; import Header from './header'; // 响应式 const siderLayout = {xxl: 4, xl: 5, lg: 5, sm: 0, xs: 0} const contentLayout = {xxl: 20, xl: 19, lg: 19, sm: 24, xs: 24} /** * Web布局组件 * @author zy * @date 2020/4/6 */ const WebLayout = props => { return () } export default WebLayout; |
2、web/header/index.js
/** * @author zy * @date 2020/4/6 * @Description: web 头部布局 */ import React from 'react'; import {Layout, Row, Col} from 'antd'; import Left from './left'; import Right from './right'; import styles from './styles.scss'; const Header = Layout.Header; /** * 头部布局组件 * @author zy * @date 2020/4/6 */ const WebHeader = () => { // 响应式 xxl:超大屏 一行显示24/4列 xl:大屏一行显示24/5 ... const responsiveLeft = {xxl: 4, xl: 5, lg: 5, sm: 4, xs: 24}; const responsiveRight = {xxl: 20, xl: 19, lg: 19, sm: 20, xs: 0}; return () } export default WebHeader; |
2、web/header/styles.scss
@import '@/styles/other.scss'; .appHeader { padding: 0; background: #fff; box-shadow: 0 2px 8px $headerBoxShadowColor; }
这里我们引入了@/styles/other.scss文件:
/** * @author zy * @date 2020/4/7 * @Description: 所有颜色定义 */ //头部颜色 $headerColor: rgba(0, 0, 0, .85); $headerBoxShadowColor: #f0f1f2; //分割线颜色 $dividerColor: rgb(235, 237, 240); //图标颜色 $searchIconColor: #ced4d9; //占位符颜色 $placeholderColor: #a3b1bf; //主页颜色 $homeBasicColor: #0cb7d5;
三、Left组件
1、index.js
/** * @author zy * @date 2020/4/6 * @Description: 头部左侧布局 */ import React from 'react'; import {DingdingOutlined} from '@ant-design/icons'; import styles from './styles.scss'; import {HEADER_BLOG_NAME} from '@/config' /** * 头部左侧布局组件 * @author zy * @date 2020/4/6 */ const HeaderLeft = props => { return ( ) } export default HeaderLeft;
2、styles.scss
@import '@/styles/other.scss'; .headerLeft { padding-left: 40px; font-size: 20px; color: $headerColor; display: flex; align-items: center; line-height: 64px; height: 64px; .blogIcon { overflow: hidden; color: $headerColor; font-size: 18px; white-space: nowrap; text-decoration: none; span { margin-right: 16px; } } }
四、Right组件
1、index.jsx
/** * @author zy * @date 2020/4/6 * @Description: 头部右侧布局 */ import React from 'react'; import Search from './right_search'; import Navbar from './right_nav_bar'; import UserInfo from './right_user_info'; import styles from './styles.scss'; /** * 头部右侧布局组件 * @author zy * @date 2020/4/6 */ const HeaderRight = props => { return () } export default HeaderRight;
2、right_search.jsx:
/** * @author zy * @date 2020/4/6 * @Description: 文章搜索 */ import React from 'react'; import {useSelector, useDispatch} from 'react-redux'; import {Input} from 'antd'; import {SearchOutlined} from '@ant-design/icons'; import styles from './styles.scss'; import {setKeyword} from '@/redux/article/actions'; /** * 搜索组件 * @author zy * @date 2020/4/6 */ function SearchButton(props) { //dispatch const dispatch = useDispatch() //将store状态article映射到组件 const article = useSelector(state => state.article); //获取文章信息 const {keyword} = article; //搜索关键字发生变化 const handleChange = e => { dispatch(setKeyword(e.target.value)); } //确定 开始搜索 const handleSubmit = () => { if (keyword) { console.log('开始搜索', keyword); } } return () } export default SearchButton;<Input type='text' value={keyword} onChange={handleChange} onPressEnter={handleSubmit} className={styles.searchInput} placeholder='搜索文章' style={{width: 200}} />
这里使用redux状态管理器保存搜索关键字keyword,当用户在输入框输入搜索内容时,触发onChange事件:
//搜索关键字发生变化 const handleChange = e => { dispatch(setKeyword(e.target.value)); }
此时调用dispatch设置keyword,当我们点击搜索时,这里将会将keyword搜索关键字输出:
//确定 开始搜索 const handleSubmit = () => { if (keyword) { console.log('开始搜索', keyword); } }
实际上,我们点搜索的时候,应当使用ajax请求服务器获取文章,然后将其保存到store状态中,然而由于此时没有开发后端接口,所以只好先输出到控制台。
我们再来看看文章reducer的配置,我们在redux下创建article文件夹:
(1).actions.js
/** * @author zy * @date 2020/4/12 * @Description: 文章action */ import * as TYPES from './types'; //设置搜索关键字 export const setKeyword = (params) => ({ type: TYPES.ARTICLE_SET_KEYWORD, payload: params })
(2).reducer.js
/** * @author zy * @date 2020/4/12 * @Description: 文章reducer */ import * as TYPES from './types'; /** * @author zy * @date 2020/4/12 * @Description: 初始化文章信息 */ const defaultState = { keyword: '' } /** * articleReducer * @author zy * @date 2020/4/12 */ export default function articleReducer(state = defaultState, action) { const {type, payload} = action switch (type) { case TYPES.ARTICLE_SET_KEYWORD: return {...state, keyword: payload} default: return state } }
(3).types.js
// article export const ARTICLE_SET_KEYWORD = '';
同时redux/root_reducers.js中引入articleReducer:
/** * @author zy * @date 2020/4/5 * @Description: 合并reducer */ import {combineReducers} from 'redux'; import article from './article/reducer'; export default combineReducers({ article})
3、right_nav_bar.jsx
/** * @author zy * @date 2020/4/7 * @Description: 导航栏 */ import React from 'react'; import {Link, useLocation} from 'react-router-dom'; import {Menu} from "antd"; import {webMenuConfig} from '@/routes/web'; import _ from 'lodash'; import styles from './styles.scss'; const {SubMenu} = Menu; /** * 导航栏组件 * @author zy * @date 2020/4/7 */ function NavBar(props) { //获取当前location对象 const location = useLocation(); //菜单样式 默认水平 const {mode = 'horizontal'} = props; /** * 生成菜单树 * @author zy * @date 2020/4/7 */ const genMenuTree = (menus) => { return _.map(menus, menu => { const title = {menu.icon &&} {menu.title}; return menu.subMenus ? !menu.invisible && {menu.title} title={title}>{genMenuTree(menu.subMenus)} : !menu.invisible &&{title} ; }) } const onSelect = ({item, key, keyPath, selectedKeys, domEvent}) => { console.log('选择项为', selectedKeys); } return ( ) } export default NavBar;
4、right_user_info.jsx
/** * @author zy * @date 2020/4/12 * @Description: 用户信息 */ import React from 'react'; import {useSelector, useDispatch} from 'react-redux'; import {Button, Dropdown, Menu, Avatar} from 'antd'; import {useBus} from '@/hooks/use_bus'; import {USER_LOGIN, USER_REGISTER} from '@/redux/user/types'; import {loginout} from '@/redux/user/actions'; /** * 用户细腻些组件 * @author zy * @date 2020/4/12 */ function UserInfo(props) { //dispatch const dispatch = useDispatch() //将store状态user映射到组件 const userInfo = useSelector(state => state.user); //获取用户信息 const {username} = userInfo; //使用bus const bus = useBus(); //菜单 const menuOverLay = ( ); return ({/*登录 or not*/} {username ? () } export default UserInfo;) : ( } src='http://img2.imgtn.bdimg.com/it/u=3906498928,936423956&fm=26&gp=0.jpg'>{username} <Button ghost type='primary' size='small' style={{marginRight: 20}} onClick={e => bus.emit('openSignModal', USER_LOGIN)}> 登录 <Button ghost type='danger' size='small' onClick={e => bus.emit('openSignModal', USER_REGISTER)}> 注册)}
这里显示通过useSelector将store中的用户信息映射到当前组件中,如果用户信息存在,则会加载下拉菜单:
如果用户信息不存在,则会显示:
这里使用到了事件发生器,当点击了登录时候,会触发openSignModal事件,并传入参数USER_LOGIN:
onClick={e => bus.emit('openSignModal', USER_LOGIN)}>
当点击注册的时候,也会触发openSignModal事件,但是传入的参数是USER_REGISTER,当有函数监听了该事件的发生,那么就会执行该监听函数,这里实际上采用的就是是发布/订阅模式。
我们在components/public下创建public公共组件:
components/public/index.jsx:
/** * @author zy * @date 2020/4/12 * @Description: Public 公共组件,挂在在 APP.jsx 中,用于存放初始化的组件/方法 或者公用的 modal 等 */ import React from 'react'; import useMount from '@/hooks/use_mount' import SignModal from '@/components/public/sign_modal'; /** * 公共组件 * @author zy * @date 2020/4/12 */ function PublicComponent(props) { useMount(() => { }) return () } export default PublicComponent;
我们引入了SignModal组件,该组件用于注册/登录,在public下创建sign_modal文件夹,并添加index.jsx文件:
/** * @author zy * @date 2020/4/12 * @Description: 注册 or 登录对话框 */ import React, {useState} from 'react'; import {Form, Input, Button, Modal} from 'antd'; import {UserOutlined, LockOutlined} from '@ant-design/icons'; import {login, register} from '@/redux/user/actions'; import {USER_LOGIN} from '@/redux/user/types' import {useDispatch} from 'react-redux'; import {busListener} from '@/hooks/use_bus'; //表单样式调整 const FormItemLayout = { labelCol: { xs: {span: 0}, sm: {span: 5} }, wrapperCol: { xs: {span: 24}, sm: {span: 19} } } /** * 用户注册 or 登录组件 * @author zy * @date 2020/4/12 */ function SignModal(props) { //获取表单 const [form] = Form.useForm(); //获取dispatch const dispatch = useDispatch(); //对话框可见? const [visible, setVisible] = useState(false) //类型:登录 or 注册 const [type, setType] = useState('login') //事件监听 如果触发登录或者注册事件,显示该对话框 busListener('openSignModal', type => { form.resetFields(); setType(type); setVisible(true); }) //提交表单且数据验证成功后回调事件 const onFinish = values => { console.log('Received values of form: ', values); const action = type === USER_LOGIN ? login : register; dispatch(action(values)).then(() => { setVisible(false); }) }; //确认密码 function compareToFirstPassword(rule, value, callback) { if (value && value !== form.getFieldValue('password')) { callback('Two passwords that you enter is inconsistent!') } else { callback() } } return ( <Modal width={420} title={type === USER_LOGIN ? 'login' : 'register'} visible={visible} onCancel={e => setVisible(false)} footer={null}> <Form form={form} name="normal_login" layout="horizontal" onFinish={onFinish} > {/*登录或注册*/} {type === USER_LOGIN ? (<Form.Item name="username" rules={[{required: true, message: 'Please input your Username!'}]} > } placeholder="Username"/> <Form.Item name="password" rules={[{required: true, message: 'Please input your Password!'}]} > <Input prefix={) : (} type="password" placeholder="Password" /> <Form.Item {...FormItemLayout} label="用户名" name="username" rules={[{required: true, message: 'Please input your Username!'}]} > <Form.Item {...FormItemLayout} label="密码" name="password" rules={[{required: true, message: 'Please input your Password!'}]} > <Input type="password" placeholder="Password" /> <Form.Item {...FormItemLayout} label="确认密码" name='confirm' rules={[ {required: true, message: 'Password is required'}, {validator: compareToFirstPassword} ]}> <Form.Item {...FormItemLayout} label="邮箱" name='email' rules={[ {type: 'email', message: 'The input is not valid E-mail!'}, {required: true, message: 'Please input your E-mail!'} ]}>)}
这里采用antd表单来实现登录/注册,我们通过busListener监听登录和注册事件:
//事件监听 如果触发登录或者注册事件,显示该对话框 busListener('openSignModal', type => { form.resetFields(); setType(type); setVisible(true); })
如果是登录,对话框内容如下:
如果是注册,对话框内容如下:
以登录为例,当我们输入了登录信息后,如果校验通过,则会执行onFinish函数:
//提交表单且数据验证成功后回调事件 const onFinish = values => { console.log('Received values of form: ', values); const action = type === USER_LOGIN ? login : register; dispatch(action(values)).then(() => { setVisible(false); }) };
这里将会调用dispatch用来保存用户信息,然后关闭对话框。
同文章reducer,我们再来看看用户reducer的实现,我们在redux下创建user文件夹:
(1).actions.js:
/** * @author zy * @date 2020/4/12 * @Description: 用户action */ import * as TYPES from './types' import {message} from 'antd' /** * 执行登录操作 * @author zy * @date 2020/4/12 */ export const login = params => { return dispatch => { return new Promise((resolve, reject) => { //设置用户信息 dispatch({ type: TYPES.USER_LOGIN, payload: params }) message.success(`登录成功, 欢迎您 ${params.username}`); resolve('这里调用登录接口'); }) } } /** * 执行注册操作 * @author zy * @date 2020/4/12 */ export const register = params => { return dispatch => { message.success('注册成功,请重新登录您的账号!') } } /** * 执行退出登录操作 * @author zy * @date 2020/4/12 */ export const loginout = () => ({ type: TYPES.USER_LOGIN_OUT })
(2).reducer.js:
/** * @author zy * @date 2020/4/12 * @Description: 用户reducer */ import * as TYPES from './types'; /** * 初始化用户信息 * @author zy * @date 2020/4/12 */ let defaultState = { username: '', userId: 0, github: null } /** * userReducer * @author zy * @date 2020/4/12 */ export default function userReducer(state = defaultState, action) { const {type, payload} = action switch (type) { case TYPES.USER_LOGIN: const {username, userId, github} = payload; return {...state, username, userId, github}; case TYPES.USER_LOGIN_OUT: return {...state, username: '', userId: 0, github: null}; default: return state; } }
(3).type.js
// user export const USER_LOGIN = 'USER_LOGIN'; export const USER_REGISTER = 'USER_REGISTER'; export const USER_LOGIN_OUT = 'USER_LOGIN_OUT';
同时redux/root_reducers.js中引入userReducer:
/** * @author zy * @date 2020/4/5 * @Description: 合并reducer */ import {combineReducers} from 'redux'; import user from './user/reducer'; import article from './article/reducer'; export default combineReducers({user, article})
最后我们需要将PublicComponent挂在到App组件上:
/** * @author zy * @date 2020/4/5 * @Description: 根组件 */ import React from 'react'; import Routes from '@/routes'; import {BrowserRouter} from 'react-router-dom'; import PublicComponent from '@/components/public'; export default function App(props) { return () }
至此,我们这节的内容介绍完毕了,代码有点多,我整理放在github上:https://github.com/Zhengyang550/react-blog-zy。
参考文章:
[1]郭大大博客系统开发