普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月27日首页

vue3+lodash+ts+tailwin 实现多行文本的展开收起代码(支持渲染html)

作者 脱缰胖虎
2026年4月27日 15:41

07a7ae24fef487dbb588a967f3803000.jpg

546cc5d2086a8d3c425bd07710220ae9.jpg

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { debounce } from 'lodash-es'

interface Props {
  text: string
  maxLines?: number
  expandText?: string
  collapseText?: string
  expandClass?: string
  collapseClass?: string
}

const props = withDefaults(defineProps<Props>(), {
  maxLines: 3,
  expandText: '展开',
  collapseText: '收起',
  expandClass: 'text-blue-500',
  collapseClass: 'text-blue-500',
})

const containerRef = ref<HTMLElement>()
const expanded = ref(false)
const isTruncated = ref(false)
const truncatedHtml = ref(props.text)

// ─── HTML 工具 ───────────────────────────────────────────────

/** 块级标签集合:仅这些标签会被认为产生新行,用于"在最后一行末尾追加"判断 */
const BLOCK_TAGS = new Set([
  'DIV', 'P', 'SECTION', 'ARTICLE', 'BLOCKQUOTE',
  'LI', 'UL', 'OL', 'PRE',
  'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
])

/**
 * 把 suffixHtml 注入到 html 的「最深一个块级容器」内部末尾,
 * 保证它与最后一行可见文字处于同一内联流。
 * 碰到 <strong> 等行内元素会停住,避免继承粗体等样式。
 */
function appendInsideLastBlock(html: string, suffixHtml: string): string {
  const wrapper = document.createElement('div')
  wrapper.innerHTML = html
  let target: Element = wrapper
  while (target.lastElementChild && BLOCK_TAGS.has(target.lastElementChild.tagName)) {
    target = target.lastElementChild
  }
  target.insertAdjacentHTML('beforeend', suffixHtml)
  return wrapper.innerHTML
}

/** 把 HTML 字符串转成纯文本(保留换行语义) */
function htmlToPlainText(html: string): string {
  const div = document.createElement('div')
  div.innerHTML = html
  // <br> / <p> / <div> 换成换行,方便行高量测一致
  div.querySelectorAll('br').forEach(br => br.replaceWith('\n'))
  div.querySelectorAll('p, div').forEach(el => {
    el.prepend('\n')
  })
  return div.innerText ?? div.textContent ?? ''
}

/**
 * 将"纯文本截断到第 visibleLen 个字符"映射回原始 HTML,
 * 返回一段合法闭合的 HTML 片段。
 *
 * 思路:遍历原始 HTML 字符,跳过标签字符,只计可见字符数;
 * 找到第 visibleLen 个可见字符在原始字符串中的位置后截断,
 * 再用 DOMParser 补全未闭合标签。
 */
function sliceHtmlByVisibleLen(html: string, visibleLen: number): string {
  let visible = 0
  let i = 0
  let inTag = false

  while (i < html.length && visible < visibleLen) {
    const ch = html[i]
    if (ch === '<') {
      inTag = true
    } else if (ch === '>') {
      inTag = false
    } else if (!inTag) {
      visible++
    }
    i++
  }

  // i 现在指向截断位置(继续把当前标签走完,避免截断在标签内部)
  if (inTag) {
    const closeIdx = html.indexOf('>', i)
    i = closeIdx === -1 ? html.length : closeIdx + 1
  }

  const raw = html.slice(0, i)

  // 用 DOMParser 补全未闭合标签
  const doc = new DOMParser().parseFromString(raw, 'text/html')
  return doc.body.innerHTML
}

// ─── 样式量测 ────────────────────────────────────────────────

function getLineHeight(el: HTMLElement): number {
  const lh = parseFloat(getComputedStyle(el).lineHeight)
  return isNaN(lh) ? parseFloat(getComputedStyle(el).fontSize) * 1.5 : lh
}

