likes
comments
collection
share

多行文本展开收起的实现方式,你知道几种?

作者站长头像
站长
· 阅读数 58

前言

多行文本超过指定行数,进行文本截断并显示省略号,并通过展开和收起来控制是否显示全部内容,这个功能很常见,实现方式也有很多种,那么我来整理一下我知道的几种实现方式,代码实现是基于Vue3。

省流

demo: xiaocheng555.github.io/text-ellips…

代码: github.com/xiaocheng55…

基于-webkit-line-clamp的实现

多行文本展开收起的实现方式,你知道几种?

实现核心就是 -webkit-line-clamp 属性,使用 -webkit-line-clamp 来截断行数,具体css代码就是下面这个:

div {
  display: -webkit-box; // 弹性伸缩盒子模型
  -webkit-box-orient: vertical; // 从顶部向底部垂直布置子元素 
  overflow: hidden; // 隐藏文本溢出内容
  -webkit-line-clamp: 3; // 截断行数
}

实现过程

1、设置省略样式,动态传入行数来控制文本截断的行数

<div class="text-ellipsis">
  <div 
    ref="contentEl" 
    class="text-ellipsis-content" 
    :style="{ '-webkit-line-clamp': isExpand ? 'unset' : rows }">
    {{content}}
  </div>
</div>

.text-ellipsis-content {
  display: -webkit-box; 
  -webkit-box-orient: vertical; 
  word-break: break-all; 
  overflow: hidden;
}

2、js判断文本是否溢出

容器的文本内容高度超过容器本身的高度, 就可以判断为文本溢出

const { offsetHeight, scrollHeight } = contentEl.value
isEll.value = scrollHeight > offsetHeight

3、显示展开、收起

文本溢出则显示展开、收起按钮

<span v-if="isEll" class="text-ellipsis-action" @click="onActionClick">{{actionText}}</span>

const actionText = computed(() => {
  return isExpand.value ? props.collapseText : props.expandText
})

// 展开/收起点击
function onActionClick () {
  isExpand.value = !isExpand.value
}

优点

  • 实现简单

  • 兼容绝大多数主流浏览器,如果要兼容ie或者非主流浏览器另说

缺点

  • 展开收起无法跟在文本内容末尾

  • ie和少数低版本浏览器兼容问题

详细代码

<template>
  <div class="text-ellipsis">
    <div 
      ref="contentEl" 
      class="text-ellipsis-content" 
      :style="{ '-webkit-line-clamp': isExpand ? 'unset' : rows }">
      {{content}}
    </div>
    <span v-if="isEll" class="text-ellipsis-action" @click="onActionClick">{{actionText}}</span>
  </div>
</template>

<script setup lang="ts">
import { defineProps, ref, onBeforeMount, watch, computed, nextTick } from 'vue'

const props = defineProps({
  // 文本内容
  content: {
    type: String,
    default: '',
    required: true
  },
  // 省略行数
  rows: {
    type: Number,
    default: 5
  },
  // 展开文案
  expandText: {
    type: String,
    default: '展开'
  },
  // 收起文案
  collapseText: {
    type: String,
    default: '收起'
  }
})

const isEll = ref(false) // 是否省略
const isExpand = ref(false) // 是否展开
const contentEl = ref<null | HTMLElement>(null) // 容器dom

const actionText = computed(() => {
  return isExpand.value ? props.collapseText : props.expandText
})

// 计算内容省略
async function calcEll () {
  await nextTick()
  if (!contentEl.value) return
  const { offsetHeight, scrollHeight } = contentEl.value
  isEll.value = scrollHeight > offsetHeight
}

// 展开/收起点击
function onActionClick () {
  isExpand.value = !isExpand.value
}

onBeforeMount(() => {
  calcEll()
})

watch(() => [
  props.content, 
  props.rows
], calcEll)
</script>

<style lang='less' scoped>
.text-ellipsis {
  white-space: pre-wrap;
}
.text-ellipsis-content {
  display: -webkit-box; 
  -webkit-box-orient: vertical; 
  word-break: break-all; 
  overflow: hidden;
}
.text-ellipsis-action {
  color: #409eff;
  cursor: pointer;
  &.is-block {
    display: block;
  }
  &:hover {
    opacity: .85;
  }
}
</style>

基于max-height的实现

多行文本展开收起的实现方式,你知道几种?

实现过程

1、计算最大高度max-height

内容设定的最大高度max-height = 指定行数row * 行高,如下:

const { lineHeight } = window.getComputedStyle(contentEl.value)
maxHeight.value = toNum(lineHeight) * props.rows

2、判断文本内容是否溢出

文本内容实际高度大于设定的最大高度(max-height)则为文本溢出,如下:

isEll.value = contentEl.value.scrollHeight > maxHeight.value

