在将文本拆分为多行时,保留内部的span标签

前端开发 2026-07-09

我正在尝试制作一个视差横幅。横幅将包含一个简短的文本句子,会换行显示。但我希望每一行都能单独执行动画。因此我的目标是把文本拆分成适应父容器宽度所需的任意多的行。这个容器的宽度会根据屏幕尺寸而变化。我也需要在屏幕尺寸变化时更新结果——例如平板电脑旋转时。

总之,如果我在视差效果中有一段文本,如下所示:

<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque fermentum sem magna, vitae pharetra neque</p>

我希望得到像下面这样的结果,每个span包含一行能容纳的最大单词数。

<p>
    <span>Lorem ipsum dolor sit amet, consectetur</span>
    <span>adipiscing elit. Quisque fermentum sem</span>
    <span>magna, vitae pharetra neque<span>
</p>

其实我已经通过逐个往一个span中添加单词,当某个单词放不下时就创建一个新的span来实现了。

let cmpParallaxTextHighlight = {
    component: null,

    init: function (el) {
        const _ = this;
        _.element = el;
        _.debugMode = true;
        _.paragraphs = document.querySelectorAll('.cmp-parallax-text-highlight__paragraph');

        _.splitLines();
        _.reRenderOnResize();
    },

    // logging for debug mode
    log: function (message, type) {
        const _ = this;

        if (_.debugMode === false) return;

        type = type || 'log';

        switch (type) {
            case 'info':
                console.info(`%cInfo : ${message}`, 'color: #0099ff')
                break;
            default:
                console.log(`${message}`)
                break;
        }
    },

    // re-render the parallax text when the window is resized
    reRenderOnResize: function () {
        const _ = this;

        window.addEventListener('resize', function () {
            _.splitLines();
        });
    },

    // break each paragraph into multiple lines depending on the available width
    // each line will be wrapped in a span
    splitLines: function () {
        const _ = this;

        _.paragraphs.forEach(function (paragraph) {
            // get the paragraph text from the data attribute.
            let originalText = paragraph.getAttribute('data-original-text');

            // if the data attribute doesn't exist, this must be the first time the component is being initialized.
            // let's set the data attribute to the paragraph text.
            if (!originalText) {
                originalText = paragraph.textContent.replace(/\s+/g, ' ').trim();
                paragraph.setAttribute('data-original-text', originalText);
            }

            // clear the paragraph on the page
            paragraph.innerHTML = '';

            // get the parent container. We will need this to define our available width
            let container = paragraph.parentElement;
            let containerWidth = container.scrollWidth;

            // break the paragraph text into words
            let words = originalText.split(' ');

            // create an array to hold the lines
            let lineArray = [];

            // create a temporary hidden span to get line widths and add it to the page
            let tempSpan = document.createElement('span');
            tempSpan.textContent = '';
            tempSpan.style.display = 'inline-block';
            tempSpan.style.whiteSpace = 'nowrap';
            tempSpan.style.opacity = '0';
            paragraph.appendChild(tempSpan);

            // create a new test line
            let testLine = '';

            // loop through the individual words. place all that fit inside the first span.
            words.forEach(function (word) {
                testLine = testLine ? testLine + ' ' + word : word;
                tempSpan.innerHTML = testLine;

                if (tempSpan.scrollWidth > containerWidth) {
                    testLine = testLine.split(' ').slice(0, -1).join(' ');

                    // now we have a line of suitable length, add it to the paragraph
                    _.addNewLineToParagraph(paragraph, testLine);

                    // clear test elements down ready for the next line
                    tempSpan.innerHTML += '';
                    testLine = word;
                }
            });

            // We would have added all but the last line to the paragraph, so add it now
            if (testLine) {
                _.addNewLineToParagraph(paragraph, testLine);
            }

            paragraph.removeChild(tempSpan);
        });
    },

    // add passed text to the paragraph as a new line contained in a span
    addNewLineToParagraph: function (paragraph, text) {
        const _ = this;

        let newLine = document.createElement('span');
        newLine.classList.add('cmp-parallax-text-highlight__paragraph_line');
        newLine.innerHTML = text;
        paragraph.appendChild(newLine);
    },
}

cmpParallaxTextHighlight.init();
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <link rel="stylesheet" href="dist/styles.css">
</head>
<body>
<div style="height: 100px; width: 100%"></div>

<div class="cmp-parallax-text-highlight" data-component="cmpParallaxTextHighlight">
    <div class="cmp-parallax-text-highlight__background"></div>
    <div class="cmp-parallax-text-highlight__inner_content container">
        <div class="row">
            <div class="col-8">
                <p class="cmp-parallax-text-highlight__paragraph">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque fermentum sem magna, vitae pharetra neque </p>
            </div>
        </div>
    </div>
</div>
<script>
</script>
</body>

现在的问题是,我还在努力解决,如何处理原始文本字符串中包含的任何HTML。比如说,如果我的文本中包含如下的span标签:

<p>Lorem ipsum dolor <span class="my_class">sit amet, consectetur adipiscing elit. </span>Quisque fermentum sem<span class="my_class"> magna, vitae pharetra</span> neque </p>

我需要输出是有效的HTML,因此如果一个span内的文本换到多行,每个span都应包含一个打开标签和一个关闭标签。于是我的结果将会是:

