Vue 指令之 v-icon-tooltip 实现指南

1,472 阅读3分钟

又是充满希望的一天!

前言

最近项目的各个模块及特殊操作需要增加名词解释,效果图如下,功能很简单,但现有的 Tooltip 组件却无法满足新需求,为此单独开发 IconTooltip 组件,并基于该组件进行 v-icon-tooltip 指令开发。

image.png

Todo list

  • 指定元素后面追加名词解释图标
  • 交互方式为单击切换提示信息
  • 通过指令简易配置即可实现需求

IconTooltip

UI 框架使用的是 ViewDesign@4.5 版本,Tooltip 组件本身只支持悬停方式显示,但是可以通过提供的 disabledalways 来控制提示信息的显示时机。

// components/IconTooltip/icon-tooltip.vue
<template>
  <div
    ref="mainRef"
    class="icon-tooltip-wrapper"
    :style="wrapperStyles"
    :class="classes"
    @click.stop
  >
    <Tooltip v-bind="tooltipProps">
      <Icon
        class="icon"
        v-bind="iconProps"
        v-click-outside:[capture]="onClickOutside"
        v-click-outside:[capture].mousedown="onClickOutside"
        v-click-outside:[capture].touchstart="onClickOutside"
        @click.native="onIconClick"
      />
    </Tooltip>
  </div>
</template>

<script>
import { defineProps } from '@/util'
import { pick } from 'lodash'
import { directive as ClickOutside } from '@/directives/v-click-outside'

/**
 * Tooltip 触发类型
 */
export const TRIGGER_TYPE = Object.freeze({
  click: 'click',
  hover: 'hover'
})

const ICON_PROP_KEYS = Object.freeze(['icon', 'custom', 'color', 'size'])
const TOOLTIP_PROP_KEYS = Object.freeze([
  'content',
  'placement',
  'theme',
  'maxWidth',
  'transfer',
  'disabled',
  'always'
])

export default {
  name: 'IconTooltip',
  directives: { ClickOutside },
  props: {
    icon: defineProps(String),
    custom: defineProps(String),
    color: defineProps(String),
    size: defineProps(Number, 16),
    content: defineProps(String, ''),
    triggerType: defineProps(String, TRIGGER_TYPE.click),
    placement: defineProps(String, 'top'),
    theme: defineProps(String, 'dark'),
    maxWidth: defineProps([String, Number], 200),
    transfer: defineProps(Boolean, true),
    capture: defineProps(Boolean, true),
    styles: defineProps(Object, null),
    classes: defineProps([String, Object, Array], null)
  },
  data() {
    return {
      // 初始化时根据当前触发类型设置是否禁用
      disabled: this.triggerType === TRIGGER_TYPE.click,
      // 单击后设为 true,可一直显示 Tooltip
      always: false
    }
  },
  computed: {
    tooltipProps() {
      return pick(this, TOOLTIP_PROP_KEYS)
    },
    iconProps() {
      const props = pick(this, ICON_PROP_KEYS)
      props.type = props.type || props.icon
      return props
    },
    wrapperStyles() {
      return Object.assign(
        {},
        // calc(100% + 6px) 是为了让元素以自身右边作为 x 轴的偏移起始坐标,6px 为偏移量
        { top: '50%', right: 0, transform: 'translate(calc(100% + 6px), -50%)' },
        this.styles
      )
    }
  },
  methods: {
    // 单击图标外部时关闭 tooltip
    onClickOutside() {
      if (this.triggerType === TRIGGER_TYPE.hover) return
      this.disabled = true
      this.always = false
    },
    // 单击图标时切换 tooltip
    onIconClick() {
      if (this.triggerType === TRIGGER_TYPE.hover) return
      this.disabled = !this.disabled
      this.always = !this.always
    }
  }
}
</script>

<style lang='less' scoped>
.icon-tooltip-wrapper {
  position: absolute;

  & .icon {
    cursor: pointer;
  }
}
</style>

根据需求实现了需要的 IconTooltip 组件,在封装组件时为了方便扩展,我们将 TooltipIcon 组件的一些属性暴露给外部调用,来方便其他特殊需求的使用。

v-icon-tooltip

在我当前的需求中,组件的图标是固定的,只是显示方式和内容不同,并且 IconTooltip 组件需要总是为其父元素设置 position 属性用来定位,此时在各个模块中去使用仍然比较繁琐。

对于一向以 懒人创造世界 为理念的我,决定再开发一个基于该组件的指令来满足特定的需求。

// directives/v-icon-tooltip.js
import { getStyle, upObjVal } from '@/util'
import Vue from 'vue'
import IconTooltip, { TRIGGER_TYPE } from '@/components/IconTooltip/icon-tooltip.vue'
import { get, omit } from 'lodash'

const PREFIX = '[v-icon-tooltip]'

const buildContent = (binding) =>
  (typeof binding.arg === 'string' ? binding.arg : '') || get(binding.value, 'content', '')

const buildProps = (...props) =>
  upObjVal(
    {
      icon: 'md-help-circle',
      custom: undefined, // icon props custom
      size: undefined,
      color: undefined,
      triggerType: TRIGGER_TYPE.click, // optional type: click, hover
      content: '',
      placement: 'top',
      theme: 'dark',
      maxWidth: 200,
      transfer: true,
      capture: true,
      styles: null,
      classes: null
    },
    ...props
  )

function buildTooltip(props) {
  // 通过 Vue.extend 获取组件的构造器
  const ctor = Vue.extend(IconTooltip)
  // 返回组件的实例化对象
  return new ctor({ propsData: props }).$mount()
}

function bindTooltip(el, binding) {
  if ('arg' in binding && typeof binding.arg !== 'string') {
    console.warn(`${PREFIX} Binding arg must be a string.`)
  }

  const $tooltip = buildTooltip(buildProps(binding.value, { content: buildContent(binding) }))

  el.$hasTooltip = true
  el.$tooltip = $tooltip

  el.appendChild($tooltip.$el)
}

export default {
  name: PREFIX,
  bind(el, binding) {
    bindTooltip(el, binding)
  },
  inserted(el) {
    // 获取指令挂载元素的 position 属性,这里的 getStyle 方法会获取元素包含类样式在内的 position 属性
    const rawPosition = getStyle(el, 'position') || ''

    // 如果原始的 position 属性可以进行定位则跳过后续步骤
    if (!['', 'static'].includes(rawPosition) || '$rawPosition' in el) return

    // 挂载原始定位属性并设置当前定位为相对定位
    el.$rawPosition = rawPosition
    el.style.position = 'relative'
  },
  update(el, binding) {
    if (el.$hasTooltip) {
      // 更新除 triggerType 的其他属性值
      return upObjVal(
        el.$tooltip._props,
        buildProps(omit(binding.value, 'triggerType'), { content: buildContent(binding) })
      )
    }
    bindTooltip(el, binding)
  },
  unbind(el) {
    // 指令销毁时销毁组件,若无这一步则在 tooltip 使用了 transfer 时,会产生垃圾元素
    el.$tooltip.$destroy()
    el.$tooltip = null

    // 还原元素的 position 值
    if ('$rawPosition' in el) {
      el.style.position = el.$rawPosition
    }

    // 逐一删除元素上的多余属性
    ;['$hasTooltip', '$tooltip', '$rawPosition'].forEach((key) => delete el[key])
  }
}
// util/index.js
export const upObjVal = (target, ...sources) => {
  const onlySource = _.merge({}, ...sources)
  return _.merge(target, _.pick(onlySource, Object.keys(target)))
}

export function getStyle(el, attr, pseudo = null) {
  if (!(el instanceof HTMLElement)) {
    throw Error('The parameter el must be a HTMLElement.')
  }
  if (typeof attr !== 'string') {
    return ''
  }
  //IE6~8不兼容backgroundPosition写法,识别backgroundPositionX/Y
  if (attr === 'backgroundPosition' && !+'\v1') {
    return el.currentStyle.backgroundPositionX + ' ' + el.currentStyle.backgroundPositionY
  }
  const currentStyle = 'currentStyle' in el ? el.currentStyle : document.defaultView.getComputedStyle(el, pseudo)
  return currentStyle[attr]
}

指令使用

image.png

image.png

结尾

我是不会告诉你开始只是想从网上 copy 份指令改改方便偷懒,没想到后面就开发了一个完整组件。

image.png

写文章小白,若是阅读让你枯燥乏味请你边听摇滚边阅读,若是还有其他问题还请各位在评论区留言。

若能顺手点个小欣欣,鄙人感激不尽!Thanks♪(・ω・)ノ