前言
Vue.js 的 API 比 React 稍多,某些 API 的功能有重叠的部分,在实际使用的时候,多少会令人造成困惑。遗憾的是,各种文章、博客理对这些 API 的介绍,都是单独挑出或者泛泛而谈(点名吐槽什么 Vue 传参的 X 种方式)。本文将从对比的角度出发,分析这些功能相似的 API 各自适合那些特定场景。
如果觉得文章太长,可以直接跳到【小结】部分。
props vs $attrs
同样是父组件传入的属性,区别在于: props 中声明的属性会被子组件捕获,并代理到组件实例上,未被捕获的属性会被放入 $attrs 中
export default {
props: {
uid: Number,
name: {
type: String,
default: 'Zhangsan'
},
}
}
props 捕获了 uid
name
,所以组件内可以通过 this.uid
this.name
访问。而 mobile
没有被翻牌,只好乖乖地排进 this.$attrs
里了。
在某些场景下,需要声明一个高阶组件,可以使用 v-bind="$attrs"
将当前组件未捕获的属性透传,比如定义一个 button 组件:
这样,我们就完成了一个除了 loading
以外其他属性和原生 button 一样的组件:
点击获得 100 块
data vs computed
data
直接意思是数据,确切地说是组件自身的状态,可以在组件内进行修改。而 computed
起到一个“归纳” 的作用,用来合并多个响应式数据,或者对响应式数据做一些逻辑计算,减少模板表达式的长度。
{
props: {
gradeAverage: Number, // 年级平均成绩
},
data () {
return {
students: [
{ name: 'xiaoming', score: 93 },
{ name: 'xiaohong', score: 96 },
{ name: 'zhang3', score: 77 },
{ name: 'li4', score: 89 },
{ name: 'wang5', score: 91 }
]
}
},
computed: {
// 计算平均分
average() {
const headCount = this.students.length;
if (!headCount) {
return 0;
}
const total = this.students.reduce((acc, student) => acc.score + student.score, 0);
return (total / headCount).toFixed(4)
},
// 计算与年级平均分差值
compareToGrade() {
const delta = this.average - this.gradeAverage;
const result = delta > 0 ? '高于' : '低于'
return `${result} 年级平均 ${Math.abs(delta) 分}`
}
}
}
如果 computed
属性同时提供 get / set
方法,这个属性也能被赋值:
{
data () {
return {
cash: 50
}
},
computed: {
yuan: {
get() {
return `${this.cash} 元`
},
// v 参数就是等号右边的值
set(v) {
this.cash = v
}
}
}
}
有了 set
方法,我们直接对 yuan
进行赋值操作,如 yuan = 168
,就能触发 yuan
的 set
方法,168
会作为参数传入。
watch 与 computed
告诉你们一个秘密:之所以放在一起说,是因为这两个 API 是兄弟关系,它们在源码中有相同的老爹 —— Watcher
,且看:
export default {
data() {
return {
params: {
name: ''
}
}
},
watch: {
'params.name' (newVal) {
if (newVal.length > 20) {
alert('名称不得超过 20 个字');
this.params.name = newVal.slice(20);
}
}
},
computed: {
inputName: {
get() {
return this.params.name;
},
set(newVal) {
if (newVal.length > 20) {
alert('名称不得超过 20 个字');
}
this.params.name = newVal.slice(0, 20);
}
}
}
}
当 vue 实例中的 params.name
改变,就会触发这个 watch 函数执行,当对inputName 进行赋值操作,如 this.inputName = 'xxx'
,就会触发 set 函数执行。
从语义上来说: watch 强调过程,当你的数据变更时可以用 watcher 处理的副作用。computed 强调的是结果,不管你用什么方法,只要返回值符合你的预期即可。
methods vs computed
(三英战 computed)
computed 与 method 不同的是,computed 会把计算结果缓存起来,当内部依赖改变并且直到下一次访问时,才会重新执行 get 函数。
来看例子:一个个人信息表单组件,初始化时从服务端获取数据,现在需要判断 params 的内容比较初始数据是否改过,使用 method
和 computed
两种方法实现:
{
data() {
return {
oldParams: null,
params: {
name: '',
age: '',
}
}
},
async created () {
const info = await fetch('/user/info');
Object.assign(this.params, info);
// 原版数据因为不需要响应式,所以将它冻结起来
oldParams = Object.freeze(info);
},
methods: {
hasChanged() {
if (!this.oldParams) {
return false;
}
return (
oldParams.name === params.name
&& oldParams.age === params.age
)
},
},
computed: {
computeChanged () {
if (!this.oldParams) {
return false;
}
return (
oldParams.name === params.name
&& oldParams.age === params.age
)
}
}
}
两种方式的代码一模一样,区别在于执行时机: 每次进行 hasChanged()
调用时 method 方法都会执行,这点没有疑问。而对于 computeChanged
:当 oldParams
、 oldParams.name
、oldParams.age
、oldParams
、oldParams
其中任何一项改变,computeChanged
会被标记为 dirty
,再次访问 computeChanged
,才会重新调用这个求值函数,写段代码:
export default {
created() {
this.computeChanged // computed 第一次调用
for (let i = 0; i < 100; i++) {
this.computedChanged // 依赖项没有改变,computed 不会再次调用
}
this.params.name = 'xxxx' // 依赖项改变, computed 标记为 dirty
this.computedChanged // 依赖项改变以后的求值, computed 更新调用
}
}
不得不说 computed 实乃响应式 API 的精髓,如果希望你的 template 代码变薄,请务必利用好 computed 。
methods vs filters
相对于 methods, filter 有以下几个特点:
- filters 只能在 template 中使用
- filters 相当于管道操作符(熟悉 shell 的程序员非常容易理解),可以将输入数据从左往右传递
- filters 是上下文无关的,无法在内部访问 this
- filters 支持全局注入,注入以后无需 import 代码就能在每个实例引用
当你的 value 需要多个函数转换时,可能会出现嵌套现象:
价格 {{ method1(method2(value || 0)) }}
filters 可以避免这个问题:
年龄: {{ value | filter1 | filter2 }}
watch 与 $watch
上文中的 watch
监听是声明式的选项,跟随组件创建和销毁。如果你需要随时撤销监听操作,可以调用组件实例上的 $watch
,它的返回值是一个撤销函数,调用即撤销:
export default {
data() {
return: {
params: {
name: '',
},
cancel: null
}
},
methods: {
startWatching() {
// 将取消函数赋给 data
this.cancel = this.$watch('params.name', ()=>{
// do sth
})
},
stopWatching() {
if (!this.cancel) {
return;
}
this.cancel();
}
}
}
另外,$watch
的第一个参数支持传入函数,以便实现更复杂的触发条件,比如:
this.$watch(
// 触发条件
()=> {
const { name } = this.params;
if (!name) {
return '名称不能为空'
}
if (name.length > 20) {
return '名称不能超过 20 个字'
}
},
// 触发回调
(message) => {
if (message) {
alert(message)
}
},
// 延迟执行
{
immediate: false
}
)
v-model vs .sync
二者都是模板的语法糖,其中 v-model 就是各路文章鼓吹的“双向绑定”,其实没那么玄乎,vue 在模板编译期会把这个指令拆成 value 和 change(或者 input) 事件罢了。由于单向数据流的关系,子组件不能直接修改 props ,需要通过发送事件实现,而 v-model / .sync 在模板层面帮你做了这层封装:
v-model
v-model 一般用于原生的 input 、 checkbox 、 select 表单作为“双向绑定”指令,当普通组件引用中出现 v-model 指令,会自动推导为 :value
和 @change
。利用这个特性,如果在组件内部拼凑出 value
属性和 change
事件,也可以完成“双向绑定”效果:
{{value}}
外层引用组件时,传入 v-model ,就可以像普通输入框一样使用了!
等价于:
myCount = v" />
在 2.2.0+ 版本,vue 支持指定 model 选项,自定义字段名和 event 事件名:
{{count}}
export default {
model: {
prop: 'count',
event: 'setCount'
},
props: {
count: Number
}
}
注意,一个组件只能定义一个 model,如果希望定义多个,请看下文的 .sync
修饰符:
.sync
同步属性属性修饰符,通过约定的事件格式,通知父组件同步更新,原理跟 v-model 相似。不同的是,事件名称要遵循 update:属性
格式:
{{count}}
外层使用:
等价于:
myCount = c" />
与 v-model 不同,.sync 可以修饰多个属性
render vs template
我们平时编写的 template 并不会马上转化为虚拟 DOM 节点,而是先编译成 render 函数。且看官方文档中 render
函数的引子:
Vue 推荐在绝大多数情况下使用模板来创建你的 HTML。然而在一些场景中,你真的需要 JavaScript 的完全编程的能力。这时你可以用渲染函数,它比模板更接近编译器。
说得再直白一点:render 函数是真正创建虚拟 DOM 的函数:
export default {
render (createElement) {
const { msg } = this;
return createElement('h3', { style: { color: '#66ccff' } }, `Hello ${msg}`)
}
}
最终生成:
Hello World
举个例子:如果提供这么一个 template
模板:
{{ msg }}
经过 Vue 的编译,它将变成:
function () {
with(this){
return createElement(
'div',
[
createElement('span', [ createTextVNode( toString(msg) ) ])
]
)
}
}
如文档所言,模板可以更直观地映射 HTML ,但某些特定场景下,直接使用 render
函数会比模板更灵活,官方文档中介绍了一种动态标题的示例,根据 level
属性决定渲染 h1
~ h6
标签,使用 render
函数可以这么写:
export default {
name: 'my-title'
props: {
level: {
type: Number
},
},
render(createElement) {
// 获取传入的属性、和插槽(子模板)
const { $attrs, level, $slot } = this;
const children = $slot.default;
// 标签名称 h1 ~ h6
const tag = `h${level}`;
return createElement(tag, $attrs, children)
}
}
使用:
这是 h1
这是 h2
这是 h3
试想一下:如果我们使用 template
语法来实现的话就要声明6个 v-if
模板,非常啰嗦。不过 render 函数也是一把双刃剑:使用 render 函数无法使用 v-model
这个模板专属的语法糖,同时也失去了静态模板优化的可能性(有得必有失)。
也许你已经发现: createElement
的思路与 React.createElement
一脉相承。既然可以通过 JSX 编写 ReactElement ,能否使用 JSX 编写 render 函数呢?答案是肯定的:根据官方文档介绍,通过这个 Babel 插件,就能在 Vue 文件中愉快地使用JSX了,详情移步官方文档
functional vs stateful
假如你的组件本身不需要响应式数据,所有的行为都受控父组件的输入,不妨对这个组件添加 functional
属性:
export default {
// 标记为函数式
functional: true,
name: 'my-button',
props: {
type: String,
style: [Object, String]
},
render (h, context) {
console.log(this); // 函数式组件没有 this,为 undefined
// 函数式组件的上下文 context
// 注意因为没有 this 所以上下文属性就不能叫做实例属性了,名称开头不带 $
const { props, slot, listeners } = context;
const { type = 'info', style } = props;
// 根据 type 决定背景颜色
const backgroundColor = {
danger: 'red',
success: 'green',
info: 'blue',
disabled: 'gray'
}[type];
return h(
'button',
{
'class': 'my-btn',
style: {
color: 'white',
outline: 'none',
backgroundColor,
...style,
},
on: listeners
},
slot.default
)
}
}
上面的代码是为了实现一个简单的样式 button , 因为添加了 functional: true
属性,这个组件就变成了一个函数式组件,与普通的组件(我称之为 stateful 组件)不同,它是无状态的,不能访问 this ,需要借助 context
对象,同时 data
、 computed
、 watch
等响应式 API 无法使用(只能响应父组件的)。但是它更为轻量,比 stateful 组件创建减少了不必要的开销。
另外,如果需要使用函数式模板,在 template 标签中添加 functional
属性即可,这里又偷懒直接用官方示例:
正常组件 template 被包裹在 this
中,而函数式组件就被包裹为 context
中,可以直接引用 context
上的属性。
$set vs assignment 赋值
export default {
data () {
params: {
name: ''
}
},
methods: {
setAge (age) {
if (!age) {
delete this.params.age;
return;
}
this.params.age = age;
}
}
}
由于 Object.defineProperty
的局限,直接对对象进行添加/删除属性操作是无法被监听的,需要换成 vm.$set
和 vm.$delete
:
setAge(age) {
if (!age) {
this.$delete(this.params, 'age');
return;
}
this.$set(this.params, 'age', age);
}
小结
-
props
中声明的属性会被捕获并挂在组件实例上,否则会挂进$attrs
中 - 使用
v-bind="$attrs"
可以实现属性透传 -
data
可以理解为组件中的响应式状态 -
watch
与computed
都是Watcher
的实例。watch
注重过程和副作用,computed
注重结果 -
computed
具有缓存响应式依赖的效果 -
filters
可以理解为模板的管道操作符,与上下文(组件实例)无关 -
$watch
是挂在组件实例上的方法,可以随时调用和撤销,支持复杂的触发条件 -
v-model
是value
与@change
的合体,组件中唯一 -
.sync
是约定化的父子组件通信,组件中可以定义多个 -
render
是真正生成虚拟 DOM 的方法,template
最终会被编译成render
函数。直接使用render
函数可以更灵活地处理组件表现逻辑。 -
functional
是无状态的组件 - 如果需要对响应式数据添加/删除属性,需要借助
$set/$delete
方法。