Vue最佳实践

记录我在使用 Vue 中发现的一些好的代码实践,希望能够保持更新。🤠

this 引用

在组件作用域内使用箭头函数可以保证 this 永远指向组件本身。

// bad
export default {
  data() {
    return {
      msg: 'hello'
    }
  },
  methods: {
    hello() {
      setTimeout(function() {
        console.log(this.msg) // this 指向 window
      })
    }
  }
}
// good
export default {
  data() {
    return {
      msg: 'hello'
    }
  },
  methods: {
    hello() {
      setTimeout(() => {
        console.log(this.msg) // this 指向组件
      })
    }
  }
}

属性绑定

绑定字符串不需要加冒号。

<!-- bad -->
<component :str="'hello'"></component>

<!-- good -->
<component str="hello"></component>

布尔属性省略值时默认为 true

<my-modal visible></my-modal>
<!--等价于-->
<!--<my-modal :visible="true"></my-modal>-->

绑定无参函数不需要加括号。

<!-- bad,括号多余 -->
<button @click="onClick()"></button>

<!-- good,隐式传递了 event 对象 -->
<button @click="onClick"></button>

只有一行代码的事件函数,可以直接写标签上。

<button @click="visible = true"></button>

双向绑定

表单组件一般都支持双向绑定,实际场景中表单组件值发生变化往往要在 POST or PUT 请求之后。如果直接在 v-model 绑定原始值往往会打破单向数据流。

