使用vue指令解決文本的溢出提示的問題
在我們項目開發(fā)中,經(jīng)常會有 超長文本溢出提示 ,未溢出則不提示的場景。
筆者就遇到了比較復(fù)雜的場景,在一個表格中,總計約有 5000 個單元格,需要對每個單元格進行這個需求的校驗,剛開始開發(fā)的時候也是用 v-if v-else el-tooltip 一把梭。導(dǎo)致運行時存在大量的 <el-tooltip> 實例。這樣的操作,代碼重復(fù)性極高,而且不利于后期維護,關(guān)鍵我們發(fā)現(xiàn)性能極差,一拉伸就卡頓。和組長溝通后,開發(fā)出了這個指令。
接下來 讓我們一步步用 vue指令 實現(xiàn)這個需求
本文涉及到的技術(shù)棧
- vue2
- element-ui
在線體驗
彥祖?zhèn)?這個 codesandbox 好像用不了了,clone 到本地跑一下吧~
報錯 Error in render: "TypeError: (0 , _vue.resolveComponent) is not a function" , 如果有知道怎么解決的彥祖,請評論區(qū) 留言。
常規(guī)開發(fā)
如下圖所示, 兩個寬度為 100px 的 div, 第一個不需要提示,第二個則需要提示

<template>
<div class="parent">
<div>
天翼云
</div>
<el-tooltip content="天翼云,海量數(shù)據(jù)">
<div class="tooltip">
天翼云,海量數(shù)據(jù)
</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>這樣的代碼比較復(fù)雜,而且復(fù)用性極低。如果在其他頁面也有類似的場景,我們就不得不做一個 cv 戰(zhàn)士 了
指令開發(fā)
如何判斷是否溢出?
這也算是一個知識點,首先我們需要判斷文本是否溢出了節(jié)點。后來在 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) {
// 省略了不重要的邏輯
}那我們就改造一下 直接拿來用吧
實現(xiàn)溢出指令
接下來,讓我們用這段代碼 實現(xiàn)一個判斷溢出的指令,非常簡單,直接上代碼
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)
}
}來看下效果吧

如何把溢出節(jié)點掛載到 el-tooltip 上?
本文的難點也是核心代碼,我們該如何把這個節(jié)點掛載到 <el-tooltip> 上呢。筆者也在這個問題上卡了非常久,嘗試過復(fù)制一個新節(jié)點, 包裹一層 <el-tooltip> 去代替老的節(jié)點,也嘗試過用 template 常規(guī)渲染調(diào)用,直接生成。但是發(fā)現(xiàn)都比較麻煩,最終不得去看了它的源碼。發(fā)現(xiàn)了以下代碼
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();
}
});
}
}其實我們發(fā)現(xiàn) 核心點 就是這個 this.$el , 它在 mounted 時對 this.$el 進行了一系列初始化操作
那么接下來就簡單了, 我們試著去替換這個 this.$el ,然后再執(zhí)行 mounted 邏輯不就行了?
但是我們最好不要去改變這個屬性
vue 官方解釋

改造 el-tooltip 源碼
替換 el-tooltip 的 $el
我們只需要把源碼的 this.$el 部分改成 this.target
也就是在源碼內(nèi)部,我們新增一個
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
我們看下此時的目錄結(jié)構(gòu)

- directive.js //指令代碼
- main.js //改造過的 el-tooltip 代碼
這里還有個很重要的知識點, 就是創(chuàng)建一個 vue 實例
我們在日常開發(fā)中, 一般只會在 main.js 進行一個 new Vue 的操作。
在閱讀了 element-ui 源碼后,我們會發(fā)現(xiàn) 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)
}
}看下此時的效果

