「Vue实战」武装你的前端项目

Posted 前端劝退师

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了「Vue实战」武装你的前端项目相关的知识,希望对你有一定的参考价值。

本文项目基于Vue-Cli3,想知道如何正确搭建请看我之前的文章:

「Vue实践」项目升级vue-cli3的正确姿势

1. 接口模块处理

1.1 axios二次封装

很基础的部分,已封装好的请跳过。这里的封装是依据 JWT

 
   
   
 
  1. import axios from 'axios'

  2. import router from '../router'

  3. import {MessageBox, Message} from 'element-ui'


  4. let loginUrl = '/login'

  5. axios.defaults.baseURL = process.env.VUE_APP_API

  6. axios.defaults.headers = {'X-Requested-With': 'XMLHttpRequest'}

  7. axios.defaults.timeout = 60000


  8. // 请求拦截器

  9. axios.interceptors.request.use(

  10. config => {

  11. if (router.history.current.path !== loginUrl) {

  12. let token = window.sessionStorage.getItem('token')

  13. if (token == null) {

  14. router.replace({path: loginUrl, query: {redirect: router.currentRoute.fullPath}})

  15. return false

  16. } else {

  17. config.headers['Authorization'] = 'JWT ' + token

  18. }

  19. }

  20. return config

  21. }, error => {

  22. Message.warning(error)

  23. return Promise.reject(error)

  24. })

紧接着的是响应拦截器(即异常处理)

 
   
   
 
  1. axios.interceptors.response.use(

  2. response => {

  3. return response.data

  4. }, error => {

  5. if (error.response !== undefined) {

  6. switch (error.response.status) {

  7. case 400:

  8. MessageBox.alert(error.response.data)

  9. break

  10. case 401:

  11. if (window.sessionStorage.getItem('out') === null) {

  12. window.sessionStorage.setItem('out', 1)

  13. MessageBox.confirm('会话已失效! 请重新登录', '提示', {confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning'}).then(() => {

  14. router.replace({path: loginUrl, query: {redirect: router.currentRoute.fullPath}})

  15. }).catch(action => {

  16. window.sessionStorage.clear()

  17. window.localStorage.clear()

  18. })

  19. }

  20. break

  21. case 402:

  22. MessageBox.confirm('登陆超时 !', '提示', {confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning'}).then(() => {

  23. router.replace({path: loginUrl, query: {redirect: router.currentRoute.fullPath}})

  24. })

  25. break

  26. case 403:

  27. MessageBox.alert('没有权限!')

  28. break

  29. // ...忽略

  30. default:

  31. MessageBox.alert(`连接错误${error.response.status}`)

  32. }

  33. return Promise.resolve(error.response)

  34. }

  35. return Promise.resolve(error)

  36. })

这里做的处理分别是会话已失效和登陆超时,具体的需要根据业务来作变更。

最后是导出基础请求类型封装。

 
   
   
 
  1. export default {

  2. get (url, param) {

  3. if (param !== undefined) {

  4. Object.assign(param, {_t: (new Date()).getTime()})

  5. } else {

  6. param = {_t: (new Date()).getTime()}

  7. }

  8. return new Promise((resolve, reject) => {

  9. axios({method: 'get', url, params: param}).then(res => { resolve(res) })

  10. })

  11. },

  12. getData (url, param) {

  13. return new Promise((resolve, reject) => {

  14. axios({method: 'get', url, params: param}).then(res => {

  15. if (res.code === 4000) {

  16. resolve(res.data)

  17. } else {

  18. Message.warning(res.msg)

  19. }

  20. })

  21. })

  22. },

  23. post (url, param, config) {

  24. return new Promise((resolve, reject) => {

  25. axios.post(url, param, config).then(res => { resolve(res) })

  26. })

  27. },

  28. put: axios.put,

  29. _delete: axios.delete

  30. }

其中给 get请求加上时间戳参数,避免从缓存中拿数据。

浏览器缓存是基于url进行缓存的,如果页面允许缓存,则在一定时间内(缓存时效时间前)再次访问相同的URL,浏览器就不会再次发送请求到服务器端,而是直接从缓存中获取指定资源。

1.2 请求按模块合并

「Vue实战」武装你的前端项目模块的请求:

 
   
   
 
  1. import http from '@/utils/request'

  2. export default {

  3. A (param) { return http.get('/api/', param) },

  4. B (param) { return http.post('/api/', param) }

  5. C (param) { return http.put('/api/', param) },

  6. D (param) { return http._delete('/api/', {data: param}) },

  7. }

utils/api/index.js:

 
   
   
 
  1. import http from '@/utils/request'

  2. import account from './account'

  3. // 忽略...

  4. const api = Object.assign({}, http, account, *...其它模块*)

  5. export default api

1.3 global.js中的处理

在 global.js中引入:

 
   
   
 
  1. import Vue from 'vue'

  2. import api from './api/index'

  3. // 略...


  4. const errorHandler = (error, vm) => {

  5. console.error(vm)

  6. console.error(error)

  7. }


  8. Vue.config.errorHandler = errorHandler

  9. export default {

  10. install (Vue) {

  11. // 添加组件

  12. // 添加过滤器

  13. })

  14. // 全局报错处理

  15. Vue.prototype.$throw = (error) => errorHandler(error, this)

  16. Vue.prototype.$http = api

  17. // 其它配置

  18. }

  19. }

写接口的时候就可以简化为:

 
   
   
 
  1. async getData () {

  2. const params = {/*...key : value...*/}

  3. let res = await this.$http.A(params)

  4. res.code === 4000 ? (this.aSata = res.data) : this.$message.warning(res.msg)

  5. }

2.Vue组件动态注册

来自 @SHERlocked93:Vue 使用中的小技巧

我们写组件的时候通常需要引入另外的组件:

 
   
   
 
  1. <template>

  2. <BaseInput v-model="searchText" @keydown.enter="search"/>

  3. <BaseButton @click="search">

  4. <BaseIcon name="search"/>

  5. </BaseButton>

  6. </template>

  7. <script>

  8. import BaseButton from './baseButton'

  9. import BaseIcon from './baseIcon'

  10. import BaseInput from './baseInput'

  11. export default {

  12. components: { BaseButton, BaseIcon, BaseInput }

  13. }

  14. </script>

写小项目这么引入还好,但等项目一臃肿起来...啧啧。 这里是借助 webpack,使用 require.context()方法来创建自己的模块上下文,从而实现自动动态 require组件。

这个方法需要3个参数:

  • 要搜索的文件夹目录

  • 是否还应该搜索它的子目录

  • 一个匹配文件的正则表达式。

「Vue实战」武装你的前端项目

在你放基础组件的文件夹根目录下新建 componentRegister.js:

 
   
   
 
  1. import Vue from 'vue'

  2. /**

  3. * 首字母大写

  4. * @param str 字符串

  5. * @example heheHaha

  6. * @return {string} HeheHaha

  7. */

  8. function capitalizeFirstLetter (str) {

  9. return str.charAt(0).toUpperCase() + str.slice(1)

  10. }

  11. /**

  12. * 对符合'xx/xx.vue'组件格式的组件取组件名

  13. * @param str fileName

  14. * @example abc/bcd/def/basicTable.vue

  15. * @return {string} BasicTable

  16. */

  17. function validateFileName (str) {

  18. return /^S+.vue$/.test(str) &&

  19. str.replace(/^S+/(w+).vue$/, (rs, $1) => capitalizeFirstLetter($1))

  20. }

  21. const requireComponent = require.context('./', true, /.vue$/)

  22. // 找到组件文件夹下以.vue命名的文件,如果文件名为index,那么取组件中的name作为注册的组件名

  23. requireComponent.keys().forEach(filePath => {

  24. const componentConfig = requireComponent(filePath)

  25. const fileName = validateFileName(filePath)

  26. const componentName = fileName.toLowerCase() === 'index'

  27. ? capitalizeFirstLetter(componentConfig.default.name)

  28. : fileName

  29. Vue.component(componentName, componentConfig.default || componentConfig)

  30. })

最后我们在 main.js

 
   
   
 
  1. import 'components/componentRegister.js'

我们就可以随时随地使用这些基础组件,无需手动引入了。

3. 页面性能调试:Hiper

