09Vue 项目最佳实践

09项目最佳实践

资源:

项目配置策略

基础配置:指定应用上下文、端口号,vue.config.js

const port = 8080

module.exports = {
  publicPath: '/best-practice', // 部署应用包时的基本 URL
  devServer: {
    port,
  }
}

配置 Webpack:configureWebpack

范例:设置一个组件存放路径的别名,vue.config.js

const path = require('path')

module.exports = {
  configureWebpack: {
    resolve: {
      alias: {
        comps: path.join(__dirname, 'src/components')
      }
    }
  }
}

范例:设置一个 Webpack 配置项用于页面 title,vue.config.js

modules.exports = {
  configureWebpack: {
    name:'林慕-Vue项目实战'  
  }
}

在宿主页面使用 lodash 插值语法使用它,./public/index.html

<title><%= webpackConfig.name %></title>

webpack-merge 合并出最终选项

范例:基于环境有条件地配置,vue.config.js

configureWebpack: config => {
  config.resolve.alias.comps = path.join(__dirname, 'src/components')
  if (process.env.NODE_ENV === 'development') {
    config.name = '林慕-Vue项目实践'
  }else {
    config.name = 'Vue Best Practice'
  }
}

结合上面的例子可以看到,Webpack 有两种常见的配置方式,第一种是结合某些特性直接修改 configureWebpack,第二种是传递一个函数给 configureWebpack,返回一个用于合并的配置对象。

配置 Webpack:chainWebpack 称为链式操作,可以更细粒度控制 Webpack 内部配置。

范例:svg icon 引入

  • 下载图标,存入 src/icons/svg 中

  • 安装依赖:svg-sprite-loader

npm i svg-sprite-loader -D
  • 修改规则和新增规则,vue.config.js
// resolve 定义一个绝对路径获取函数
const path = require('path')

function resolve(dir) {
  return path.join(__dirname, dir)
}
// 链式配置
chainWebpack(config) {
  // 配置 svg 规则排除 icons 目录中 svg 文件处理
  // 目标给 svg 规则增加一个排除选项 exclude:['path/to/icon']
  config.module.rule('svg')
    .exclude.add(resolve('src/icons'))

  // 新增 icons 规则,设置 svg-sprite-loader 处理 icons 目录中的 svg
  config.module.rule('icons')
    .test(/\.svg$/)
    .include.add(resolve('./src/icons')).end()
    .use('svg-sprite-loader')
    .loader('svg-sprite-loader')
    .options({symbolId: 'icon-[name]'})
}
  • 使用图标,App.vue
<template>
  <svg>
    <use xlink:href='#icon-wx' />
  </svg>
</template>
<script>
import '@/icons/svg/wx.svg'
</script>
  • 自动导入
  1. 创建 icons/index.js

自动化加载 svg 目录下的所有 svg 文件,使用 Webpack 提供require.context() 指定 svg 为固定上下文。

import Vue from ’vue'
import SvgIcon from '@/components/SvgIcon.vue'

const req = require.context('./svg', false, /\.svg$/)
// keys返回上下文中所有文件名
req.keys().map(req)

// 注册 svg-icon 组件
Vue.component('svg-icon', SvgIcon)
  1. 创建 SvgIcon 组件,components/SvgIcon.vue
<template>
  <svg :class="svgClass" v-on="$listeners">
    <use :xlink:href="iconName" />
  </svg>
</template>

<script>
export default {
  name: 'SvgIcon',
  props: {
    iconClass: {
      type: String,
      required: true
    },
    className: {
      type: String,
      default: ''
    }
  },
  computed: {
    iconName() {
      return `#icon-${this.iconClass}`
    },
    svgClass() {
      if(this.className) {
        return 'svg-icon' + this.className
      }  else {
        return 'svg-icon'
      }
    }
  }
}
</script>

<style scoped>
.svg-icon{
  width: 1em;
  height: 1em;
  vertical-align: -0.15em;
  fill: currentColor;
  overflow: hidden;
}
</style>

使用svg

<svg-icon icon-class="wx"></svg-icon>

环境变量和模式

如果想给多种环境做不同配置,可以利用 Vue-cli 提供的模式。默认有 development、production、test 三种模式,对应的,它们的配置文件形式是 .env.development。

范例:定义一个开发时可用的配置项,创建 .env.dev

# 只能用于服务器
foo=bar
# 可用于客户端
VUE_APP_DONG=dong

注:如果需要在客户端加多个变量,需要以 VUE_APP_xxx 这种形式,VUE_APP 为前缀。

修改 mode 选项覆盖模式名称,package.json

"serve": "vue-cli-service serve --mode dev"

权限控制

