vue + iview 项目实践总结 【完】

一直想把一大篇的总结写完、写好,感觉自己拖延太严重还总想写完美,然后好多笔记都死在编辑器里了,以后还按照一个小节一个小节的更新吧,小步快跑?,先发出来,以后再迭代吧。

最近我们参与开发了一个(年前了)BI项目,前端使用vue全家桶,项目功能基本开发完成,剩下的修修补补,开发过程还算顺畅,期间遇到好多问题,也记录了一下,发出来一起交流,主要是思路,怎么利用vue给的API实现功能,避免大家在同样的坑里待太长时间,如果有更好实现思路可以一起交流讨论??。

前后端分离形式开发,vue+vueRouter+vueX+iviewUI+elementUI,大部分功能我们都用的iviewUI,有部分组件我们用了elementUI,比如表格、日历插件,我们没接mock工具,接口用文档的形式交流,团队氛围比较和谐,三个PHP三个前端,效率还可以,两个前端伙伴比较厉害,第一次使用vue,就承担了90%的开发工作任务,我没到上线就跑回家休陪产假了,特别感谢同事们的支持,我才能回家看娃。

前端其实不太复杂,但是只要用vue开发基本上都会遇到的几个问题,比如菜单组件多级嵌套、刷新后选中当前项、

涉及几个点,表格表头表体合并、文件上传、富文本编辑器、权限树等等。

项目介绍

系统的主要功能就是面向各个部门查看报表数据,后端同学们很厉害,能汇总到一个集团的所有数据,各种炫酷大数据技术;

菜单功能:

  • 数据看板: 筛选、展示日期和表格分页
  • 业务报表: 报表类型,日期筛选、表格分页
  • 数据检索: 筛选项联动、表格分页
  • 损耗地图: 筛选项、关系图插件
  • 展开分析: 筛选项、分类、卡片、表格
  • 系统信息: 版本发布、步骤条、富文本编辑
  • 数据源上传: 手动上传、表格展示
  • 权限管理: 用户管理、角色管理(权限菜单配置)

项目预览图:

对勾为已更新。

  • 1. 使用v-if解决异步传参
  • 2. 使用$refs调用子组件方法
  • 3. 组件递归实现多级菜单
  • 4. 使用watch监听路由参数重新获取数据
  • 5. 页面刷新后Menu根据地址选中当前菜单项
  • 6. 使用Axios统一状态码判断、统一增加token字段
  • 7. 点击左侧菜单选中项点击刷新页面
  • 8. 使用Axios.CancelToken切换路由取消请求
  • 9. 使用element的table组件实现 表头表体合并
  • 10. iview的Menu组件+vuex实现面包屑导航
  • 11. iview上传组件手动上传与富文本编辑器接入
  • 12. 使用cheerio获取表格数据
  • 13. keep-live组件缓存
  • 14. 让数据保持单向流动(不要在子组件中操作父组件的数据)

1. 使用v-if解决异步传参组件重绘

大部分的交互的流程都是 “ajax请求数据=>传入组件渲染”,很多属性需要异步传入子组件然后进行相关的计算,如果绑定很多computed或者watch,性能开销会很大,而且有些场景并不需要使用computed和watch,我们只需要在最初创建的时候获取一次就够了。

如下gif例子,点击上方TAB后重新刷新折线组件:


<mapBox v-if="mapData" :data="mapData">mapBox>
复制代码

let This = this
// setp1 重点
this.mapData = false

this.$http
.post('/api/show/mapcondition',{key:key,type:type})
.then(function(response){
// setp2 重点
    this.mapData = response.data
})
复制代码

有时候会出现DOM元素与数据不同步,可以使用使用其他方式让DOM强刷

- setTimeou
- $forceUpdate()
- $nextTick()
- $set()
复制代码

2. 使用$refs调用子组件方法

