likes
comments
collection
share

什么,你还不会用指令解决文本的溢出提示?

作者站长头像
站长
· 阅读数 16

前言

在我们项目开发中,经常会有超长文本溢出提示,未溢出则不提示的场景。

笔者就遇到了比较复杂的场景,在一个表格中,总计约有 5000 个单元格,需要对每个单元格进行这个需求的校验,刚开始开发的时候也是用 v-if v-else el-tooltip 一把梭。导致运行时存在大量的<el-tooltip>实例。这样的操作,代码重复性极高,而且不利于后期维护,关键我们发现性能极差,一拉伸就卡顿。和组长沟通后,开发出了这个指令。

接下来 让我们一步步用 vue指令 实现这个需求

本文涉及到的技术栈

  • vue2
  • element-ui

动手开发

在线体验

codesandbox.io/s/autumn-fr…

彦祖们,这个 codesandbox 好像用不了了,clone 到本地跑一下吧~

报错 Error in render: "TypeError: (0 , _vue.resolveComponent) is not a function", 如果有知道怎么解决的彦祖,请评论区 留言。

常规开发

如下图所示, 两个宽度为 100px 的 div, 第一个不需要提示,第二个则需要提示

什么,你还不会用指令解决文本的溢出提示?

<template>
  <div class="parent">
    <div>
      天翼云
    </div>
    <el-tooltip content="天翼云,海量数据">
      <div class="tooltip">
        天翼云,海量数据
      </div>
    </el-tooltip>
  </div>
</template>
<style lang="scss" scoped>
.parent{
  margin:100px;
  >div{
    border:1px solid lightblue;
    width:100px;
    margin-bottom:20px;
    &.tooltip{
      overflow: hidden; //超出的文本隐藏
      text-overflow: ellipsis; //溢出用省略号显示
      white-space: nowrap;  // 默认不换行;
    }
  }
}
</style>

这样的代码比较复杂,而且复用性极低。如果在其他页面也有类似的场景,我们就不得不做一个cv 战士

指令开发

如何判断是否溢出?

这也算是一个知识点,首先我们需要判断文本是否溢出了节点。后来在 element-ui/packages/table/src/table-body.js 上找到了这段代码

const range = document.createRange();
range.setStart(cellChild, 0);
range.setEnd(cellChild, cellChild.childNodes.length);
const rangeWidth = range.getBoundingClientRect().width;
const padding = (parseInt(getStyle(cellChild, 'paddingLeft'), 10) || 0) +
(parseInt(getStyle(cellChild, 'paddingRight'), 10) || 0);
if ((rangeWidth + padding > cellChild.offsetWidth || cellChild.scrollWidth > cellChild.offsetWidth) && this.$refs.tooltip) {
    // 省略了不重要的逻辑
}

那我们就改造一下 直接拿来用吧

实现溢出指令

接下来,让我们用这段代码 实现一个判断溢出的指令,非常简单,直接上代码

function getElStyleAttr (element, attr) {
  const styles = window.getComputedStyle(element)
  return styles[attr]
}
const isOverflow = (target) => {
  const scrollWidth = target.scrollWidth
  const offsetWidth = target.offsetWidth
  const range = document.createRange()
  range.setStart(target, 0)
  range.setEnd(target, target.childNodes.length)
  const rangeWidth = range.getBoundingClientRect().width
  const padding = (parseInt(getElStyleAttr(target, 'paddingLeft'), 10) || 0) + (parseInt(getElStyleAttr(target, 'paddingRight'), 10) || 0)
  return (rangeWidth + padding > target.offsetWidth) || scrollWidth > offsetWidth
}
export const ellipsisTooltip = {
  bind (el, binding, vnode, oldVnode) {
    // 避免用户遗漏样式,我们必须强制加上超出...样式
    el.style.overflow = 'hidden'
    el.style.textOverflow = 'ellipsis'
    el.style.whiteSpace = 'nowrap'
    
    const onMouseEnter = (e) => {
      if (isOverflow(el)) {
        console.log('溢出了')
      } else {
        console.log('未溢出')
      }
    }
    el.addEventListener('mouseenter', onMouseEnter)
  }
}

来看下效果吧

什么,你还不会用指令解决文本的溢出提示?

如何把溢出节点挂载到 el-tooltip 上?