路由分为两种:constantRoutes 和 asyncRoutes,前者是默认路由可直接访问,后者中定义的路由需要先登录,获取角色并过滤后动态加入到 Router 中。

image.png
  • 路由定义,router/index.js

  • 创建用户登录页面,views/Login.vue

  • 路由守卫:创建 ./src/permission.js,并在 main.js 引入

用户登录状态维护

维护用户登录状态:路由守卫 => 用户登录 => 获取 token 并缓存

image.png
  • 路由守卫:src/permission.js

  • 请求登录:components/Login.vue

  • user 模块:维护用户数据、处理用户登录等,store/modules/user.js

  • 测试

用户角色获取和权限路由过滤

登录成功后,请求用户信息获取用户角色信息,然后根据角色过滤 asyncRoutes,并将结果动态添加至 router

  • 维护路由信息,实现动态路由生成逻辑,store/modules/permission.js

  • 获取用户角色,判断用户是否拥有访问权限,permission.js

// 引入store
import store from './store'

router.beforeEach(async (to, from, next) => {
  //...
  if (hasToken) {
    if (to.path === '/login') { }
    else {
      // 若用户角色已附加则说明权限以判定,动态路由已添加
      const hasRoles = store.getters.roles && store.getters.roles.length > 0

      if (hasRoles) {
        // 说明用户以获取过角色信息,放行
        next()
      } else {
        try {
          // 先请求获取用户信息
          const { roles } = await store.dispatch('user/getInfo')
          // 根据当前用户角色过滤出可访问路由
          const accessRoutes = await store.dispatch('permission/generateRoutes', roles)
          // 添加至路由器
          router.addRoutes(accessRoutes)
          // 继续路由切换,确保addRoutes完成
          next({ ...to, replace: true })
        } catch (error) {
          // 出错需重置令牌并重新登录(令牌过期、网络错误等原因)
          await store.dispatch('user/resetToken')
          next(`/login?redirect=${to.path}`)
          alert(error || '未知错误')
        }
      }
    }
  } else {
    // 未登录...
  }
})

异步获取路由表

可以当用户登录后向后端请求可访问的路由表,从而动态生成可访问页面,操作和原来是相同的,这里多了一步将后端返回路由表中组件名称和本地的组件映射步骤:

// 前端组件名和组件映射表
const map = {
  // xx:require('@/views/xx.vue').default // 同步的方式
  xx: () => import('@/views/xx.vue') // 异步的方式
}
// 服务端返回的asyncRoutes
const asyncRoutes = [
  {
    path: '/xx', component: 'xx'
  }
]
// 遍历asyncRoutes,将component替换为map[component]
function mapComponent (asyncRoutes) {
  asyncRoutes.forEach(route => {
    route.component = map[route.component]
    if (route.children) {
      route.children.map(child => mapComponent(child))
    }
  })
}

mapComponent(asyncRoutes)

按钮权限

页面中某些按钮、链接有时候需要更细粒度权限控制,这时候可以封装一个指令 v-permission,放在需要控制的按钮上,从而实现按钮级别权限控制。

  • 创建指令,src/directives/permission.js

  • 测试,About.vue

该指令只能管控挂载指令的元素,对于那些额外生成的和指令无关的元素无能为力,比如:

<el-tabs>
<el-tab-pane label="⽤户管理" name="first" v-permission="['admin',
'editor']">
⽤户管理</el-tab-pane>
<el-tab-pane label="配置管理" name="second" v-permission="['admin',
'editor']">
配置管理</el-tab-pane>
<el-tab-pane label="角色管理" name="third" v-permission="['admin']">
⻆⾊管理</el-tab-pane>
<el-tab-pane label="定时任务补偿" name="fourth" v-permission="['admin',
'editor']">
定时任务补偿</el-tab-pane>
</el-tabs>

此时只能使用 v-if 来实现

<template>
  <el-tab-pane v-if="checkPermission(['admin'])">
</template>

<script>
export default {
  methods:{
    checkPermission(permissionRoles){
      return roles.some(role =>{
        return permissionRoles.includes(role)
      })
    }
  }
}
</script>

自定义指令参考

自动生成导航菜单

导航菜单是根据路由信息并结合权限判断而动态生成的,它需要对应路由的多级嵌套,所以要用到递归组件。

  • 创建侧边栏组件,components/Sidebar/index.vue

  • 创建侧边栏项目组件,layout/components/Sidebar/SidebarItem.vue

  • 创建侧边栏菜单项组件,layout/components/Sidebar/Item.vue

数据交互

数据交互流程:

API 服务 => axios 请求 => 本地 mock/线上 mock/服务器 api

封装 request

对 axios 做一次封装,统一处理配置、请求和响应拦截。

安装 axios:

npm i axios -S
  • 创建 @/utils/request.js

  • 设置 VUE_APP_BASE_API 环境变量,创建 .env.development 文件

  • 编写服务接口,创建 @/api/user.js

数据 mock

数据模拟两种常见方式,本地 mock 和线上 easy-mock。

本地 mock:利用 webpack-dev-server 提供的 before 钩子可以访问 express 实例,从而定义接口。

  • 修改 vue.config.js,给 devServer 添加相关代码

  • 调用接口,@/store/modules/user.js

线上 esay-mock

诸如 easy-mock 这类线上 mock 工具优点是使用简单,mock 工具库也比较强大,还能根据 swagger 规范生成接口。

使用步骤:

  1. 登录 easy-mock

若远程不可用,可以搭建本地 easy-mock 服务(nvm + node + redis + mongodb)

先安装 node 8.x、redis 和 mongodb

启动命令:

  • 切 node v8: nvm list , nvm use 8.16.0

  • 起 redis: redis-server

  • 起 mongodb: mongod

  • 起 easy-mock 项目: npm run dev

  1. 创建一个项目

  2. 创建需要的接口

// user/login
{
  "code": function({ _req }) {
    const { username } = _req.body;
    if (username === "admin" || username === "jerry") {
      return 1
    } else {
      return 10008
    }
  },
  "data": function({ _req }) {
    const { username } = _req.body;
    if (username === "admin" || username === "jerry") {
      return username
    } else {
      return ''
    }
  }
}
// user/info
{
  code: 1,
    "data": function({ _req }) {
      return _req.headers['authorization'].split(' ')[1] === 'admin' ?
        ['admin'] : ['editor']
    }
}
  1. 调用:修改 base_url,.env.development
VUE_APP_BASE_API = 'http://localhost:7300/mock/5e9032aab92b8c71eb235ad5'

解决跨域

如果请求的接口在另一台服务器上,开发时则需要设置代理避免跨域问题。

  • 添加代理配置,vue.config.js

  • 创建一个独立接口服务器,~/server/index.js

项目测试

测试分类

常见的开发流程里,都有测试人员,他们不管内部实现机制,只看最外层的输入输出,这种我们成为黑盒测试。比如你写一个加法的页面,会设计 N 个用例,测试加法的正确性,这种测试我们称之为 E2E测试

还有一种测试叫做白盒测试,我们针对一些内部核心实现逻辑编写测试代码,称之为单元测试

更负责一些的我们称之为集成测试,就是集合多个测试过的单元一起测试。

组件的单元测试有很多好处:

  • 提供描述组件行为的文档

  • 节省手动测试的时间

  • 减少研发新特性时产生的 bug

  • 改进设计

  • 促进重构

准备工作

在 vue-cli 中,预置了 Mocha + Chai 和 Jest 两套单测方案,我们的演示代码使用 Jest,他们语法基本一致。

新建 Vue 项目时

  • 选择特性 Unit Testing 和 E2E Testing
image.png
  • 单元测试解决方案选择:Jest
image.png
  • 端到端测试解决方案选择:Cypress
image.png

在已存在项目中集成

集成 Jest:

vue add @vue/unit-jest

集成 cypress:

vue add @vue/e2e-cypress

编写单元测试

单元测试:是指对软件中的最小测试单元进行检查和验证。

  • 新建 test/unit/test.spec.js,*.spec.js 是命名规范

function add (num1, num2) {
  return num1 + num2
}

// 测试套件 test suite
describe('test', () => {
  // 测试用例 test case
  it('测试add函数', () => {
    // 断言 assert
    expect(add(1, 3)).toBe(3)
    expect(add(1, 3)).toBe(4)
    expect(add(-2, 3)).toBe(1)
  })
})

执行单元测试

  • 执行:
npm run test:unit

断言 API 简介

  • describe:定义一个测试套件

  • it:定义一个测试用例

  • expect:断言的判断条件

刚刚仅展示了 toBe,更多断言 API

测试 Vue 组件

Vue 官方提供了用于单元测试的实用工具库 @vue/test-utils

  • 创建一个 Vue 组件 components/Kaikeba.vue

  • 测试该组件,test/unit/kaikeba.spec.js

import Kaikeba from '@/components/Kaikeba.vue'
describe('Kaikeba.vue', () => {
  // 检查组件选项
  it('要求设置created⽣命周期', () => {
    expect(typeof Kaikeba.created).toBe('function')
  })
  it('message初始值是vue-test', () => {
    // 检查data函数存在性
    expect(typeof Kaikeba.data).toBe('function')
    // 检查data返回的默认值
    const defaultData = Kaikeba.data()
    expect(defaultData.message).toBe('vue-test')
  })
})
image.png