我们写单页面应用,想看页面修改后性能变更其实挺繁琐的。有时想知道是「正优化」还是「负优化」只能靠手动刷新查看 network。而 Hiper很好解决了这一痛点(其实 Hiper是后台静默运行 Chromium来实现无感调试)。

「Vue实战」武装你的前端项目

Hiper官方文档

我们开发完一个项目或者给一个项目做完性能优化以后,如何来衡量这个项目的性能是否达标?

我们的常见方式是在 DevTool中的 performance和 network中看数据,记录下几个关键的性能指标,然后刷新几次再看这些性能指标。

有时候我们发现,由于样本太少,受当前「网络」、「CPU」、「内存」的繁忙程度的影响很重,有时优化后的项目反而比优化前更慢。

如果有一个工具,一次性地请求N次网页,然后把各个性能指标取出来求平均值,我们就能非常准确地知道这个优化是「正优化」还是「负优化」。

并且,也可以做对比,拿到「具体优化了多少」的准确数据。这个工具就是为了解决这个痛点的。

安装

 
   
   
 
  1. sudo npm install hiper -g

  2. # 或者使用 yarn:

  3. # sudo yarn global add hiper

性能指标

Key Value
DNS查询耗时 domainLookupEnd - domainLookupStart
TCP连接耗时 connectEnd - connectStart
第一个Byte到达浏览器的用时 responseStart - requestStart
页面下载耗时 responseEnd - responseStart
DOM Ready之后又继续下载资源的耗时 domComplete - domInteractive
白屏时间 domInteractive - navigationStart
DOM Ready 耗时 domContentLoadedEventEnd - navigationStart
页面加载总耗时 loadEventEnd - navigationStart

「Vue实战」武装你的前端项目

用例

 
   
   
 
  1. # 当我们省略协议头时,默认会在url前添加`https://`


  2. # 最简单的用法

  3. hiper baidu.com

  4. # 如何url中含有任何参数,请使用双引号括起来

  5. hiper "baidu.com?a=1&b=2"

  6. # 加载指定页面100次

  7. hiper -n 100 "baidu.com?a=1&b=2"

  8. # 禁用缓存加载指定页面100次

  9. hiper -n 100 "baidu.com?a=1&b=2" --no-cache

  10. # 禁javascript加载指定页面100次

  11. hiper -n 100 "baidu.com?a=1&b=2" --no-javascript

  12. # 使用GUI形式加载指定页面100次

  13. hiper -n 100 "baidu.com?a=1&b=2" -H false

  14. # 使用指定useragent加载网页100次

  15. hiper -n 100 "baidu.com?a=1&b=2" -u "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (Khtml, like Gecko) Chrome/66.0.3359.181 Safari/537.36"

此外,还可以配置 Cookie访问

 
   
   
 
  1. module.exports = {

  2. ....

  3. cookies: [{

  4. name: 'token',

  5. value: process.env.authtoken,

  6. domain: 'example.com',

  7. path: '/',

  8. httpOnly: true

  9. }],

  10. ....

  11. }

 
   
   
 
  1. # 载入上述配置文件(假设配置文件在/home/下)

  2. hiper -c /home/config.json


  3. # 或者你也可以使用js文件作为配置文件

  4. hiper -c /home/config.js

4. Vue高阶组件封装

我们常用的 <transition>和 <keep-alive>就是一个高阶(抽象)组件。

 
   
   
 
  1. export default {

  2. name: 'keep-alive',

  3. abstract: true,

  4. ...

  5. }

所有的高阶(抽象)组件是通过定义 abstract选项来声明的。高阶(抽象)组件不渲染真实 DOM。 一个常规的抽象组件是这么写的:

 
   
   
 
  1. import { xxx } from 'xxx'

  2. const A = () => {

  3. .....

  4. }


  5. export default {

  6. name: 'xxx',

  7. abstract: true,

  8. props: ['...', '...'],

  9. // 生命周期钩子函数

  10. created () {

  11. ....

  12. },

  13. ....

  14. destroyed () {

  15. ....

  16. },

  17. render() {

  18. const vnode = this.$slots.default

  19. ....

  20. return vnode

  21. },

  22. })

4.1 防抖/节流 抽象组件

关于防抖和节流是啥就不赘述了。这里贴出组件代码:

