本篇带你使用 组件库为我们的系统换上产品级的UI!
安装组件库
- 在项目目录下执行:
npm i antd@3.3.0 -S 或 yarn add antd
安装组件包 - 执行:
npm i babel-plugin-import -D
安装一个babel插件用于做组件的按需加载(否则项目会打包整个组件库,非常大) - 根目录下新建
.roadhogrc
文件(别忘了前面的点,这是roadhog工具的配置文件,下面的代码用于加载上一个命令安装的import插件),写入:
{ "extraBabelPlugins": [ ["import", { "libraryName": "antd", "libraryDirectory": "lib", "style": "css" }] ]}
改造HomeLayout
我们计划把系统改造成这个样子:
上方显示LOGO,下方左侧显示一个菜单栏,右侧显示页面的主要内容。
所以新的HomeLayout应该包括LOGO和Menu部分,然后HomeLayout的children放置在Content区域。
Menu我们使用AntDesign提供的Menu组件来完成,菜单项为:
- 用户管理
- 用户列表
- 添加用户
- 图书管理
- 图书列表
- 添加图书
来看新的组件代码:
/** * 布局组件 */import React from 'react';// 路由import { Link } from 'react-router';// Menu 导航菜单 Icon 图标import { Menu, Icon } from 'antd';import '../styles/home-layout.less';// 左侧菜单栏const SubMenu = Menu.SubMenu; class HomeLayout extends React.Component { render () { const {children} = this.props; return (); }} export default HomeLayout;ReactManager {children}
HomeLayout引用了/src/styles/home-layout.less
这个样式文件,样式代码为:
@import '~antd/dist/antd.css'; // 引入antd样式表.main { height: 100vh; padding-top: 50px;} .header { position: absolute; top: 0; height: 50px; width: 100%; font-size: 18px; padding: 0 20px; line-height: 50px; background-color: #108ee9; color: #fff; a { color: inherit; }} .menu { height: 100%; width: 240px; float: left; background-color: #404040;} .content { height: 100%; padding: 12px; overflow: auto; margin-left: 240px; align-self: stretch;}
现在的首页是这个样子:
逼格立马就上来了有没?
改造HomePage
由于现在有菜单了,就不需要右侧那个HomePage里的链接了,把他去掉,然后放个Welcome吧(HomeLayout也去掉了,在下面会提到):
src / pages / Home.js
/** * 主页 */import React from 'react';// 引入样式表import '../styles/home-page.less';class Home extends React.Component { // 构造器 constructor(props) { super(props); // 定义初始化状态 this.state = {}; } render() { return (Welcome); }}export default Home;
新增样式文件/src/styles/home-page.less
,代码:
.welcome{ width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 32px;}
优化HomeLayout使用方式
现在的HomeLayout里有一个菜单了,菜单有展开状态需要维护,如果还是像以前那样在每个page组件里单独使用HomeLayout,会导致菜单的展开状态被重置(跳转页面之后都会渲染一个新的HomeLayout),所以需要将HomeLayout放到父级路由中来使用:
src / index.js
/** * 配置路由 */import React from 'react';import ReactDOM from 'react-dom';// 引入react-routerimport { Router, Route, hashHistory } from 'react-router';// 引入布局组件import HomeLayout from './layouts/HomeLayout';import HomePage from './pages/Home'; // 首页import LoginPage from './pages/Login'; // 登录页import UserAddPage from './pages/UserAdd'; // 添加用户页import UserListPage from './pages/UserList'; // 用户列表页import UserEditPage from './pages/UserEdit'; // 用户编辑页面import BookAddPage from './pages/BookAdd'; // 添加图书页import BookListPage from './pages/BookList'; // 图书列表页import BookEditPage from './pages/BookEdit'; // 用户编辑页面// 渲染ReactDOM.render((), document.getElementById('root'));
效果图:
然后需要在各个页面中移除HomeLayout:
src / pages / BookAdd.js
/** * 图书添加页面 * 这个组件除了返回BookEditor没有做任何事,其实可以直接export default BookEditor */import React from 'react';// 编辑组件import BookEditor from '../components/BookEditor';class BookAdd extends React.Component { render() { return (); }}export default BookAdd;
src / pages / BookEdit.js
...render () { const {book} = this.state; return book ?: 加载中...;}...
src / pages / BookList.js
...render () { ... return (
剩下的UserAdd.js、UserEdit.js、UserList.js与上面Book对应的组件做相同更改。
还有登录页组件在下面说。
升级登录页面
下面来对登录页面进行升级,修改/src/pages/Login.js
文件:
/** * 登录页 */import React from 'react';// 引入antd组件import { Icon, Form, Input, Button, message } from 'antd';// 引入 封装后的fetch工具类import { post } from '../utils/request';// 引入样式表import styles from '../styles/login-page.less';// 引入 prop-typesimport PropTypes from 'prop-types';const FormItem = Form.Item; class Login extends React.Component { // 构造器 constructor () { super(); this.handleSubmit = this.handleSubmit.bind(this); } handleSubmit (e) { // 通知 Web 浏览器不要执行与事件关联的默认动作 e.preventDefault(); // 表单验证 this.props.form.validateFields((err, values) => { if(!err){ // 发起请求 post('http://localhost:8000/login', values) // 成功的回调 .then((res) => { if(res){ message.info('登录成功'); // 页面跳转 this.context.router.push('/'); }else{ message.info('登录失败,账号或密码错误'); } }); } }); } render () { const { form } = this.props; // 验证规则 const { getFieldDecorator } = form; return (); }} Login.contextTypes = { router: PropTypes.object.isRequired}; Login = Form.create()(Login); export default Login;ReactManager
新建样式文件/src/styles/login-page.less
,样式代码:
.wrapper { height: 100vh; display: flex; align-items: center; justify-content: center;}.body { width: 360px; box-shadow: 1px 1px 10px 0 rgba(0, 0, 0, .3);}.header { color: #fff; font-size: 24px; padding: 30px 20px; background-color: #108ee9;}.form { margin-top: 12px; padding: 24px;}.btn { width: 100%;}
酷酷的登录页面:
改造后的登录页组件使用了antd提供的Form组件,Form组件提供了一个create方法,和我们之前写的一样,是一个高阶组件。使用Form.create({ ... })(Login)
处理之后的Login组件会接收到一个props.form
,使用props.form
下的一系列方法,可以很方便地创造表单,上面有一段代码:
...{getFieldDecorator('account',{ rules: [ { required: true, message: '请输入管理员帐号', type: 'string' } ] })( } /> )} ...
这里使用了props.form.getFieldDecorator
方法来包装一个Input输入框组件,传入的第一个参数表示这个字段的名称,第二个参数是一个配置对象,这里设置了表单控件的校验规则rules(更多配置项请查看)。使用getFieldDecorator方法包装后的组件会自动表单组件的value以及onChange事件;此外,这里还用到了Form.Item
这个表单项目组件(上面的FormItem),这个组件可用于配置表单项目的标签、布局等。
在handleSubmit方法中,使用了props.form.validateFields
方法对表单的各个字段进行校验,校验完成后会调用传入的回调方法,回调方法可以接收到错误信息err和表单值对象values,方便对校验结果进行处理:
...handleSubmit (e) { // 通知 Web 浏览器不要执行与事件关联的默认动作 e.preventDefault(); // 表单验证 this.props.form.validateFields((err, values) => { if(!err){ // 发起请求 post('http://localhost:8000/login', values) // 成功的回调 .then((res) => { if(res){ message.info('登录成功'); // 页面跳转 this.context.router.push('/'); }else{ message.info('登录失败,账号或密码错误'); } }); } });}...
升级UserEditor
升级UserEditor和登录页面组件类似,但是在componentWillMount里需要使用this.props.setFieldsValue
将editTarget的值设置到表单:
src/components/UserEditor.js
/** * 用户编辑器组件 */import React from 'react';// 引入 antd 组件import { Form, Input, InputNumber, Select, Button, message } from 'antd';// 引入 prop-typesimport PropTypes from 'prop-types';// 引入 封装fetch工具类import request from '../utils/request';const FormItem = Form.Item;const formLayout = { labelCol: { span: 4 }, wrapperCol: { span: 16 }};class UserEditor extends React.Component { // 生命周期--组件加载完毕 componentDidMount(){ /** * 在componentWillMount里使用form.setFieldsValue无法设置表单的值 * 所以在componentDidMount里进行赋值 */ const { editTarget, form } = this.props; if(editTarget){ // 将editTarget的值设置到表单 form.setFieldsValue(editTarget); } } // 按钮提交事件 handleSubmit(e){ // 阻止表单submit事件自动跳转页面的动作 e.preventDefault(); // 定义常量 const { form, editTarget } = this.props; // 组件传值 // 验证 form.validateFields((err, values) => { if(!err){ // 默认值 let editType = '添加'; let apiUrl = 'http://localhost:8000/user'; let method = 'post'; // 判断类型 if(editTarget){ editType = '编辑'; apiUrl += '/' + editTarget.id; method = 'put'; } // 发送请求 request(method,apiUrl,values) // 成功的回调 .then((res) => { // 当添加成功时,返回的json对象中应包含一个有效的id字段 // 所以可以使用res.id来判断添加是否成功 if(res.id){ message.success(editType + '添加用户成功!'); // 跳转到用户列表页面 this.context.router.push('/user/list'); return; }else{ message.error(editType + '添加用户失败!'); } }) // 失败的回调 .catch((err) => console.error(err)); }else{ message.warn(err); } }); } render() { // 定义常量 const { form } = this.props; const { getFieldDecorator } = form; return (); }}// 必须给UserEditor定义一个包含router属性的contextTypes// 使得组件中可以通过this.context.router来使用React Router提供的方法UserEditor.contextTypes = { router: PropTypes.object.isRequired};/** * 使用Form.create({ ... })(UserEditor)处理之后的UserEditor组件会接收到一个props.form * 使用props.form下的一系列方法,可以很方便地创造表单 */UserEditor = Form.create()(UserEditor);export default UserEditor;
升级BookEditor
BookEditor中使用了AutoComplete组件,但是由于antd提供的AutoComplete组件有一些问题(见),这里暂时使用我们之前实现的AutoComplete。
src/components/BookEditor.js
/** * 图书编辑器组件 */import React from 'react';// 引入 antd 组件import { Input, InputNumber, Form, Button, message } from 'antd';// 引入 prop-typesimport PropTypes from 'prop-types';// 引入自动完成组件import AutoComplete from '../components/AutoComplete'; // 也可以写为 './AutoComplete'// 引入 封装fetch工具类import request,{get} from '../utils/request';// const Option = AutoComplete.Option;const FormItem = Form.Item;// 表单布局const formLayout = { // label 标签布局,同 组件 labelCol: { span: 4 }, wrapperCol: { span: 16 }};class BookEditor extends React.Component { // 构造器 constructor(props) { super(props); this.state = { recommendUsers: [] }; // 绑定this this.handleSubmit = this.handleSubmit.bind(this); this.handleOwnerIdChange = this.handleOwnerIdChange.bind(this); } // 生命周期--组件加载完毕 componentDidMount(){ /** * 在componentWillMount里使用form.setFieldsValue无法设置表单的值 * 所以在componentDidMount里进行赋值 */ const {editTarget, form} = this.props; if(editTarget){ form.setFieldsValue(editTarget); } } // 按钮提交事件 handleSubmit(e){ // 阻止submit默认行为 e.preventDefault(); // 定义常量 const { form, editTarget } = this.props; // 组件传值 // 验证 form.validateFields((err, values) => { if(err){ message.warn(err); return; } // 默认值 let editType = '添加'; let apiUrl = 'http://localhost:8000/book'; let method = 'post'; // 判断类型 if(editTarget){ editType = '编辑'; apiUrl += '/' + editTarget.id; method = 'put'; } // 发送请求 request(method,apiUrl,values) // 成功的回调 .then((res) => { // 当添加成功时,返回的json对象中应包含一个有效的id字段 // 所以可以使用res.id来判断添加是否成功 if(res.id){ message.success(editType + '添加图书成功!'); // 跳转到用户列表页面 this.context.router.push('/book/list'); }else{ message.error(editType + '添加图书失败!'); } }) // 失败的回调 .catch((err) => console.error(err)); }); } // 获取推荐用户信息 getRecommendUsers (partialUserId) { // 请求数据 get('http://localhost:8000/user?id_like=' + partialUserId) .then((res) => { if(res.length === 1 && res[0].id === partialUserId){ // 如果结果只有1条且id与输入的id一致,说明输入的id已经完整了,没必要再设置建议列表 return; } // 设置建议列表 this.setState({ recommendUsers: res.map((user) => { return { text: `${user.id}(${user.name})`, value: user.id } }) }); }) } // 计时器 timer = 0; handleOwnerIdChange(value){ this.setState({ recommendUsers: [] }); // 使用"节流"的方式进行请求,防止用户输入的过程中过多地发送请求 if(this.timer){ // 清除计时器 clearTimeout(this.timer); } if(value){ // 200毫秒内只会发送1次请求 this.timer = setTimeout(() => { // 真正的请求方法 this.getRecommendUsers(value); this.timer = 0; }, 200); } } render() { // 定义常量 const {recommendUsers} = this.state; const {form} = this.props; const {getFieldDecorator} = form; return (); }}// 必须给BookEditor定义一个包含router属性的contextTypes// 使得组件中可以通过this.context.router来使用React Router提供的方法BookEditor.contextTypes = { router: PropTypes.object.isRequired};BookEditor = Form.create()(BookEditor);export default BookEditor;
升级AutoComplete
因为要继续使用自己的AutoComplete组件,这里需要把组件中的原生input控件替换为antd的Input组件,并且在Input组件加了两个事件处理onFocus、onBlur和state.show,用于在输入框失去焦点时隐藏下拉框:
src/components/AutoComplete.js
/** * 自动完成组件 */import React from 'react';// 引入 antd 组件import { Input } from 'antd';// 引入 prop-typesimport PropTypes from 'prop-types';// 引入样式import styles from '../styles/auto-complete.less';// 获得当前元素value值function getItemValue (item) { return item.value || item;}class AutoComplete extends React.Component { // 构造器 constructor(props) { super(props); // 定义初始化状态 this.state = { show: false, // 新增的下拉框显示控制开关 displayValue: '', activeItemIndex: -1 }; // 对上下键、回车键进行监听处理 this.handleKeyDown = this.handleKeyDown.bind(this); // 对鼠标移出进行监听处理 this.handleLeave = this.handleLeave.bind(this); } // 处理输入框改变事件 handleChange(value){ // 选择列表项的时候重置内部状态 this.setState({ activeItemIndex: -1, displayValue: '' }); /** * 通过回调将新的值传递给组件使用者 * 原来的onValueChange改为了onChange以适配antd的getFieldDecorator */ this.props.onChange(value); } // 处理上下键、回车键点击事件 handleKeyDown(e){ const {activeItemIndex} = this.state; const {options} = this.props; /** * 判断键码 */ switch (e.keyCode) { // 13为回车键的键码(keyCode) case 13: { // 判断是否有列表项处于选中状态 if(activeItemIndex >= 0){ // 防止按下回车键后自动提交表单 e.preventDefault(); e.stopPropagation(); // 输入框改变事件 this.handleChange(getItemValue(options[activeItemIndex])); } break; } // 38为上方向键,40为下方向键 case 38: case 40: { e.preventDefault(); // 使用moveItem方法对更新或取消选中项 this.moveItem(e.keyCode === 38 ? 'up' : 'down'); break; } default: { // } } } // 使用moveItem方法对更新或取消选中项 moveItem(direction){ const {activeItemIndex} = this.state; const {options} = this.props; const lastIndex = options.length - 1; let newIndex = -1; // 计算新的activeItemIndex if(direction === 'up'){ // 点击上方向键 if(activeItemIndex === -1){ // 如果没有选中项则选择最后一项 newIndex = lastIndex; }else{ newIndex = activeItemIndex - 1; } }else{ // 点击下方向键 if(activeItemIndex < lastIndex){ newIndex = activeItemIndex + 1; } } // 获取新的displayValue let newDisplayValue = ''; if(newIndex >= 0){ newDisplayValue = getItemValue(options[newIndex]); } // 更新状态 this.setState({ displayValue: newDisplayValue, activeItemIndex: newIndex }); } // 处理鼠标移入事件 handleEnter(index){ const currentItem = this.props.options[index]; this.setState({ activeItemIndex: index, displayValue: getItemValue(currentItem) }); } // 处理鼠标移出事件 handleLeave(){ this.setState({ activeItemIndex: -1, displayValue: '' }); } // 渲染 render() { const {show, displayValue, activeItemIndex} = this.state; // 组件传值 const {value, options} = this.props; return (this.handleChange(e.target.value)} onKeyDown={this.handleKeyDown} onFocus={() => this.setState({show: true})} onBlur={() => this.setState({show: false})} /> {show && options.length > 0 && (); }}/** * 由于使用了antd的form.getFieldDecorator来包装组件 * 这里取消了原来props的isRequired约束以防止报错 */AutoComplete.propTypes = { value: PropTypes.any, // 任意类型 options: PropTypes.array, // 数组 onChange: PropTypes.func // 函数};// 向外暴露export default AutoComplete;{ options.map((item, index) => { return (
)}- this.handleEnter(index)} onClick={() => this.handleChange(getItemValue(item))} > {item.text || item}
); }) }
同时也更新了组件的样式/src/styles/auto-complete.less
,给.options加了一个z-index:
.options { z-index: 2; background-color:#fff; ...}
升级列表页组件
最后还剩下两个列表页组件,我们使用antd的Table组件来实现这两个列表:
src/pages/BookList.js
/** * 图书列表页面 */import React from 'react';// 引入 antd 组件import { message, Table, Button, Popconfirm } from 'antd';// 引入 prop-typesimport PropTypes from 'prop-types';// 引入 封装fetch工具类import { get, del } from '../utils/request'; class BookList extends React.Component { // 构造器 constructor(props) { super(props); // 定义初始化状态 this.state = { bookList: [] }; } /** * 生命周期 * componentWillMount * 组件初始化时只调用,以后组件更新不调用,整个生命周期只调用一次 */ componentWillMount(){ // 请求数据 get('http://localhost:8000/book') .then((res) => { /** * 成功的回调 * 数据赋值 */ this.setState({ bookList: res }); }); } /** * 编辑 */ handleEdit(book){ // 跳转编辑页面 this.context.router.push('/book/edit/' + book.id); } /** * 删除 */ handleDel(book){ // 执行删除数据操作 del('http://localhost:8000/book/' + book.id, { }) .then(res => { /** * 设置状态 * array.filter * 把Array的某些元素过滤掉,然后返回剩下的元素 */ this.setState({ bookList: this.state.bookList.filter(item => item.id !== book.id) }); message.success('删除用户成功'); }) .catch(err => { console.error(err); message.error('删除用户失败'); }); } render() { // 定义变量 const { bookList } = this.state; // antd的Table组件使用一个columns数组来配置表格的列 const columns = [ { title: '图书ID', dataIndex: 'id' }, { title: '书名', dataIndex: 'name' }, { title: '价格', dataIndex: 'price', render: (text, record) => ¥{record.price / 100} }, { title: '所有者ID', dataIndex: 'owner_id' }, { title: '操作', render: (text, record) => () } ]; return ( this.handleDel(record)}>
src/pages/UserList.js
/** * 用户列表页面 */import React from 'react';// 引入 antd 组件import { message, Table, Button, Popconfirm } from 'antd';// 引入 prop-typesimport PropTypes from 'prop-types';// 引入 封装后的fetch工具类import { get, del } from '../utils/request';class UserList extends React.Component { // 构造器 constructor(props) { super(props); // 定义初始化状态 this.state = { userList: [] }; } /** * 生命周期 * componentWillMount * 组件初始化时只调用,以后组件更新不调用,整个生命周期只调用一次 */ componentWillMount(){ // 请求数据 get('http://localhost:8000/user') .then((res) => { /** * 成功的回调 * 数据赋值 */ this.setState({ userList: res }); }); } /** * 编辑 */ handleEdit(user){ // 跳转编辑页面 this.context.router.push('/user/edit/' + user.id); } /** * 删除 */ handleDel(user){ // 执行删除数据操作 del('http://localhost:8000/user/' + user.id, { }) .then((res) => { /** * 设置状态 * array.filter * 把Array的某些元素过滤掉,然后返回剩下的元素 */ this.setState({ userList: this.state.userList.filter(item => item.id !== user.id) }); message.success('删除用户成功'); }) .catch(err => { console.error(err); message.error('删除用户失败'); }); } render() { // 定义变量 const { userList } = this.state; // antd的Table组件使用一个columns数组来配置表格的列 const columns = [ { title: '用户ID', dataIndex: 'id' }, { title: '用户名', dataIndex: 'name' }, { title: '性别', dataIndex: 'gender' }, { title: '年龄', dataIndex: 'age' }, { title: '操作', render: (text, record) => { return (); } } ]; return ( this.handleDel(record)}>
antd的Table组件使用一个columns数组来配置表格的列,这个columns数组的元素可以包含title(列名)、dataIndex(该列数据的索引)、render(自定义的列单元格渲染方法)等字段(更多配置请参考)。
然后将表格数据列表传入Table的dataSource,传入一个rowKey来指定每一列的key,就可以渲染出列表了。
效果图: