实现文本溢出的展开收起功能,纯 CSS 方案在网页中可行,但在小程序中存在兼容性问题。
最优的解决方案就是使用 JavaScript 的二分截断法。
看了下 vant 的 TextEllipsis 组件源码。
理解了算法的实现原理后就写了一个uniapp版本和vue3版本的展开收起组件。
算法步骤:
- 创建隐藏容器并渲染内容。
- 计算最大行高(行数 × 单行行高)。
- 使用递归算法,类似于 tail(left, content.length)。
- 取中间值,并将其写入隐藏容器。
- 等待渲染完成后获取最新高度。
- 如果隐藏容器的高度超过最大行高,则继续调用 tail,使用 left = left,right = middle。
- 否则,可能是内容太少了(或者无法再继续截断,那就返回截取的内容)。使用 left = middle,right = right 继续调用 tail。
这个算法通过不断地二分截断,寻找到最合适的截取内容。
就算是1000多字,限定2行展示,截断次数也只在10次左右。
扩展:canvas海报的文字溢出功能也可以用这个算法。
uniapp版本
下面是从源码抽离出来单独封装的uniapp和vue3版本(网页,小程序,app都测试过)
先上效果图 300多ms:
uniapp版本有一些需要注意的点,如果兼容运行在小程序和app的话。
- 在小程序中,样式计算是在渲染过程中异步进行的,必须nextTick后才能获取容器最新高度(因为小程序样式计算是异步的。所以性能比不上网页的2ms,实测是300+ms)。
- 获取元素节点信息的方法也不一样。
- 行高如果是继承的获取的就是inherit。所以需要传行高进去。
js
<template>
<view
:class="{root:true,visible:!show}"
:style="{ lineHeight: props.lineHeight }"
>
{{ expanded ? props.content : text }}
<text class="action" v-if="hasAction" @click="onClickAction"
>{{ actionText }}</text
>
</view>
<view :class="{hiddenText:true}" :style="{ lineHeight: props.lineHeight }"
>{{ text }}</view
>
</template>
<script lang="ts" setup>
import { defineProps, ref, getCurrentInstance, nextTick, computed, onMounted } from 'vue';
const instance = getCurrentInstance(); // 获取组件实例
const props = defineProps({
content: {
type: String,
default: ''
},
rows: {
type: Number,
default: 2
},
lineHeight: {
type: Number,
default: '30rpx'
}
});
const expanded = ref(false);
const text = ref(props.content);
const hasAction = ref(false);
const show= ref(false);
const actionText = computed(() => {
return expanded.value ? '收起' : '展开';
});
const onClickAction = () => {
expanded.value = !expanded.value;
};
// 查询元素形状信息
const qeuryRect = queryText => {
let query = uni.createSelectorQuery().in(instance);
return new Promise((resolve, reject) => {
query
.select(queryText)
.boundingClientRect(rect => {
resolve(rect);
})
.exec();
});
};
// 查询元素样式属性等信息
const qeuryRectProp = queryText => {
let query = uni.createSelectorQuery().in(instance);
return new Promise((resolve, reject) => {
query
.select(queryText)
.fields({ computedStyle: ['lineHeight', 'height'], dataset: true, size: true }, rect => {
resolve(rect);
})
.exec();
});
};
let dots = '...';
let content = props.content;
let end = content.length;
const setHiddenText = val => {
return new Promise((_, reject) => {
text.value = val;
console.error(val);
nextTick(() => {
_(val);
});
});
};
// 计算截断
const calcEllipsisText = maxHeight => {
const tail = async (left, right) => {
// 递归终止条件
if (right - left <= 1) {
return content.slice(0, left) + dots;
}
const middle = Math.round((left + right) / 2);
// 设置拦截位置(注意slice 0,middle,虽然left ,right不断变,但是0是不变的)
await setHiddenText(content.slice(0, middle) + dots + actionText.value);
let result = await qeuryRectProp('.hiddenText');
if (parseInt(result.height) > maxHeight) {
return tail(left, middle);
}
// 太往左了,内容不够,需要往右边移动
return tail(middle, right);
};
tail(0, end).then(res => {
text.value = res;
show.value=true
console.timeEnd("完成计算")
});
};
// 开始计算
onMounted(() => {
console.time("完成计算")
nextTick(async () => {
let result = await qeuryRectProp('.hiddenText');
let maxHeight = parseInt(result.lineHeight) * props.rows;
// 隐藏的行高大于限定行数高度
if (maxHeight < parseInt(result.height)) {
hasAction.value = true;
calcEllipsisText(maxHeight);
} else {
hasAction.value = false;
text.value = props.content;
show.value=true
}
});
});
</script>
<style lang="scss" scoped>
.visible {
visibility: hidden;
}
.hiddenText {
position: fixed;
z-index: -999;
top: -9999px;
}
.action{
color:#1989fa;
}
</style>
vue3版本
先上效果图:2ms
js
<template>
<div ref="root">
{{ expanded ? props.content : text }}
<span v-if="hasAction" class="action" @click="onClickAction">
{{ actionText }}
</span>
</div>
</template>
<script setup>
import { ref, watch, computed, onMounted, onUnmounted, onActivated, defineProps, defineEmits } from 'vue'
const emit = defineEmits(['clickAction'])
const props = defineProps({
rows: {
type: Number,
default: 2,
},
dots: {
type: String,
default: '...',
},
content: {
type: String,
default: '',
},
expandText: {
type: String,
default: '展开',
},
collapseText: {
type: String,
default: '收起',
},
})
const useWindowResize = () => {
const window_width = ref(window.innerWidth)
onMounted(() => {
window.addEventListener('resize', () => {
windowWidth.value = window.innerWidth
})
})
onUnmounted(() => {
window.removeEventListener('resize', () => {
windowWidth.value = window.innerWidth
})
})
return window_width
}
const windowWidth = useWindowResize()
const text = ref('')
const expanded = ref(false)
const hasAction = ref(false)
const root = ref(null)
let needRecalculate = false
const actionText = computed(() => (expanded.value ? props.collapseText : props.expandText))
const pxToNum = (value) => {
if (!value) return 0
const match = value.match(/^\d*(\.\d*)?/)
return match ? Number(match[0]) : 0
}
const cloneContainer = () => {
if (!root.value || !root.value.isConnected) return
const originStyle = window.getComputedStyle(root.value)
const container = document.createElement('div')
const styleNames = Array.from(originStyle)
styleNames.forEach((name) => {
container.style.setProperty(name, originStyle.getPropertyValue(name))
})
container.style.position = 'fixed'
container.style.zIndex = '-9999'
container.style.top = '-9999px'
container.style.height = 'auto'
container.style.minHeight = 'auto'
container.style.maxHeight = 'auto'
container.innerText = props.content
document.body.appendChild(container)
return container
}
const calcEllipsised = () => {
console.time('完成计算')
const calcEllipsisText = (container, maxHeight) => {
const { content, dots } = props
const end = content.length
const calcEllipse = () => {
const tail = (left, right) => {
// 递归终止条件
if (right - left <= 1) {
return content.slice(0, left) + dots
}
const middle = Math.round((left + right) / 2)
// 设置拦截位置
container.innerText = content.slice(0, middle) + dots + actionText.value
if (container.offsetHeight > maxHeight) {
return tail(left, middle)
}
// 太往左了,内容不够,需要往右边移动
return tail(middle, right)
}
container.innerText = tail(0, end)
console.timeEnd('完成计算')
}
calcEllipse()
return container.innerText
}
// 计算截断文本
const container = cloneContainer()
if (!container) {
needRecalculate = true
return
}
const { paddingBottom, paddingTop, lineHeight } = container.style
const maxHeight = Math.ceil(
(Number(props.rows) + 0.5) * pxToNum(lineHeight) + pxToNum(paddingTop) + pxToNum(paddingBottom)
)
if (maxHeight < container.offsetHeight) {
hasAction.value = true
text.value = calcEllipsisText(container, maxHeight)
} else {
hasAction.value = false
text.value = props.content
}
document.body.removeChild(container)
}
const toggle = (isExpanded = !expanded.value) => {
expanded.value = isExpanded
}
const onClickAction = (event) => {
toggle()
emit('clickAction', event)
}
onMounted(calcEllipsised)
onActivated(() => {
if (needRecalculate) {
needRecalculate = false
calcEllipsised()
}
})
watch([windowWidth, () => [props.content, props.rows]], calcEllipsised)
defineExpose({ toggle })
</script>
<style scoped>
.action {
color: #1989fa;
}
</style>