有时候会涉及到父组件调用子组件方法的情况,例如,iview的Tree组件暴露出来的getCheckedAndIndeterminateNodes方法,详见官网文档link。


<Tree v-if="menu" :data="menu" show-checkbox multiple ref="Tree">Tree>
复制代码
let rules = this.$refs.Tree.getCheckedAndIndeterminateNodes();
复制代码

3. 组件递归实现多级菜单

递归组件用的很多,我们的左侧菜单还有无限拆分的表格合并,都用到了递归组件,详见官网链接link。

效果图:

大致思路就是先创建一个子组件,然后再创建一个父组件,循环引用,拿左侧菜单说明,代码如下,数据结构也在父组件中。


<template>
    <Menu width="auto"
        theme="dark"
        :active-name="activeName"
        :open-names="openNames"
        @on-select="handleSelect"
        :accordion="true"
    >

      <template v-for="(item,index) in items">
        <side-menu-item
            v-if="item.children&&item.children.length!==0"
            :parent-item="item"
            :name="index+''"
            :index="index"
        >
        side-menu-item>
        <menu-item v-else
            :name="index+''"
            :to="item.path"
        >
            <Icon :type="item.icon" :size="15"/>
            <span>{{ item.title }}span>
        menu-item>
      template>
    Menu>
template>

<script>
import sideMenuItem from '@/components/Menu/side-menu-item.vue'
export default {
    name: 'sideMenu',
    props: {
        activeName: {
            type: String,
            default: 'auth'
        },
        openNames: {
            type: Array,
            default: () => [
                'other',
                'role',
                'auth'
            ]
        },
        items: {
            type: Array,
            default: () => [
                {
                    name : 'system',
                    title : '数据看板',
                    icon : 'ios-analytics',
                    children: [
                        { name : 'user', title : '用户管理', icon : 'outlet',
                          children : [
                                { name : 'auth', title : '权限管理1', icon : 'outlet' },
                                { name : 'auth', title : '权限管理', icon : 'outlet',
                                  children:[
                                    { name : '334', title : '子菜单', icon : 'outlet' },
                                    { name : '453', title : '子菜单', icon : 'outlet' }
                                  ]
                                }
                            ]
                         }
                    ]
                },
                {
                    name : 'other',
                    title: '其他管理',
                    icon : 'outlet',
                }
            ]
        }
    },
    components: {
        sideMenuItem
    },
    methods: {
        handleSelect(name) {
           this.$emit('on-select', name)
        }
    }
}
script>
复制代码

<template>
    <Submenu :name="index+''">
        <template slot="title" >
            <Icon :type="parentItem.icon" :size="10"/>
            <span>{{ parentItem.title }}span>
        template>
        <template v-for="(item,i) in parentItem.children">
            <side-menu-item
                v-if="item.children&&item.children.length!==0"
                :parent-item="item"
                :to="item.path"
                :name="index+'-'+i"
                :index="index+'-'+i"
            >
            side-menu-item>
            <menu-item v-else
                :name="index+'-'+i"  :to="item.path">
                <Icon :type="item.icon" :size="15" />
                <span>{{ item.title }}span>
            menu-item>
        template>
    Submenu>
template>

<script>
export default {
    name: 'sideMenuItem',
    props: {
        parentItem: {
            type: Object,
              default: () => {}
        },
        index:{}
    },
    created:function(){
    }
}
script>
复制代码

4. 使用watch监听路由参数重新获取数据

很多菜单项都只是入参不一样,是不会重新走业务逻辑的,我们就用watch监听$router,如果改变就重新请求新的数据。

export default {
    watch: {
    '$route':'isChange'
    },
    methods:{
        getData(){
            // Do something
        },
        isChange(){
            this.getData()
        },
    }
}
复制代码

5. 刷新:根据地址选中当前菜单项

