Vue 插槽 & 高复用组件
# 前言
组件是Vue插槽中最为关键的一个特性之一,而组件的API分三大模块
- Props: 允许外环境给组件传递数据,
- Events:允许组件触发外部环境的副作用 和
- 插槽:允许内外环境作用于交叉,将额外的内容组合到组件中去。
正确理解了这三个,相信你能写出很优美的组件
本文主要描述
- 对插槽的理解
- 匿名插槽
- 具名插槽
- 作用域插槽
- 编写高复用组件的几点思路
# 为什么用插槽
组件的最大特性就是复用性,而用好插槽能大大提高组件的可复用能力。
组件的复用性常见情形如“在有相似功能的模块中,他们具有类似的UI界面,通过使用组件间的通信机制传递数据,从而达到一套代码渲染不同数据的效果”。
然而这种利用组件间通信的机制只能满足在结构上相同,渲染数据不同的情形;假设两个相似的页面,他们只在某一模块有不同的UI效果,以上办法就做不到了。可能你会想,使用 v-if
和 v-else
来特殊处理这两个功能模块,不就解决了?很优秀,解决了,但不完美。极端一点,假设我们有一百个这种页面,就需要写一百个v-if
、v-else-if
、v-else
来处理?那组件看起来将不再简小精致,维护起来也不容易。
而 插槽 “SLOT
”就可以完美解决这个问题
# 什么情况下使用插槽
顾名思义,插槽即往卡槽中插入一段功能块。还是举刚才的例子。如果有一百个基本相似,只有一个模块功能不同的页面,而我们只想写一个组件。可以将不同的那个模块单独处理成一个卡片,在需要使用的时候将对应的卡片插入到组件中即可实现对应的完整的功能页。而不是在组件中把所有的情形用if-else
罗列出来
可能你会想,那我把一个组件分割成一片片的插槽,需要什么拼接什么,岂不是只要一个组件就能完成所有的功能?思路上没错,但是需要明白的是,卡片是在父组件上代替子组件实现的功能,使用插槽无疑是在给父组件页面增加规模,如果全都使用拼装的方式,和不用组件又有什么区别。因此,插槽并不是用的越多越好。
插槽是组件最大化利用的一种手段,而不是替代组件的策略,当然也不能替代组件。如果能在组件中实现的模块,或者只需要使用一次v-else
, 或一次v-else-if
,v-else
就能解决的问题,都建议直接在组件中实现。
# 准备工作
使用插槽前,需要先了解什么是编译作用域, 即
父组件模板的内容在父组件的作用域内编译,子组件模板的内容在子组件的作用域内编译
什么意思?假设有如下案例
// 父组件
<template>
<p>{{ greet }}</p>
<child-component :data="myData">
{{ messages }}
</child-component>
</template>
// 组件child-component
<template>
<div>
<p>{{ myName }}</p>
<slot></slot>
</div>
</template>
在父组件作用域中参与编译的内容有:(1) 父组件P
标签的greet
。(2)【重点上】变量 message
; (3) 变量myData
;
在子组件中参与编译的内容有:(1)子组件 p
标签中的myName
。(2) 【重点下】子组件标签中的data
属性
需要强调的是,【重点上】中的存在于父组件编译作用于上的message
部分也就是插槽内容,是不能访问存在于子组件【重点下】中的data
属性的,如果需要访问这部分内容,需要使用到作用域插槽
功能
上面提到过一个观点:“卡片是在父组件上代替子组件实现的功能,使用插槽无疑是在给父组件页面增加规模”。从上面案例中也可以看出,子组件只提供了插槽<slot>
,而具体什么内容它并不管,都交给了父组件作用于中存在于<child-component>
包含的那部分内容去分发。这部分内容,就是我们所说的卡片
# 单个插槽 (匿名插槽)
在没有使用插槽前,我们在组件内部写入的内容都会被抛弃,原因很简单,在父组件渲染的时候,会使用子组件里的内容来替换它在父组件的占位。如果不想被丢弃,就需要在子组件中使用单个插槽来接收内容
单个插槽一般都是匿名的,当然也可以给他命名,命名的方式使用slot="slotName"
// 父组件中定义卡片
<div>
<h1>父组件</h1>
<child-component>
<p>卡片内容1</p>
<p>卡片内容2</p>
</child-component>
</div>
// child-component组件中使用slot接收
<div>
<h2>子组件</h2>
<slot>
插槽默认内容
</slot>
</div>
在案例中除了有卡片内容与插槽内容,我们还看到了在<slot>
中定义的一段话,它是插槽标签的默认内容,会在子组件编译作用域内编译,只有当宿主元素为空,且没有相应的插入内容时才显示。上面的案例我们可以得到如下结果:
// 渲染结果:
<div>
<h1>父组件</h1>
<div>
<h2>子组件</h2>
<p>卡片内容1</p>
<p>卡片内容2</p>
</div>
</div>
# 具名插槽
我们可以给插槽定义名字,使其成为具名插槽。在单个插槽中,会将父组件中所有的卡片(假设都没有命名)按其在父组件中定义的顺序都接收过来;
而具名插槽则是接收指定的卡片。这样,我们就可以在不同位置定义多个插槽,分别用来接收不同的卡片内容。也可以增加一个匿名插槽,用来接收父组件编译作用域中未被指定名称的卡片内容(剩余内容),从而达到卡片的最大化利用。
在父组件中,通过使用slot = "slotName"
来给卡片内容命名,如下案例中,我们将内容分成了两个卡片,一个卡片名为header
, 另一个为footer
。需要注意的是,包含slot
的标签元素也会被插入到卡槽中。如案例中的div
<div>
<child-component>
<div slot="header">
<h2>插槽标题</h2>
</div>
<div>没被命名的“剩余”内容一</div>
<div slot="footer">
<p>版权所有,翻版我也没办法</p>
</div>
<div>没有被命名的“剩余”内容二</div>
</child-component>
</div>
卡片我们设定好了,接下来设定接收的卡槽
// child-component 中的内容
<div>
<slot name="header"></slot>
<div>
<p>这里是组件实现页面相似的功能模块的地方</p>
</div>
// 定义默认的卡槽用来存放“剩余”内容
<slot></slot>
<slot name="footer"></slot>
</div>
# 作用域插槽
作用域插槽(Scope slot
)是Vue中很重要的一个特性,可以使组件更加的通用,复用性更高。但因为它存在父子作用域的交织关系,使得组件难以理解。
再次回到编译作作用域的内容,父组件模板的所有东西都会在父级作用域内编译;子组件模板的所有东西都会在子级作用域内编译。存在于父组件编译作用域内的内容不能访问子组件作用域中的变量,反之亦然。但是我们知道,插槽的内容其实是为了实现子组件的定制化设计,因此往往有部分信息需要子组件中的数据来控制或渲染,这时就需要知道子组件会传递一些什么信息过来。
既然需要根据子组件的信息来决定卡片的内容,我们需要将子组件中整个待定制模块的内容寄托给一个slot
,让它在父组件卡片上实现,然后将这个slot
所需要的数据也传递出去。此时出现了一个待解决问题就是:需要将子组件编译作用域中的数据让其在父组件作用域中生效。Vue的作用域插槽就是来解决这个问题的。
v2.1.0 版本使用(且必须用) template 对卡片内容进行统一包装,并使用
slot-scope
(以前使用scope
)属性来接收子组件传出的数据
为了更好的对比,回顾一下常规的<todo-list>
如下
// 子组件中...
<ul>
<li v-for="todo in todos" :key="todo.id">
{{ todo.text }}
</li>
</ul>
如果在子组件中我们如此设计,那么只能实现某一种列表形式的列表结构页面,假设我们页面每行需要多一个图标,就无法复用这个组件了,怎么办?我们可以抽出行元素到父组件中进行定制化设计,谁需要单独设计就抽出来,不需要的就是用默认结构
// 子组件中...
<ul>
<li v-for="item in todos" :key="item.id">
// 将这部分需要定制化的内容外传,并把所需的数据以属性形式外传
// 这种属性外传的形式和 父组件给子组件传递数据的思路非常相似,因此父组件接收处也常被命名为props,当然命名可以随意取。
<slot :todo="item">这里是默认内容<slot>
</li>
</ul>
// 父组件
<div>
<todo-list :todos="todos">
// 定义一个template来包裹卡片内容,并利用slot-scope属性来接收子组件的数据
<template slot-scope="props" >
<span v-if="props.todo.isComplete">✓</span>
{{ props.todo.text }}
</template>
</todo-list>
</div>
在父组件中的slot-scope
中定义的变量是父组件编译作用域中的临时变量,用来存放从子组件中传递过来的props
对象。该对象中,定义在子组件上的属性作为props
的键,如<slot :todo="item"><slot>
中的todo
; 子组件中属性对应的值作为props
的值,如<slot :todo="item"><slot>
中的item
。当然这里的item
是一个对象。
在 2.5.0+,slot-scope 不再限制在 <template> 元素上使用,而可以用在插槽内的任何元素或组件上。
在 2.5.0+ 版本里,上面的例子可以这么写
// 父组件
<div>
<todo-list :todos="todos">
// 不需要再使用template来包裹卡片内容,直接将slot-scope定义在span上(这里稍作改动假设文本在span中)
<span slot-scope="props">{{ props.todo.text }}</span>
</todo-list>
</div>
# 利用结构赋值简化代码
利用es6的解构赋值特性,可以使得代码结构更加清晰易懂,作用域插槽也变得更干净一些
<todo-list v-bind:todos="todos">
<template slot-scope="{ todo }">
<span v-if="todo.isComplete">✓</span>
{{ todo.text }}
</template>
</todo-list>
# 如何编写一个高复用的组件
Vue 作为一套构建用户的渐进式框架,倡导使用简单的API来实现响应式的数据来绑定和组合视图组件。然而因为vue的语法自由,方案众多,不同人解决问题的思路不一样,写出来的代码自然有差别,如果是多人开发,就容易造成规范不统一,自成一套的问题。
对于业务量较小的系统,组件的可复用性和规模编写影响并不大,但随着业务代码日益庞大,组件必将会越来越多,组件逻辑的耦合性也更加严重,容易出现维护困难,牵一发而动全身的困恼。笔者查阅了相关资料书籍结合自身的理解,得出如下几个要点。
0. 说明 - 组件职责
组件根据其用处可粗略分为两类:一类是通用组件(可复用组件)即本章重点,一类是业务组件(几乎为一次性组件)。Vue提倡将页面划分成不同的模块,将每一个模块封装成一个组件。这种思路决定了不可能所有的组件都是通用组件,必然存在一些一次性的业务组件,封装它们的目的是为了提高代码的可读性和易维护性。
虽说有这两类,但并没有一条特别清晰的分界线,原因是Vue组件的编写极具艺术性,通过Vue语法的巧妙利用,典型代表就是「作用域插槽」,理想情况下能将业务组件拆分成一个插槽的卡片内容,但这也存在难度。这也是为什么称Vue是渐进式框架的原因
可复用组件实现通用的功能(无关使用位置,使用场景的变化)
- UI 效果展示
- 与用户的交互 (如点击事件)
- CSS特效如动画效果
业务组件则实现偏向业务话的功能
- 获取数据
- 和vuex相关的操作(不应该在通用组件中出现)
- 埋点功能
- 引用可复用组件
1. 业务无关
组件的命名应该和业务无关,而是根据功能命名。
假设有一个团队列表,需要把每一项作为一个组件,你可能会想使用Team。这时,有另一个需求要求展示为每一个人员赠送的节日礼物列表,再使用这个Team组件显然感觉不合适。
关于如何智慧的命名,给一个建议: 可以借用ElementUI等这类UI框架的规范,他们实质上也是对Vue组件的一些封装,可以学习他们的做法。 举个例子如 Item
,ListItem
,Cell
等命名
2. 数据无关
编写的组件应该尽可能的无状态,除非真实具有某些适普功能的特殊组件。应尽量不要在组件内部去获取业务数据,以及任何与服务器端打交道的操作,这将严重缩小组件的可用范围。
3.命名空间
可复用组件除了定义一个清晰的公开接口,还需要有命名空间,避免与浏览器保留的标签和其他组件发生冲突。特别是当项目引用外部UI或迁移到其他项目时,也能解决很多命名冲突问题。命名空间建议使用项目名称的缩写。
当然,业务组件也建议有命名空间
上下文无关
所谓上下文无关并不是说全无关,而是尽可能减少对外部环境的依赖。虽说Vue是拆分组件,拆分模块的思想,但并不是无意义拆分。并不希望把一个具有独立功能的组件按照他的模块拆散,这样不进增加了无意义的数据传输,还不利于上下文无关特性。
数据扁平化
传递数据时,不要将整个对象作为一个prop传递进来。很常见的一个现象就是
<child :data="resData"></child>
然后resData
的结构为一个JS对象。这么做不是不行,而是有一些弊端。
(1)组件的接口不清晰,甚至需要写注释才能看明白这组数据如何处理。
(2)props 校验无法校验对象内部的属性类型
(3)当服务器端返回的对象中带有的key与组件接口不一致时,需要手动转换或构建。
当然,这是一把双刃剑,当需要渲染的数据字段不多时,提倡使用扁平数据分格。如下
<child :title="resData.title" :describe="resData.describe" :author="resData.author"></child>
项目骨架
单组件不异过重,组件在功能独立的前提下应该尽量简单,越简单的组件可复用性越强。当你实现组件的代码,不包括CSS,有好几百行了(这个大小视业务而定),那么就要考虑拆分成更小的组件。
当组件足够简单时,就可以在一个更大的业务组件中去自由组合这些组件,实现我们的业务功能。因此,理想情况下,组件的引用层级,只有两级。业务组件引用通用组件。
而对于一个庞大的项目,必然会有更深层的组件嵌套,此时建议将业务层组件和通用组件分离
使用插槽将[业务组件]剥离成[通用组件]
插槽绝对是Vue中的利器。通过插槽我们不难将一个业务组件剥离出公用部分成为通用组件,通过slot
再将所需要的业务内容插入对应插槽中。如下案例
// 组件two-col-layout
<template>
<ul slot="content" v-if="Lists.length">
<li v-for="item in Lists" :key="item.id">
<div class="l">
<slot name="left" :item="item">图片区域</slot>
</div>
<div class="r">
<slot name="right" :item="item">详情区域</slot>
</div>
</li>
<slot name="after"></slot>
</ul>
</template>
案例中展示的是一个两列布局的通用组件。其设置了左边栏为图片展示区域,右边栏为详情展示区域。但是关于这两栏具体信息如何展示,那是业务组件需要干的事情。
- 案例中的组件与业务无关:他不关心页面需要些什么,详情区域会放些什么东西,有几栏,而是将这些交给父组件实现。
- 与数据无关:他同样不关心数据是什么样的,有些什么字段,字段名是什么,他只关心数据类型能通过Props验证即可。毕竟这里需要做
v-for
循环。 - 与上下文无关:告诉该组件一个数据名称即可,它只做数据转交工作
- 结构扁平:他将业务信息交回给父组件完成,因此自己不需要做太多的子组件封装,也就避免了多层组件嵌套
- 命名规范:名称根据组件的功能命名,两列布局
two-col-layout
,很容易看懂。
# 结束语
Vue为渐进式框架,上手简单并不代表这门技术就简单。经常复习官网和查阅相关书籍,会发现不同的东西。太多时候埋头于写业务代码,而忽略了对这门极具艺术的语言有较多的研究。多思考,虚心学,或许你会觉得,越学,不会的越多~那就对了