改编自:Vue实现函数防抖组件

 
   
   
 
  1. const throttle = function(fn, wait=50, isDebounce, ctx) {

  2. let timer

  3. let lastCall = 0

  4. return function (...params) {

  5. if (isDebounce) {

  6. if (timer) clearTimeout(timer)

  7. timer = setTimeout(() => {

  8. fn.apply(ctx, params)

  9. }, wait)

  10. } else {

  11. const now = new Date().getTime()

  12. if (now - lastCall < wait) return

  13. lastCall = now

  14. fn.apply(ctx, params)

  15. }

  16. }

  17. }


  18. export default {

  19. name: 'Throttle',

  20. abstract: true,

  21. props: {

  22. time: Number,

  23. events: String,

  24. isDebounce: {

  25. type: Boolean,

  26. default: false

  27. },

  28. },

  29. created () {

  30. this.eventKeys = this.events.split(',')

  31. this.originMap = {}

  32. this.throttledMap = {}

  33. },

  34. render() {

  35. const vnode = this.$slots.default[0]

  36. this.eventKeys.forEach((key) => {

  37. const target = vnode.data.on[key]

  38. if (target === this.originMap[key] && this.throttleMap[key]) {

  39. vnode.data.on[key] = this.throttledMap[key]

  40. } else if (target) {

  41. this.originMap[key] = target

  42. this.throttledMap[key] = throttle(target, this.time, this.isDebounce, vnode)

  43. vnode.data.on[key] = this.throttledMap[key]

  44. }

  45. })

  46. return vnode

  47. },

  48. })

通过第三个参数 isDebounce来控制切换防抖节流。 最后在 main.js里引用:

 
   
   
 
  1. import Throttle from '../Throttle'

  2. Vue.component('Throttle', Throttle)

使用:

 
   
   
 
  1. <div id="app">

  2. <Throttle :time="1000" events="click">

  3. <button @click="onClick($event, 1)">click+1 {{val}}</button>

  4. </Throttle>

  5. <Throttle :time="1000" events="click" :isDebounce="true">

  6. <button @click="onAdd">click+3 {{val}}</button>

  7. </Throttle>

  8. <Throttle :time="3300" events="mouseleave" :isDebounce="true">

  9. <button @mouseleave.prevent="onAdd">click+3 {{val}}</button>

  10. </Throttle>

  11. </div>

使用:

 
   
   
 
  1. const app = new Vue({

  2. el: '#app',

  3. data () {

  4. return {

  5. val: 0

  6. }

  7. },

  8. methods: {

  9. onClick ($ev, val) {

  10. this.val += val

  11. },

  12. onAdd () {

  13. this.val += 3

  14. }

  15. }

  16. })

抽象组件是一个接替Mixin实现抽象组件公共功能的好方法,不会因为组件的使用而污染DOM(添加并不想要的div标签等)、可以包裹任意的单一子元素等等

至于用不用抽象组件,就见仁见智了。

5. 性能优化:eventBus封装

中央事件总线eventBus的实质就是创建一个vue实例,通过一个空的vue实例作为桥梁实现vue组件间的通信。它是实现非父子组件通信的一种解决方案。

而 eventBus实现也非常简单

 
   
   
 
  1. import Vue from 'Vue'

  2. export default new Vue

我们在使用中经常最容易忽视,又必然不能忘记的东西,那就是:清除事件总线 eventBus

不手动清除,它是一直会存在,这样当前执行时,会反复进入到接受数据的组件内操作获取数据,原本只执行一次的获取的操作将会有多次操作。本来只会触发并只执行一次,变成了多次,这个问题就非常严重。

当不断进行操作几分钟后,页面就会卡顿,并占用大量内存。

所以一般在vue生命周期 beforeDestroy或者 destroyed中,需要用vue实例的 $off方法清除 eventBus

 
   
   
 
  1. beforeDestroy(){

  2. bus.$off('click')

  3. }

可当你有多个 eventBus时,就需要重复性劳动 $off销毁这件事儿。 这时候封装一个 eventBus就是更佳的解决方案。

5.1 拥有生命周期的 eventBus