本文的难点也是核心代码,我们该如何把这个节点挂载到 <el-tooltip>上呢。笔者也在这个问题上卡了非常久,尝试过复制一个新节点, 包裹一层 <el-tooltip> 去代替老的节点,也尝试过用template常规渲染调用,直接生成。但是发现都比较麻烦,最终不得去看了它的源码。发现了以下代码

mounted() {
    this.referenceElm = this.$el;
    if (this.$el.nodeType === 1) {
      this.$el.setAttribute('aria-describedby', this.tooltipId);
      this.$el.setAttribute('tabindex', this.tabindex);
      on(this.referenceElm, 'mouseenter', this.show);
      on(this.referenceElm, 'mouseleave', this.hide);
      on(this.referenceElm, 'focus', () => {
        if (!this.$slots.default || !this.$slots.default.length) {
          this.handleFocus();
          return;
        }
        const instance = this.$slots.default[0].componentInstance;
        if (instance && instance.focus) {
          instance.focus();
        } else {
          this.handleFocus();
        }
      });
      on(this.referenceElm, 'blur', this.handleBlur);
      on(this.referenceElm, 'click', this.removeFocusing);
    }
    // fix issue https://github.com/ElemeFE/element/issues/14424
    if (this.value && this.popperVM) {
      this.popperVM.$nextTick(() => {
        if (this.value) {
          this.updatePopper();
        }
      });
    }
}

其实我们发现 核心点 就是这个 this.$el, 它在 mounted 时对 this.$el 进行了一系列初始化操作

那么接下来就简单了, 我们试着去替换这个 this.$el,然后再执行 mounted 逻辑不就行了?

但是我们最好不要去改变这个属性

vue 官方解释

什么,你还不会用指令解决文本的溢出提示?

改造 el-tooltip 源码

替换 el-tooltip 的 $el

我们只需要把源码的 this.$el 部分改成 this.target

也就是在源码内部,我们新增一个

setEl(el){
  this.target = el
}

替换 mounted 逻辑

同样非常简单,我们把 mounted 的逻辑重新封装一个 init 方法

init () {
  this.referenceElm = this.target
  if (this.target.nodeType === 1) {
    this.target.setAttribute('aria-describedby', this.tooltipId)
    this.target.setAttribute('tabindex', this.tabindex)
    on(this.referenceElm, 'mouseenter', this.show)
    on(this.referenceElm, 'mouseleave', this.hide)
    on(this.referenceElm, 'focus', () => {
      if (!this.$slots.default || !this.$slots.default.length) {
        this.handleFocus()
        return
      }
      const instance = this.$slots.default[0].componentInstance
      if (instance && instance.focus) {
        instance.focus()
      } else {
        this.handleFocus()
      }
    })
    on(this.referenceElm, 'blur', this.handleBlur)
    on(this.referenceElm, 'click', this.removeFocusing)
  }
  // fix issue https://github.com/ElemeFE/element/issues/14424
  if (this.value && this.popperVM) {
    this.popperVM.$nextTick(() => {
      if (this.value) {
        this.updatePopper()
      }
    })
  }
}

引入改造后的 el-tooltip

我们看下此时的目录结构 什么,你还不会用指令解决文本的溢出提示?

  • directive.js //指令代码
  • main.js //改造过的 el-tooltip 代码

这里还有个很重要的知识点, 就是创建一个 vue 实例

我们在日常开发中, 一般只会在 main.js 进行一个 new Vue 的操作。

在阅读了element-ui 源码后,我们会发现 el-message el-date-picker 中也用到了这个实例化的操作,更有 Vue.extend等高阶操作。

我们看下此时的 directive.js 代码

import Vue from 'vue'
import Tooltip from './main'
function getElStyleAttr (element, attr) {
  const styles = window.getComputedStyle(element)
  return styles[attr]
}
const isOverflow = (target) => {
  const scrollWidth = target.scrollWidth
  const offsetWidth = target.offsetWidth
  const range = document.createRange()
  range.setStart(target, 0)
  range.setEnd(target, target.childNodes.length)
  const rangeWidth = range.getBoundingClientRect().width
  const padding = (parseInt(getElStyleAttr(target, 'paddingLeft'), 10) || 0) + (parseInt(getElStyleAttr(target, 'paddingRight'), 10) || 0)
  return (rangeWidth + padding > target.offsetWidth) || scrollWidth > offsetWidth
}
export const ellipsisTooltip = {
  bind (el, binding, vnode, oldVnode) {
    // 加上超出...样式
    el.style.overflow = 'hidden'
    el.style.textOverflow = 'ellipsis'
    el.style.whiteSpace = 'nowrap'

    const onMouseEnter = (e) => {
      // 需要展示
      if (isOverflow(el)) {
        // 参考 https://v2.cn.vuejs.org/v2/api/#vm-mount
        const vm = new Vue(Tooltip).$mount()
        vm.setEl(el)
        vm.init()
      }
    }
    el.addEventListener('mouseenter', onMouseEnter)
  }
}

