优雅实现文本展开收起功能(全平台兼容)

实现文本溢出的展开收起功能,纯 CSS 方案在网页中可行,但在小程序中存在兼容性问题。

最优的解决方案就是使用 JavaScript 的二分截断法。

看了下 vant 的 TextEllipsis 组件源码。

理解了算法的实现原理后就写了一个uniapp版本和vue3版本的展开收起组件。

算法步骤:

  1. 创建隐藏容器并渲染内容。
  2. 计算最大行高(行数 × 单行行高)。
  3. 使用递归算法,类似于 tail(left, content.length)。
  4. 取中间值,并将其写入隐藏容器。
  5. 等待渲染完成后获取最新高度。
  6. 如果隐藏容器的高度超过最大行高,则继续调用 tail,使用 left = left,right = middle。
  7. 否则,可能是内容太少了(或者无法再继续截断,那就返回截取的内容)。使用 left = middle,right = right 继续调用 tail。

这个算法通过不断地二分截断,寻找到最合适的截取内容。

就算是1000多字,限定2行展示,截断次数也只在10次左右。

扩展:canvas海报的文字溢出功能也可以用这个算法。

uniapp版本

下面是从源码抽离出来单独封装的uniapp和vue3版本(网页,小程序,app都测试过)

先上效果图 300多ms:

uniapp版本有一些需要注意的点,如果兼容运行在小程序和app的话。

  1. 在小程序中,样式计算是在渲染过程中异步进行的,必须nextTick后才能获取容器最新高度(因为小程序样式计算是异步的。所以性能比不上网页的2ms,实测是300+ms)。
  2. 获取元素节点信息的方法也不一样。
  3. 行高如果是继承的获取的就是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>
相关推荐
也无晴也无风雨27 分钟前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang1 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、4 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤5 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui