据上节文章发布已经有了两个星期了。期间收到了 1000+ 个赞,30000+ 阅读量,这是我万万没想到的。自己的文章能有这么高的关注度,真的很令人满意!
但是相反,写文章的压力更加大了。一篇文章总是反反复复的修改,总是担心自己的认知水平和技术水平不够,甚至导致有些地方会误导读者。
也会揣摩自己写作风格有没有什么问题、会不会太啰嗦、哪些地方没讲清楚等等...如果有不好的地方可以评论指出来,接受批评,批评也是一种进步的动力!
本来准备上下两节写完全部内容,发现实际不太可能,还没写完 4 章,就已经 6000—+ 字了。最后一节写完之后就准备回家过年了,这里提前祝大家新年快乐!
常规操作,先点赞后观看哦!你的点赞是我创作的动力之一!
这节我将从 5 个方面来论述 vue 开发过程中的一些技巧和原理。如果你还未观看上节文章,可以移步至16个方面深入前端工程化开发技巧《上》观看。
本节内容主要围绕下列问题展开:
实践之前:我希望你有如下准备,或者知识储备。
- 了解
npm/yarn/git/sass/pug/vue/vuex/vue-router/axios/mock/ssr/jest
的使用和原理。- 当然上面知识不了解也没关系哈哈哈,文章中会提到大致用法和作用。
vue 编写组件有两种方式,一种是单文件组件,另外一种函数组件。根据组件引入和创建还可以分为动态组件和异步组件。
动态组件
keep-alive
使之缓存。异步组件原理和异步路由一样,使用import()
实现异步加载也就是按需加载。
所谓 vue 单文件组件,就是我们最常见的这种形式:
<template lang="pug"> .demo h1 hello </template> <script> export default { name: 'demo', data() { return {} } } </script> <style lang="scss" scoped> .demo { h1 { color: #f00; } } </style> 复制代码
这里的template
也可以使用 render
函数来编写
Vue.component('demo', { render: function (createElement) { return createElement( 'h1', 'hello', // ... ) } }) 复制代码
我们可以发现render
函数写模版让我们更有编程的感觉。对模版也可以编程,在vue
里面我们可以很容易联想到,很多地方都有两种写法,一种是 template
, 一种是js
。
比如:对于路由,我们既可以使用:to=""
,又可以使用$router.push
,这也许是 vue 用起来比较爽的原因。
函数式组件是什么呢?
functional
,这意味它无状态 (没有响应式数据),也没有实例 (没有 this
上下文)
2.5.0+
<template functional> </template> 复制代码
Vue.component('my-component', { functional: true, render function (createElement, context) { return createElement('div') } } 复制代码
为什么要使用函数组件呢?
最重要的原因就是函数组件开销低,也就是对性能有好处,在不需要响应式和this
的情况下,写成函数式组件算是一种优化方案。
组件写好了,需要将组件注册才能使用
组件注册分为两种,一种是全局注册,一种是局部注册
局部注册就是我们常用的 Vue.component('s-button', { /* ... */ })
,比较简单不详细论述
全局注册上节已经提到,在new Vue
之前在 mian.js
注册,这里还提到一种自动全局注册的方法 require.text
import Vue from 'vue' import upperFirst from 'lodash/upperFirst' import camelCase from 'lodash/camelCase' const requireComponent = require.context( './components', // 是否查询其子目录 false, /Base[A-Z]\w+\.(vue|js)$/ ) requireComponent.keys().forEach(fileName => { // 获取组件配置 const componentConfig = requireComponent(fileName) const componentName = upperFirst( camelCase( // 获取和目录深度无关的文件名 fileName .split('/') .pop() .replace(/\.\w+$/, '') ) ) // 全局注册组件 Vue.component( componentName, componentConfig.default || componentConfig ) }) 复制代码
基本原理和全局注册一样,就是将 components
中的组件文件名,appButton
变成 AppButton
作为注册的组件名。把原来需要手动复制的,变成之间使用 keys
方法批量注册。
现在,我们以写一个简单的原生button
组件为例,探讨一下组件开发的一些关键点。
写之前,我们需要抓住 4 个核心的要点:
button
还是 div
标签button
组件的颜色 color
、形状 type
、大小 size
button
组件的点击事件button
组件的内容这些思考点在其他原生组件开发和高阶组件封装里面也需要考虑到
首先看第一个问题,大部分原生组件第一考虑的地方,就是主要标签用原生<button></button>
标签还是用<div></div>
去模拟。
为什么不考虑
<input/>
呢,因为<button>
元素比<input>
元素更容易添加内部元素。你可以在元素内添加HTML内容(像<em>
、<strong>
甚至<img>
),以及::after
和::before
伪元素来实现复杂的效果,而<input>
只支持文本内容。
下面分析这两种写法的优劣
使用原生button
标签的优势:
buff
,一些自带的键盘事件行为等为什么说更好的语义化呢?有人可能会说,可以使用
role
来增强div
的语义化。确实可以,但是可能存在问题——有些爬虫并不会根据role
来确定这个标签的含义。
另外一点,对开发者来说,
<button></button>
比<div role="button"></div>
阅读起来更好。
使用 div
模拟的优势:
button
原生样式带来的一些干扰,少写一些覆盖原生 css
的代码,更干净纯粹。div
,不需要再去找原生标签、深入了解原生标签的一些兼容相关的诡异问题。div
作为组件主体的原因。貌似 div 除了语义不是很好以外,其他方面都还行,但是具体用哪一种其实都可以,只要代码写的健壮适配性强,基本都没啥大问题。
我们这里使用原生<button></button>
作为主要标签,使用s-xx
作为class
前缀
为什么需要使用前缀,因为在有些时候,比如使用第三方组件,多个组件之间的 class 可能会产生冲突,前缀用来充当组件 css 的一个命名空间,不同组件之间不会干扰。
<template lang="pug"> button.s-button(:class="xxx" :style="xxx" ) slot </template> 复制代码
然后,我们看第二个问题:
如何根据属性来控制 button
的样式
其实这个很简单,基本原理就是:
使用 props
获取父组件传过来的属性。
根据相关属性,生成不同的class
,使用 :class="{xxx: true, xxx: 's-button--' + size}"
这种形式,在 style
里面对不同的s-button--xxx
做出不同的处理。
<template lang="pug"> button.s-button(:class="" :style="" ) slot </template> <script> export default { name: 's-button' data: return {} props: { theme: {}, size: {}, type: {} } } </script> 复制代码
如何使用事件以及如何扩展组件
扩展组件的原理,主要就是使用 props
控制组件属性,模版中使用 v-if/v-show
增加组件功,比如增加内部 ICON,使用:style``class
控制组件样式。
type
类型,原生默认是submit
,这里我们默认设置为button
<template lang="pug"> button.s-button(:class="" :style="" :type="nativeType") slot s-icon(v-if="icon && $slots.icon" name="loading") </template> <script> export default { name: 's-button' data: return {} props: { nativeType: { type: String, default: 'button' }, theme: {}, size: {}, type: {} } } </script> 复制代码
控制事件,直接使用 @click=""
+ emit
<template lang="pug"> button.s-button(@click="handleClick") </template> <script> export default { methods: { handleClick (evt) { this.$emit('click', evt) } } } </script> 复制代码
一般就直接使用
template
单文件编写组件,需要增强 js编写能力可以使用render()
常规编写组件需要考虑:1. 使用什么标签 2. 如何控制各种属性的表现 3. 如何增强组件扩展性 4. 如何处理组件事件
对响应式
this
要求不高,使用函数functional
组件优化性能。
基础组件通常全局注册,业务组件通常局部注册
使用
keys()
遍历文件来实现自动批量全局注册
使用
import()
异步加载组件提升减少首次加载开销,使用keep-alive + is
缓存组件减少二次加载开销
我们知道组件中通信有以下几种方式:
props
传递给子组件,不详细论述emit
事件传递数据给父组件,父组件通过 on
监听,也就是一个典型的订阅-发布模型
@
为v-on:
的简写
<template lang="pug"> <!--子组件--> div.component-1 <template> export default { mounted() { this.$emit('eventName', params) } } </script> 复制代码
<!-- 父组件--> <template lang="pug"> Component-1(@eventName="handleEventName") <template> <script> export default { methods: { handleEventName (params) { console.log(params) } } } </script> 复制代码
原理很简单其实就是在 emit
与 on
的基础上加了一个事件中转站 “bus”。我觉得更像是现实生活中的集线器。
普遍的实现原理大概是这样的 “bus” 为 vue
的一个实例,实例里面可以调用emit
,off
,on
这些方法。
var eventHub = new Vue() // 发布 eventHub.$emit('add', params) // 订阅/响应 eventHub.$on('add', params) // 销毁 eventHub.$off('add', params) 复制代码
但是稍微复杂点的情况,使用这种方式就太繁锁了。还是使用 vuex 比较好。
从某种意义而言,我觉得 vuex 不仅仅是它的一种进化版。
store
作为状态管理的仓库,并且引入了状态这个概念bus
模型感觉像一个电话中转站与
git
类似,它不能直接修改代码,需要参与者提交commit
,提交完的commit
修改仓库,仓库更新,参与者fetch
代码更新自己的代码。不同的是代码仓库需要合并,而vuex
是直接覆盖之前的状态。
“store”基本上就是一个容器,它包含着你的应用中大部分的状态 (
state
)。Vuex 和单纯的全局对象有以下两点不同
dom
)mutation
)
基本用法:就是在 state
里面定义各种属性,页面或组件组件中,直接使用 $store.state
或者$store.getters
来使用。如果想要改变状态state
呢,就commit
一个mutation
但是假如我想提交一连串动作呢?可以定义一个action
,然后使用 $store.dispatch
执行这个 action
使用action
不仅能省略不少代码,而且关键是action
中可以使用异步相关函数,还可以直接返回一个promise
而为什么不直接到mutation
中写异步呢? 因为mutation
一定是个同步,它是唯一能改变 state 的,一旦提交了 mutation
,mutation
必须给定一个明确结果。否则会阻塞状态的变化。
下面给出常用 vuex 的使用方式
新建一个store
并将其他各个功能化分文件管理
import Vue from 'vue' import Vuex from 'vuex' import state from './states' import getters from './getters' import mutations from './mutations' import actions from './actions' import user from './modules/user' Vue.use(Vuex) export default new Vuex.Store({ //在非生产环境下,使用严格模式 strict: process.env.NODE_ENV !== 'production', state, getters, mutations, actions, modules: { user } }) 复制代码
操作状态两种方式
console.log(store.state.count) 复制代码
store.commit('xxx') 复制代码
单一状态树, 这也意味着,每个应用将仅仅包含一个 store 实例。单一状态树让我们能够直接地定位任一特定的状态片段,在调试的过程中也能轻易地取得整个当前应用状态的快照。
// states 文件 export default { count: 0 } 复制代码
计算属性中返回,每当 state
中属性变化的时候, 其他组件都会重新求取计算属性,并且触发更新相关联的 DOM
const Counter = { template: '<div>{{count}}<div>', computed: { count() { return store.state.count } } } 复制代码
getters
相当于store
的计算属性。不需要每次都要在计算属性中过滤一下,也是一种代码复用。 我们在getters
文件中管理
export default { count: (state) => Math.floor(state.count) } 复制代码
更改 Vuex 的 store 中的状态的唯一方法是提交 mutation。Vuex 中的 mutation 非常类似于事件:每个 mutation 都有一个字符串的 事件类型 (type) 和 一个 回调函数 (handler)。这个回调函数就是我们实际进行状态更改的地方
使用 types 大写用于调试,在mutationTypes
文件中export const ROUTE_ADD = 'ROUTE_ADD'
然后在mutations
文件中管理
import * as MutationTypes from './mutationTypes' export default { [MutationTypes.ADDONE]: function(state) { state.count = state.count + 1 }, //... } 复制代码
this.$store.commit(MutationTypes.ADDONE) 复制代码
和 mutations
类似,actions
对应的是dispatch
,不同的是action
可以使用异步函数,有种更高一级的封装。
// 简化 actions: { increment ({ commit }) { setTimeout(() => { commit(MutationTypes.ADDONE) }, 1000) } } // 触发 store.dispatch('increment') 复制代码
上述用法都可以使用载荷的形式,引入也可以使用
mapXxxx
进行批量引入,这里不详细论述,有兴趣可以查看官网。
状态太多太杂,分模块管理是一个良好的代码组织方式。
import count from './modules/count' export default new Vuex.Store({ modules: { count } }) 复制代码
每一个模块都可以有独立的相关属性
import * as ActionTypes from './../actionTypes' export default { state: { count: 0 }, mutations: { ADD_ONE: function(state) { state.count = state.count + 1 } }, actions: { [ActionTypes.INIT_INTENT_ORDER]: function({ commit }) { commit('ADD_ONE') } }, getters: { pageBackToLoan: (state) => Math.floor(state.count) } } 复制代码
vuex
主要有几个应用场景,一个是用于组件通信和状态共享,一个是用于数据缓存,还有就是用于减少请求。这些场景归根节点都是对于缓存和共享来说的。
首先,状态统一管理在仓库,就实现了组件通信的可能性。
当一个组件通过 commit
提交 mutation
就改了 state
,其他组件就可以通过store.state
获取最新的state
,这样一来就相当于更新的值通过 store
传递给了其他组件,不仅实现了一对一的通信,还实现了一对多的通信。
我们经常使用的一个场景就是权限管理。
写权限管理时候,首次进入页面就要将权限全部拿到,然后需要分发给各个页面使用,来控制各个路由、按钮的权限。
/** * 判断用户有没有权限访问页面 */ function hasPermission(routeItem, menus) { return menus.some(menuItem => { return menuItem.name === routeItem.name }) } /** * 递归过滤异步路由表,返回符合用户角色权限的路由表 * @param {*} routes 路由表 * @param {*} menus 菜单信息 */ function filterRoutes(routes, menus) { return routes.filter(routeItem => { if (routeItem.hidden) { return true } else if (hasPermission(routeItem, menus)) { const menuItem = menus.find(item => item.name === routeItem.name) if (menuItem && menuItem.children && menuItem.children.length > 0) { routeItem.children = filterRoutes(routeItem.children, menuItem.children) if (!routeItem.children.length) return false } return true } else { return false } }) } const permission = { state: { routers: constantRouterMap, addRouters: [], roles: [], user_name: '', avatar_url: '', onlyEditor: false, menus: null, personal: true, teamList: [] }, mutations: {} } export default permission 复制代码
而且权限还可以被更改,更改后的权限直接分发到其他页面组件中。这个场景要是不使用 vuex
,代码将会比较复杂。
store
是一个仓库,它从创建开始就一直存在,只有页面 Vuex.store
实例被销毁,state 才会被清空。具体表现就是刷新页面。
这个数据缓存适用于:页面加载后缓存数据,刷新页面请求数据的场景。在一般Hybrid
中,一般不存在刷新页面这个按钮,所以使用 vuex 缓存数据可以应对大多数场景。
export default { state: { // 缓存修改手机号需要的信息 changePhoneInfo: { nameUser: '', email: '', phone: '' }, } } 复制代码
如果需要持久化缓存,结合浏览器或 APP 缓存更佳。
export default { // 登陆成功后,vuex 写入token,并写入app缓存,存储持久化 [ActionTypes.LOGIN_SUCCESS]: function(store, token) { store.commit(MutationTypes.SET_TOKEN, token) setStorage('token', token) router.replace({ name: 'Home', params: { source: 'login' } }) } } 复制代码
在写后台管理平台时候,经常会有 list
选型组件,里面数据从服务端拿的数据。如果我们把这个 list
数据存储起来,下次再次使用,直接从 store
里面拿,这样我们就不用再去请求数据了。相当于减少了一次请求。
假设我现在有个需求,需要将性别0、1、2,分别转换成男、女、不确定这三个汉字展示。页面中多处地方需要使用。
<template lang="pug"> .user-info .gender label(for="性别") 性别 span {{gender}} </template> 复制代码
完成这个需求,我们知道有 4 种方式:
应该选择哪种方式呢?
我从下面三个方面来论述这个问题
1. 可实现性
computed
实现成功,我们知道computed
不是一个函数是无法传参的,这里有个技巧,return
一个函数接受传过来的参数// ... computed: { convertIdToName() { return function(value) { const item = list.find(function(item) { return item.id === value }) return item.name } } } 复制代码
methods
实现成功,这里直接可以传参数,一种常规的写法。注意
methods
、computed
和data
相互之前是不能同名的
// ... methods: { convertIdToName(value) { const item = list.find(function(item) { return item.id === value }) return item.name } } 复制代码
utils
和 methods
差不多基本上也可以实现filter
也是实现的,有个可以和methods
、computed
同名哦filters: { console.log(this.render) convertIdToName(value) { const item = list.find(function(item) { return item.id === value }) return item.name } }, 复制代码
总的来说他们全部都可以实现这个需求
2. 局限性
computed
、methods
和data
三者互不同名,他们没办法被其他组件使用,除非通过 mixins
filters
与 utils
无法访问 this
,也就是于响应式绝缘。但是通过定义全局filters
,可以其他地方使用,另外还可以直接加载第三方filter
和utils
3. 总结比较
filters
与 utils
归属一对,他们既是脱离了 this
,获得了自由,又是被this
弃之门外。相反 methods
与 computed
与 this
紧紧站在一起,但又是无法获得自由。
export const thousandBitSeparator = (value) => { return value && (value .toString().indexOf('.') !== -1 ? value.toString().replace(/(\d)(?=(\d{3})+\.)/g, function($0, $1) { return $1 + ','; }) : value.toString().replace(/(\d)(?=(\d{3})+$)/g, function($0, $1) { return $1 + ','; })); } 复制代码
两款插件
vue-filter:www.npmjs.com/package/vue… 使用 use
引入
vue2-filters:www.npmjs.com/package/vue… 使用mixins
引入
有需要的话,我一般就用第二个了,大多数都是自己写一下小过滤器
自定义过滤器之后,直接全局自动注册,其他地方都可以使用
遍历过滤属性值,一次性全部注册
for (const key in filters) { Vue.filter(key, filters[key]) } 复制代码
我们思考一下测试 js 代码需要哪些东西
如果是测试 vue 代码呢? 那得再加一个 vue 测试容器
{ "@vue/cli-plugin-unit-jest": "^4.0.5", "@vue/test-utils": "1.0.0-beta.29", "jest": "^24.9.0", // ... } 复制代码
// For a detailed explanation regarding each configuration property, visit: // https://jestjs.io/docs/en/configuration.html module.exports = { preset: '@vue/cli-plugin-unit-jest', automock: false, "/private/var/folders/10/bb2hb93j34999j9cqr587ts80000gn/T/jest_dx", clearMocks: true, // collectCoverageFrom: null, coverageDirectory: 'tests/coverage' //... } 复制代码
对我们之前写的一个性别名称转换工具进行测试
import { convertIdToName } from './convertIdToName' describe('测试convertIdToName方法', () => { const list = [ { id: 0, name: '男' }, { id: 1, name: '女' }, { id: 2, name: '未知' } ] it('测试正常输入', () => { const usage = list usage.forEach((item) => { expect(convertIdToName(item.id, list)).toBe(item.name) }) }) it('测试非正常输入', () => { const usage = ['a', null, undefined, NaN] usage.forEach((item) => { expect(convertIdToName(item, list)).toBe('') }) }) }) 复制代码
这样一测试,发现原来我们之前写的工具有这么多漏洞
测试正常输入全部通过了,非正常输入失败了,根据测试用例改进我们的代码export const convertIdToName = (value, list) => { if (value !== 0 && value !== 1 && value !== 2) return '' const item = list.find(function(item) { return item.id === value }) return item.name } 复制代码
现在测试都通过了呢
对我们最简单的
hello world
进行测试
<template lang="pug"> .hello h1 {{ msg }} </template> <script> export default { props: { msg: String } } </script> 复制代码
import { shallowMount } from '@vue/test-utils' import HelloWorld from '@/components/HelloWorld.vue' describe('HelloWorld.vue', () => { it('renders props.msg when passed', () => { const msg = 'new message' const wrapper = shallowMount(HelloWorld, { propsData: { msg } }) expect(wrapper.text()).toMatch(msg) }) }) 复制代码
异步测试有几种常见写法
async
与 await
done()
简单的异步测试,测试一个简单的登陆请求
export const login = (data) => post('/user/login', data) 复制代码
测试代码
import { login } from '@/api/index' describe('login api', () => { const response = { code: '1000', data: {} } const errorResponse = { code: '5000', data: {}, message: '用户名或密码错误' } it('测试正常登陆', async () => { const params = { user: 'admin', password: '123456' } expect(await login(params)).toEqual(response) }) it('测试异常登陆', async () => { const params = { user: 'admin', password: '123123' } expect(await login(params)).toEqual(errorResponse) }) }) 复制代码
组件,api
,工具这些零零碎碎都测试了,而且这些都是比较通用、和业务关系不大的代码,它们改动较少,测试到这里其实已经足够了,已经达到了 20%
的测试工作量了很大一部分代码的目的。
为什么我说只有 20% 的工作量呢?因为这些都是不怎么变化的逻辑,是一劳永逸的事情。长远来说占用的工作量确实很少。
但是有些情况业务还是必须得测,也就是必须要功能模块集成测试。
经常得回归的业务,那种迭代对原有的系统比较大,避免改动之后使旧的代码各种新的问题。这种经常回归测试,采用 BDD
+ 集成测试,比不停改 bug
要轻松的多。
像版本一样,每次测试之后生成一个版本,比较与上一个版本的区别。 这是一种粒度及其小的测试,可以测试到每一个符号。
这是我们一个配置文件
export const api = { develop: 'http://xxxx:8080', mock: 'http://xxxx', feature: 'http://xxxx', test: 'http://xxxx', production: 'http://xxxx' } export default api[process.env.NODE_ENV || 'dev'] 复制代码
使用快照测试
import { api } from './config' describe('配置文件测试', () => { it('测试配置文件是否变动', () => { expect(api).toMatchSnapshot({ develop: 'http://xxxx:8080', mock: 'http://xxxx', feature: 'http://xxxx', test: 'http://xxxx', production: 'http://xxxx' }) }) }) 复制代码
使用快照第一次测试后,通过测试,代码被写入快照
改动配置再次测试,未通过测试 这时如果要改变配置怎么办呢? 只需同时改一下用例就好了。快照将再次写入快照生成版本2,这样配置改动也有根据了最近讨论比较多的算是测试驱动开发和行为驱动开发,其实总得来说是 4 种
bug
多,代码质量低。那么你是哪一种? 反正我比较佛系哈,有的不写测试,也有的写满测试。
本篇文章耗费作者一个多星期的业余时间,存手工敲打 6000+字,同时收集,整理之前很多技巧和边写作边思考和总结。如果能对你有帮助,便是它最大的价值。都看到这里还不点赞,太过不去啦!😄
由于技术水平有限,文章中如有错误地方,请在评论区指出,感谢!
文中大多数代码将在suo-design-pro 中更新
项目有时间会尽量完善
写实践总结性文章真的很耗费时间。如何文章中有帮到你的地方分享下呗,让更多人看到!