function createMeasureEl(el: HTMLElement, width: number): HTMLDivElement {
  const cs = getComputedStyle(el)
  const div = document.createElement('div')
  div.style.cssText = `
    position: absolute;
    visibility: hidden;
    pointer-events: none;
    width: ${width}px;
    font-size: ${cs.fontSize};
    font-family: ${cs.fontFamily};
    font-weight: ${cs.fontWeight};
    line-height: ${cs.lineHeight};
    letter-spacing: ${cs.letterSpacing};
    word-break: ${cs.wordBreak};
    white-space: ${cs.whiteSpace};
  `
  document.body.appendChild(div)
  return div
}

// ─── 截断计算 ────────────────────────────────────────────────

function calcTruncation() {
  const el = containerRef.value
  if (!el || expanded.value) return

  const cs = getComputedStyle(el)
  const width = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)
  if (width <= 0) return

  const lineHeight = getLineHeight(el)
  const maxHeight = lineHeight * props.maxLines
  const measureEl = createMeasureEl(el, width)

  // 用 innerHTML 量高,与实际渲染一致
  measureEl.innerHTML = props.text
  const fullHeight = measureEl.scrollHeight

  if (fullHeight <= maxHeight+1) {
    document.body.removeChild(measureEl)
    isTruncated.value = false
    truncatedHtml.value = props.text
    return
  }

  isTruncated.value = true

  // 二分搜索操作纯文本字符数
  const plain = htmlToPlainText(props.text)
  const suffix = `...${props.expandText}x` // 占位 x 抵消 ml-0.5 偏差

  let lo = 0
  let hi = plain.length

  while (lo < hi) {
    const mid = Math.floor((lo + hi + 1) / 2)
    const slicedHtml = sliceHtmlByVisibleLen(props.text, mid)
    // 把 suffix 注入到最后一个块级容器内部,量测才会跟实际渲染一致
    measureEl.innerHTML = appendInsideLastBlock(slicedHtml, suffix)
    if (measureEl.scrollHeight <= maxHeight+1) {
      lo = mid
    } else {
      hi = mid - 1
    }
  }

  document.body.removeChild(measureEl)
  truncatedHtml.value = sliceHtmlByVisibleLen(props.text, lo)
}

// ─── 生命周期 & 侦听 ─────────────────────────────────────────

const debouncedCalc = debounce(calcTruncation, 100)
let resizeObserver: ResizeObserver | null = null

onMounted(() => {
  nextTick(() => {
    calcTruncation()
    if (containerRef.value) {
      resizeObserver = new ResizeObserver(debouncedCalc)
      resizeObserver.observe(containerRef.value)
    }
  })
})

onUnmounted(() => {
  resizeObserver?.disconnect()
  debouncedCalc.cancel()
})

watch(
  () => [props.text, props.maxLines],
  () => {
    expanded.value = false
    nextTick(calcTruncation)
  },
)

// ─── 展开 / 收起 ─────────────────────────────────────────────

function expand() {
  expanded.value = true
}

function collapse() {
  expanded.value = false
  nextTick(calcTruncation)
}

// ─── 最终渲染 HTML ───────────────────────────────────────────

/**
 * 把按钮 HTML 注入到内容末尾。
 * 展开态:全文 + 收起按钮
 * 收起态:截断 HTML + ...展开按钮
 */
const btnClass = computed(() =>
  `inline ml-0.5 cursor-pointer bg-transparent border-none p-0 [font-family:inherit] [font-size:inherit] [line-height:inherit]`,
)

const renderedHtml = computed(() => {
  if (expanded.value) {
    const collapseBtn =
      `<button class="${btnClass.value} ${props.collapseClass}"
               onclick="this.dispatchEvent(new CustomEvent('collapse', { bubbles: true }))"
       >${props.collapseText}</button>`
    return appendInsideLastBlock(props.text, collapseBtn)
  }
  if (isTruncated.value) {
    const expandBtn =
      `...<button class="${btnClass.value} ${props.expandClass}"
                  onclick="this.dispatchEvent(new CustomEvent('expand', { bubbles: true }))"
       >${props.expandText}</button>`
    return appendInsideLastBlock(truncatedHtml.value, expandBtn)
  }
  return props.text
})
</script>

<template>
  <div
    ref="containerRef"
    v-html="renderedHtml"
    @expand="expand"
    @collapse="collapse"
  />
</template>
❌
❌