使用计算属性的 get/set 方式可以解决这个问题。(也适用 .sync

export default {
  template: `
        <div>
            <input type="radio" v-model="nameVal" value="1">
            <input type="radio" v-model="nameVal" value="2">
        </div>`,
  data() {
    return {
      name: ''
    }
  },
  computed: {
    nameVal: {
      get() {
        return this.name
      },
      set(val) {
        this.edit(val)
      }
    }
  },
  methods: {
    edit(name) {
      this.$http.put('/name', { name }).then(data => {
        this.name = name
      })
    }
  },
  created() {
    this.$http.get('/name').then(data => {
      this.name = data.name
    })
  }
}

释放资源

善用 destory 释放原生事件、第三方组件、全局事件总线等。

import bus from 'event-bus'
import plugin from 'plugin'

export default {
  // ...
  created() {
    bus.$on('hello', this.hello) // 注册全局事件
    window.addEventListener('resize', this.onResize) // DOM 事件
    plugin.init() // 第三方组件初始化
  },
  destoryed() {
    bus.$off('hello', this.hello)
    window.removeEventListener('resize', this.onResize)
    plugin.destory()
  }
}

修饰符

Vue 内置了许多常用修饰符可以让你少写几行代码,提高开发效率。

<!-- 输入字符串转数字 -->
<input type="text" v-model.number="value">

<!-- 输入字符串去前后空格 -->
<input type="text" v-model.trim="value">

<!-- 监听鼠标按键,支持 left, right, middle -->
<button @click.left="onLeftClick">点击鼠标左键</button>
<button @click.right="onRightClick">点击鼠标右键</button>

<!-- 停止冒泡,阻止默认行为 -->
<button @click.stop.prevent="doThis"></button>

<!-- 键盘按下确认键,支持 keycode 和键别名 -->
<input @keyup.13="onEnter">

<!-- 只执行一次事件 -->
<button @click.once="doThis"></button>

<!-- 监听原生事件 -->
<el-button @click.native="doThis"></el-button>

以上是一些常用的修饰符,更多用法可以去文档上找找。

数据请求

切换路由请求数据时,一般都需要兼容两种视图打开方式:路由跳转和直接 URL 输入。

export default {
    watch: {
        $route() {
            this.fetchData()
        },
    },
    methods() {
        fetchData() {
            // 避免重复请求
            if(this.isLoading) return
            this.isLoading = true
            // 请求数据
            // ajax...
        }
    },
    created() {
        this.fetchData()
    }
}

路由跳转会触发 watch -> $route,如果是未创建的组件还会触发 create,直接 URL 只会触发 created 钩子。一般在两个位置都执行数据请求,再通过判断避免重复请求,还可以利用 isLoading 标记做加载动画。如果使用了 keep-alive 组件,还需要考虑 activated 钩子。

减少嵌套层级

组件即使未在 props 声明,也可以传递一些原生 DOM 属性。

<!-- bad -->
<div class="content-view">
    <router-view></router-view>
</div>

<!-- good -->
<router-view class="content-view"></router-view>

命名插槽中需要放置多个块时,可以利用 template 组件。

<!-- bad -->
<my-component>
    <div slot="hello">
        <div class="block1"></div>
        <div class="block2"></div>
    </div>
</my-component>

<!-- good -->
<my-component>
    <template slot="hello">
        <div class="block1"></div>
        <div class="block2"></div>
    </template>
</my-component>

不管是内置组件还是自己的组件,有时候不需要多一层包裹去添加样式,反而因此增加了嵌套层级。

过滤器

过滤器的最佳应用场景应该是值的转换,比如:Date 类型日期转字符串、货币、字符截断、markdown 等等。

// 按长度截断文字,补...,中文 = 2
const cnReg = /[\u4e00-\u9fa5]/
Vue.filter('ellipsis', (str, len = 10) => {
  let i = 0
  let j = 0
  let ret = ''
  const text = String(str).trim()
  const max = text.length
  while (j < max && i < len) {
    const c = text.charAt(j)
    ret += c
    j += 1
    i = cnReg.test(c) ? i + 2 : i + 1
  }
  return ret === text ? text : `${ret}...`
})

// 日期转相对时间
Vue.filter('calendar', value => moment(value).calendar())

也可以作一些业务数据区别展示。

Vue.filter('userRole', value => ['创建者', '管理员', '成员'][value])

Props

  • 布尔属性默认值为 false 可以省略
  • 数组最好声明默认值 [],保证数据请求成功前模版里的 v-for 不会出错
  • 对象也需要注意是否声明了默认值 {},避免模版中使用 obj.xx 报错
{
    props: {
        visible: Boolen, // 默认即为 false
        data: Array,     // 需要进行非空判断
        data2: {         // 可安全使用 v-for
            type: Array,
            default: []
        },
        obj: Object,     // 需要进行非空判断
        obj2: {          // 可安全使用 obj.xx
            type: Object,
            default() {
                return {}
            }
        }
    }
}

v-if

如果模版中绑定了 obj.xx 时,需要注意 obj 是否是异步数据,默认值是否为 null。安全起见,可在组件最外层加 v-if 判断。

<template>
    <div v-if="!!obj">
        <p>{{obj.name}}</p>
        <p>{{obj.age}}</p>
    </div>
</template>
<script>
export default {
    data() {
        return {
            obj: null
        }
    }
}
</script>

路由

对于经常发生变化的一级、二级菜单导航,可以和路由数据结合起来,按模块划分,视图直接引用对应模块的路由数据来生成导航,减少维护成本。

// routes.js
export const settingRoutes = []
export const userRoutes = []

export default [...settingRoutes, ...userRoutes]

菜单组件中:

<template>
    <ul>
        <li v-for="item in menus" :key="item.name">
            <router-link :to="item">{{item.text}}</router-link>
        </li>
    </ul>
</template>
<script>
import { settingRoutes } from '../routes'

export default {
    data() {
        menus: settingRoutes
    }
}
</script>

继承和混合

用过ElementUI的同学,都知道其 Dialog 组件 是不支持垂直居中,只提供了一个top属性用于设置组件内容节点到顶部的距离。早期 1.x 版本时 Dialog 组件也不支持append-to-body。我们可以通过继承和混合来扩展这些需要的特性。

// dialogEx.js
import { Dialog } from 'element-ui'

export default {
  name: 'ElDialogEx',
  extends: Dialog,
  props: {
    appendToBody: {
      // 把组件插入 body 下
      type: Boolean,
      default: true
    },
    center: Boolean // 设置垂直居中
  },
  computed: {
    sizeClass() {
      // 这个 sizeClass 计算属性是组件源码里就有的,这里是利用了类名支持字符串拼接的特性,在这个函数里增加了垂直居中的自定义类拼接
      return `el-dialog--${this.size}` + this.center ? ' dialog-center ' : ''
    }
  },
  mounted() {
    if (this.appendToBody) document.body.appendChild(this.$el)
  },
  beforeDestroy() {
    if (this.appendToBody) this.$el.parentNode.remove(this.$el)
  }
}

之后你又发现,在其他的一些组件中也需要appendToBody这个特性,那么就可以把相关的代码写成mixins

// appendToBody.js
export default {
  props: {
    appendToBody: {
      // 把组件插入 body 下
      type: Boolean,
      default: true
    }
  },
  mounted() {
    if (this.appendToBody) document.body.appendChild(this.$el)
  },
  beforeDestroy() {
    if (this.appendToBody) this.$el.parentNode.remove(this.$el)
  }
}

现在dialogEx组件可以写的更简单。

// dialogEx.js
import { Dialog } from 'element-ui'
import appendToBody from 'mixins/appendToBody'

export default {
  name: 'ElDialogEx',
  extends: Dialog,
  mixins: [appendToBody],
  props: {
    center: Boolean // 设置垂直居中
  },
  computed: {
    sizeClass() {
      // 这个 sizeClass 计算属性是组件源码里就有的,这里是利用了类名支持字符串拼接的特性,在这个函数里增加了垂直居中的自定义类拼接
      return `el-dialog--${this.size}` + this.center ? ' dialog-center ' : ''
    }
  }
}

第三方库的集成

第三方库一般是传统的基于 DOM 和原生 js。它们虽然写起来没有使用任何的代码模版,但出于作者的编程经验其实都符合了大众使用预期。

任何一个库一般都会提供以下的接口:

  • 使用自定义配置初始化
  • 可访问的属性
  • 可调用的功能函数
  • 事件绑定
  • 良好的生命周期钩子

如果没有足够的编程经验用原生 js 去写一个插件可能最后就是一团乱麻。这也是 Vue 等众多前端框架的作用,它们约束了一个模块的代码模版,提供了事件管理、生命周期运行、属性和函数的定义,使即使经验不足的人也能写出一个看得过去的模块。

把第三方库转换为一个 Vue 组件,其实就是把这个库的接口挂到 Vue 组件对应的组件选项上去。

import Lib from 'lib'

export default {
  props: {
    options: Object
  },
  data() {
    return {
      instance: null
    }
  },
  methods: {
    doSomething(xxx) {
      // lib 的操作函数
      // 外部使用 $refs 调用
      this.instance.doSomething(xxx)
    }
  },
  computed: {
    libProp() {
      // lib 的可访问属性使用计算属性访问
      // 外部使用 $refs 调用
      return this.instance.prop
    }
  },
  watch: {
    options(val) {
      // 监听配置更新,调用 lib 接口更新配置
      if (val) this.instance.updateOptions(val)
    }
  },
  mounted() {
    // mounted 或者 created 对应 lib 实例化并传入自定义配置
    this.instance = new Lib(this.$el, this.options)
    // lib 内的事件 $emit 出去,外部监听
    this.instance.on('update', (...args) => {
      this.$emit('update', ...args)
    })
  },
  destroyed() {
    // lib 如果提供了 destroy 等销毁资源的函数一般都会对其内部的 DOM 事件解绑
    this.instance.destroy()
  }
}

也可能你想把一个库变为一个 Vue 指令。

import Lib from 'lib'

export default {
  install(Vue, option = {}) {
    // 存放全局配置
    const defaults = option

    Vue.directive('my-directive', {
      bind(el, { value }) {
        // 当前配置混合全局配置
        const options = Object.assign({}, defaults, value)
        const lib = new Lib(el, options)
        el._libInstace = lib // 缓存 lib 实例
      },
      update(el, { value }, vnode) {
        // 更新 lib 配置
        el._libInstace.setOptions(value)
      },
      unbind(el) {
        // 销毁 lib
        el._libInstace.destroy()
        delete el._libInstace
      }
    })
  }
}

指令有着完善的生命周期钩子,但在数据管理上偏弱。一般用于单一功能的集成,或者只需要一次初始化的插件。

指令中可通过 elel.dataset 进行生命周期间的数据共享。

Comment