使用vue指令解决文本的溢出提示的问题
作者:前端手术刀
在我们项目开发中,经常会有 超长文本溢出提示
,未溢出则不提示的场景。
笔者就遇到了比较复杂的场景,在一个表格中,总计约有 5000 个单元格,需要对每个单元格进行这个需求的校验,刚开始开发的时候也是用 v-if
v-else
el-tooltip
一把梭。导致运行时存在大量的 <el-tooltip>
实例。这样的操作,代码重复性极高,而且不利于后期维护,关键我们发现性能极差,一拉伸就卡顿。和组长沟通后,开发出了这个指令。
接下来 让我们一步步用 vue指令
实现这个需求
本文涉及到的技术栈
- vue2
- element-ui
在线体验
彦祖们,这个 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-tooltip
在 render
的时候, 对应的 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指令解决文本的溢出提示的问题的详细内容,更多关于vue指令解决文本溢出提示的资料请关注脚本之家其它相关文章!