我们从Vue.init中可以得知:

 
   
   
 
  1. Vue.prototype._init = function (options?: Object) {

  2. const vm: Component = this

  3. // a uid vm实例唯一标识

  4. vm._uid = uid++

  5. // ....

  6. }


每个Vue实例有自己的 _uid作为唯一标识,因此我们让 EventBus和_uid`关联起来,并将其改造:

实现来自:让在Vue中使用的EventBus也有生命周期

 
   
   
 
  1. class EventBus {

  2. constructor (vue) {

  3. if (!this.handles) {

  4. Object.defineProperty(this, 'handles', {

  5. value: {},

  6. enumerable: false

  7. })

  8. }

  9. this.Vue = vue

  10. // _uid和EventName的映射

  11. this.eventMapUid = {}

  12. }

  13. setEventMapUid (uid, eventName) {

  14. if (!this.eventMapUid[uid]) this.eventMapUid[uid] = []

  15. this.eventMapUid[uid].push(eventName) // 把每个_uid订阅的事件名字push到各自uid所属的数组里

  16. }

  17. $on (eventName, callback, vm) {

  18. // vm是在组件内部使用时组件当前的this用于取_uid

  19. if (!this.handles[eventName]) this.handles[eventName] = []

  20. this.handles[eventName].push(callback)

  21. if (vm instanceof this.Vue) this.setEventMapUid(vm._uid, eventName)

  22. }

  23. $emit () {

  24. let args = [...arguments]

  25. let eventName = args[0]

  26. let params = args.slice(1)

  27. if (this.handles[eventName]) {

  28. let len = this.handles[eventName].length

  29. for (let i = 0; i < len; i++) {

  30. this.handles[eventName][i](...params)

  31. }

  32. }

  33. }

  34. $offVmEvent (uid) {

  35. let currentEvents = this.eventMapUid[uid] || []

  36. currentEvents.forEach(event => {

  37. this.$off(event)

  38. })

  39. }

  40. $off (eventName) {

  41. delete this.handles[eventName]

  42. }

  43. }

  44. // 写成Vue插件形式,直接引入然后Vue.use($EventBus)进行使用

  45. let $EventBus = {}


  46. $EventBus.install = (Vue, option) => {

  47. Vue.prototype.$eventBus = new EventBus(Vue)

  48. Vue.mixin({

  49. beforeDestroy () {

  50. // 拦截beforeDestroy钩子自动销毁自身所有订阅的事件

  51. this.$eventBus.$offVmEvent(this._uid)

  52. }

  53. })

  54. }


  55. export default $EventBus

使用:

 
   
   
 
  1. // main.js中

  2. ...

  3. import EventBus from './eventBus.js'

  4. Vue.use(EnemtBus)

  5. ...

组件中使用:

 
   
   
 
  1. created () {

  2. let text = Array(1000000).fill('xxx').join(',')

  3. this.$eventBus.$on('home-on', (...args) => {

  4. console.log('home $on====>>>', ...args)

  5. this.text = text

  6. }, this) // 注意第三个参数需要传当前组件的this,如果不传则需要手动销毁

  7. },

  8. mounted () {

  9. setTimeout(() => {

  10. this.$eventBus.$emit('home-on', '这是home $emit参数', 'ee')

  11. }, 1000)

  12. },

  13. beforeDestroy () {

  14. // 这里就不需要手动的off销毁eventBus订阅的事件了

  15. }


6. webpack插件:真香

6.1 取代 uglifyjs 的 TerserPlugin

在二月初项目升级Vue-cli3时遇到了一个问题: uglifyjs不再支持webpack4.0。找了一圈,在 Google搜索里查到 TerserPlugin这个插件。

「Vue实战」武装你的前端项目

我主要用到了其中这几个功能:

  • cache,启用文件缓存。

  • parallel,使用多进程并行来提高构建速度。

  • sourceMap,将错误消息位置映射到模块(储存着位置信息)。

  • drop_console,打包时剔除所有的 console语句

  • drop_debugger,打包时剔除所有的 debugger语句

作为一个管小组前端的懒B,很多时候写页面会遗留 console.log,影响性能。设置个 drop_console就非常香。以下配置亲测有效。

 
   
   
 
  1. const TerserPlugin = require('terser-webpack-plugin')

  2. ....

  3. new TerserPlugin({

  4. cache: true,

  5. parallel: true,

  6. sourceMap: true, // Must be set to true if using source-maps in production

  7. terserOptions: {

  8. compress: {

  9. drop_console: true,

  10. drop_debugger: true

  11. }

  12. }

  13. })

更多的配置请看Terser Plugin

6.2 双端开启 gzip

「Vue实战」武装你的前端项目

开启gzip压缩的好处是什么?

可以减小文件体积,传输速度更快。gzip是节省带宽和加快站点速度的有效方法。

  • 服务端发送数据时可以配置 Content-Encoding:gzip,用户说明数据的压缩方式

  • 客户端接受到数据后去检查对应字段的信息,就可以根据相应的格式去解码。

  • 客户端请求时,可以用 Accept-Encoding:gzip,用户说明接受哪些压缩方法。

6.2.1 Webpack开启 gzip

这里使用的插件为: CompressionWebpackPlugin

 
   
   
 
  1. const CompressionWebpackPlugin = require'compression-webpack-plugin'

  2. module.exports = {

  3. plugins”:[new CompressionWebpackPlugin]

  4. }

具体配置:

 
   
   
 
  1. const CompressionWebpackPlugin = require('compression-webpack-plugin');

  2. webpackConfig.plugins.push(

  3. new CompressionWebpackPlugin({

  4. asset: '[path].gz[query]',

  5. algorithm: 'gzip',

  6. test: new RegExp('\.(js|css)$'),

  7. // 只处理大于xx字节 的文件,默认:0

  8. threshold: 10240,

  9. // 示例:一个1024b大小的文件,压缩后大小为768b,minRatio : 0.75

  10. minRatio: 0.8 // 默认: 0.8

  11. // 是否删除源文件,默认: false

  12. deleteOriginalAssets: false

  13. })

  14. )

「Vue实战」武装你的前端项目

开启gzip前

「Vue实战」武装你的前端项目

开启gzip后

gzip后的大小从277KB到只有~91.2KB!


6.2.2 扩展知识: nginx的 gzip设置

打开 /etc/nginx/conf.d编写以下配置。

 
   
   
 
  1. server {

  2. gzip on;

  3. gzip_static on;

  4. gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;

  5. gzip_proxied any;

  6. gzip_vary on;

  7. gzip_comp_level 6;

  8. gzip_buffers 16 8k;

  9. gzip_http_version 1.1;

  10. ...

  11. }

Nginx尝试查找并发送文件 /path/to/bundle.js.gz。如果该文件不存在,或者客户端不支持 gzip,Nginx则会发送该文件的未压缩版本。

保存配置后,重新启动 Nginx:

 
   
   
 
  1. $ sudo service nginx restart

「Vue实战」武装你的前端项目

开启gzip前


「Vue实战」武装你的前端项目

开启gzip后

6.2.3 如何验证gzip

通过使用 curl测试每个资源的请求响应,并检查 Content-Encoding

「Vue实战」武装你的前端项目

显示 Content-Encoding:gzip,即为配置成功。

求一份深圳的内推

目前本人在(又)准备跳槽,希望各位大佬和HR小姐姐可以内推一份靠谱的深圳前端岗位! 996.ICU 就算了。

「Vue实战」武装你的前端项目

  • 微信: huab119

  • 邮箱: 454274033@qq.com

作者掘金文章总集

  • 「从源码中学习」面试官都不知道的Vue题目答案

  • 「从源码中学习」Vue源码中的JS骚操作

  • 「从源码中学习」彻底理解Vue选项Props

  • 「Vue实践」项目升级vue-cli3的正确姿势

  • 为何你始终理解不了JavaScript作用域链?

公众号

 


以上是关于「Vue实战」武装你的前端项目的主要内容,如果未能解决你的问题,请参考以下文章

Vue实战Vue开发中的的前端代码风格规范

Vue + Spring Boot 项目实战笔记

vue.js实战:如何构建你的第一个Vue.js组件

「免费开源」基于Vue和Quasar的前端SPA项目crudapi后台管理系统实战之EXCEL数据导入

前端 高级 (二十五)vue2.0项目实战一 配置简要说明代码简要说明Import/Export轮播和列表例子

EKS 训练营-vue 项目实战(16)