"我在团队中的地位,在于我懂他们不会的东西。因此要保持核心竞争力,就是不要告诉别人自己会的东西"
技术团队中,保持技术分享和持续的学习是完全必要的。企业主会说:"公司不是培训机构。"这固然正确。但一个公司,总会遇到这种或那种需要攻关的难题。当你不愿意分享解决方案,或者身边的同事既不愿意学习,也不接受新的东西,反而一而再再而三糊弄。那团队怎么配合?
有个技术大牛曾经曰过(名字不可考,但确不是我臆造的):一个乐队里,你要把自己当成最水的那个。如果你不幸成为了乐队里最牛的那个成员,就可以考虑离开这个乐队了。同理,在类似的技术团队里,你不牛,就是留下去的理由。你牛,你就应该培育副手。自身的核心竞争力在于能够不断地提出攻关的方案,去带领团队成员去以技术创新驱动业务发展。
本文将用umi完成一个问卷发布系统项目。(logo暂时盗用问卷星)
笔者曾经写过类似的,一个相当大的项目。由于种种原因,留下了太多太多太多的遗憾。现在想实现一个精简优化版(不妨称之为umi问卷发布系统)。使用更加规范,更加精致的技术手段去实现。当然,我希望会是一个更加牛逼的体现。
和分享一样,如果一个项目不敢开源,那就是代码写的烂。因此届时也将会是开源的。
而本文将避免涉及产品业务的内容,更偏重于讨论技术问题:
- 布局
- antd-pro
- 用户登录认证
- 题库
看这篇文章之前,建议重新复习这2篇文章的内容。
React全家桶之Redux使用
react全家桶之router使用
项目技术栈
阿里系项目框架。 蚂蚁金服antd-pro https://pro.ant.design/index-cn umi:https://umijs.org/zh/guide/ dva:https://dvajs.com/guide/
antd-pro在antd的基础上,针对后台管理,抽取了更加详细的业务组件。官网描述其为"开箱即用"的解决方案。
设计精美,遗憾的是,文档有点烂。
代码语言:javascript复制npm install ant-design-pro --save
umi,中文可发音为乌米,是一个可插拔的企业级 react 应用框架。也是蚂蚁金服的底层前端框架。
代码语言:javascript复制$ sudo npm i yarn tyarn -g
# 后面文档里的 yarn 换成 tyarn
$ tyarn -v
$ yarn global add umi
$ umi -v
2.0.0
Dva,原为《守望先锋》的游戏角色。是由阿里架构师 sorrycc 带领 team 完成的一套前端框架,在作者的 github 里是这么描述它的:”dva 是 react 和 redux 的最佳实践”。(项目已集成)
在本项目中,可以克隆一下项目的基础(原型是一个商城架构)
代码语言:javascript复制git clone -b step01 https://github.com/57code/umi-test.git
在这个项目里:
直接 umi dev
执行即可。
后台布局容器(layout/index.js)
后台布局一般是要自己写。但在antd-pro中,这是不必要的。在antd-pro中,自动化创建优秀到让人咋舌的地步。修改 layout/index.js
import {Layout} from 'antd';
import styles from './index.css'
const {Header,Footer,Content}=Layout;
export default function(props) {
return (
<Layout>
<Header className={styles.header}>导航</Header>
<Content className={styles.content}>
<div className={styles.box}>
{props.children}
</div>
</Content>
<Footer className={styles.footer}>页脚</Footer>
</Layout>
)
}
接着把样式(style)写一写:
代码语言:javascript复制.header {
color: white;
}
.content {
margin: 16px;
}
.box {
padding: 24px;
background: #fff;
min-height: 500px;
}
.footer {
text-align: center;
}
如果想要自己的组件生效,需要改一下配置(config),让layout作为父组件把路由包起来即可:
代码语言:javascript复制// config/index.js
export default {
plugins: [
[
"umi-plugin-react",
{
antd: true,
dva: true
}
]
],
routes: [
{ path: "/login", component: "./login" },
{
path: "/",
component: "../layouts",
routes: [
// 移动之前路由配置到这里
]
}
]
};
把所有后台相关的页面组件全部放倒layout中。不需要登录的除外。
导航
导航可引入antd的菜单( Menu
)组件和umi的Link组件( importLinkfrom"umi/link"
)。
<Header className={styles.header}>
{/* 新增内容 */}
// <Menu />
</Header>
动态菜单
代码语言:javascript复制const menus=[
{path:'/',name:'首页'},
{path:'/questionBank',name:'题库'},
{path:'/addQuestionnaire',name:'我的'}
]
let menus=menus.map((x,i)=>{
return <Menu.Item key={x.path}>
<Link to={x.path}>{x.name}</Link>
</Menu.Item>
});
注意,这里不以i作为key。
导航匹配路由
当我在那个路由下,自动激活路由。
代码语言:javascript复制const selectedKeys=menus.filter(x=>{
if(x.path==='/'){
return pathname==='/';
}
return pathname.indexOf(x.path)!==-1;
}).map(x=>{return x.path});
<Menu
// ...
defaultSelectedKeys={selectedKeys} >
用户登录认证(又是登录)
先以404页面为例示范antd-pro的用法:
代码语言:javascript复制import {Exception} from 'ant-design-pro'
export default function() {
return (
<Exception type="404" backText="返回首页"></Exception>
);
}
登录,有太多的东西可以扯,接下来看看umi下的登录流程业务是如何实现的。
页面
antd-pro给我们提供了一个特别好用的组件Login,里面有优秀的语义化应用。
代码语言:javascript复制import React, { Component } from "react";
import styles from "./login.css";
import router from "umi/router";
import { Login } from "ant-design-pro";
const { UserName, Password, Submit } = Login; // 通用的用户名、密码和提交组件
// 改为类形式组件,可持有状态
export default class extends Component {
// let from = props.location.state.from || "/";
// 重定向地址
onSubmit = (err, values) => {
console.log(err, values);
};
render() {
return (
<div className={styles.loginForm}>
{/* logo */}
<img className={styles.logo}
src="https://www.wjx.cn/images/commonImgPC/logo@2x.png" />
{/* 登录表单 */}
<Login onSubmit={this.onSubmit}>
<UserName
name="username"
placeholder="dangjingtao"
rules={[{ required: true, message: "请输入用户名" }]}
/>
<Password
name="password"
placeholder="123456"
rules={[{ required: true, message: "请输入密码" }]}
/>
<Submit>登录</Submit>
</Login>
</div>);
}
}
目前情况下如果要做有状态组件,还只能用传统的class-like component。
Mock数据
login要求登录发回一个对象,包括权限,基本信息和token。
在mock下新建login.js
代码语言:javascript复制// mock登录接口
export default {
"post /api/login"(req, res, next) {
const { username, password } = req.body;
// console.log(username, password);
if (username == "dangjingtao" && password == "123456") {
return res.json({
code: 0,
data: {
token: "666",
role: "admin",
balance: 1000,
username: "党某某"
}
});
}
if (username == "djtao" && password == "123") {
return res.json({
code: 0,
data: {
token: "666",
role: "user",
balance: 100,
username: "东尼大涛"
}
});
}
// 返回一个失败的回调
return res.status(401).json({
code: -1,
msg: "密码错误"
});
}
};
mock的接口和写express基本一样。
有了"接口",就可以尝试写一个model与之联调(tiao)了。
model
models主要放登录方法,保存登录态(redux)。
代码语言:javascript复制import axios from "axios";
import router from "umi/router";
// 初始状态:本地缓存或空值对象
const userinfo = JSON.parse(localStorage.getItem("userinfo")) || {
token: "",
role: "",
username: "",
balance: 0
};
// 登录请求返回值
function login(payload) {
return axios.post("/api/login", payload);
}
export default {
// 命名空间。可省略
namespace: "user",
state: userinfo,
// 副作用登录后续操作
effects: {
// action: user/login
async login({ payload }, { call, put }) {
// 调用login传参数payload
const { data: { code, data: userinfo } } = await login(payload);
if (code == 0) {
// 登录成功: 缓存用户信息
localStorage.setItem("userinfo", JSON.stringify(userinfo));
// 派发action
await put({ type: "init", payload: userinfo });
// 重定向
router.push('/');
} else {
// 登录失败:弹出提示信息,可以通过响应拦截器实现
}
}
},
reducers: {
init(state, action) {
// 覆盖旧状态
return action.payload;
}
}
};
让login组件带上状态
从dva中获取connect。
代码语言:javascript复制import React, { Component } from "react";
import styles from "./login.css";
import router from "umi/router";
import { Login } from "ant-design-pro";
import { connect } from "dva"
const { UserName, Password, Submit } = Login; // 通用的用户名、密码和提交组件
export default connect()(function (props) {
// let from = props.location.state.from || "/";
// 登录业务
const onSubmit = (err, values) => {
console.log(err, values);
// value就是你的传参
if (!err) {
props.dispatch({
type:'user/login',
payload:values
})
}
};
return (
<div className={styles.loginForm}>
{/* logo */}
<img className={styles.logo}
src="https://www.wjx.cn/images/commonImgPC/logo@2x.png" />
{/* 登录表单 */}
<Login onSubmit={onSubmit}>
<UserName
name="username"
placeholder="dangjingtao"
rules={[{ required: true, message: "请输入用户名" }]}
/>
<Password
name="password"
placeholder="123456"
rules={[{ required: true, message: "请输入密码" }]}
/>
<Submit>登录</Submit>
</Login>
</div>);
})
错误处理
一个登录业务逻辑写到现在,已经有很多地方可以捕捉登录错误。从前端角度说,最佳的捕捉地点user.js中的effect。那么什么 if(code===0)
之类的都可以去掉了。
// 调用login传参数payload
const { data: { code, data: userinfo } } = await login(payload);
try {
// 登录成功: 缓存用户信息
localStorage.setItem("userinfo", JSON.stringify(userinfo));
// 派发action
await put({ type: "init", payload: userinfo });
// 重定向
router.push('/');
} catch (error) {
// 登录失败:弹出提示信息,可以通过响应拦截器实现
console.log(error)
}
然后是axios拦截器,在src下新建interceptor.js,直接调用ui框架报错。
代码语言:javascript复制import axios from "axios";
import { notification } from "antd";
// 列举常见错误码
const codeMessage = {
202: "一个请求已经进入后台排队(异步任务)。",
401: "用户没有权限(令牌、用户名、密码错误)。",
404: "发出的请求针对的是不存在的记录,服务器没有进行操作。", 500: "服务器发生错误,请检查服务器。"
};
// 仅拦截异常状态响应
axios.interceptors.response.use(null, ({ response }) => {
if (codeMessage[response.status]) {
notification.error({
message: `请求错误 ${response.status}: ${response.config.url}`,
description: codeMessage[response.status]
});
}
return Promise.reject(err);
});
然而intercepter是不会无端起作用的。必须找个地方执行一下。
在src下新建一个global.js,gloal.js将在umi初始化时执行一次。
代码语言:javascript复制// 全局入口
import interceptor from './interceptor'
是的这样就可以了。
路由守卫
login页面守卫的是"私有"的路由。回到config下的config.js:
我要保护 /me
下的一系列路由,最直接的方法是输出一个高阶组件 PrivateRoute.js
,让它来承载登录保护的路由。
{
path: "/me",
component: "./me",
Routes: ["./routes/PrivateRoute.js"]
},
继续翻到routers文件夹下的PrivateRoute.js,添加登录态判断(又是拿connect):
代码语言:javascript复制import Redirect from "umi/redirect";
import {connect} from 'dva';
export default connect(state=>({
isLogin:!!state.user.token
})) (props => {
console.log(props);
if (!props.isLogin) {
// 如果没登录,重定向。
return (
<Redirect
to={{
pathname: "/login",
state: { from: props.location.pathname } // 传递重定向地址
}}
/>
);
}else{
// 登录了
return (
<div>
<div>PrivateRoute (routes/PrivateRoute.js)</div>
{props.children}
</div>
);
}
});
题库(questionBank)
从业务上说,题库相当于一个市场。用户就像买菜的人,可以从中采集内容。添加到"我的收藏中"
技术上说,题库的主体是一个列表页,透过列表可以拿到详情页。通过实现题库,可以学习如何在umi的框架下创建页面。
页面的架构,应该是在pages下面定义一个questionBank文件夹,在里面写子页面,样式和models方法。
路由配置
代码语言:javascript复制// config.js
{
path: "/questionBank",
component: "./questionBank/_layout",
routes: [
{ path: "/questionBank/", component: "./questionBank/index" },
{ path: "/questionBank/:id", component: "./questionBank/$id" }
]
},
mock接口
定义:/api/questions
为获取题库的列表。有基本的筛选功能。
// mock/questions.js
let data = [
{
title: "试试你的爱情果是什么",
type:"singleChoice",
question:"请选择你的爱情果:(只能选一个哦)",
tags:'1,2',
options:[
{
option:"A",
content:"菠萝",
discription:"丑陋式的恋爱"
},
{
option:"B",
content:"柠檬",
discription:"同性恋"
},
{
option:"C",
content:"西瓜",
discription:"老土式的恋爱"
},
{
option:"D",
content:"椰子",
discription:"暴力式恋爱的爱"
},
]
},
// ...
];
export default {
// "method url": (req, res) => {}
"get /api/questions": function (req, res, next) {
setTimeout(() => {
res.json({
result: data
});
}, 2500);
}
}
models
models负责接口调用,redux状态变更等事宜。
代码语言:javascript复制// /questionBank/models/
import axios from 'axios';
// api
function getQuestions(){
return axios.get('/api/questions')
}
export default {
namespace: "questionBank",
state: { // 初始状态包括问题和标签
questions: [], // 课程
},
effects: {
*getList(action, { call, put }) {
let res=yield call(getQuestions);
const questions = res.data;
// 派发initGoods
yield put({ type: "init", payload: questions});
}
},
reducers: {
init(state, { payload }) {
state.questions=payload.result;
return state;
},
}
};
页面
链接dva
通过connect链接到redux,
触发数据修改
代码语言:javascript复制import React, { Component } from "react";
import {List,Avatar,Progress} from 'antd'
import styles from "./index.less";
import { connect } from "dva";
@connect(
state => ({
questions: state.questionBank.questions,
tags:[]
// tags: state.goods.tags,
// loading: state.loading
})
)
class Questions extends Component{
constructor(props){
super(props);
console.log(props);
this.state={
questions:new Array(8).fill({}), // 设置size可用于骨架屏展示
tags:[]
}
}
componentDidMount(){
this.props.dispatch({
type:'questionBank/getList'
}).then(()=>{
this.setState({
questions:this.props.questions
})
})
}
render(){
let {questions}=this.state;
return (
<div className={styles.normal}>
<h2>题库</h2>
<ul>
{
//...
}
</ul>
</div>
);
}
}
export default Questions;
接下来就是写题库样式了。
标签筛选
假设我有一系列的标签比如恋爱,都市,职场等等。允许作者进行快捷筛选。怎么办?
那么得先mock新的接口。
代码语言:javascript复制// mock/question.js
let tags=[
{
id:1,
name:'恋爱'
},
{
id:2,
name:'都市'
},
{
id:3,
name:'职场'
}
]
// ...
"get /api/questionTags":function(req,res,next){
setTimeout(()=>{
res.json({
code:0,
types
})
},2500)
}
修改models:
代码语言:javascript复制export default {
namespace: "questionBank",
state: { // 初始状态包括问题和标签
questions: [], // 问题
tags:[]
},
effects: {
*getList(action, { call, put }) {
let res=yield call(getQuestions);
// 派发initGoods
yield put({ type: "init", payload: res.data});
},
*getTags(action,{call,put}){
let res =yield call(getTags)
yield put({ type: "init", payload: res.data});
}
},
reducers: {
init(state, { payload }) {
console.log(Object.assign(state,payload))
return {...state,payload};
},
}
};
在page页面中拿tags:
代码语言:javascript复制componentDidMount(){
this.props.dispatch({
type:'questionBank/getList'
}).then(()=>{
this.setState({
questions:this.props.questions
})
})
this.props.dispatch({
type:'questionBank/getTags'
}).then(()=>{
this.setState({
tags:this.props.tags
})
})
}
tag应该是多选的。所以引入新状态tagSelect=[]
那么展示页面就不能是tag。而是根据tag过滤之后的 displayQuestion
接下来就是一串无聊的业务代码了。因为多处用到了比较,所以双循环也很多:
代码语言:javascript复制// 判断是否存在数组中,有则返回索引值,没有则返回-1
isSelect = (item, arr) => {
return arr.indexOf(item)
}
// 标签选择处理:参数为0时,默认全选
setTags = (id) => {
if (id === 0) {
this.setState({
tagSelect: []
})
} else {
this.setState((preState) => {
let ret = preState.tagSelect;
let isSelect = this.isSelect(id, ret);
if (isSelect < 0) {
ret.push(id);
} else {
ret.splice(isSelect, 1)
}
return {
tagSelect: ret
};
})
}
}
// 标签过滤
filter = (data) => {
if (this.state.tagSelect.length == 0) {
return data;
} else {
return data.filter((x, i) => {
const itemTags = x.tags.split(',');
// 问题标签中只要有一个在tagSelect中,就可以了
let bCheck = itemTags.some((y, i) => {
return this.state.tagSelect.indexOf(Number(y)) >= 0;
});
return bCheck;
})
}
}
render(){
let { questions, tags, tagSelect } = this.state;
// 标签渲染
let cTags = tags.map(x => {
return (<CheckableTag
checked={this.isSelect(x.id, tagSelect) >= 0}
onChange={() => { this.setTags(x.id) }} key={x.id}>
{x.name}
</CheckableTag>)
});
// 列表标签条件渲染
let displayQuestions = this.filter(questions);
// ...
return (
// ...
<div>
<CheckableTag
key={0}
checked={this.state.tagSelect.length == 0 || this.state.tagSelect.length == this.state.tags.length}
onChange={() => { this.setTags(0) }}
>全部</CheckableTag>
{cTags}
</div>
// ...
)
}
那么效果就基本实现了。