资源
Vue-CLI 3.0:https://cli.vuejs.org/zh/guide/webpack.html
vue-element-admin:https://github.com/PanJiaChen/vue-element-admin
⽬标
代码规范 项⽬配置 权限 导航 数据mock 环境变量 测试 优化、告警、发布和部署项目配置策略
基础配置:指定应⽤上下⽂、端⼝号,vue.config.js
//node
const port = 7070;
module.exports = {
publicPath: '/best-practice', // 部署应⽤包时的基本 URL,上下文路径
devServer: {
port,
}
};
配置webpack:configureWebpack
范例:设置⼀个组件存放路径的别名,vue.config.js
//webpack
const path=require('path')
module.exports = {
configureWebpack: {
resolve: {
alias: {
//@指向components目录
comps: path.join(__dirname, 'src/components'),
}
}
}
}
范例:设置⼀个webpack配置项⽤于⻚⾯title,vue.config.js
module.exports = {
configureWebpack: {
name: "vue项⽬最佳实践"
}
};
如果想用一下name,则可以在模版文件中尝试引用它。如下:
BASE_URL:vue-cli中基本变量,与paulicpath有关。在webpack中设置的特殊的配置,其实是可以拿出来用的。比如:想将title中名字写成活的。方法如下:
在宿主⻚⾯使⽤lodash插值语法使⽤它,./public/index.html
webpack-merge合并出最终选项
https://github.com/survivejs/webpack-merge
范例:基于环境有条件地配置,vue.config.js
// 传递⼀个函数给configureWebpack
// 可以直接修改,或返回⼀个⽤于合并的配置对象,config默认配置对象。
configureWebpack: config => {
config.resolve.alias.comps = path.join(__dirname, 'src/components')
//环境变量,如果启动方式是build,NODE_ENV的值是production,若用dev serve值为development
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 inspect命令:可以检查项目中webpack配置,但该命令输出的内容太多,不方便用户查看。可以用vue inspect >output.json,就可以在该文件中查看该项目配置。还有一种方式:vue inspect --rules
vue inspect --rule svg
修改规则和新增规则,vue.config.js
// resolve定义⼀个绝对路径获取函数
const path = require('path')
function resolve(dir) {
return path.join(__dirname, dir) }
//...
chainWebpack(config) {
// 配置svg规则排除icons⽬录中svg⽂件处理,项目中默认svg加载rule排除掉icons/svg
// ⽬标给svg规则增加⼀个排除选项exclude:['path/to/icon']
config.module.rule("svg")
.exclude.add(resolve("src/icons"))
// 新增icons规则,设置svg-sprite-loader处理icons⽬录中的svg,svg-loader设置
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
import '@/icons/svg/wx.svg'
验证一下:
⾃动导⼊
创建icons/index.js
//自动化加载svg目录下所有svg文件
//使用webpack提供require.context()指定svg为固定上下文。
//false为不要递归
const req = require.context('./svg', false, /\.svg$/)
//keys返回上下文中所有文件名
req.keys().map(req);
app.vue中注释掉“import '@/icons/svg/wx.svg'”;main.js中引入“./icons”
创建SvgIcon组件,components/SvgIcon.vue
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'
}
}
}
}
.svg-icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
index.js中:
app.vue中:
结果:
如果想改变该样式,可以在className中改变它的样式。
若还想要再假如一个图片,则可以直接引用:
环境变量和模式
如果想给多种环境做不同配置,可以利⽤vue-cli提供的模式。默认有 development 、 production 、 test 三种模式,(serve:开发;默认值为--mode development,build:生产)
对应的,它们的配置⽂件形式是 .env.development 。
若配置的名称为.env.dev,则
范例:定义⼀个开发时可⽤的配置项,创建.env.dev
# 只能⽤于服务端
foo=bar
# 可⽤于客户端
//VUE_APP 为vue-cli的约定
VUE_APP_DONG=dong
可以在vue.config.js中访问一下:
输出结果:
再次验证:app.vue中:
输出结果:
这里只能看到dong,看不到foo,这就是刚才约定的验证。
修改mode选项覆盖模式名称,package.json
"serve": "vue-cli-service serve --mode dev"
权限控制
路由分为两种:constantRoutes (常量路由)和 asyncRoutes (动态路由),前者是默认路由可直接访问,后者中定义的路由
需要先登录,获取⻆⾊并过滤后动态加⼊到Router中。
路由定义,router/index.js
创建⽤户登录⻚⾯,views/Login.vue
路由守卫:创建./src/permission.js,并在main.js中引⼊
切换代码:git reset --hard step-1
roles:就说明了将来哪些角色是可以看的。过滤的时候可以通过用户的角色表和这里面的配置做交集。
⽤户登录状态维护
维护⽤户登录状态:路由守卫 => ⽤户登录 => 获取token并缓存
路由守卫:src/permission.js
import router from './router'
import store from './store'
const whiteList = ['/login'] // 无需令牌白名单
router.beforeEach(async (to, from, next) => {
// 获取令牌判断用户是否登录
const hasToken = localStorage.getItem('token')
// 已登录
if (hasToken) {
if (to.path === '/login') {
// 若已登录没有必要显示登录页,重定向至首页
next('/')
} 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 {// 未登录
if (whiteList.indexOf(to.path) !== -1) {
// 白名单中路由放过
next()
} else {
// 重定向至登录页
next(`/login?redirect=${to.path}`)
}
}
})
login.vue中:
<template> | |
<div> | |
<h2>用户登录h2> | |
<div> | |
<input type="text" v-model="username" /> | |
<button @click="login">登录button> | |
div> | |
div> | |
template> | |
<script> | |
export default { | |
data() { | |
return { | |
username: "admin" | |
}; | |
}, | |
methods: { | |
login() { | |
this.$store | |
.dispatch("user/login", { username: this.username }) | |
.then(() => { | |
this.$router.push({ | |
path: this.$route.query.redirect || "/" | |
}); | |
}) | |
.catch(error => { | |
alert(error); | |
}); | |
} | |
} | |
}; | |
script> |
user模块:维护⽤户数据、处理⽤户登录等,store/user.js
import {login, getInfo} from '@/api/user';
const state = {
token: localStorage.getItem('token'),
// 其他用户信息
roles: []
};
const mutations = {
setToken: (state, token) => {
state.token = token;
},
setRoles: (state, roles) => {
state.roles = roles;
}
};
const actions = {
// 模拟用户登录
login({ commit }, userInfo) {
return login(userInfo).then((res) => {
commit("setToken", res.data);
localStorage.setItem("token", res.data);
});
// const { username } = userInfo;
// return new Promise((resolve, reject) => {
// setTimeout(() => {
// if (username === "admin" || username === "jerry") {
// commit("setToken", username);
// localStorage.setItem('token', username);
// resolve();
// } else {
// reject("用户名、密码错误");
// }
// }, 1000);
// });
},
getInfo({ commit, state }) {
return getInfo(state.token).then(({data: roles}) => {
commit("setRoles", roles);
return {roles}
})
// 模拟获取用户角色信息
// return new Promise((resolve) => {
// setTimeout(() => {
// const roles = state.token === 'admin' ? ['admin'] : ['editor']
// commit("setRoles", roles);
// resolve({ roles });
// }, 1000);
// });
},
resetToken({ commit }) {
// 模拟清空令牌和角色状态
return new Promise(resolve => {
commit("setToken", "");
commit("setRoles", []);
localStorage.removeItem('token');
resolve();
});
}
};
export default {
namespaced: true,
state,
mutations,
actions
};
store->promission.js
import { asyncRoutes, constRoutes } from "@/router";
const state = {
routes: [], // 完整路由表
addRoutes: [] // 用户可访问路由表
};
const mutations = {
setRoutes: (state, routes) => {
state.addRoutes = routes;
state.routes = constRoutes.concat(routes);
}
};
const actions = {
// 路由生成:在得到用户角色后会第一时间调用
generateRoutes({ commit }, roles) {
return new Promise(resolve => {
// 根据角色做过滤处理
const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles);
commit("setRoutes", accessedRoutes);
resolve(accessedRoutes);
});
}
};
/**
* 递归过滤AsyncRoutes路由表
* @routes 待过滤路由表,首次传入的就是AsyncRoutes
* @roles 用户拥有角色
*/
export function filterAsyncRoutes(routes, roles) {
const res = [];
routes.forEach(route => {
// 复制一份
const tmp = { ...route };
// 如果用户有访问权则加入结果路由表
if (hasPermission(roles, tmp)) {
// 如果存在子路由则递归过滤之
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, roles);
}
res.push(tmp);
}
});
return res;
}
/**
* 根据路由meta.role确定是否当前用户拥有访问权限
* @roles 用户拥有角色
* @route 待判定路由
*/
function hasPermission(roles, route) {
// 如果当前路由有roles字段则需判断用户访问权限
if (route.meta && route.meta.roles) {
// 若用户拥有的角色中有被包含在待判定路由角色表中的则拥有访问权
return roles.some(role => route.meta.roles.includes(role));
} else {
// 没有设置roles则无需判定即可访问
return true;
}
}
export default {
namespaced: true,
state,
mutations,
actions
};
⽤户⻆⾊获取和权限路由过滤
登录成功后,请求⽤户信息获取⽤户⻆⾊信息,然后根据⻆⾊过滤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
该指令只能删除挂载指令的元素,对于那些额外⽣成的和指令⽆关的元素⽆能为⼒,⽐如:
'editor']">
⽤户管理
'editor']">
配置管理
⻆⾊管理
'editor']">
定时任务补偿
此时只能使⽤v-if来实现
export default {
methods: {
checkPermission(permissionRoles) {
return roles.some(role => {
return permissionRoles.includes(role);
});
}
}
}
自定义指令:https://cn.vuejs.org/v2/guide/custom-directive.html
⾃动⽣成导航菜单
导航菜单是根据路由信息并结合权限判断⽽动态⽣成的。它需要对应路由的多级嵌套,所以要⽤到递归组件。
创建侧边栏组件,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和线上esay-mock
本地mock:利⽤webpack-dev-server提供的before钩⼦可以访问express实例,从⽽定义接⼝
修改vue.config.js,给devServer添加相关代码
调⽤接⼝,@/store/modules/user.js
线上esay-mock(或rap2)
诸如easy-mock这类线上mock⼯具优点是使⽤简单,mock⼯具库也⽐较强⼤,还能根据swagger规范⽣成接⼝。
使⽤步骤:
1. 登录easy-mock:https://easy-mock.com/
若远程不可⽤,可以搭建本地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 --config /user/local/etc/mongod.conf
起easy-mock项⽬:npm run dev
2. 创建⼀个项⽬
3. 创建需要的接⼝
// 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']
}
}
4. 调⽤:修改base_url,.env.development
VUE_APP_BASE_API = 'http://localhost:7300/mock/5e9032aab92b8c71eb235ad5'
解决跨域
如果请求的接⼝在另⼀台服务器上,开发时则需要设置代理避免跨域问题
添加代理配置,vue.config.js
创建⼀个独⽴接⼝服务器,~/server/index.js
代码下载:
https://github.com/57code/vue-study/tree/step-7
命令⽤下⾯这个:
git fetch origin step-7
git reset --hard step-7
注意切换⼀个新分⽀
项⽬测试
测试分类
常⻅的开发流程⾥,都有测试⼈员,他们不管内部实现机制,只看最外层的输⼊输出,这种我们称为⿊盒测试。⽐如你写⼀个加法的⻚⾯,会设计N个⽤例,测试加法的正确性,这种测试我们称之为E2E测试。
还有⼀种测试叫做⽩盒测试,我们针对⼀些内部核⼼实现逻辑编写测试代码,称之为单元测试。
更负责⼀些的我们称之为集成测试,就是集合多个测试过的单元⼀起测试。
组件的单元测试有很多好处:
提供描述组件⾏为的⽂档
节省⼿动测试的时间
减少研发新特性时产⽣的 bug
改进设计
促进重构
准备⼯作
在vue-cli中,预置了Mocha+Chai和Jest两套单测⽅案,我们的演示代码使⽤Jest,它们语法基本⼀致
新建vue项⽬时
选择特性 Unit Testing 和 E2E Testing
单元测试解决⽅案选择:Jest
端到端测试解决⽅案选择:Cypress
在已存在项⽬中集成集成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 + num2
}
// 测试套件 test suite
describe('Kaikeba', () => {
// 测试⽤例 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:https://jestjs.io/docs/zh-Hans/expect
测试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')
})
})
检查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("按钮点击");
// 等效的⽅式
expect(wrapper.find("span").text()).toBe("按钮点击");
});
测试覆盖率
Jest⾃带覆盖率,很容易统计我们测试代码是否全⾯。如果⽤的mocha,需要使⽤istanbul来统计覆盖率。
package.json⾥修改jest配置
"jest": {
"collectCoverage": true,
"collectCoverageFrom": ["src/**/*.{js,vue}"],
}
若采⽤独⽴配置,则修改jest.confifig.js:
module.exports = {
"collectCoverage": true,
"collectCoverageFrom": ["src/**/*.{js,vue}"]
}
在此执⾏npm run test:unit
%stmts是语句覆盖率(statement coverage):是不是每个语句都执⾏了?
%Branch分⽀覆盖率(branch coverage):是不是每个if代码块都执⾏了?
%Funcs函数覆盖率(function coverage):是不是每个函数都调⽤了?
%Lines⾏覆盖率(line coverage):是不是每⼀⾏都执⾏了?
可以看到我们kaikeba.vue的覆盖率是100%,我们修改⼀下代码
{{ message }} 点击
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;
}
}
};
现在的代码,依然是测试没有报错,但是覆盖率只有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', '一起学习')
})
})
测试未通过,因为没有使⽤Kaikeba.vue,修改App.vue
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', '按钮点击')
})
})