看下此时的效果

什么,你还不会用指令解决文本的溢出提示?

已经初步成效了,但是没有任何显示内容❓

填充显示内容

彦祖们,这个可更简单了 直接一个 setContent 搞定了

  • main.js
setContent(content){
    this.content = content
}
  • directive.js
if (isOverflow(el)) {
    // 参考 https://v2.cn.vuejs.org/v2/api/#vm-mount
    const vm = new Vue(Tooltip).$mount()
    vm.setEl(el)
    vm.init()
    vm.setContent('天翼云,海量数据')
}

什么,你还不会用指令解决文本的溢出提示?

此时,可能有彦祖会说,这个 setContent 也太复杂了,难道我每次都需要手动传入数据吗?

当然不必, 我们 默认 vm.setContent(content || el.innerText) 就好了

此处只做演示说明, 心急的彦祖 请参看 完整代码

此时我们基本已经实现了一个 简单的 ellipsis-tooltip

开始进阶

以下优化代码,只展示核心代码,已去掉冗余代码

防止重复实例化

如果我们不进行 实例化的检测,那么我们可能会存在大量的 vue 实例,用户操作久了,就可能导致页面卡顿

const vmMap = new WeakMap()
if (isOverflow(el)) {
    if(vmMap.get(el)) return
    const vm = new Vue(Tooltip).$mount()
    // ...
    vmMap.set(el,vm)
}

兼容 el-tooltip 属性

此时,还有很多 el-tooltip 的源生属性待支持的。比如 placement effect...

其实我们只需要在代码上加上

const vm = new Vue(Tooltip).$mount()
vm.placement = placement || 'top-start'
vm.effect = effect || 'dark'
// ...其他属性 不做赘述,自行拓展

兼容 文本 宽度改变的场景

什么,你还不会用指令解决文本的溢出提示? 业务中的文本宽度可能不是固定的

溢出的变成非溢出,非溢出也能变成溢出

所以我们需要在 mouseenter 的时候进行对应判断

const vm = vmMap.get(el)
    if (isOverflow(el)) {
        if(vm) return vm.disabled = false
    } else {
        vm.disabled = true // 没有溢出,则应该禁用 tooltip
    }
}

移除大量实例化时候的节点

在开发中,笔者发现,el-tooltiprender 的时候, 对应的 dom 并不会从 文档中移除,这个在表格或者树这种大量节点的场景中, 性能开销是我们不能接受的

什么,你还不会用指令解决文本的溢出提示?

我们开放一个 destroyOnLeave 配置,用于设置 移出时 是否销毁对应 提示节点

const onMouseLeave = () => {
    const elVm = vmMap.get(el)
    if (!elVm) return
    elVm.disabled = true
    elVm.$nextTick(elVm.unMount) //卸载 tooltip,在 main.js 做了注释
    vmMap.set(el, null) // 销毁内存
}
if (destroyOnLeave) el.addEventListener('mouseleave', onMouseLeave)

其他优化项

其他一些常规的 eventListener 的移除操作,自我优化,就不浪费彦祖们的青春了

完整代码

  • main.js
import Popper from 'element-ui/src/utils/vue-popper'
import debounce from 'throttle-debounce/debounce'
import { addClass, removeClass, on, off } from 'element-ui/src/utils/dom'
import { generateId } from 'element-ui/src/utils/util'
import Vue from 'vue'

