Vue 概念篇
响应式原理
Vue 将遍历 data 选项所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter。这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 能够追踪依赖,在 property 被访问和修改时通知变更。 每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。
检测变化的注意事项
对于对象,Vue 无法检测 property 的添加或移除;对于数组,Vue 无法检测到直接修改数组项。 解决方法:
- 使用 Vue.set() 方法
- 对于数组,可以使用变异方法
异步更新队列
Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。
例如,当你设置 vm.someData = 'new value',该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环“tick”中更新。多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,这就可能会有些棘手。虽然 Vue.js 通常鼓励开发人员使用“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们必须要这么做。为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)。这样回调函数将在 DOM 更新完成后被调用。
计算属性vs侦听属性
虽然在某些使用方面可以通用,但是两者有明显的区别。
- computed 计算属性,当前属性通过另外几个属性计算出来,具有缓存性。
- watch 侦听属性,在当前属性变化时,主动执行异步或开销较大的操作。
指令
常用的指令,v-bind、v-on、v-model、v-for、v-if、v-show、v-html 特别注意:
v-if 和 v-show
v-if 是真正的条件渲染,它会确保在切换过程中条件块内的事件监听器和子组件适当地被销毁和重建;v-show 只是简单切换元素的 css 属性。 v-if 也是惰性的:如果在初始渲染时条件为假,一直到条件第一次变为真时,才会开始渲染条件块;v-show 不管初始条件是什么,元素总是会被渲染。 v-if 有更高的切换开销,而 v-show 有更高的初始渲染开销。因此,如果需要非常频繁地切换,则使用 v-show 较好;如果在运行时条件很少改变,则使用 v-if 较好。
v-for 和 v-if 优先级
当处于同一节点时,v-for 的优先级比 v-if 更高,这意味着 v-if 将分别重复运行于每个 v-for 循环中。在 vscode 编辑器中,如果在一个节点上同时使用 v-for 和 v-if,编辑器会报错,这对性能是不利的。 正确做法:
- 如果可以,将 v-if 提升到外层节点
- 先将列表数据过滤出来,使用数组 filter 方法返回需要的数据
事件修饰符
常用的事件修饰符
- .stop
- .prevent
- .captrue
- .self
- .enter
- .esc
- .number: v-model 专用
- .trim: v-model 专用
在组件上使用 v-model
在组件上使用 v-model,组件内的 <input>(可能是别的表单标签) 必须:
- 将其
valueattribute 绑定到一个名叫value的 prop 上 - 在其
input事件被触发时,将新的值通过自定义的input事件抛出
.sync 修饰符
在有些情况下,我们可能需要对一个 prop 进行“双向绑定”。不幸的是,真正的双向绑定会带来维护上的问题,因为子组件可以变更父组件,且在父组件和子组件都没有明显的变更来源。 推荐以 update:myPropName 的模式触发事件取而代之。例如,在一个包含 title prop 的假设的组件中,我们可以用以下方法表达对其赋新值的意图:
this.$emit('update:title', newTitle)
然后父组件可以监听那个事件并根据需要更新一个本地的数据 property。例如:
<text-document
v-bind:title="doc.title"
v-on:update:title="doc.title = $event"
></text-document>
为了方便,这种模式提供一个缩写,即 .sync 修饰符:
<text-document v-bind:title.sync="doc.title"></text-document>
缓存组件 keep-alive
keep-alive 主要是为了保持组件的状态,以避免反复重渲染导致的性能问题。使用 keep-alive 包裹后的组件,会多出 activated、deactivated 两个生命周期。
动态组件 component
渲染一个“元组件”为动态组件。依 is 的值,来决定哪个组件被渲染。
<!-- 动态组件由 vm 实例的 `componentName` property 控制 -->
<component :is="componentName"></component>
异步组件
在大型应用中,我们可能需要将应用分割成小一些的代码块,并且只在需要的时候才从服务器加载一个模块。我们可以使用工厂函数定义组件,这个工厂函数会异步解析组件定义。Vue 只有在这个组件需要被渲染的时候才会触发该工厂函数,且会把结果缓存起来供未来重渲染。 老语法:
Vue.component('async-webpack-example', function (resolve) {
// 这个特殊的 `require` 语法将会告诉 webpack
// 自动将你的构建代码切割成多个包,这些包会通过 Ajax 请求加载
require(['./my-async-component'], resolve)
})
webpack2+ 新语法:
Vue.component(
'async-webpack-example',
// 这个动态导入会返回一个 `Promise` 对象。
() => import('./my-async-component')
)
开发实战篇
跨域问题
开发中首要问题,解决跨域。主流方案一般两种: CORS 和 代理
CORS
由后端配置,前端不用做任何配置,正常调用接口即可。 node express 配置示例:
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Methods', 'OPTIONS,GET,PUT,POST,DELETE,PATCH')
res.header('Access-Control-Allow-Headers', 'Content-Type,X-Requested-With,Authorization')
next()
})
代理
后端不用处理,由前端进行配置。
// vue.config.js
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
pathRewrite: {
'^/api': '/'
}
}
}
}
}
// axios 封装文件,request.js
const instance = axios.create({
baseURL: '/api'
})
以上只是针对于开发环境的配置,如果是生产环境,静态服务器也需要配置。 nginx 示例:
# 对于后端接口没有统一前缀的情况
location /api/ {
proxy_pass http://localhost:3000/;
}
# 对于后端接口有统一前缀的情况
location /api {
proxy_pass http://localhost:3000/api;
}
路由参数解耦
在组件中使用 $route 接收参数会使之与其对应路由形成高度耦合,从而使组件只能在某些特定的 URL 上使用,限制了其灵活性。
$route 接收参数:
const User = {
template: '<div>User {{ $route.params.id }}</div>'
}
const router = new VueRouter({
routes: [{ path: '/user/:id', component: User }]
})
通过 props 解耦:
const User = {
props: ['id'],
template: '<div>User {{ id }}</div>'
}
const router = new VueRouter({
routes: [
// props 支持布尔值,对象,函数多种方式
{ path: '/user/:id', component: User, props: true },
// 对于包含命名视图的路由,你必须分别为每个命名视图添加 `props` 选项:
{
path: '/user/:id',
components: { default: User, sidebar: Sidebar },
props: { default: true, sidebar: false }
}
]
})
组件的自动化全局注册
大量的引用和注册全局组件太麻烦,可以通过 webpack 的 require.context 来自动导入。
import Vue from 'vue'
import upperFirst from 'lodash/upperFirst'
import camelCase from 'lodash/camelCase'
const requireComponent = require.context(
// 其组件目录的路径
'./components',
// 是否查询其子目录
false,
// 匹配基础组件文件名的正则表达式
/\w+\.(vue|js)$/
)
requireComponent.keys().forEach(fileName => {
// 获取组件配置
const componentConfig = requireComponent(fileName)
// 获取组件的 PascalCase 命名
const componentName = upperFirst(
camelCase(
// 获取和目录深度无关的文件名
fileName
.split('/')
.pop()
.replace(/\.\w+$/, '')
)
)
// 全局注册组件
Vue.component(
// 加上统一前缀,表明是全局组件
'Com' + componentName,
// 如果这个组件选项是通过 `export default` 导出的,
// 那么就会优先使用 `.default`,
// 否则回退到使用模块的根。
componentConfig.default || componentConfig
)
})
绑定 key 值
在使用 v-for 进行列表渲染时,需要同时加上 key 值,Vue 会复用 key 值相同的标签,以提高渲染效率。
不同路由使用同一个组件,Vue 会复用这个组件,在路由跳转时,不会重新渲染组件。如果需要触发重新渲染,可以在 router-view 上加上不同的 key,可以是路由路径 :key="$route.path",这样每次路由跳转组件都会重新渲染。
样式穿透(深度作用选择器)
当 <style> 标签有 scoped 属性时,它的 CSS 只作用于当前组件中的元素。当我们使用 scoped 时,样式只会作用到当前组件,如果要修改当前组件的子组件的样式,那么就要用到样式穿透。
使用 >>> 操作符:
<style scoped>
.a >>> .b { /* ... */ }
</style>
将会编译成:
.a[data-v-587b5356] .b { /* ... */ }
sass 预处理器无法正确解析 >>> 操作符,会抛出编译错误。可以使用 /deep/ 或 ::v-deep 操作符代替,两者都是 >>> 的别名,都可以正常工作。
推荐使用 ::v-deep , /deep/ 已经是废弃的标准,不建议使用了
程序化的事件侦听器
Vue 实例在其事件接口中提供了一系列方法:
$on(eventName, eventHandler)侦听一个事件$once(eventName, eventHandler)一次性侦听一个事件$off(eventName, eventHandler)停止侦听一个事件
一般不会用到这些,但是如果需要在一个组件实例上手动侦听事件时,它们是派得上用场的。
例如,经常使用第三方插件的代码可能是:
// 一次性将这个日期选择器附加到一个输入框上
// 它会被挂载到 DOM 上。
mounted() {
// Pikaday 是一个第三方日期选择器的库
this.picker = new Pikaday({
field: this.$refs.input,
format: 'YYYY-MM-DD'
})
},
// 在组件被销毁之前,也销毁这个日期选择器。
beforeDestroy() {
this.picker.destroy()
}
上面代码是一般插件的使用模式,存在两个潜在问题:
- 需要在组件实例中保存
picker,如果可以的话最好只有生命周期钩子可以访问到它。 - 我们的建立代码独立于我们的清理代码,这使得我们比较难于程序化地清理我们建立的所有东西。
可以通过一个程序化的侦听器解决这两个问题:
mounted() {
const picker = new Pikaday({
field: this.$refs.input,
format: 'YYYY-MM-DD'
})
this.$once('hook:beforeDestroy', function () {
picker.destroy()
})
}
命令式调用组件
这种组件类似于 element-ui 的 Message 组件,它并不是在模板中写好 html 结构通过状态切换,而是在 js 里使用 Message.success() 这种命令式的方式调用。这种简单的提示类组件,通过命令式的方式来调用更方便。 使用 Vue.extend() 实现:
import Vue from 'vue'
import MessageComponent from './message'
// 构造子类
const MessageConstructor = Vue.extend(MessageComponent)
const Message = function(options) {
options = options || {}
if (typeof options === 'string') {
options = {
message: options
}
}
// 实例化组件
instance = new MessageConstructor({
data: options
})
// 设置 visible 状态,v-show将会显示组件
instance.visible = true
// 类似 document.createElement() 在内存中生成 dom
instance.$mount()
// 将真实 dom 插入到父节点中
document.body.appendChild(instance.$el)
return instance
}
// 添加快捷方法到 Message 函数上
['success', 'warning', 'info', 'error'].forEach(type => {
Message[type] = options => {
if (typeof options === 'string') {
options = {
message: options
}
}
options.type = type
return Message(options)
}
})
export default Message
代码层面优化
- 能用 computed 不用 watch
- v-for 和 v-if 不要同时使用
- prop 尽量声明完整,至少应该指明类型
- 当同一个属性在大量使用 v-if 时,考虑使用 render 函数或者 JSX
- 使用 mixin 复用公共逻辑
- 多个组件使用 v-if 切换时,考虑使用 动态组件,通过 :is 属性绑定组件名
- 当路由配置过多时,考虑按模块拆分路由
Vue 可用的渲染优化
- 合理使用 keep-alive,避免组件重新渲染
- 适当使用 v-once 渲染大量的静态内容
构建优化
- 开启 gzip 压缩
- 静态服务器配置缓存,配合文件 hash 值提高缓存利用率
- 合理使用 preload 和 prefetch 预加载,但会增加带宽,对于访问速度要求高的页面可以关闭
- bundle spliting,将一个大文件拆分成多个小文件,充分利用浏览器并行下载能力,提高下载速度
- 路由懒加载,按需加载,避免无用的加载
- 路由懒加载使用魔法注释(webpackChunkName),将一些体积小的文件合并到一个 chunk,避免小文件过多而导致过多的 http 请求
- UI 组件库和工具库的按需加载,babel-plugin-component/babel-plugin-import
- 图片压缩,image-webpack-loader
- moment 减少体积,配置webpack只保留 zh-cn 语言包 或者 使用 dayjs 代替
- icon 图标使用 svg 方案
- 使用频率少的公共方法或者第三方插件不要挂到 Vue.prototype 上,应该哪里用哪里引入
通用优化
- 节流防抖
- 图片懒加载
- 虚拟列表
- 减少异步请求,代码层面合理缓存数据
- 路由跳转取消上个路由还未完成的请求
- 耗时操作放入web worker
- cdn 加速
- 启用 http2, http3, 支持多路复用