已經(jīng)初步成效了,但是沒有任何顯示內(nèi)容?
填充顯示內(nèi)容
彥祖?zhèn)?這個可更簡單了 直接一個 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('天翼云,海量數(shù)據(jù)')
}
此時,可能有彥祖會說,這個 setContent 也太復(fù)雜了,難道我每次都需要手動傳入數(shù)據(jù)嗎?
當然不必, 我們 默認 vm.setContent(content || el.innerText) 就好了
此處只做演示說明
此時我們基本已經(jīng)實現(xiàn)了一個 簡單的 ellipsis-tooltip
開始進階
以下優(yōu)化代碼,只展示核心代碼,已去掉冗余代碼
防止重復(fù)實例化
如果我們不進行 實例化的檢測,那么我們可能會存在大量的 vue 實例,用戶操作久了,就可能導(dǎo)致頁面卡頓
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' // ...其他屬性 不做贅述,自行拓展
兼容 文本 寬度改變的場景

業(yè)務(wù)中的文本寬度可能不是固定的
溢出的變成非溢出,非溢出也能變成溢出
所以我們需要在 mouseenter 的時候進行對應(yīng)判斷
const vm = vmMap.get(el)
if (isOverflow(el)) {
if(vm) return vm.disabled = false
} else {
vm.disabled = true // 沒有溢出,則應(yīng)該禁用 tooltip
}
}移除大量實例化時候的節(jié)點
在開發(fā)中,筆者發(fā)現(xiàn), el-tooltip 在 render 的時候, 對應(yīng)的 dom 并不會從 文檔中移除,這個在表格或者樹這種大量節(jié)點的場景中, 性能開銷是我們不能接受的

我們開放一個 destroyOnLeave 配置,用于設(shè)置 移出時 是否銷毀對應(yīng) 提示節(jié)點
const onMouseLeave = () => {
const elVm = vmMap.get(el)
if (!elVm) return
elVm.disabled = true
elVm.$nextTick(elVm.unMount) //卸載 tooltip,在 main.js 做了注釋
vmMap.set(el, null) // 銷毀內(nèi)存
}
if (destroyOnLeave) el.addEventListener('mouseleave', onMouseLeave)其他優(yōu)化項
其他一些常規(guī)的 eventListener 的移除操作,自我優(yōu)化,就不浪費彥祖?zhèn)兊那啻毫?/p>
完整代碼
- 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: {
// 掛載目標節(jié)點
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() // 移除對應(yīng)的 tooltip節(jié)點
}
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() // 調(diào)用 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]
]) // 用于拓展后續(xù)的監(jiān)聽
el.addEventListener('mouseenter', onMouseEnter)
},
unbind (el) {
const events = listenerMap.get(el)
if (events?.length) {
events.forEach(([name, event]) => el.removeEventListener(name, event))
}
}
}以上就是使用vue指令解決文本的溢出提示的問題的詳細內(nèi)容,更多關(guān)于vue指令解決文本溢出提示的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
解讀Vue3中keep-alive和動態(tài)組件的實現(xiàn)邏輯
這篇文章主要介紹了Vue3中keep-alive和動態(tài)組件的實現(xiàn)邏輯,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-05-05
Vue實現(xiàn)導(dǎo)出Excel表格文件提示“文件已損壞無法打開”的解決方法
xlsx用于讀取解析和寫入Excel文件的JavaScript庫,它提供了一系列的API處理Excel文件,使用該庫,可以將數(shù)據(jù)轉(zhuǎn)換Excel文件并下載到本地,適用于在前端直接生成Excel文件,這篇文章主要介紹了Vue實現(xiàn)導(dǎo)出Excel表格,提示文件已損壞,無法打開的解決方法,需要的朋友可以參考下2024-01-01
Vue.set() this.$set()引發(fā)的視圖更新思考及注意事項
this.$set()和Vue.set()本質(zhì)方法一樣,前者可以用在methods中使用。這篇文章主要介紹了Vue.set() this.$set()引發(fā)的視圖更新思考及注意事項,需要的朋友可以參考下2018-08-08

