Vue项目最佳实践
资源
目标
- 项目配置
- 权限管理
- 导航菜单
- 数据mock
- 测试
知识点
项目配置策略
基础配置:指定应用上下文、端口号,vue.config.js
const port = 7070;
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
module.exports = {
configureWebpack: {
name: "vue项目最佳实践"
}
};
在宿主⻚面使用lodash插值语法使用它,./public/index.html
<title><%= webpackConfig.name %></title>
webpack-merge合并出最终选项
范例:基于环境有条件地配置,vue.config.js
// 传递一个函数给configureWebpack
// 可以直接修改,或返回一个用于合并的配置对象
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:chainWebpack
webpack-chain称为链式操作,可以更细粒度控制webpack内部配置。
范例:svg icon引入
- 下载图标,存入src/icons/svg中
安装依赖:svg-sprite-loader
npm i svg-sprite-loader -D
修改规则和新增规则,vue.config.js ```javascript // 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
```javascript
<template>
<svg>
<use xlink:href="#icon-wx" />
</svg>
</template>
<script>
import '@/icons/svg/wx.svg'
</script>
- 自动导入
创建icons/index.js
const req = require.context('./svg', false, /\.svg$/)
req.keys().map(req);
创建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>
/
环境变量和模式
如果想给多种环境做不同配置,可以利用vue-cli提供的 模式 。默认有development
、production
、test
三种模式,对应的,它们的配置文件形式是.env.development
。
范例:定义一个开发时可用的配置项,创建.env.dev
# 只能用于服务端
foo=bar
# 可用于客户端
VUE_APP_DONG=dong
修改mode选项覆盖模式名称,package.json
"serve": "vue-cli-service serve --mode dev"
权限控制
参考代码:git reset —hard step-7
路由定义
路由分为两种:
constantRoutes
:通用路由可直接访问asyncRoutes
:权限路由,需要先登录,获取⻆色后才能判断是否可以访问
router/index.js
路由守卫
- 默认路由守卫规则:
- 已登录访问登录⻚:跳转首⻚
- 已登录访问其他⻚:
- 已获取⻆色:放行
- 为获取⻆色:请求⻆色 =》过滤可访问路由 =》动态增加到router
- 未登录访问白名单⻚面:放行
- 未登录访问其他⻚:跳转至登录⻚
参考代码:src/permission.js
用户登录
请求登录dispatch('user/login')
,Login.vue
用户⻆色获取
登录成功后,请求用户⻆色信息
权限路由过滤
根据⻆色过滤asyncRoutes,并动态添加至router。
请求生成路由,src/permission.js
生成路由,store/modules/permission.js
异步获取路由表 可以当用户登录后 向后端请求可访问的路由表 ,从而动态生成可访问⻚面,操作和原来是相同的,这里多了一步将后端返回路由表中 组件名称和本地的组件映射 步骤:
// 前端组件名和组件映射表
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
该指令只能删除挂载指令的元素,对于那些额外生成的和指令无关的元素无能为力,比如:
<el-tabs>
<el-tab-pane label="用户管理" name="first">
用户管理</el-tab-pane>
<el-tab-pane label="⻆色管理" name="third">
⻆色管理</el-tab-pane>
</el-tabs>
尝试添加
v-permission="..."
并不能删除动态生成的内容部分
<el-tab-pane label="用户管理" name="first"
v-permission="['admin', 'editor']">用户管理</el-tab-pane>
此时可使用v-if来实现
<template>
<el-tab-pane v-if="checkPermission(['admin'])">
</template>
导航菜单
导航菜单可根据前面生成的最终路由信息动态生成。
侧边栏组件,components/Sidebar/index.vue
侧边栏菜项目组件,components/Sidebar/SidebarItem.vue
默认菜单项规则:
- 跳转链接:没有子路由或只有一个需要展示子路由
- 其他情况:嵌套子菜单
菜单项组件,components/Sidebar/Item.vue
数据交互
常⻅需求:
- 统一配置请求库
- 请求拦截和响应拦截
- 数据mock
- 请求代理
请求封装
对axios做一次封装,统一处理配置、请求和响应拦截,utils/request.js
数据mock
数据模拟两种常⻅方式, 本地mock 和 线上mock
本地mock
在vue.config.js中定义模拟接口
线上mock
诸如easy-mock
这类线上mock工具优点是使用简单,mock工具强大,还能整合swagger。
环境搭建
- 线上使用:登录easy-mock
- 搭建本地服务(基于docker)
- 安装docker desktop
- 创建docker-compose.yml ```javascript version: ‘3’
services: mongodb: image: mongo:3.4.1
# volumes:
# /apps/easy-mock/data/db是数据库文件存放地址,根据需要修改为本地地址
# - '/apps/easy-mock/data/db:/data/db'
networks:
- easy-mock
restart: always
redis: image: redis:4.0.6 command: redis-server —appendonly yes
volumes:
/apps/easy-mock/data/redis 是 redis 数据文件存放地址,根据需要修改为本地地址
- ‘/apps/easy-mock/data/redis:/data’
networks:
- easy-mock
restart: always
web: image: easymock/easymock:1.6.0
easy-mock 官方给出的文件,这里是 npm start,这里修改为 npm run dev
command: /bin/bash -c “npm run dev” ports:
- 7300:7300
volumes:
# 日志地址,根据需要修改为本地地址
# - '/apps/easy-mock/logs:/home/easy-mock/easy-mock/logs'
networks:
- easy-mock
restart: always
networks: easy-mock:
- 启动:docker-compose up
<a name="w4SxH"></a>
#### 使用介绍
- 创建一个项目
- 创建需要的接口:
登录接口`user/login`
```javascript
{
"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']
}
}
- 调用:修改base_url,.env.development
VUE_APP_BASE_API = 'http://localhost:7300/mock/5f6301c446875b001d8a2961'
解决跨域
如果请求的接口在另一台服务器上,开发时则需要设置代理避免跨域问题
代理配置,vue.config.js
测试
测试分类
常⻅的开发流程里,都有测试人员,他们不管内部实现机制,只看最外层的输入输出,这种我们称为 黑盒测试 。比如你写一个加法的⻚面,会设计N个用例,测试加法的正确性,这种测试我们称之为 E2E测试 。
还有一种测试叫做 白盒测试 ,我们针对一些内部核心实现逻辑编写测试代码,称之为 单元测试 。
更负责一些的我们称之为 集成测试 ,就是集合多个测试过的单元一起测试。
测试的好处
- 提供描述组件行为的文档
- 节省手动测试的时间
- 减少研发新特性时产生的 bug
- 改进设计
- 促进重构
准备工作
在vue-cli中,预置了Mocha+Chai和Jest两套单测方案,我们的演示代码使用Jest,它们语法基本一致
新建vue项目时
选择特性Unit Testing
和E2E Testing
单元测试解决方案选择:Jest
在已存在项目中集成
集成Jest:vue add @vue/unit-jest
集成cypress:vue add @vue/e2e-cypress
编写单元测试
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。
新建test/unit/kaikeba.spec.js,*.spec.js
是命名规范
function add (num1, num2) {
return num1 + num
}
// 测试套件 test suite
describe('add方法', () => {
// 测试用例 test case
it('应该能正确计算加法', () => {
// 断言 assert
expect(add(1, 3)).toBe(4)
})
})
更多断言API
执行单元测试
执行:npm run test:unit
测试Vue组件
官方提供了用于单元测试的实用工具库@vue/test-utils
检查mounted之后预期结果
使用mount
或shallowMount
挂载组件,example.spec.js
import { mount } from '@vue/test-utils'
it('renders props.msg when passed', () => {
const msg = 'new message'
// 给组件传递属性
const wrapper = shallowMount(HelloWorld, {
propsData: { msg }
})
// expect(wrapper.text()).toMatch(msg)
// 查找元素
const h1 = wrapper.find('h1')
expect(h1.text()).toBe('new message')
})
更新操作通常是异步的,dom更新结果放在await
语句后面测试
<p class="p1" @click="foo = 'baz'">{{foo}}</p>
test('点击p之后验证更新结果 ', async () => {
const wrapper = shallowMount(HelloWorld)
// 模拟点击行为
const p1 = wrapper.find('.p1')
// 把变更状态操作放在await后面
await p1.trigger('click')
expect(p1.text()).toBe('baz')
})
获取自定义组件
<comp v-if="foo === 'baz'"></comp>
components: {
comp: {
name: 'comp',
render (h) {
return h('div', 'comp')
}
},
}
const comp = wrapper.findComponent({name: 'comp'})
expect(comp.exists()).toBe(true)
覆盖率
Jest自带覆盖率,很容易统计我们测试代码是否全面。
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
可以看到HelloWorld.vue的覆盖率是50%
stmts是语句覆盖率(statement coverage):是不是每个语句都执行了?
Branch分支覆盖率(branch coverage):是不是每个if代码块都执行了?
Funcs函数覆盖率(function coverage):是不是每个函数都调用了?
Lines行覆盖率(line coverage):是不是每一行都执行了?
通过分析报告可以找到没有覆盖的地方,coverage/Icov-report/index.html
我们添加一些测试代码
test('验证comp组件render结果 ', () => {
const h = (type, children) => ({ type, children })
const vnode = HelloWorld.components.comp.render(h)
console.log(vnode);
expect(vnode).toEqual({ type: 'div', children: 'comp' })
})
现在覆盖率是100%了,但是整个项目还很糟糕,我们还需要继续努力提高呀!
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', '开课吧')
})
})
测试未通过,因为没有使用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', '按钮点击')
})
})