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

实现文本溢出的展开收起功能,纯 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>
相关推荐
To_OC13 分钟前
LC 17 电话号码的字母组合:我的回溯算法,就是从这道题开窍的
javascript·算法·leetcode
vipbic21 分钟前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy27 分钟前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志1 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble2 小时前
c端系统,其实更像一个信息展示平台
前端
你怎么知道我是队长2 小时前
JavaScript的变量和数据类型介绍
开发语言·javascript·ecmascript
李姆斯3 小时前
管理是否可以被完全量化
前端·产品经理·团队管理
名字还没想好☜3 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司4 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能
心中有国也有家4 小时前
AtomGit Flutter 鸿蒙客户端:ModalBottomSheet 实战
android·javascript·学习·flutter·华为·harmonyos