页面刷新后左侧菜单的默认选中项就和页面对应不上了,我们用$router的beforeEnter方法做判断,根据地址获得路由的key(每一个路由都有一个key的参数),储存到localStorage中,然后菜单组件再从localStorage中取出key,再遍历匹配到当前选项目,比较冗余的是我们要在beforeEnter中获取一遍菜单数据,然后到菜单组件又获取一次数据,请求两次接口。

step1 router.js中设置beforeEnter方法,获得地址栏中的key 存储到localStorage

step2 菜单组件取出localStorage中key,递归匹配
复制代码

6. Axios统一状态码判断、统一增加token字段

Axios的interceptors方法有request和response两个方法对请求的入参和返回结果做统一的处理。


axios.interceptors.request.use(function (config) {
  let token = localStorage.getItem('token')
  if(token== null && router.currentRoute.path == '/login'){// 本地无token,未登录 跳转至登录页面
    router.push('/login')
  }else{
    if(config.data==undefined){
      config.data = {
        "token":token
      }
    }else{
      Object.assign(config.data,{"token":token})
    }
  }
  return config
}, function (error) {
  iView.Message.error('请求失败')
  return Promise.reject(error)
})


axios.interceptors.response.use(function (response) {
  if(response.hasOwnProperty("data") && typeof response.data == "object"){
      if(response.data.code === 998){// 登录超时 跳转至登录页面
          iView.Message.error(response.data.msg)
          router.push('/login')
          return Promise.reject(response)
      }else if (response.data.code === 1000) {// 成功
        return Promise.resolve(response)
      } else if (response.data.code === 1060){ //数据定制中
         return Promise.resolve(response)
      }else {// 失败
        iView.Message.error(response.data.msg)
        return Promise.reject(response)
      }
  } else {
    return Promise.resolve(response)
  }

}, function (error) {
  iView.Message.error('请求失败')
  // 请求错误时做些事
  return Promise.reject(error)
})
复制代码

7. 点击左侧菜单选中项点击刷新页面

测试同学提出bug,左侧菜单选中后,再次点击选中项没有刷新,用户体验不好,产品同学一致通过,我们就用野路子来解决了。 给菜单组件设置on-select事件,点击后存储当前选中项的path,每次执行当前点击的path和存储的path做对比,如果一致,跳转到空白页,空白页再返回到当前页,实现假刷新,注:不知道是router.push有节流控制还是怎么回事,不加setTimeout不管用。


handleSelect(name) {
    let This = this
    if((this.selectIndex == 'reset') || (name == this.selectIndex)){
        // 点击再次刷新
        setTimeout(function function_name(argument) {
          This.$router.push({
              path: '/Main/about',
              query: {
                t: Date.now()
              }
            })
        },1)
    }
    this.selectIndex = name
    this.$emit('on-select', name)
},
复制代码

created(){
    let This = this
    setTimeout(function function_name(argument) {
      This.$router.go(-1);
    },1)
}
复制代码

8. 使用Axios.CancelToken切换路由取消请求

有一部分情况是切换路由时,只改变参数,在“4. 使用watch监听路由参数重新获取数据”中提到过,还有一部分功能的接口数据返回的特别慢,会出现切换菜单后,数据才加载出来,需要增加切换菜单后取消原来的请求,代码注释中 setp1、2、3为顺序

export default {
  data(){
    return {
      // setp1 创建data公共的source变量 
      source:''                
    }
  },
  created:function(){
    // 获取搜索数据
    this.getData()
  },
  watch:{
    '$route':'watchGetSearchData',
  },
  methods:{
    getData(){
      // setp2 请求时创建source实例 
      let CancelToken = this.$http.CancelToken
      this.source = CancelToken.source();
    },
    watchGetSearchData(){
      // setp3 切换路由时取消source实例 
      this.source.cancel('0000')
      this.getData()
      this.$http
        .post('/api/show/map',data,{cancelToken:this.source.token})
        .then(function(response){
            
        })
    }
  }
}
复制代码

9. element的table组件实现 表头表体合并

我们项目用到的的组件表格有两种,一种用iview的table,带操作按钮的表格,支持表头跨行跨列,另一种element的table组件,纯数据展示,支持表头和标题的跨行跨列。

element的table组件支持表头标题合并,我们定义数据结构包含三部分,表头、表体、表体合并项。 表头直接使用递归组件嵌套就可以了,表体数据直接扔给table组件,合并通过cellMerge方法遍历合并项数据遍历合并,代码如下。

数据结构

data:{
    historyColumns:[  // 表头数据
        {
            "title": " ",
            "key": "column"
        },
        {
            "title": "指标",
            "key": "target"
        },
        {
            "title": "11/22",
            "key": "11/22"
        },
        {
            "title": "日环比",
            "key": "日环比"
        },
        {
            "title": "当周值",
            "key": "当周值"
        },
        {
            "title": "上周同期",
            "key": "上周同期"
        },
        {
            "title": "周环比",
            "key": "周环比"
        },
        {
            "title": "近7日累计",
            "key": "近7日累计"
        },
        {
            "title": "当月累计",
            "key": "当月累计"
        }
    ],
    histories:[  // 表体数据
        {
            "target": "在售量",
            "11/22": 912,
            "日环比": "-",
            "当周值": 912,
            "上周同期": 0,
            "周环比": "100%",
            "近7日累计": 912,
            "当月累计": 912,
            "column": "基础指标"
        },
        {
            "target": "-在售外库车量",
            "11/22": 29,
            "日环比": "-",
            "当周值": 29,
            "上周同期": 0,
            "周环比": "100%",
            "近7日累计": 29,
            "当月累计": 29,
            "column": "基础指标"
        }
    ],
    merge:[  // 表体合并项
        {
            "rowNum": 0,
            "colNum": 0,
            "ropSpan": 1,
            "copSpan": 4
        },
        {
            "rowNum": 4,
            "colNum": 0,
            "ropSpan": 1,
            "copSpan": 27
        }
    ]
}
复制代码

表体合并说明: 表格有cellMerge方法,每一td在渲染时都会执行这个方法,在cellMerge里遍历merge数据,根据cellMerge的入参行、列定位到td,如果是要合并的表格,则return出要合并的行数和列数,如果在合并的范围内,则要return [0,0],隐藏当前td。

比如要把A、B、C、D,merge的数据rowNum为A的行、colNum为A的列、ropSpan为2、copSpan为2,在cellMerge方法中,如果坐标为A的单元格,return ropSpan和copSpan,如果坐标为B、C、D则要return [0,0]隐藏,否则会出现表格错乱

merge方法代码:

// 表格合并主方法  row:行数组  column:列数据  rowIndex、columnIndex行列索引
cellMerge({ row, column, rowIndex, columnIndex }) {

  let This = this;
  if(This.configJson){
      for(let i = 0; i < This.configJson.length; i++){

      let rowNum = This.configJson[i].rowNum   // 行
      let colNum = This.configJson[i].colNum   // 列

      let ropSpan = This.configJson[i].ropSpan // 跨列数
      let copSpan = This.configJson[i].copSpan // 跨行数

      if(rowIndex == rowNum && columnIndex == colNum ){// 当前表格index 合并项
        return [copSpan,ropSpan]
      // 隐藏范围内容的单元格
      // 行范围 rowNum <= rowIndex && rowIndex < (rowNum+copSpan)
      // 列范围 colNum <= columnIndex && columnIndex < (colNum+ropSpan)
      }else if( rowNum <= rowIndex && rowIndex < (rowNum+copSpan) && colNum <= columnIndex && columnIndex < (colNum+ropSpan) ){

        return [0,0]
      }

    }
  }

}

复制代码

**表头合并说明:**element和iview的表头合并数据格式可以一样,都是递归形式,区别是iview的table组件直接把数据扔给组件就可以了,而element需要自己封装一下表头。

// 子组件