前言
之前使用过 Vue 开发后台、中台项目,也做过移动端 H5,弄过一点小的前端架构。每做一个项目都会收获了不一样的经验和理解。下面我把这些点点滴滴的经验总结下来,做一个系列的文章分享和阶段性的总结。
常规操作,先点赞后收藏哦
概览
问题
本节内容主要围绕下列问题展开:
- 如何编写原生组件,以及组件编写的思考与原则?
- 如何使用vuex 以及它的应用场景和原理 如何使用过滤器,编写自己的过滤器
- 如何使用Jest 测试你的代码?TDD 与 BDD 的比较
- 回顾 vue 的生命周期,他们都使用哪些场景?
- 使用 mixins 与 组件的区别,如何充分的复用代码?
- vue ssr 原理及其应用 - vue 技术栈架构设计上的思考
实践
实践之前:我希望你有如下准备,或者知识储备。 - 了解 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>
Render 函数形式
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 组件
现在,我们以写一个简单的原生button组件为例,探讨一下组件开发的一些关键点。 写之前,我们需要抓住 4 个核心的要点:
- 用哪个标签作为组件主体,button 还是 div 标签
- 如何根据属性控制 button 组件的颜色 color、形状 type、大小 size
- 如何处理 button 组件的点击事件
- 如何去扩展 button 组件的内容 这些思考点在其他原生组件开发和高阶组件封装里面也需要考虑到
首先看第一个问题,大部分原生组件第一考虑的地方,就是主要标签用原生标签还是用
去模拟。为什么不考虑 呢,因为 button> 元素比
元素更容易使用样式。你可以在元素内添加HTML内容(像、 甚至 ),以及 ::after 和
::before 伪元素来实现复杂的效果,而 只支持文本内容。 下面分析这两种写法的优劣
使用原生button标签的优势:
- 更好的标签语义化
- 原生标签自带的 buff,一些自带的键盘事件行为等
为什么说更好的语义化呢?有人可能会说,可以使用 role 来增强 div 的语义化。确实可以,但是可能>.存在就是,有些爬虫并不会根据 role 来确定这个标签的含义。
人类本身自己阅读起来也更好
另外一点, 比
使用 div 模拟的优势:
- 不需要关心button原生样式带来的一些干扰,少写一下覆盖原生 css 的代码,更干净纯粹。
- 全部用 div 不需要在去找原生标签,深入了解原生标签的一些兼容相关的诡异问题
- 可塑性更强,也是拓展性和兼容性。这也是大多数组件都会选择使用 div 作为组件主体的原因。
貌似 div 除了语义不是很好的化,其他方面都还行,但是具体用哪一种其实都可以,只有代码写的健壮> 适配强,基本都没啥大问题。
我们这里使用原生作为主要标签,使用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缓存组件减少二次加载开销
如何使用 vuex 以及它的应用
由来以及原理
我们知道组件中通信有以下几种方式:
- 父组件通过 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 模型感觉像一个电话中转站。
而 vuex 的模型更像是集中式的代码仓库。
与 git 类似,它不能直接修改代码,需要参与者提交 commit,提交完的 commit修改仓库,仓库更新,参与者 fetch 代码更新自己的代码。不同的是代码仓库需要合并,而 vuex 是直接覆盖之前的状态。
管理 vuex
原理
“store”基本上就是一个容器,它包含着你的应用中大部分的状态 (state)。Vuex 和单纯的全局对象有以下两点不同 - 响应式 - 不能改变状态(唯一途径是提交 mutation)
基本用法:就是在 state 里面定义各种属性,页面或组件组件中,直接使用 s t o r e . s t a t e 或 者 store.state或者 store.state或者store.getters来使用。如果想要改变状态state呢,就commit 一个mutation
但是拿我想提交一连串动作呢?可以定义一个action,然后使用 $store.dispatch 执行这个 action
使用action 不仅能省略不少代码,而且关键是action 中可以使用异步相关函数,还可以直接返回一个promise。
而为什么不直接到mutation中写异步呢? 因为mutation 一定是个同步,它是唯一能改变 state 的,一旦提交了 mutation,mutation 必须给定一个明确结果。否则会阻塞状态的变化。
下面给出常用 vuex 的使用方式
新建 Store
新建一个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')
管理 states
单一状态树, 这也意味着,每个应用将仅仅包含一个 store 实例。单一状态树让我们能够直接地定位任一特定的状态片段,在调试的过程中也能轻易地取得整个当前应用状态的快照。
// states 文件
export default {
count: 0
}
计算属性中返回,每当 state 中属性变化的时候, 其他组件都会重新求取计算属性,并且触发更新相关联的 DOM
const Counter = {
template: '<div>{{count}}<div>',
computed: {
count() {
return store.state.count
}
}
}
管理 getter
getter 相当于 store 的计算属性。不需要每次都要在计算属性中过滤一下,也是一种代码复用。 我们在getters文件中管理
export default {
count: (state) => Math.floor(state.count)
}
管理 mutations
更改 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)
管理 actions
和 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 主要有几个应用场景,一个是用于状态共享,一个是用于数据缓存,还有就是用于减少请求。这些场景归根节点都缓存和共享来说的。
状态共享
原理: 状态统一管理,组件页面更改状态可以更改仓库里面的状态。 首先状态统一管理,就实现了组件通信的可能性。
我们经常使用的一个场景就是,权限管理。写权限管理时候,首次进入页面就要将权限全部拿到,然后需要分发给各个页面使用,来控制各个路由、按钮的权限。
而且权限还可以被更改,更改后的权限直接分发到其他页面组件中。这个场景要是不使用 vuex ,代码将会比较复杂。
数据缓存
store 是一个仓库,它从创建开始就一直存在,只有页面 Vuex.store 实例被销毁,state 才会被清空。具体表现就是刷新页面。
这个数据缓存适用于:页面加载后缓存数据,刷新页面请求数据的场景。在一般Hybrid中,一般不存在刷新页面这个按钮,所以使用 vuex 缓存数据可以应对大多数场景。
如果需要持久化缓存,结合浏览器或 APP 缓存更佳。
减少请求(数据缓存的变种)
在写后台管理平台时候,经常会有 list 选型组件,里面数据从服务端拿的数据。如果我们把这个 list 数据存储起来,下次再次使用,直接从 store 里面拿,这样我们就不用再去请求数据了。相当于减少了一次请求。
如何使用过滤器,编写自己的过滤器
原理
假设我现在有个需求,需要将性别0、1、2,分别转换成男、女、不确定这三个汉字展示。页面中多处地方需要使用。
template(lang="pug")
.user-info
.gender
label(for="性别") 性别
span {{transferMemberDetail.gender}}
有 4 种方式: 1. 使用 computed 方法 2. 使用 method 方法 3. 使用 utils 工具类 4. 使用 filters
/**
* 通过id和数组获取name
* example 当只有性别id,需要获取性别name
* use {{ id | convertIdToName(list)}}
*/
export const convertIdToName = (value, list) => {
if (!value) return ''
const item = list.find(function(item) {
return item.id === value
})
return item.name
}
例子
编写一个简单的千分位过滤器
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 插件
安装使用
注册全局过滤器
for (const key in filters) {
Vue.filter(key, filters[key])
}
如何使用 Jest 测试你的代码
原理
我们思考一下测试 js 代码需要哪些东西
- 浏览器运行环境
- 断言库
如果是测试 vue 代码呢? 那得在加一个 vue 测试容器
Jest + 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'
//...
}
单元测试
测试 utils 工具类
对我们之前写的一个性别名称转换工具进行测试
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('')
})
})
})
测试 components 单文件组件
对我们之前写的 button 进行测试
import { shallowMount } from '@vue/test-utils'
import Button from '@/components/s-button.vue'
describe('Button 组件', () => {
it('button 文字正确渲染', () => {
const msg = '确认'
const wrapper = shallowMount(Button, {
propsData: { msg }
})
expect(wrapper.text()).toMatch(msg)
})
it('button 样式正确渲染', () => {
const msg = 'new message'
const wrapper = shallowMount(Button, {
propsData: { color, size, theme }
})
expect(wrapper.text()).toMatch(msg)
})
it('button click 事件正确执行', () => {
const msg = 'new message'
const wrapper = shallowMount(Button, {
propsData: { color, size, theme }
})
expect(wrapper.text()).toMatch(msg)
})
// ...
})
测试 api 请求
简单的异步测试,测试一个简单的请求
export const login = (data) => post('/user/login', data)
测试
集成测试
镜像测试
TDD 与 BDD
测试驱动开发
行为驱动开发
vue 的生命周期,他们都使用哪些场景?
create 创建过程
原理
我们看看创建过程中 vue 干了哪些事情? - 创建配置 - 创建事件 - 创建生命周期
create 勾子函数
Create 阶段有两个勾子,一个是在创建之前 beforeCreate、一个是创建之后 created
在 beforeCreate 勾子中执行的代码是执行在什么之前呢? 我们使用 demo 来测试一下
beforeCreate() {
console.log('-------------beforeCreate start--------------')
console.log(this.$el, 'el beforeCreate')
console.log(this.$data, 'data beforeCreate')
},
created() {
console.log('-------------created start--------------')
console.log(this.$el, 'el created')
console.log(this.$data, 'data Created')
}
create 勾子的应用场景
mount 挂载过程
原理
在这个过程中
mount 勾子函数
beforeMount
mounted
应用场景
update 更新渲染过程
原理
在这个过程中
update 勾子函数
beforeUpdate
updated
应用场景
destory 销毁过程
原理
在这个过程中
destory 勾子函数
beforeDestory
destoryed
应用场景
其他场景
errorCaptured 错误捕获
activated 与 disactivated 激活与停止激活
如何使用 mixins,如何充分的复用代码
原理与解决方案
为什么需要使用 mixins, 它解决了什么问题
如何搭建 SSR
SSR 是 Serve Side Render 的缩写,翻译过来就是我们常说的服务端渲染
由来以及原理
为什么需要 SSR
我们现在通过 vue/react/angular 构建的项目,都是一种 SPA 单页应用。
简单来说就是将一个 HTML 页面通过模块引入各种方式拆成了不同的文件。最终打包编译后的仍然是这种形式。
<!DOCTYPE html>
<html lang=en>
<head>
<meta charset=utf-8>
<meta http-equiv=X-UA-Compatible content="IE=edge">
<meta name=viewport content="width=device-width,initial-scale=1">
<link rel=icon href=/favicon.ico> <title>suo-design-pro</title>
</head>
<body>
<div id=app></div>
<script src=/static/js/chunk-vendors.f75711c4.js> </script>
<script src=/static/js/app.0c897380.js> </script> </body>
</html>
这会有什么问题呢?
我们知道搜索引擎通过爬虫来找页面dom里面的关键字,然后提取出来作为页面关键字。达到页面识别的目的。一般不会去解析你的 JavaScript 文件。
而现在它就只能找到一个 id=app 这种东西,怎么识别页面?换句话说,就是我怎么做 SEO(搜索引擎优化)?
基于这个基本又简单的问题,就有人想到了一种解决方案:
我 HTML 还是要,从服务端去拿。而我 JavaScript 渲染出来的页面也还是保留,让它们各自干不同的事情。
服务端生成 HTML 就初次访问时候直接展示出来,JavaScript 渲染的页面就在页面影响变动的时候起作用。
但是问题是我们看到的页面到底是 服务端渲染的还是客户端渲染的呢?这就是问题的关键所在。
配置实现一个简单的 SSR
这里使用 node 作为 SSR 的后端,主要是方便,不需要配置其他的后端服务器了
搭建两个入口
一个客户端的入口
// entry-client.js
import { createApp } from './main'
const { app, eventBus } = createApp()
console.log(app)
if (window.__INITIAL_STATE__) {
eventBus._data = window.__INITIAL_STATE__
}
app.$mount(’#app’)
一个服务端的入口
这么说来,服务端渲染是 SPA 中的最后一公里。
编写自己的组件库
参考资料
commitizen
MDN import()
vant 文档
eslint 文档
vue-router 文档
sass 文档
axios 文档