react实战:umi问卷发布系统

2019-07-31 16:07:51 浏览数 (1)

"我在团队中的地位,在于我懂他们不会的东西。因此要保持核心竞争力,就是不要告诉别人自己会的东西"

技术团队中,保持技术分享和持续的学习是完全必要的。企业主会说:"公司不是培训机构。"这固然正确。但一个公司,总会遇到这种或那种需要攻关的难题。当你不愿意分享解决方案,或者身边的同事既不愿意学习,也不接受新的东西,反而一而再再而三糊弄。那团队怎么配合?

有个技术大牛曾经曰过(名字不可考,但确不是我臆造的):一个乐队里,你要把自己当成最水的那个。如果你不幸成为了乐队里最牛的那个成员,就可以考虑离开这个乐队了。同理,在类似的技术团队里,你不牛,就是留下去的理由。你牛,你就应该培育副手。自身的核心竞争力在于能够不断地提出攻关的方案,去带领团队成员去以技术创新驱动业务发展。

本文将用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

代码语言:javascript复制
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")。

代码语言:javascript复制
<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)之类的都可以去掉了。

代码语言:javascript复制
// 调用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,让它来承载登录保护的路由。

代码语言:javascript复制
{
  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为获取题库的列表。有基本的筛选功能。

代码语言:javascript复制
// 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>
      // ...
    )
}

那么效果就基本实现了。


0 人点赞