前言
给组件与组件之前设置间距,相信大家都知道用的margin,那么如何给组件之前提供统一的间距?今天就跟着element-plus的ElSpace
组件源码一起学习一下~
收获清单
-
ElSpace
组件源码分析 -
ElSpace
组件原理 - 如何追加vnode,动态添加样式、类名等
组件介绍
- 功能
通过这个组件来给组件之间提供统一的间距,具体用法可参考官网 - 属性值及说明
属性名 | 说明 | 类型 | 默认值 |
---|---|---|---|
alignment | 对齐的方式 | enum | center |
class | 类名 | string / object / array | — |
direction | 排列的方向 | enum | horizontal |
prefix-cls | 给 space-items 的类名前缀 | string | — |
style | 额外样式 | string / object | — |
spacer | 间隔符 | string / number / VNode | — |
size | 间隔大小 | enum / number / array | small |
wrap | 设置是否自动折行 | boolean | false |
fill | 子元素是否填充父容器 | boolean | false |
fill-ratio | 填充父容器的比例 | number | 100 |
- 效果预览
源码下载与定位
- 下载源码
git clone https://github.com/element-plus/element-plus.git
cd element-plus
pnpm install
pnpm docs:dev
- 定位到组件源码
源码分析
入口文件
- withInstall函数
export const withInstall = <T, E extends Record<string, any>>(
main: T,
extra?: E
) => {
;(main as SFCWithInstall<T>).install = (app): void => {
for (const comp of [main, ...Object.values(extra ?? {})]) {
app.component(comp.name, comp)
}
}
if (extra) {
for (const [key, comp] of Object.entries(extra)) {
;(main as any)[key] = comp
}
}
return main as SFCWithInstall<T> & E
}
withInstall
函数的作用主要是用app.component
全局注册组件
- index.ts
import { withInstall } from '@element-plus/utils'
import Space from './src/space'
export const ElSpace = withInstall(Space)
export default ElSpace
export * from './src/space'
export * from './src/item'
export * from './src/use-space'
入口文件作用主要是注册了名为ElSpace
的组件,同时把相关的方法及组件暴露出去,我们可以在play\src\App.vue
文件中copy官网的使用例子,接着在入口文件element-plus\packages\components\space\index.ts
打断点,截图如下,可以清晰看到Space组件的props属性,接着我们看一下具体实现:
space.ts文件
- Space组件
// 代码有删减
const Space = defineComponent({
name: 'ElSpace',
props: spaceProps,
setup(props, { slots }) {
// 组件类名,container样式以及item组件样式
const { classes, containerStyle, itemStyle } = useSpace(props)
// 删减代码 extractChildren
return () => {
const { spacer, direction } = props
const children = renderSlot(slots, 'default', { key: 0 }, () => [])
if ((children.children ?? []).length === 0) return null
// loop the children, if current children is rendered via `renderList` or `<v-for>`
if (isArray(children.children)) {
let extractedChildren = extractChildren(children.children)
if (spacer) {
// track the current rendering index, when encounters the last element
// then no need to add a spacer after it.
const len = extractedChildren.length - 1
extractedChildren = extractedChildren.reduce<VNode[]>(
(acc, child, idx) => {
const children = [...acc, child]
if (idx !== len) {
children.push(
createVNode(
'span',
// adding width 100% for vertical alignment,
// when the spacer inherit the width from the
// parent, this span's width was not set, so space
// might disappear
{
style: [
itemStyle.value,
direction === 'vertical' ? 'width: 100%' : null,
],
key: idx,
},
[
// if spacer is already a valid vnode, then append it to the current
// span element.
// otherwise, treat it as string.
isVNode(spacer)
? spacer
: createTextVNode(spacer as string, PatchFlags.TEXT),
],
PatchFlags.STYLE
)
)
}
return children
},
[]
)
}
// spacer container.
return createVNode(
'div',
{
class: classes.value,
style: containerStyle.value,
},
extractedChildren,
PatchFlags.STYLE | PatchFlags.CLASS
)
}
return children.children
}
},
})
这里主要是通过createVNode来添加包裹组件,并且通过extractedChildren函数遍历子组件,给子组件添加间距或自定义间隔符(如|之类的)
- extractChildren函数
function extractChildren(
children: VNodeArrayChildren,
parentKey = '',
extractedChildren: VNode[] = []
) {
const { prefixCls } = props
children.forEach((child, loopKey) => {
if (isFragment(child)) {
if (isArray(child.children)) {
child.children.forEach((nested, key) => {
if (isFragment(nested) && isArray(nested.children)) {
extractChildren(
nested.children,
`${parentKey + key}-`,
extractedChildren
)
} else {
extractedChildren.push(
createVNode(
Item,
{
style: itemStyle.value,
prefixCls,
key: `nested-${parentKey + key}`,
},
{
default: () => [nested],
},
PatchFlags.PROPS | PatchFlags.STYLE,
['style', 'prefixCls']
)
)
}
})
}
} else if (isValidElementNode(child)) {
extractedChildren.push(
createVNode(
Item,
{
style: itemStyle.value,
prefixCls,
key: `LoopKey${parentKey + loopKey}`,
},
{
default: () => [child],
},
PatchFlags.PROPS | PatchFlags.STYLE,
['style', 'prefixCls']
)
)
}
})
return extractedChildren
}
这个函数的作用注释写得很清楚,简单翻译一下就是通过一个简单的for循环获取子元素,这里的极端情况是当用户使用像<v-for>, <v-if>
这样的指令时,需要更深入遍历节点,直到子节点不是Fragment类型,如果当前子节点是有效的vnode,则追加当前的vnode,并将item作为子节点。然后我们看一下useSpace的实现~
use-space.ts
import { computed, ref, watchEffect } from 'vue'
import { isArray, isNumber } from '@element-plus/utils'
import { useNamespace } from '@element-plus/hooks'
import type { SpaceProps } from './space'
import type { CSSProperties, StyleValue } from 'vue'
// 尺寸map
const SIZE_MAP = {
small: 8,
default: 12,
large: 16,
} as const
export function useSpace(props: SpaceProps) {
// 命名空间
const ns = useNamespace('space')
// 根据传入的方向动态添加类名,如el-space--vertical
const classes = computed(() => [ns.b(), ns.m(props.direction), props.class])
const horizontalSize = ref(0)
const verticalSize = ref(0)
// 容器的样式
const containerStyle = computed<StyleValue>(() => {
const wrapKls: CSSProperties =
props.wrap || props.fill
? { flexWrap: 'wrap', marginBottom: `-${verticalSize.value}px` }
: {}
const alignment: CSSProperties = {
alignItems: props.alignment,
}
return [wrapKls, alignment, props.style]
})
// 子组件的样式
const itemStyle = computed<StyleValue>(() => {
const itemBaseStyle: CSSProperties = {
paddingBottom: `${verticalSize.value}px`,
marginRight: `${horizontalSize.value}px`,
}
const fillStyle: CSSProperties = props.fill
? { flexGrow: 1, minWidth: `${props.fillRatio}%` }
: {}
return [itemBaseStyle, fillStyle]
})
// 监听传入的size、wrap、direction、fill值改变horizontalSize、verticalSize的值
watchEffect(() => {
const { size = 'small', wrap, direction: dir, fill } = props
// when the specified size have been given
if (isArray(size)) {
const [h = 0, v = 0] = size
horizontalSize.value = h
verticalSize.value = v
} else {
let val: number
if (isNumber(size)) {
val = size
} else {
val = SIZE_MAP[size || 'small'] || SIZE_MAP.small
}
if ((wrap || fill) && dir === 'horizontal') {
horizontalSize.value = verticalSize.value = val
} else {
if (dir === 'horizontal') {
horizontalSize.value = val
verticalSize.value = 0
} else {
verticalSize.value = val
horizontalSize.value = 0
}
}
}
})
return {
classes,
containerStyle,
itemStyle,
}
}
use-space.ts文件其实就揭秘了间距组件的原理,即通过动态改变容器的margin以及alignItems、padding来达到统一设间距的效果
item.ts
import { computed, defineComponent, h, renderSlot } from 'vue'
import { buildProps } from '@element-plus/utils'
import { useNamespace } from '@element-plus/hooks'
import type { ExtractPropTypes } from 'vue'
const spaceItemProps = buildProps({
prefixCls: {
type: String,
},
} as const)
export type SpaceItemProps = ExtractPropTypes<typeof spaceItemProps>
const SpaceItem = defineComponent({
name: 'ElSpaceItem',
props: spaceItemProps,
setup(props, { slots }) {
const ns = useNamespace('space')
const classes = computed(() => `${props.prefixCls || ns.b()}__item`)
return () =>
h('div', { class: classes.value }, renderSlot(slots, 'default'))
},
})
export type SpaceItemInstance = InstanceType<typeof SpaceItem>
export default SpaceItem
这个ts文件的作用就是定义ElSpaceItem组件,即利用h函数【也可以理解为createVNode函数】来追加类名为el-space__item
的div元素
总结
至此,ElSpace
的源码分析告一段落,虽然这个组件可能并不那么常用,但动态创建vnode以及动态添加类名、样式的处理方式是值得我们在日常开发中借鉴的,毕竟麻雀虽小五脏俱全。最后,用《半山文集》的一句话结束今天的学习:可快,不可急;可慢,不可怠。