最佳实践
为列表渲染设置属性key
key
这个特殊属性主要用在Vue.js
的虚拟DOM算法中,在对比新旧虚拟节点时辨识虚拟节点。
我们在介绍虚拟DOM时提到,在更新子节点时,需要从旧虚拟节点列表中查找与新虚拟节点相同的节点进行更新。如果这个查找过程设置了属性key,那么查找速度会快很多。所以无论何时,建议大家尽可能地在使用v-for
时提供key
,除非遍历输出的DOM内容非常简单,或者是刻意依赖默认行为以获取性能上的提升。示例如下:
<div v-for="item in items" :key="item.id"></div>
在v-if
/v-if-else/v-else
中使用key
如果一组v-if+v-else
的元素类型相同,最好使用属性key
我们之前介绍了v-if
指令编译后是下面的样子:
(has)
? _c('li',[_v("if")])
: _c('li',[_v("else")])
所以当状态发生变化时,生成的虚拟节点既有可能是v-if
上的虚拟节点,也有可能是v-else
上的虚拟节点。
默认情况下, Vue.js
会尽可能高效地更新DOM,这意味着,当它在相同类型的元素之间切换时,会修补已存在的元素,而不是将旧的元素移除,然后在同一位置添加一个新元素。如果本不相同的元素被识别为相同,则会出现意料之外的副作用。
如果添加了属性key
,那么在比对虚拟DOM时,则会认为它们是两个不同的节点,于是会将旧元素移除并在相同的位置添加一个新元素,从而避免意料之外的副作用。
<div v-if="error" key="search-status">
错误:{{error}}
</div>
<div v-else key="search-results">
{{results}}
</div>
路由切换组件不变
在使用Vuejs开发项目时,最常遇到的一个典型问题就是,当页面切换到同一个路由但不同·参数的地址时,组件的生命周期钩子并不会重新触发。
例如,路由是下面这样的:
const routes = [
{
path: '/detail/:id',
name: 'detail',
component:Detail
}
]
当我们从路由/detail/1
切换到/detail/2
时,组件是不会发生任何变化的。
这是因为vue-router
会识别出两个路由使用的是同一个组件从而进行复用,并不会重新创建组件,因此组件的生命周期钩子自然也不会被触发
组件本质上是一个映射关系,所以先销毁再重建一个相同的组件会存在很大程度上的性能浪费,复用组件才是正确的选择。但是这也意味着组件的生命周期钩子不会再被调用。
我相信大家都遇到过这个场景,下面总结了3个方法来解决这个问题。
路由导航守卫
vue-router
提供了导航守卫beforeRouteUpdate
,该守卫在当前路由改变且组件被复用时调用,所以可以在组件内定义路由导航守卫来解决这个问题。
组件的生命周期钩子虽然不会重新触发,但是路由提供的beforeRouteUpdate
守卫可以被触发。因此,只需要把每次切换路由时需要执行的逻辑放到beforeRouteupdate
守卫中即可。例如,在beforeRouteUpdate
守卫中发送请求拉取数据,更新状态并重新渲染视图。这种方式是我最推荐的一种方式,在vue-router2.2
之后的版本可以使用。
观察$route
对象的变化
通过watch
可以监听到路由对象发生的变化,从而对路由变化作出响应。例如:
const User = {
template: '...',
watch: {
'$route' (to, from) {
// 对路由变化作出响应
}
}
}
这种方式也可以解决上述问题,但代价是组件内多了一个watch
,这会带来依赖追踪的内存开销。
如果最终选择使用watch
解决这个问题,那么在某些场景下我推荐在组件里只观察自己需要的query
,这样有利于减少不必要的请求
假设有这样一个场景,页面中有两部分内容,上面是个人的描述信息,下面一个带翻页的列表,这时假设路由中的参数是/user?id=4&page=1
时,说明用户ID
是4
,列表是第一页。
我们可以断定每次翻页时只需要发送列表的请求,而个人的描述信息只需要第一次进入组件时请求一次即可。当翻到第二页时,路由应该是这样的:/user?id=4&page=2
.可以看到,参数中的id
没有变,只有page
变了。所以为了避免发送多余的请求,应该这样去观察路由:
const User = {
template: '...',
watch: {
'$route.query.id' () {
// 请求个人描述信息
},
'$route.query.page' () {
// 请求列表
}
}
}
为router-view
组件添加属性key
这种做法非常取巧,非常“暴力”,但非常有效。它本质上是利用虚拟DOM在渲染时通过key
来对比两个节点是否相同的原理。通过给router-view
组件设置key
,可以使每次切换路由时的key
都不一样,让虚拟DOM认为router-view
组件是一个新节点,从而先销毁组件,然后再重新创建新组件。即使是相同的组件,但是如果url
变了, key
就变了, Vuejs
就会重新创建这个组件。
因为组件是新创建的,所以组件内的生命周期会重复触发。示例如下:
<router-view :key="$route.fullPath"></router-view>
这种方式的坏处很明显,每次切换路由组件时都会被销毁并且重新创建,非常浪费性能。其优点更明显,简单粗暴,改动小。为router-view
组件设置了key
之后,立刻就可以看到问题被解决了。
为所有路由统一添加query
如果路由上的query
中有一些是从上游链路上传下来的,那么需要在应用的任何路由中携带,但是在所有跳转路由的地方都设置一遍会非常麻烦。例如,在应用中的所有路由上都加上参数: https://berwin.me/a?referer-hao360cn
和https://berwin.me/b?referer-hao360cn
。
理想状态是,在全局统一配置一个基础的query
,它会在应用的所有路由中携带,并且不影响应用中各个路由的切换,也无须在切换路由时进行任何特殊处理遗憾的是, vue-router
并没有提供相应的API
来处理这种情况。下面提供了两种方式来解决这个问题。
使用全局守卫beforeEach
事实上,全局守卫beforeEach
并不具备修改query
的能力,但可以在其中使用next
方法来中断当前导航,并切换到新导航,添加一些新query
进去。
当然,单单这样做会出问题,因为在进入新导航后,依然会被全局守卫beforeEach
拦截,然后再次开启新导航,从而导致无限循环。解决办法是在beforeEach
中判断这个全局添加的参数在路由对象中是否存在,如果存在,则不开启新导航:
const query = {referer: 'vodjkcom'}
router.beforeEach((to, from, next) => {
to.query.referer
? next()
: next({...to, query: {...to.query, ...query}})
})
这种方式的优点是,可以全局统一配置公共的query参数,并且在组件内切换路由时无须进行特殊处理。缺点是每次切换路由时,全局守卫beforeEach
会执行两次,即每次切换路由其实是切换两次。
下面的这种方法完美解决了这个问题
使用函数劫持
这种方式的原理是:通过拦截router.history.transitionTo
方法,在vue-router
内部,在切换路由之前将参数添加到query
中。其使用方式如下:
const query = {referer: 'vodjkcom'}
const transitionTo = router.history.transitionTo;
router.history.transitionTo = function (local, onComplete, onAbort) {
location = typeof location === 'object'
? {...location, query: {...location.query, ...query}}
: {path: location, query}
transitionTo.call(router.history, location, onComplete, onAbort)
}
代码中,先将vue-router
内部的router.history. transitionTo
方法缓存到变量transitionTo
中。随后使用一个新的函数重写router.history. transitionTo
方法,通过在函数中修改参数来达到全局添加query
参数的目的。当执行缓存的原始方法时,将修改后的参数传递进去即可。
这种方式的优点是可以全局添加query
参数并且不会导致路由切换两次。缺点是通过修改vue-router
内部方法实现目的,这是一种很危险的操作。
区分Vuex
与props
的使用边界
通常,在项目开发中,业务组件会使用Vuex
维护状态,使用不同组件统一操作Vuex
中的状态。这样不论是父子组件间的通信还是兄弟组件间的通信,都很容易。
对于通用组件,我会使用props
以及事件进行父子组件间的通信(通用组件不需要兄弟组件间的通信),这样做是因为通用组件会拿到各个业务组件中使用,它要与业务解耦,所以需要使用props
获取状态。
通用组件要定义细致的prop
,并且尽可能详细,至少需要指定其类型。这样做的好处是:
- 写明了组件的API,所以很容易看懂组件的用法;
- 在开发环境下,如果向一个组件提供格式不正确的
prop
,Vuejs
将会在控制台发出警告,帮助我们捕获潜在的错误来源。
避免v-if
和v-for
一起使用
Vue.js
官方强烈建议不要把v-if和v-for同时用在同一个元素上。
通常,我们在下面两种常见的情况下,会倾向于不同的做法。
- 为了过滤一个列表中的项目(比如
v-for="user in users"v-if="user.isActive"
),请将users替换为一个计算属性(比如activeUsers
) ,让它返回过滤后的列表。 - 为了避免渲染本应该被隐藏的列表(比如
v-for="user in users" v-if="shouldShowUsers"
),请将v-if移动至容器元素上(比如u1和o1)。
对于第一种情况,Vuejs
官方给出的解释是:当Vuejs
处理指令时,v-for
比v-if
具有更高的优先级,所以即使我们只渲染出列表中的一小部分元素,也得在每次重渲染的时候遍历整个列表,而不考虑活跃用户是否发生了变化。通过将列表更换为在一个计算属性上遍历并过滤掉不需要渲染的数据,我们将会获得如下好处, - 过滤后的列表只会在数组发生相关变化时才被重新运算,过滤更高效。
- 使用
v-for=-"user in activeUsers"
之后,我们在渲染时只遍历活跃用户,渲染更高效。 - 解藕渲染层的逻辑,可维护性(对逻辑的更改和扩展)更强。
例如下面的模板
<ul>
<li
v-for="user in users"
v-if="user.isActive"
:key="user.id"
>
{{user.name}}
</li>
</ul>
可以更换为在如下一个计算属性上遍历并过滤属性:
computed: {
activeUser: function () {
return this.users.filter(function (user) {
return user.isActive
})
}
}
模板改为:
<ul>
<li
v-for="user in activeUser"
:key="user.id"
>
{{user.name}}
</li>
</ul>
对于第二种情况,官方的解释是为了获取同样的好处,可以把:
<ul>
<li
v-for="user in users"
:key="user.id"
v-if="shouldShowUsers"
>
{{user.name}}
</li>
</ul>
更新为:
<ul v-if="shouldShowUsers">
<li
v-for="user in users"
:key="user.id"
>
{{user.name}}
</li>
</ul>
通过将v-if
移动到容器元素,我们不会再检查每个用户的shouldShowUsers
,取而代之的是,我们只检查它一次,且不会在shouldShowUsers
为false
的时候运算v-for
.
为组件样式设置作用域
CSS的规则都是全局的,任何一个组件的样式规则都对整个页面有效。因此,我们很容易在一个组件中写了某个样式,而不小心影响了另一个组件的样式,或者自己的组件被第三方库的css影响了。
对于应用来说,最佳实践是只有顶级App组件和布局组件中的样式可以是全局的,其他所有组件都应该是有作用域的。
注意这条规则只在单文件组件下生效。
在Vue.js中,可以通过scoped
特性或CSS Modules
(一个基于class的类似BEM的策略来设置组件样式作用域。
对于组件库,我们应该更倾向于选用基于class
的策略而不是scoped
特性。因为基于class
的策略使覆写内部样式更容易,它使用容易理解的class
名称且没有太高的选择器优先级,不容易导致冲突。
避免在scoped
中使用元素选择器
在scoped
样式中,类选择器比元素选择器更好,因为大量使用元素选择器是很慢的。
为了给样式设置作用域, Vuejs会为元素添加一个独一无二的特性,例如data-v-f3f3eg9
。然后修改选择器,使得在匹配选择器的元素中,只有带这个特性的才会真正生效(比如button [data-v-f3f3eg9]
)
问题在于,大量的元素和特性组合的选择器(比如button[data-v-f3f3eg9]
)会比类和特性组合的选择器慢,所以应该尽可能选用类选择器。
避免隐性的父子组件通信
我们应该优先通过prop
和事件进行父子组件之间的通信,而不是使用this.$parent
或改变prop
.
一个理想的Vuejs
应用是"prop
向下传递,事件向上传递”。遵循这一约定会让你的组件· 更容易理解。然而,在一些边界情况下, prop
的变更或this.$parent
能够简化两个深度耦合的组件。
问题在于,这种做法在很多简单的场景下可能会更方便。但要注意,不要为了一时方便(少写代码)而牺牲数据流向的简洁性(易于理解)。
单文件组件如何命名
单文件组件的命名虽然不会影响代码的正常运转,但是一个良好的命名规范能够在绝大多数工程中改善可读性和开发体验。
单文件组件的文件名大小写
单文件组件的文件名应该始终是单词首字母大写(PascalCase
),或者始终是横线连接的(kebab-case
)。
单词首字母大写对于代码编辑器的自动补全最为友好,因为这会使JS(X)
和模板中引用组件的方式尽可能一致。然而,混用文件的命名方式有时候会导致文件系统对大小写不敏感的问题,这也横连接命名可取的原因。
基础组件名
应用特定样式和约定的基础组件(也就是展示类的、无逻辑的或无状态的组件)应该全部以一个特定的前缀开头,比如Base
、App
或V
。这些组件可以为你的应用奠定一致的基础样式和行为。它们可能只包括:
- HTML元素
- 其他基础组件
- 第三方U1组件库
它们绝不会包括全局状态(比如来自Vuex store
).
它们的名字通常包含所包裹元素的名字(比如BaseButton
、BaseTable
),除非没有现成的对应功能的元素(比如Baselcon
),如果你为特定的上下文构建类似的组件,那么它们几乎总会消费这些组件(比如BaseButton
可能会用在ButtonSubmit
上)
这样做的几个好处如下。 - 当你在编辑器中以字母顺序排序时,应用的基础组件会全部列在一起,这样更容易识别。
- 因为组件名应该始终是多个单词,所以这样做可以避免你在包裹简单组件时随意选择前缀(比如
MyButton
和VueButton
) - 因为这些组件会被频繁使用,所以你可能想把它们放到全局而不是在各处分别导入它们。
单例组件名
只拥有单个活跃实例的组件以The
前缀命名,以示其唯一性。但这并不意味着组件只可用于一个单页面,而是每个页面只使用一次。这些组件永远不接受任何prop
,因为它们是为你的应用定制的,而不是应用中的上下文。如果你发现有必要添加prop
,就表明这实际上是一个可复用的组件,只是目前在每个页面里只使用了一次。
自闭和组件
在单文件组件、字符串模板和JSX
中,没有内容的组件应该是自闭合的,但在DOM模板中永远不要这样做。
自闭合组件表示它们不仅没有内容,而且刻意没有内容,这就好像书上的一页白纸对比贴有,“本页有意留白”标签的白纸。而且没有额外的闭合标签,你的代码也更简洁。
不幸的是, HTML并不支持自闭合的自定义元素,只有官方的“空”元素。所以上述策略仅适用于,进入DOM之前Vuejs
的模板编译器能够触达的地方,然后再生成符合DOM规范的HTML,这也是不要在DOM模板中这样做的原因
<!-- 在单文件组件、字符串模板和JSX中 -->
<MyComponent>
<!-- 在DOM模板中 -->
<my-component></my-component>
props
名的大小写
在声明prop
的时候,其命名应该始终使用驼峰式命名规则,而在模板和JSX
中应该始终使用横线连接的方式。
这里我们遵循每个语言的约定,在JavaScript
中更多使用驼峰式命名规则,而在HTML
中则是横线连接的方式。
props: {
'greening-text': String
}
<WelcomeMessage greening-text='hi'>
多个特性的元素
多个特性的元素应该分多行撰写,每个特性一行。在JavaScript
中,用多行分隔对象的多个属性是很常见的最佳实践,因为这更易读。模板和JSX
值得我们做相同的考虑。
<img
src=""
alt=""
>
<MyComponent
foo="a"
bar="b"
baz="c"
/>
模板中简单的表达式
组件模板应该只包含简单的表达式,复杂的表达式则应该重构为计算属性或方法。
复杂的表达式会让模板变得不是那么声明式。我们应该尽量描述理应出现的是什么,而非如何计算那个值。而且计算属性和方法使得代码可以重用。
<!-- 在模板中 -->
{{normalizedFullName}}
// 复杂表达式已经移入一个计算属性
computed: {
normalizedFullName: function() {
return this.fullName.split(' ').map(function (word) {
return word[0].toUpperCase() + word.slice(1)
}).join(' ')
}
}
简单的计算属性
应该把复杂的计算属性分隔为尽可能多更简单的属性。简单、命名得当的计算属性具有以下特点。
- 易于测试
当每个计算属性都包含一个非常简单且很少依赖的表达式时,撰写测试以确保其正确工作会更加容易。 - 易于阅读
简化计算属性要求你为每一个值都起一个描述性的名称,即便它不可复用。这使得开发者更容易专注在代码上并搞清楚发生了什么。 - 更好地“拥抱变化”
任何能够命名的值都可能用在视图上。举个例子,我们可能打算展示一个信息,告诉用户他们存了多少钱;也可能打算计算税费,但是可能会分开展现,而不是作为总价的一部分。
较小的、专注的计算属性减少了信息使用时的假设性限制,所以需求变更时也不需要那么多重构了。
cpmputed: {
basePrice: function() {
return this.manufactureCose / (1 - this.profitMargin);
},
discount: function () {
return this.basePrice * (this.discountPercent || 0);
},
finalPrice: function () {
return this.basePrice - this.discount
}
}
指令缩写
指令缩写(用:
表示v-bind:
、@
表示v-on:
)要保持统一。
良好的代码顺序
代码顺序指的是组件/实例的选项的顺序、元素特性的顺序以及单文件组件的顶级元素的顺序。
组件/实力的选项的顺序
组件/实例的选项应该有统一的顺序。下面是Vuejs
官方推荐的组件选项默认顺序,它们被划分为几大类,从中能知道从插件里添加的新属性应该放到哪里
- 副作用(触发组件外的影响)
el
- 全局感知(要求组件以外的知识)
- name
- parent
- 组件类型(更改组件的类型)
- functional
- 模板修改器(改变模板的编译方式)
- delimiters
- comments
- 组合(向选项里合并属性)
- extends
- mixins
- 接口(组件的接口)
- inheritAttrs
- model
- props/propsData
- 本地状态(本地的响应式属性)
- data
- computed
- 事件
- watch
- 生命周期钩子(按照他们被调用的顺序)
- beforeCreate
- created
- beforeMount
- mounted
- beforeUpdate
- updated
- activated
- deactivated
- beforeDestroy
- destroyed
- 非响应式的属性(不依赖响应系统的实例安全)
- methods
- 渲染(组件输出的声明式描述)
- template/render
- renderError
元素特性的顺序
元素(包括组件)的特性应该有统一的顺序。下面是Vuejs官方为元素特性推荐的默认顺序,"它们被划分为几大类,从中也能知道新添加的自定义特性和指令应该放到哪里。
- 定义(提供组件的选项)
- is
- 列表渲染(创建多个变化的相同元素)
- v-for
- 条件渲染(元素是否渲染/显示)
- v-if
- v-else-if
- v-else
- v-show
- v-cloak
- 渲染方式
- v-pre
- v-once
- 全局感知(需要超越组件的知识)
- id
- 唯一的特性(需要唯一值的特性)
- ref
- key
- slot
- 双向绑定
- v-model
- 其他特性(所有普通的绑定或未绑定的特性)
- 事件(组件事件监听器)
- v-on
- 内容(覆写元素内容)
- v-html
- v-text
总结
最佳实践可以规避错误,同时大幅提升应用的性能。使用Vuejs开发项目的最佳实践,包括:
- 为列表渲染设置属性
key
- 在
v-if
/v-else-if
/v-else
中使用key
- 如何解决路由切换组件不变问题
- 如何为所有路由统一添加
query
- 区分
Vuex
与props
的使用边界 - 避免
v-if
和v-for
一起使用 - 为组件样式设置作用域
- 避免在
scoped
中使用元素选择器 - 避免隐性的父子组件通信
风格规范可以规避小纠结与反模式,同时能在绝大多数工程中改善可读性和开发体验,包括:
- 单文件组件如何命名
- 自闭和组件
prop
名的大小写- 多个特性的元素
- 模板中简单的表达式
- 简单的计算属性
- 指令缩写
- 良好的代码顺序