上一篇博文梳理了vue的数据驱动和响应式相关的特性,这一篇博文就来梳理vue的一个很重要的特性,组件化。
自定义组件之于vue,其意义不亚于函数之于C,java之类的编程语言。
函数是计算机科学中的一大重要的发明。
一方面,它代表着一种自顶向下,逐步求精的分而治之的思维,另外一方面,它能够封装复杂实现的细节,提供更高抽象的接口,降低软件工程的复杂度。
在vue中,自定义组件也起着类似的作用。
<!--more-->
我们知道,在组件化的GUI界面上,GUI可以被视为一棵树,浏览器的DOM就是一个最好的例子。
从布局上来看,界面可以看成大盒子套小盒子,小盒子再套更小的盒子。。。
反映到DOM上,DOM某节点的所有子节点,都是该组件的子组件,都是该组件内的元素。
在vue中也是如此,vue组件之间的关系也是类似DOM一样,是树状的。
在定义一个组件时,需要引用的所有组件,都成为了该组件的子组件。
组件通信
组件作为一个模块性质的东西,自然就有着它一定的独立性。而且,与其它模块的耦合都理所应当的有着明确的接口约定。
在vue中,父子组件通信通过组件属性和事件来进行的。
其中,通过组件属性,父组件的数据流向子组件;通过事件,子组件的数据流向父组件。
从抽象的角度看,组件作为一个黑盒子,它有着特定的属性用以接收外部传递给它的数据,它也有着特定的事件,当特定操作发生时调用回调函数,以通知别的组件。
组件的属性
自定义属性
<div id="x-child">
<span> {{ titleMessage }} <span>
</div>
<script>
Vue.component('x-child', {
template: '#x-child',
props: ['titleMessage']
});
</script>
<div id="x-parent">
<x-child title-message="A"></x-child>
<x-child title-message="B"></x-child>
<x-child :title-message="message"></x-child>
</div>
<script>
Vue.component('x-parent', {
template: '#x-parent',
data: function () {
return {
message: "C"
};
}
});
</script>
上面的例子中,定义了x-child自定义组件,并且在x-parent组件中引用它。
之前在介绍数据驱动的时候,我们知道,定义vue组件时,可以通过data定义组件内部的状态,它是组件数据的一部分。
除了data之外,prop(属性)也是组件数据的来源之一,父组件通过prop将自己的数据传递给子组件。
在定义组件时我们可以看到:
- 通过props定义组件能够接收的属性,甚至还能指定属性的默认值及类型,甚至还能编写任意的函数验证属性的合法性。明确的指定类似接口声明,增强可读性,降低debug难度。
- 属性和内部状态类似,都作为组件数据的一部分。区别在于,在vue设计上,属性是只读的,可以作为数据驱动视图,但是无法被改变。(我不太清楚vue有没有从语法强制要求这点,但是良好实践的vue组件是这样的)
在使用自定义组件时可以看到:
- 组件属性的灵活性特别强,你不仅能传递给它一个固定的数据,还能够使用vue的数据绑定语法把父组件内的数据通过prop传递给子组件。
当然,这也是响应式的。当父组件中该数据变化时,自然的,传递给子组件的数据也会变化。那么,子组件中绑定了该数据的视图部分也当然会被重新渲染以展现在浏览器上。 - 子组件中定义中的属性是驼峰写法,这是符合js编码规范的。然而,在引用子组件的地方, 属性应该要写成短横线分割式的写法。
这是因为,html是不区分大小写的,vue对此也很无奈。这是在实际编码中很任意犯的一个错误,需要注意。
子组件的独立性
vue中,属性被设计用于父组件传递数据给子组件的,如果子组件改变了属性,那么父组件不会受到任何影响。这在vue中被称为 单向数据流。
但是,如果属性用来传递数组或对象等复合的数据结构,那么可能会出问题。考虑以下的场景:
- 父组件把数据中的对象传递给了子组件的属性。
- 由于可能修改该属性,子组件把该属性直接赋值给内部状态,作为内部状态修改。
- 在某些操作触发后,该内部状态被修改。
问题在于,由于js是引用类型语言,简单的赋值仅仅是传递引用,那么,以上场景中,父组件中的数据,子组件中的属性,还有子组件中的状态, 指向的都是同一份对象 !
这会造成一个问题,如果子组件修改了该对象的属性,那么父组件的数据也会受到影响,这破坏了单向数据流,会造成很多诡异的bug。
解决方法也很显然:
- 方案一是父组件传递数组或对象给子组件时,使用深拷贝拷贝一份过去,或者子组件将属性赋值给内部状态时,深拷贝一份过去,这样就能够互不干扰。
- 方案二是使用不可变数据结构,每次修改都是产生新的拷贝,因此也能解决问题。
组件的事件
自定义事件
<div id="x-child">
<button @click="onClick">click</button>
</div>
<script>
Vue.component('x-child', {
template: '#x-child',
data: function() {
return {
counter: 0
};
},
methods: {
onClick: function() {
this.counter++;
this.$emit("on-counter-add", this.counter);
}
}
});
</script>
以上自定义了x-child组件,并且自定义了组件事件。我们可以看到:
- 使用vue组件实例的$emit方法,用以触发一个自定义事件。触发事件可以携带数据,这些数据被用于传递给绑定了事件的其它组件的回调函数上,进而被传递给其它组件。
- 不像属性,自定义事件没有一个统一声明的地方,至于为什么我也不清楚。。。得问vue作者去。
- 该自定义组件内部包含了一个按钮,按钮被点击事件触发了自定义组件的回调函数,进而触发了该自定义组件的自定义事件。
从一个角度来看,该自定义组件像是转发了原生组件的事件而已。但是从另外一个角度来看,该自定义组件封装了这些细节,对外展现的是一个点下按钮触发计数器增加的事件的这样一个计数器。 - 事件名称的定义用的短横线分割的写法,原因和属性类似。
<div id="x-parent">
<x-child @on-counter-add="onCounterAdd"></x-child>
<span> { { counter }} </span>
</div>
<script>
Vue.component('x-parent', {
template: '#x-parent',
data: function () {
return {
counter: 0
};
},
methods: {
onCounterAdd: function (counter) {
this.counter = counter;
}
}
});
</script>
以上定义了x-parent组件,并且引用了上面定义的子组件。可以看出:
- 子组件事件触发了父组件的回调函数,并且将数据从回调函数中传入。父组件可以在回调函数里做任何事情,颇有灵活性。
- 一般情况下,父组件会在回调函数中更新自己的状态数据。数据更新后触发新的视图渲染,用户即可在界面上看到了反馈。这样,通过事件,子组件的数据传递到了父组件中。
事件绑定的表达式写法
在监听事件的地方,上面的写法是使用了一个回调函数,不过,也可以使用js表达式,比如:
<x-child @on-counter-add="counter = arguments[0]"></x-child>
上面代码的重点在于arguments[0]
,如果是js表达式写法,使用arguments引用事件的参数,就好像这段js表达式被放入了一个vue提供的匿名函数,然后使用匿名函数监听这个事件一样。
那它有什么用呢?在上面的场景里这样写当然是不好的,因为削弱了可读性。
之前在我同事碰到的一个场景里,是一个涉及到插槽分发作用域的场景,如果写成回调函数的形式,那么在回调函数中无法访问插槽作用域的变量。
因此,必须使用js表达式的写法,将插槽作用域中的变量显式的带到回调函数中,代码类似这种,懒得构造具体的例子了 :
<x-child @on-counter-add="onCountAdd(arguments[0], scope.id)"></x-child>
双向绑定
由于vue设计的父子组件通信是单向数据流,但是由于一些需求的需要,如果能提供双向数据流,会使使用起来更方便。
便捷性和设计的统一性冲突,怎么办?当然是用语法糖解决了。
实际上,vue提供的两种好像是双向数据流的机制,.sync
和 v-model
,都是语法糖。
.sync修饰符
<comp :foo.sync="bar"></comp>
这种写法只是下面的语法糖:
<comp :foo="bar" @update:foo="val => bar = val"></comp>
子组件内,如果修改了foo时,需要触发update:foo
事件。
v-model
v-model常用于类似表单这样的自定义控件:
<my-checkbox v-model="foo"></my-checkbox>
它也是如下语法的语法糖:
<my-checkbox
:value="foo"
@input="val => foo = val" >
</my-checkbox>
插槽
仔细思考刚才的自定义组件的定义,不难发现,上面的自定义组件只能对DOM中的一棵子树做抽象和封装。
那么,考虑这样一种情况,我们封装了一个card组件,card的内容可以使用任意的vue组件填充。
这种场景,就需要在自定义组件时,能够在组件的DOM树里 挖个洞 ,这个洞能够让该组件的调用者填充。
vue提供的这种类似的机制,被称为插槽。
定义插槽
<div id="x-my-card">
<h2>我是子组件的标题
<slot name="title"></slot>
</h2>
<slot>
</slot>
</div>
<script>
Vue.component('x-my-card', {
template: '#x-my-card'
});
</script>
<div id="x-component">
<x-my-card>
<p>这是一些初始内容</p>
<p>这是更多的初始内容</p>
</x-my-card>
<x-my-card>
<h2 slot="title">标题</h2>
<p>这是一些初始内容</p>
<p>这是更多的初始内容</p>
</x-my-card>
</div>
<script>
Vue.component('x-component', {
template: '#x-component'
});
</script>
从上面的示例中可以看到:
- 在自定义组件时,使用
slot
标签给自定义组件留了一个“洞”。 - 在引用该自定义组件时,自定义组件标签内部的子元素会填补上这个洞,被渲染出来。
- 默认的插槽只能有一个。可以使用
slot
标签的name
属性定义插槽名称以区分不同的插槽,这样能够在自定义组件上挖多个”洞”。
数据传递
vue提供的插槽机制,在给自定义组件挖”洞”的同时,还能使自定义组件给洞里填充的组件传递数据。如下:
<div id="x-my-card">
<slot text="hello from child"></slot>
</div>
<div id="x-component">
<x-my-card>
<template slot-scope="scope">
<span>{{ scope.text }}</span>
</template>
</x-my-card>
</div>
从上面可以看出:
- 在定义
slot
时,可以通过属性将数据传递给它。在引用自定义组件的地方,将插槽内容放入template
标签内,通过slot-scope
指定变量名,即可在template
标签内引用该变量从而使用插槽传递过来的数据。 - 在实际使用中,一个典型的例子是,表格组件提供插槽自定义表格行的样式和布局,同时通过插槽将该表格行的数据传递给插槽内容。
最后
本篇博文梳理了vue的自定义组件机制,通过自定义组件,就能够在vue项目中很好的将项目组件化。
一方面,能够提取共同的组件进行复用,降低代码冗余;另外一方面,也能够提供一种强大的抽象机制,提高vue的表达能力。
注:该文于2018-04-10撰写于我的github静态页博客,现同步到我的segmentfault来。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。