<p>
    <span>Lorem ipsum dolor <span class="my_class">sit amet, consectetur</span></span>
    <span><span class="my_class">adipiscing elit</span>. Quisque fermentum sem</span>
    <span><span class="my_class">magna, vitae pharetra</span> neque<span>
</p>

我尝试过的所有方法都以灾难性的方式失败。根本没有接近目标。我甚至不知道我的思路应该是什么,更不用说怎么编码实现。有人知道我该如何实现吗?我知道市场上有很多视差库,但最终目标如此定制化,我找不到能提供太多帮助的库。

谢谢

解决方案

不要尝试拆分HTML字符串。拆分渲染后的DOM。

我做了以下修改

使用data-original-html代替data-original-text,因为必须保留原始标记。

我找到了一个TreeWalker,用来遍历段落内的真实文本节点,包括嵌套在span内的文本。

我们需要使用Range来测量,并在后续用 range.cloneContents() 克隆渲染后的DOM,即便一行的起始或结束落在现有的内联元素内部,也能创建有效的片段。

我尽量让实现接近你的代码。

请注意空白处的不可换行。

let cmpParallaxTextHighlight = {
  lineTolerance: 6,
  leftTolerance: 2,

  init: function() {
    const _ = this;

    _.paragraphs = document.querySelectorAll('.cmp-parallax-text-highlight__paragraph');

    _.splitLines();
    _.reRenderOnResize();
  },

  reRenderOnResize: function() {
    const _ = this;
    let resizeFrame;

    window.addEventListener('resize', function() {
      cancelAnimationFrame(resizeFrame);

      resizeFrame = requestAnimationFrame(function() {
        _.splitLines();
      });
    });
  },

  splitLines: function() {
    const _ = this;

    _.paragraphs.forEach(function(paragraph) {
      paragraph.dataset.originalHtml ||= paragraph.innerHTML.trim();
      paragraph.innerHTML = paragraph.dataset.originalHtml;

      const words = _.getWordsWithPositions(paragraph);
      const lines = _.groupWordsIntoLines(words);
      const lineSpans = lines.map(function(line) {
        return _.createLineSpan(_.createLineFragment(paragraph, line));
      });

      paragraph.replaceChildren(...lineSpans);
    });
  },

  getWordsWithPositions: function(paragraph) {
    const words = [];
    const walker = document.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT);
    const regex = /\S+\s*/g;

    while (walker.nextNode()) {
      const node = walker.currentNode;
      const text = node.nodeValue;
      let match;

      regex.lastIndex = 0;

      while ((match = regex.exec(text))) {
        const range = document.createRange();

        range.setStart(node, match.index);
        range.setEnd(node, match.index + match[0].length);

        const rect = range.getClientRects()[0];

        if (!rect) continue;

        words.push({
          node: node,
          start: match.index,
          end: match.index + match[0].length,
          top: rect.top,
          left: rect.left
        });
      }
    }

    return words;
  },

  groupWordsIntoLines: function(words) {
    const _ = this;

    return words.reduce(function(lines, word) {
      const lastLine = lines[lines.length - 1];
      const previousWord = lastLine && lastLine.end;

      const isNewLine =
        !lastLine ||
        Math.abs(lastLine.top - word.top) > _.lineTolerance ||
        word.left < previousWord.left - _.leftTolerance;

      if (isNewLine) {
        lines.push({
          top: word.top,
          start: word,
          end: word
        });
      } else {
        lastLine.end = word;
      }

      return lines;
    }, []);
  },

  createLineFragment: function(paragraph, line) {
    const _ = this;
    const range = document.createRange();

    range.setStart(line.start.node, line.start.start);
    range.setEnd(line.end.node, line.end.end);

    return _.wrapFragment(
      range.cloneContents(),
      _.getSharedWrappers(paragraph, line.start.node, line.end.node)
    );
  },

  getSharedWrappers: function(paragraph, startNode, endNode) {
    const wrappers = [];
    let node = startNode.parentElement;

    while (node && node !== paragraph) {
      if (node.contains(endNode)) {
        wrappers.push(node);
      }

      node = node.parentElement;
    }

    return wrappers;
  },

  wrapFragment: function(fragment, wrappers) {
    return wrappers.reduce(function(currentFragment, wrapper) {
      const clone = wrapper.cloneNode(false);
      const wrappedFragment = document.createDocumentFragment();

      clone.appendChild(currentFragment);
      wrappedFragment.appendChild(clone);

      return wrappedFragment;
    }, fragment);
  },

  createLineSpan: function(fragment) {
    const line = document.createElement('span');

    line.className = 'cmp-parallax-text-highlight__paragraph_line';
    line.appendChild(fragment);

    return line;
  }
};

cmpParallaxTextHighlight.init();
.myClass {
  color: green
}

.cmp-parallax-text-highlight__paragraph_line {
  display: block;
  white-space: nowrap;
}
<div style="height: 100px; width: 100%"></div>

<div class="cmp-parallax-text-highlight" data-component="cmpParallaxTextHighlight">
  <div class="cmp-parallax-text-highlight__background"></div>
  <div class="cmp-parallax-text-highlight__inner_content container">
    <div class="row">
      <div class="col-8">
        <p class="cmp-parallax-text-highlight__paragraph">Lorem ipsum dolor <span class="myClass">sit amet</span>, consectetur adipiscing elit. <span class="myClass">Quisque fermentum sem magna</span>, vitae pharetra neque </p>
      </div>
    </div>
  </div>
</div>
站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章