3、省略文本末尾添加省略符号 ...

如何让省略符号显示在文本的右下角呢,此处我用的是浮动布局,因为浮动布局有环绕作用,而且不会影响文本内容的布局。

首先要用到两个span标签,都向右边浮动。一个span的作用是占位符,向上撑起高度,而且它本身宽度为0,不影响文本内容的自身的布局;第二个span为填充省略符号的容器,由于浮动的环绕作用,第二个span会撑起自身宽度,因为有第一个span撑起了高度使第二个span刚好处于文本末尾的位置。

<div 
  ref="contentEl" 
  class="text-ellipsis-content" 
  :style="{ maxHeight: isExpand ? 'none' : `${maxHeight}px` }">
  <!-- 占位符 -->
  <span class="text-ellipsis-placeholder" :style="{height: placeholderHeight + 'px'}"></span>
  <!-- 省略符号 -->
  <span v-if="isEll" ref="tailEl" class="text-ellipsis-tail">
    <span class="text-ellipsis-dot" v-if="!isExpand">...</span>
  </span>{{content}}
</div>

.text-ellipsis-placeholder {
  float: right;
}
.text-ellipsis-tail {
  float: right;
  clear: both;
}

如何让第一个占位符span撑起的高度恰好让省略符号的span刚好在文本右下角,其实不难,只要用整个容器高度减去省略符号的容器高度即可。

if (isEll.value) {
  await nextTick()
  if (tailEl.value) {
    placeholderHeight.value = maxHeight.value - tailEl.value.offsetHeight
  }
}

⚠️此处参考文章: juejin.cn/post/696390…

4、增加展开、收起逻辑

<span v-if="isEll" ref="tailEl" class="text-ellipsis-tail">
  <span class="text-ellipsis-dot" v-if="!isExpand">{{dot}}</span><span v-if="!single" class="text-ellipsis-action" @click="onActionClick">{{actionText}}</span>
</span>


const actionText = computed(() => {
  return isExpand.value ? props.collapseText : props.expandText
})
// 展开/收起点击
function onActionClick () {
  isExpand.value = !isExpand.value
}

优点

  • 没有兼容问题

  • 省略号可以自定义

  • 省略号可以跟随在省略文本末尾,也可以单独一行

缺点

火狐浏览器,如果设置文本内容为 white-space: pre-wrap; 时,text-align: justify; 两端对齐会失效,偶尔会导致省略号跟文本末尾出有一小段空白,有些小瑕疵,但不影响使用。如图:

多行文本展开收起的实现方式,你知道几种?

详细代码

<template>
  <div class="text-ellipsis" :class="[!isExpand && 'un-expand']">
    <div 
      ref="contentEl" 
      class="text-ellipsis-content" 
      :style="{ maxHeight: isExpand ? 'none' : `${maxHeight}px` }">
      <!-- 占位符 -->
      <span class="text-ellipsis-placeholder" :style="{height: placeholderHeight + 'px'}"></span>
      <!-- 内容+操作按钮,不留空格 -->
      {{isExpand ? content : '' }}<span v-if="isEll" ref="tailEl" class="text-ellipsis-tail">
        <span class="text-ellipsis-dot" v-if="!isExpand">{{dot}}</span><span v-if="!single" class="text-ellipsis-action" @click="onActionClick">{{actionText}}</span>
      </span>{{isExpand ? '' : content}}
    </div>
    <span v-if="single && isEll" class="text-ellipsis-action" @click="onActionClick">{{actionText}}</span>
  </div>
</template>

<script setup lang="ts">
import { defineProps, defineExpose, ref, onBeforeMount, watch, computed, nextTick } from 'vue'

const props = defineProps({
  // 文本内容
  content: {
    type: String,
    default: '',
    required: true
  },
  // 省略行数
  rows: {
    type: Number,
    default: 5
  },
  // 展开文案
  expandText: {
    type: String,
    default: '展开'
  },
  // 收起文案
  collapseText: {
    type: String,
    default: '收起'
  },
  // 省略点
  dot: {
    type: String,
    default: '...'
  },
  single: {
    type: Boolean,
    default: false
  }
})

const isEll = ref(false) // 是否省略
const isExpand = ref(false) // 是否展开
const contentEl = ref<null | HTMLElement>(null) // 容器dom
const tailEl = ref<null | HTMLElement>(null) // 操作按钮dom
const placeholderHeight = ref(0) // 占位符高度
const maxHeight = ref(0) // 最大高度

const actionText = computed(() => {
  return isExpand.value ? props.collapseText : props.expandText
})

function toNum (val: any) : number {
  if (!val) return 0
  
  return parseFloat(val)
}

let lazyToCalc = false // 延迟执行

