【源码学习】第27期 | element-plus是如何给组件之间提供统一的间距的?

565 阅读3分钟

前言

    给组件与组件之前设置间距,相信大家都知道用的margin,那么如何给组件之前提供统一的间距?今天就跟着element-plus的ElSpace组件源码一起学习一下~

收获清单

  • ElSpace组件源码分析
  • ElSpace组件原理
  • 如何追加vnode,动态添加样式、类名等

组件介绍

  • 功能
    通过这个组件来给组件之间提供统一的间距,具体用法可参考官网
  • 属性值及说明
属性名说明类型默认值
alignment对齐的方式enumcenter
class类名string / object / array
direction排列的方向enumhorizontal
prefix-cls给 space-items 的类名前缀string
style额外样式string / object
spacer间隔符string / number / VNode
size间隔大小enum / number / arraysmall
wrap设置是否自动折行booleanfalse
fill子元素是否填充父容器booleanfalse
fill-ratio填充父容器的比例number100
  • 效果预览

el-space.gif

源码下载与定位

  • 下载源码
    git clone https://github.com/element-plus/element-plus.git
    cd element-plus
    pnpm install
    pnpm docs:dev
  • 定位到组件源码

el-space.gif

源码分析

入口文件

  • 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属性,接着我们看一下具体实现:

图片.png

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以及动态添加类名、样式的处理方式是值得我们在日常开发中借鉴的,毕竟麻雀虽小五脏俱全。最后,用《半山文集》的一句话结束今天的学习:可快,不可急;可慢,不可怠。