export default {
  name: 'TooltipWrapper',

  mixins: [Popper],

  props: {
    openDelay: {
      type: Number,
      default: 0
    },
    disabled: Boolean,
    manual: Boolean,
    effect: {
      type: String,
      default: 'dark'
    },
    arrowOffset: {
      type: Number,
      default: 0
    },
    popperClass: String,
    content: String,
    visibleArrow: {
      default: true
    },
    transition: {
      type: String,
      default: 'el-fade-in-linear'
    },
    popperOptions: {
      default () {
        return {
          boundariesPadding: 10,
          gpuAcceleration: false
        }
      }
    },
    enterable: {
      type: Boolean,
      default: true
    },
    hideAfter: {
      type: Number,
      default: 0
    },
    tabindex: {
      type: Number,
      default: 0
    }
  },

  data () {
    return {
      tooltipId: `el-tooltip-${generateId()}`,
      timeoutPending: null,
      focusing: false
    }
  },
  beforeCreate () {
    if (this.$isServer) return

    this.popperVM = new Vue({
      data: { node: '' },
      render (h) {
        return this.node
      }
    }).$mount()

    this.debounceClose = debounce(200, () => this.handleClosePopper())
  },

  render (h) {
    if (this.popperVM) {
      this.popperVM.node = (
        <transition
          name={ this.transition }
          onAfterLeave={ this.doDestroy }>
          <div
            onMouseleave={ () => { this.setExpectedState(false); this.debounceClose() } }
            onMouseenter= { () => { this.setExpectedState(true) } }
            ref="popper"
            role="tooltip"
            id={this.tooltipId}
            aria-hidden={ (this.disabled || !this.showPopper) ? 'true' : 'false' }
            v-show={!this.disabled && this.showPopper}
            class={
              ['el-tooltip__popper', 'is-' + this.effect, this.popperClass]
            }>
            { this.$slots.content || this.content }
          </div>
        </transition>)
    }

    const firstElement = this.getFirstElement()
    if (!firstElement) return null

    const data = firstElement.data = firstElement.data || {}
    data.staticClass = this.addTooltipClass(data.staticClass)

    return firstElement
  },
  watch: {
    focusing (val) {
      if (val) {
        addClass(this.referenceElm, 'focusing')
      } else {
        removeClass(this.referenceElm, 'focusing')
      }
    }
  },
  methods: {
    // 挂载目标节点
    setEl (el) {
      this.target = el
    },
    setContent (content) {
      this.content = content
    },
    init () {
      this.referenceElm = this.target
      if (this.target.nodeType === 1) {
        this.target.setAttribute('aria-describedby', this.tooltipId)
        this.target.setAttribute('tabindex', this.tabindex)
        on(this.referenceElm, 'mouseenter', this.show)
        on(this.referenceElm, 'mouseleave', this.hide)
        on(this.referenceElm, 'focus', () => {
          if (!this.$slots.default || !this.$slots.default.length) {
            this.handleFocus()
            return
          }
          const instance = this.$slots.default[0].componentInstance
          if (instance && instance.focus) {
            instance.focus()
          } else {
            this.handleFocus()
          }
        })
        on(this.referenceElm, 'blur', this.handleBlur)
        on(this.referenceElm, 'click', this.removeFocusing)
      }
      // fix issue https://github.com/ElemeFE/element/issues/14424
      if (this.value && this.popperVM) {
        this.popperVM.$nextTick(() => {
          if (this.value) {
            this.updatePopper()
          }
        })
      }
    },
    show () {
      this.setExpectedState(true)
      this.handleShowPopper()
    },

    hide () {
      this.setExpectedState(false)
      this.debounceClose()
    },
    handleFocus () {
      this.focusing = true
      this.show()
    },
    handleBlur () {
      this.focusing = false
      this.hide()
    },
    removeFocusing () {
      this.focusing = false
    },

    addTooltipClass (prev) {
      if (!prev) {
        return 'el-tooltip'
      } else {
        return 'el-tooltip ' + prev.replace('el-tooltip', '')
      }
    },

    handleShowPopper () {
      if (!this.expectedState || this.manual) return
      clearTimeout(this.timeout)
      this.timeout = setTimeout(() => {
        this.showPopper = true
      }, this.openDelay)

      if (this.hideAfter > 0) {
        this.timeoutPending = setTimeout(() => {
          this.showPopper = false
        }, this.hideAfter)
      }
    },

    handleClosePopper () {
      if (this.enterable && this.expectedState || this.manual) return
      clearTimeout(this.timeout)

      if (this.timeoutPending) {
        clearTimeout(this.timeoutPending)
      }
      this.showPopper = false

      if (this.disabled) {
        this.doDestroy()
      }
    },

    setExpectedState (expectedState) {
      if (expectedState === false) {
        clearTimeout(this.timeoutPending)
      }
      this.expectedState = expectedState
    },

    getFirstElement () {
      if (this.slotEl) return this.slotEl
      const slots = this.$slots.default
      if (!Array.isArray(slots)) return null
      let element = null
      for (let index = 0; index < slots.length; index++) {
        if (slots[index] && slots[index].tag) {
          element = slots[index]
        }
      }
      return element
    },
    unMount () {
      if (this.popperVM) {
        this.popperVM.$destroy() // 销毁 popperVM 实例
        this.popperVM.node && this.popperVM.node.elm.remove() // 移除对应的 tooltip节点
      }
      const reference = this.referenceElm
      // 解绑事件
      if (reference.nodeType === 1) {
        off(reference, 'mouseenter', this.show)
        off(reference, 'mouseleave', this.hide)
        off(reference, 'focus', this.handleFocus)
        off(reference, 'blur', this.handleBlur)
        off(reference, 'click', this.removeFocusing)
      }

      this.$nextTick(() => {
        this.doDestroy() // 调用 mixins的 Popper.doDestroy 销毁 popper
      })
    }
  },

  beforeDestroy () {
    this.popperVM && this.popperVM.$destroy()
  },

  destroyed () {
    const reference = this.referenceElm
    if (reference.nodeType === 1) {
      off(reference, 'mouseenter', this.show)
      off(reference, 'mouseleave', this.hide)
      off(reference, 'focus', this.handleFocus)
      off(reference, 'blur', this.handleBlur)
      off(reference, 'click', this.removeFocusing)
    }
  }
}
  • directive.js