// 计算内容省略
async function calcEll () {
  await nextTick()
  if (!contentEl.value) return
  // 计算最大高度
  const { lineHeight } = window.getComputedStyle(contentEl.value)
  if (Number.isNaN(lineHeight)) {
    console.warn(`text-ellipsis 组件不能设置line-height为${lineHeight}`)
  }
  maxHeight.value = toNum(lineHeight) * props.rows
  // 判断是否省略内容
  isEll.value = contentEl.value.scrollHeight > maxHeight.value
  // 计算占位符高度: 容器高度 - 操作按钮高度
  if (isEll.value) {
    // 延迟执行,解决内容已经展开时,触发计算,tailEl容器展开时的高度与收起时高度不一致,导致错位
    if (isExpand.value) { 
      lazyToCalc = true
      return
    }
    await nextTick()
    if (tailEl.value) {
      placeholderHeight.value = maxHeight.value - tailEl.value.offsetHeight
    }
  }
}

// 展开/收起点击
function onActionClick () {
  isExpand.value = !isExpand.value
  if (lazyToCalc) {
    lazyToCalc = false
    calcEll()
  }
}

onBeforeMount(() => {
  calcEll()
})

watch(() => [
  props.content, 
  props.rows
], calcEll)

defineExpose({
  update: calcEll
})
</script>

<style lang='less' scoped>
.text-ellipsis {  
  line-height: 1.5;
  &.un-expand {
    .text-ellipsis-placeholder {
      float: right;
    }
    .text-ellipsis-tail {
      float: right;
      clear: both;
    }
  }
  &.is-single {
    .text-ellipsis-action {
      display: block;
    }
  }
}
.text-ellipsis-content {
  overflow: hidden;
  text-overflow: ellipsis;
  word-wrap: break-word;
  word-break: break-word;
  text-justify: inter-character;
  text-align: justify;
  white-space: pre-line;
  // white-space: pre-wrap;
}
.text-ellipsis-action {
  color: #409eff;
  cursor: pointer;
  &:hover {
    opacity: .85;
  }
}
</style>

facebook的文本省略

多行文本展开收起的实现方式,你知道几种?

文本省略规则

  • 当超过5个换行符时,文本省略

  • 当字数超过480时,文本省略(代码里设置为400)

  • 展开、收起按钮不是固定在右下角,而是跟随省略文本末尾的随意位置。

具体实现

根据文本省略规则,不需要处理dom和计算位置,只需要计算文本内容自身即可。

使用 content.split(/\n/) 根据换行符切割内容,就知道有几个换行符了;判断内容字数就知道是否超过最大字数了。

const { content, newline, maxLen } = props
const rowTexts = content.split(/\n/) // 每行的内容
const curRows = rowTexts.length

let curText = content
isEll.value = false

// 超出最大行数
if (curRows > newline) {
  isEll.value = true
  curText = rowTexts.slice(0, newline).join('\n')
}

// 超出最大字数
if (curText.length > maxLen) {
  isEll.value = true
  curText = curText.slice(0, maxLen)
}
text.value = curText

看法

  • 实现简单,不需要操作dom

  • 效果看着还行

  • 仅适用于个别场景

具体代码

<template>
  <div class="text-ellipsis">
    {{textVisible}}
    <span class="text-ellipsis-dots" v-if="isEll && !isExpand">{{dot}}</span><span v-if="isEll" class="text-ellipsis-action" @click="onActionClick">
      <slot v-bind="{isExpand}">
        {{actionText}}
      </slot>
    </span>
  </div>
</template>

<script setup lang="ts">
import { defineProps, ref, onBeforeMount, watch, computed } from 'vue'

const props = defineProps({
  content: {
    type: String,
    default: ''
  },
  newline: {
    type: Number,
    default: 5
  },
  maxLen: {
    type: Number,
    default: 400
  },
  expandText: {
    type: String,
    default: '展开'
  },
  collapseText: {
    type: String,
    default: '收起'
  },
  dot: {
    type: String,
    default: '...'
  }
})

const text = ref('') // 显示的文本内容
const isEll = ref(false) // 是否省略
const isExpand = ref(false) // 是否展开

const actionText = computed(() => {
  return isExpand.value ? props.collapseText : props.expandText
})

const textVisible = computed(() => {
  return isExpand.value ? props.content : text.value
})

// 计算显示的内容
function calcContent () {
  const { content, newline, maxLen } = props
  const rowTexts = content.split(/\n/) // 每行的内容
  const curRows = rowTexts.length
  
  let curText = content
  isEll.value = false
  
  // 超出最大行数
  if (curRows > newline) {
    isEll.value = true
    curText = rowTexts.slice(0, newline).join('\n')
  }
  
  // 超出最大字数
  if (curText.length > maxLen) {
    isEll.value = true
    curText = curText.slice(0, maxLen)
  }
  text.value = curText
}