检查 mounted 之后预期结果

使用 @vue/test-utils 挂载组件

import { mount } from '@vue/test-utils'
it("mount之后测data是开课吧", () => {
  const wrapper = mount(Kaikeba);
  expect(wrapper.vm.message).toBe("开课吧");
});
it("按钮点击后", () => {
  const wrapper = mount(KaikebaComp);
  wrapper.find("button").trigger("click");
  // 测试数据变化
  expect(wrapper.vm.message).toBe("按钮点击");
  // 测试html渲染结果
  expect(wrapper.find("span").html()).toBe("<span>按钮点击</span>");
  // 等效的⽅式
  expect(wrapper.find("span").text()).toBe("按钮点击");
});

测试覆盖率

Jest 自带覆盖率,很容易统计我们测试代码是否全面。如果用的 mocha,需要使用 istanbul 来统计覆盖率。

  • package.json 里修改 jest 配置
"jest": {
  "collectCoverage": true,
  "collectCoverageFrom": ["src/**/*.{js,vue}"],
}

若采用独立配置,则修改 jest.config.js:

module.exports = {
  "collectCoverage": true,
  "collectCoverageFrom": ["src/**/*.{js,vue}"]
}
  • 在此执行 npm run test:unit
image.png

%stmts 是语句覆盖率(statement coverage):是不是每个语句都执行了?
%Branch 分支覆盖率(branch coverage):是不是每个 if 代码块都执行了?
%Funcs 函数覆盖率(function coverage):是不是每个函数都调用了?
%Lines 行覆盖率(line coverage):是不是每一行都执行了?

可以看到我们 kaikeba.vue 的覆盖率是100%,我们修改⼀下代码:

<template>
  <div>
    <span>{{ message }}</span>
    <button @click="changeMsg">点击</button>
  </div>
</template>
<script>
export default {
  data () {
    return {
      message: "vue-text",
      count: 0
    };
  },
  created () {
    this.message = "开课吧";
  },
  methods: {
    changeMsg () {
      if (this.count > 1) {
        this.message = "count⼤于1";
      } else {
        this.message = "按钮点击";
      }
    },
    changeCount () {
      this.count += 1;
    }
  }
};
</script>

现在的代码,依然是测试没有报错,但是覆盖率只有66%了,而且没有覆盖的代码行数,都标记了出来,继续努力加测试吧。

Vue 组件单元测试 cookbook:https://cn.vuejs.org/v2/cookbook/unit-testing-vue-components.html

Vue Test Utils 使用指南:https://vue-test-utils.vuejs.org/zh/

E2E 测试

借用浏览器的能力,站在用户测试人员的角度,输入框,点击按钮等,完全模拟用户,这个和具体的框架关系不大,完全模拟浏览器的行为。

运行 E2E 测试

npm run test:e2e

修改 e2e/spec/test.js

// https://docs.cypress.io/api/introduction/api.html
describe('端到端测试,抢测试人员的饭碗', () => {
  it('先访问⼀下', () => {
    cy.visit('/')
    // cy.contains('h1', 'Welcome to Your Vue.js App')
    cy.contains('span', '开课吧')
  })
})
image.png

测试未通过,因为没有使用 Kaikeba.vue,修改 App.vue

<div id="app">
  <img alt="Vue logo" src="./assets/logo.png">
  <!-- <HelloWorld msg="Welcome to Your Vue.js App"/> -->
  <Kaikeba></Kaikeba>
</div>
import Kaikeba from './components/Kaikeba.vue'
export default {
  name: 'app',
  components: {
    HelloWorld,Kaikeba
  }
}

测试通过~

测试用户点击

// https://docs.cypress.io/api/introduction/api.html
describe('端到端测试,抢测试⼈员的饭碗', () => {
  it('先访问⼀下', () => {
  cy.visit('/')
  // cy.contains('h1', 'Welcome to Your Vue.js App')
  cy.contains('#message', '开课吧')
    cy.get('button').click()
    cy.contains('span', '按钮点击')
  })
})

总结

image.png

这篇文章还是比较浅显易懂的,像项目配置策略,权限控制和自动生成导航菜单这三个还是需要了解学习的,至于单元测试的话,就见仁见智了,如果你项目很紧急的话,单元测试和业务开发并行几乎是不可能的。但是,如果你的项目对于标准化作业这一块很看中的话,单元测试还是很有必要的。

本文项目 demo:

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,458评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,454评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,171评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,062评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,440评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,661评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,906评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,609评论 0 200
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,379评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,600评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,085评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,409评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,072评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,088评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,860评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,704评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,608评论 2 270