import Vue from 'vue'
import Tooltip from './main'
const vmMap = new WeakMap()
const listenerMap = new WeakMap()
function getElStyleAttr (element, attr) {
  const styles = window.getComputedStyle(element)
  return styles[attr]
}
const isOverflow = (target) => {
  const scrollWidth = target.scrollWidth
  const offsetWidth = target.offsetWidth
  const range = document.createRange()
  range.setStart(target, 0)
  range.setEnd(target, target.childNodes.length)
  const rangeWidth = range.getBoundingClientRect().width
  const padding = (parseInt(getElStyleAttr(target, 'paddingLeft'), 10) || 0) + (parseInt(getElStyleAttr(target, 'paddingRight'), 10) || 0)
  return (rangeWidth + padding > target.offsetWidth) || scrollWidth > offsetWidth
}
export const ellipsisTooltip = {
  bind (el, binding, vnode, oldVnode) {
    const { value: { placement, disabled, content, effect, destroyOnLeave } = {} } = binding
    if (disabled) return
    // 加上超出...样式
    el.style.overflow = 'hidden'
    el.style.textOverflow = 'ellipsis'
    el.style.whiteSpace = 'nowrap'

    const onMouseLeave = () => {
      const elVm = vmMap.get(el)
      if (!elVm) return
      elVm.disabled = true
      elVm.$nextTick(elVm.unMount)
      vmMap.set(el, null)
    }
    const onMouseEnter = (e) => {
      const elVm = vmMap.get(el)
      // 需要展示
      if (isOverflow(el)) {
        if (elVm) {
          elVm.disabled = false
          return
        }
        const vm = new Vue(Tooltip).$mount()
        vm.placement = placement || 'top-start'
        vm.effect = effect || 'dark'
        vm.setEl(el)
        vm.setContent(content || el.innerText)
        vm.init()
        vm.show()
        vmMap.set(el, vm)
        if (destroyOnLeave) el.addEventListener('mouseleave', onMouseLeave)
      } else {
        if (elVm) elVm.disabled = true
      }
    }
    listenerMap.set(el, [
      ['mouseenter', onMouseEnter]
    ]) // 用于拓展后续的监听

    el.addEventListener('mouseenter', onMouseEnter)
  },
  unbind (el) {
    const events = listenerMap.get(el)
    if (events?.length) {
      events.forEach(([name, event]) => el.removeEventListener(name, event))
    }
  }
}

写在最后

上一篇

什么,你还不会 vue 拉伸指令?

收到了很多彦祖的好评, 接下来 会输出更多服务于业务,提高开发效率的文章。

个人能力有限 如有不对,欢迎指正🌟 如有帮助,建议小心心大拇指三连🌟

转载自:https://juejin.cn/post/7255651765038596151
评论
请登录