// 展开/收起点击
function onActionClick () {
  isExpand.value = !isExpand.value
}

onBeforeMount(() => {
  calcContent()
})

watch(() => [props.content, props.maxLen, props.newline], calcContent)
</script>

<style lang='less' scoped>
.text-ellipsis {
  white-space: pre-wrap;
}
.text-ellipsis-action {
  color: #409eff;
  cursor: pointer;
}
</style>

ant-design-mobile的文本省略

多行文本展开收起的实现方式,你知道几种?

实现过程

1、克隆文本内容的容器

为什么要克隆呢,直接在原本的容器上操作不行吗?

直接上答案,因为需要频繁操作dom,并获取dom位置的值,为避免触发多次重绘重排,需要克隆一个样式、大小一样的容器,并设置 fixed 固定布局,插入到body下。

function cloneBox () {
  if (!boxEl.value) return
  
  // 复制样式
  const originStyle = window.getComputedStyle(boxEl.value)
  const div = document.createElement('div')
  const styleNames: string[] = Array.prototype.slice.apply(originStyle)
  styleNames.forEach(name => {
    div.style.setProperty(name, originStyle.getPropertyValue(name))
  })
  // 重置样式
  div.style.position = 'fixed'
  div.style.zIndex = '-9999'
  div.style.top = '-9999px'
  div.style.height = 'auto'
  div.style.minHeight = 'auto'
  div.style.maxHeight = 'auto'
  // 插入body
  div.textContent = props.content
  document.body.appendChild(div)
  return div
}

2、计算容器最大高度

容器设定的最大高度 = 指定行数 * 行高 + 上边距 + 下边距

const div = cloneBox()
if (!div) return 
const { paddingBottom, paddingTop, lineHeight } = div.style
// 最大高度: 行高 * 行数 + 上下内边距; 
// 补: 加上 1/2 为了增加最大高度的安全范围
const maxHeight = (props.rows + 1 / 2) * toNum(lineHeight) + toNum(paddingTop) + toNum(paddingBottom)

3、计算省略文本

由于计算出了容器设定的最大高度maxHeight,那么,只要将文本内容填充到容器里,如果容器高度小于maxHeight,则为文本溢出,否则为未溢出。

如果文本溢出了,就要去计算省略文本的内容是多少,从容器里将文本一个字一个字地拿出来,从末尾开始拿,拿到恰好容器的高度等于maxHeight,剩下就是省略文本了。为了加快拿的速度,此时可以使用二分法去加快计算的速度。

当然,这里的计算,每次都要在文本末尾加上 ...展开 后缀去计算(这个是可配置的,代码是 dot + expandText)。

// 内容溢出,则进行文本省略
if (maxHeight < div.offsetHeight) {
  isEll.value = true
  const ellText = calcEllTextEnd(div, maxHeight)
  text.value = ellText
} else {
  // 内容未溢出
  isEll.value = false
  text.value = props.content
}
  
// 计算省略的文本内容(结束位置)
function calcEllTextEnd (div: HTMLElement, maxHeight: number) {
  // 二分法计算省略时的文本
  const { content, dot, expandText, single } = props
  let l = 0
  let r = content.length
  let res = -1
  
  while (l <= r) {
    const mid = Math.floor((l + r) / 2)
    div.textContent = content.slice(0, mid) + dot + (single ? '' : expandText)
    if (div.offsetHeight <= maxHeight) {
      // 未溢出
      l = mid + 1
      res = mid // 记录满足条件的值
    } else { 
      // 溢出
      r = mid - 1
    }
  }
  return content.slice(0, res) + dot
}

4、展示在页面

<div class="text-ellipsis" ref="boxEl">
  {{textVisible}}<span v-if="isEll" :class="single && 'is-block'" class="text-ellipsis-action" @click="onActionClick">{{actionText}}</span>
</div>

const actionText = computed(() => {
  return isExpand.value ? props.collapseText : props.expandText
})

const textVisible = computed(() => {
  return isExpand.value ? props.content : text.value
})

// 展开/收起点击
function onActionClick () {
  isExpand.value = !isExpand.value
}

5、加上dom监听

如果容器的尺寸变化了,计算出的省略文本就不正确了,需要重新计算,这里加上一个dom监听即可。

import ResizeObserver from 'resize-observer-polyfill'

observer = new ResizeObserver(() => {
  calcContent()
})
observer.observe(boxEl.value)

6、省略的位置

省略的位置可以是在开头、中间、结尾,就要稍微改下省略文本的计算方式。

优点

  • ant-design-mobile实现方案真的不错

  • 支持省略位置,可以在开头、中间、结尾

缺点

  • 计算有点复杂,需要耗费一点计算量

结尾

地址:

demo: xiaocheng555.github.io/text-ellips…

代码: github.com/xiaocheng55…