最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

javascript - Performance improvement to React Text Clamp? - Stack Overflow

programmeradmin4浏览0评论

I'm trying to make a reusable React text-clamp ponent. The user passes in the number of lines to render and the text they want to display, and the ponent renders their text, cutting it off at the specified number of lines and inserting an ellipsis (...) at the end.

The way I'm calculating where to cut off the text and insert the ellipsis is to add one word at a time until the clientHeight of the text is bigger than the clientHeight of the container div.

While it works, I'm seeing the following in the chrome dev tools:

[Violation] Forced reflow while executing JavaScript took 179ms.

This is probably due to the fact that reading clientHeight forces reflow.

Here's my code:

class TextClamp extends React.PureComponent {

    constructor(props) {
        super(props);
        this.renderText = this.renderText.bind(this);
        this.state = {
            words: this.props.textToDisplay.split(' '),
        };
    }

    ponentDidMount() {
        this.renderText(); 
    }

    renderText(isResizing = false) {
        const textEl = this.displayedText;
        const clampContainer = this.clampContainer;
        const heightToStop = isResizing ? clampContainer.style.height : this.letterHeightText.clientHeight * this.props.linesToRender;
        const dummyText = this.dummyText;
        const dummyDiv = this.dummyDiv;
        const words = this.state.words;
        const numWords = words.length;
        dummyDiv.style.cssText = `width: ${clampContainer.clientWidth}px; position: absolute; left: -1000px;`;

        let i = this.props.estimatedWordCount || 20;
        let strToRender = words.slice(0, i).join(' ');
        dummyText.textContent = strToRender;
        if (dummyText.clientHeight <= heightToStop && i>=numWords) {
            return;
        }
        while (dummyText.clientHeight <= heightToStop && i<numWords) {
           dummyText.textContent += ' ' + words[i++];
        };
        strToRender = dummyText.textContent;
        while (dummyText.clientHeight > heightToStop) {
            strToRender = strToRender.substring(0, strToRender.lastIndexOf(' '));
            dummyText.textContent = strToRender + '\u2026';
        }
        textEl.textContent = dummyText.textContent;
    }

    render() {
        const estimatedHeight = this.props.estimatedHeight || 20 * this.props.linesToRender;
        const containerStyle = { height: estimatedHeight, overflow: 'hidden'};
        if (typeof window !== 'undefined') {
            const dummyDiv = document.createElement('div');
            const dummyText = document.createElement('p');
            dummyDiv.appendChild(dummyText);
            this.dummyDiv = dummyDiv
            this.dummyText = dummyText
            document.body.appendChild(dummyDiv);
        }
        return (
            <div style={containerStyle} ref={(input) => {this.clampContainer = input;}}>
                <p ref={(input) => {this.displayedText = input;}}>{this.props.textToDisplay}</p>
                <p style={{visibility: 'hidden'}} ref={(input) => {this.letterHeightText = input;}}>Q</p>
            </div>
        );
    }
}

So basically, the main workhorse of the ponent is the renderText() function. In there I'm adding one word at a time until the height of the text is greater than that of its container. From there, I remove the last word and add the ellipsis.

The optimizations I've made are the following:

  1. estimatedWordCount allows the loop that adds one word a time to not have to start at the beginning each time.

  2. I calculate the text that should be displayed by copying the dimensions of the actual container div to an offscreen, position:absolute div so it has no interaction with the other DOM elements.

However, even with my optimizations chrome is still plaining that reflow due to javascript is taking too long.

Are there any optimizations to my renderText() function I can make to avoid reading the clientHeight so often?

I'm trying to make a reusable React text-clamp ponent. The user passes in the number of lines to render and the text they want to display, and the ponent renders their text, cutting it off at the specified number of lines and inserting an ellipsis (...) at the end.

The way I'm calculating where to cut off the text and insert the ellipsis is to add one word at a time until the clientHeight of the text is bigger than the clientHeight of the container div.

While it works, I'm seeing the following in the chrome dev tools:

[Violation] Forced reflow while executing JavaScript took 179ms.

This is probably due to the fact that reading clientHeight forces reflow.

Here's my code:

class TextClamp extends React.PureComponent {

    constructor(props) {
        super(props);
        this.renderText = this.renderText.bind(this);
        this.state = {
            words: this.props.textToDisplay.split(' '),
        };
    }

    ponentDidMount() {
        this.renderText(); 
    }

    renderText(isResizing = false) {
        const textEl = this.displayedText;
        const clampContainer = this.clampContainer;
        const heightToStop = isResizing ? clampContainer.style.height : this.letterHeightText.clientHeight * this.props.linesToRender;
        const dummyText = this.dummyText;
        const dummyDiv = this.dummyDiv;
        const words = this.state.words;
        const numWords = words.length;
        dummyDiv.style.cssText = `width: ${clampContainer.clientWidth}px; position: absolute; left: -1000px;`;

        let i = this.props.estimatedWordCount || 20;
        let strToRender = words.slice(0, i).join(' ');
        dummyText.textContent = strToRender;
        if (dummyText.clientHeight <= heightToStop && i>=numWords) {
            return;
        }
        while (dummyText.clientHeight <= heightToStop && i<numWords) {
           dummyText.textContent += ' ' + words[i++];
        };
        strToRender = dummyText.textContent;
        while (dummyText.clientHeight > heightToStop) {
            strToRender = strToRender.substring(0, strToRender.lastIndexOf(' '));
            dummyText.textContent = strToRender + '\u2026';
        }
        textEl.textContent = dummyText.textContent;
    }

    render() {
        const estimatedHeight = this.props.estimatedHeight || 20 * this.props.linesToRender;
        const containerStyle = { height: estimatedHeight, overflow: 'hidden'};
        if (typeof window !== 'undefined') {
            const dummyDiv = document.createElement('div');
            const dummyText = document.createElement('p');
            dummyDiv.appendChild(dummyText);
            this.dummyDiv = dummyDiv
            this.dummyText = dummyText
            document.body.appendChild(dummyDiv);
        }
        return (
            <div style={containerStyle} ref={(input) => {this.clampContainer = input;}}>
                <p ref={(input) => {this.displayedText = input;}}>{this.props.textToDisplay}</p>
                <p style={{visibility: 'hidden'}} ref={(input) => {this.letterHeightText = input;}}>Q</p>
            </div>
        );
    }
}

So basically, the main workhorse of the ponent is the renderText() function. In there I'm adding one word at a time until the height of the text is greater than that of its container. From there, I remove the last word and add the ellipsis.

The optimizations I've made are the following:

  1. estimatedWordCount allows the loop that adds one word a time to not have to start at the beginning each time.

  2. I calculate the text that should be displayed by copying the dimensions of the actual container div to an offscreen, position:absolute div so it has no interaction with the other DOM elements.

However, even with my optimizations chrome is still plaining that reflow due to javascript is taking too long.

Are there any optimizations to my renderText() function I can make to avoid reading the clientHeight so often?

Share Improve this question edited Jul 17, 2017 at 5:34 MarksCode asked Jul 13, 2017 at 22:16 MarksCodeMarksCode 8,61417 gold badges71 silver badges143 bronze badges 8
  • 1 The first idea that es to mind is averaging the number of lines and multiplying this by the line heights. Then you increment the lines, instead of words, without calculating the heights. You kind of "debounce" your height putations this way. – Sumi Straessle Commented Jul 17, 2017 at 14:34
  • Sorry, I'm a little confused. Can you please elaborate? – MarksCode Commented Jul 17, 2017 at 18:46
  • Work with lines instead of words, get a sense of how many lines you need before adding the lines, pute heights only after 80% of the job is already done. You want to avoid te-renders. – Sumi Straessle Commented Jul 17, 2017 at 18:47
  • 1 overflow: hidden; text-overflow: ellipsis – nanobar Commented Jul 20, 2017 at 12:32
  • 1 @DominicTobias that doesn't work with multiline text – MarksCode Commented Jul 20, 2017 at 17:39
 |  Show 3 more ments

2 Answers 2

Reset to default 3 +50

Going off the requirements as stated:

The user passes in the number of lines to render and the text they want to display, and the ponent renders their text, cutting it off at the specified number of lines and inserting an ellipsis (...) at the end.

One route is to forgo height calculations and only worry about the width, adding words up until our line width bumps with its container, and keeping track of lines added until the max number of specified lines is reached.

This approach gives a large speedup as it avoids reaching out to the DOM as much. Anecdotally I see a 3x speed up in render time. Using this approach and a few other optimizations, see the inline ments for more.

Take a look at this ponent which I coded up, listed here for context. Also look at the example usage below.

import React, {Component} from "react";

class TextClamp extends Component {
    constructor(props) {
        super(props);
        this.state = {
            lines: []
        }
    }

    puteText = () => {

        // Our desired text width we are trying to hit
        const width = this.container.clientWidth;

        // we reverse the word list so can take grab elements efficiently using pops
        // pops are O(1) while unshift is O(n).
        let words = this.props.textToDisplay.split(/\s+/).reverse();

        // we keep lines separate, rather than all concatenated together with \n,
        // because react will remove new lines unless we resort to using
        // dangerouslySetInnerHTML, which we should prefer to avoid
        let lines = [];

        // we reset any previous text to avoid bugs if we happen to call puteText more than once
        this.textContainer.textContent = "";

        let lineNumber = 0;

        // first word and line init
        let word = words.pop();
        lines[lineNumber] = "";

        // Our goal is to build up the lines array to contain at most
        // linesToRender elements, with each line's width being at most
        // the width of our container
        while (word ) {

            // add our word
            lines[lineNumber] += " " + word;
            this.textContainer.textContent += " " + word;


            // too wide, so we instead start a new line
            if (this.textContainer.clientWidth >= width) {
                // add back the word for the next line
                words.push(word);
                // remove our last added and clean up
                lines[lineNumber] = lines[lineNumber].slice(0, -word.length).trim();

                // already at linesToRender, therefore we cannot render plete text,
                // so we add our ellipsis
                if(lineNumber === this.props.linesToRender-1) {
                    lines[lineNumber] += " ..."
                    break;
                }

                // remove current text so we can calculate our next line width
                this.textContainer.textContent = "";

                console.log(lineNumber, this.props.linesToRender)


                lineNumber++;
                // init our next line
                lines[lineNumber] = "";
            }



            // next word
            word = words.pop()
            console.log(word)
        }

        // clean up just like we added a new line,
        lines[lineNumber] = lines[lineNumber].trim();


        // remove current text so when react renders it has a clean slate to add text elements
        this.textContainer.textContent = "";

        this.setState({
            lines: lines,
        })
    };

    ponentDidMount() {
        this.puteText();
    }

    render() {

        // we need our 'pre for our whiteSpace, to explicitly control when our text breaks
        const containerStyle = {whiteSpace: 'pre'};
        // we need 'inline-block' so our p tag's width reflects the amount of text added, not its parent
        const textStyle = {display: 'inline-block'};

        // put line breaks between all the lines, except the first
        const lines = this.state.lines.map((text, i) => i ? [<br/>, text] : text);
        console.log(this.state.lines)
        return (
            <div style={containerStyle} ref={(input) => {
                this.container = input;
            }}>
                <p style={textStyle} ref={(input) => {
                    this.textContainer = input;
                }}>
                    {lines}
                </p>
            </div>
        );
    }
}

TextClamp.defaultProps = {
    linesToRender: 2,
    textToDisplay: ""

};

Usage:

const exampleText = "This is an example piece of text. It should properly break lines at the correct width of it's parent, until it a certain max number of lines have been created. However sometimes the text, is too long to fit on the specified number of lines. At that point the line should be cut off."
const lines = 3
<TextClamp  linesToRender={lines} textToDisplay={exampleText} />

Here is a very fast solution to this problem that uses a technique to store the width of each word in the text and then build each line based off the a maxWidth and an accumulated width of the words on the line. Very little DOM manipulation so its very fast. Even works with resize option without throttling and looks great :)

Only one DOM manipulation per update! Auto clamps on resizing! All you need to do is provide it 2 properties. A text property of the text you want clamped and a numeri lines property denoting how many lines you want displayed. You can set reset={ false } if you want, but I don't really see a need. It resizes super fast.

Hope you enjoy and feel free to ask any question you may have! The code below is es6, and here's a working Codepen that has been slightly adapted to work on Codepen.io.

I remend loading the codepen and resizing your window to see how fast it recalculates.

EDIT: I updated this ponent so that you could add custom functionality for both expand and collapse. These are pletely optional, and you can provide any portion of the controls object you want. I.E. only provide the text for a collapse option.

You can provide a controls object as <TextClamp controls={ ... } now. Here is the shame of the controls object:

controls = {
    expandOptions: {
        text: string, // text to display
        func: func // func when clicked
    },
    collapseOptions: {
        text: string, // text to display
        func: func // func when clicked
    }
}

Both text and lines are requires props.

Text-clamp.js

import React, { PureComponent } from "react";
import v4 from "uuid/v4";
import PropTypes from "prop-types";

import "./Text-clamp.scss"

export default class TextClamp extends PureComponent {
    constructor( props ) {
        super( props );

        // initial state
        this.state = {
            displayedText: "",
            expanded: false
        }

        // generate uuid
        this.id = v4();

        // bind this to methods
        this.produceLines = this.produceLines.bind( this );
        this.handleExpand = this.handleExpand.bind( this );
        this.handleCollapse = this.handleCollapse.bind( this );
        this.updateDisplayedText = this.updateDisplayedText.bind( this );
        this.handleResize = this.handleResize.bind( this );

        // setup default controls
        this.controls = {
            expandOptions: {
                text: "Show more...",
                func: this.handleExpand
            },
            collapseOptions: {
                text: "Collapse",
                func: this.handleCollapse
            }
        }

        // merge default controls with provided controls
        if ( this.props.controls ) {
            this.controls = mergedControlOptions( this.controls, this.props.controls );
            this.handleExpand = this.controls.expandOptions.func;
            this.handleCollapse = this.controls.collapseOptions.func;
        }
    }

    ponentDidMount() {
        // create a div and set some styles that will allow us to measure the width of each
        // word in our text
        const measurementEl = document.createElement( "div" );
        measurementEl.style.visibility = "hidden";
        measurementEl.style.position = "absolute";
        measurementEl.style.top = "-9999px";
        measurementEl.style.left = "-9999px";
        measurementEl.style.height = "auto";
        measurementEl.style.width = "auto";
        measurementEl.style.display = "inline-block";

        // get putedStyles so we ensure we measure with the correct font-size and letter-spacing
        const putedStyles = window.getComputedStyle( this.textDisplayEl, null );
        measurementEl.style.fontSize = putedStyles.getPropertyValue( "font-size" );
        measurementEl.style.letterSpacing = putedStyles.getPropertyValue( "letter-spacing" );

        // add measurementEl to the dom
        document.body.appendChild( measurementEl );

        // destructure props
        const { text, lines, resize } = this.props;

        // reference container, linesToProduce, startAt, and wordArray on this
        this.container = document.getElementById( this.id );
        this.linesToProduce = lines;
        this.startAt = 0;
        this.wordArray = text.split( " " );


        // measure each word and store reference to their widths
        let i, wordArrayLength = this.wordArray.length, wordArray = this.wordArray, wordWidths = { };
        for ( i = 0; i < wordArrayLength; i++ ) {
            measurementEl.innerHTML = wordArray[ i ];
            if ( !wordWidths[ wordArray[ i ] ] ) {
                wordWidths[ wordArray[ i ] ] = measurementEl.offsetWidth;
            }
        }

        const { expandOptions } = this.controls;

        measurementEl.innerHTML = expandOptions.text;
        wordWidths[ expandOptions.text ] = measurementEl.offsetWidth;
        measurementEl.innerHTML = "&nbsp;";
        wordWidths[ "WHITESPACE" ] = measurementEl.offsetWidth;

        // reference wordWidths on this
        this.wordWidths = wordWidths;

        // produce lines from ( startAt, maxWidth, wordArray, wordWidths, linesToProduce )
        this.updateDisplayedText();

        this.resize = resize === false ? reisze : true

        // if resize prop is true, enable resizing
        if ( this.resize ) {
            window.addEventListener( "resize", this.handleResize, false );
        }
    }

    produceLines( startAt, maxWidth, wordArray, wordWidths, linesToProduce, expandOptions ) {
        // use _produceLine function to recursively build our displayText
        const displayText = _produceLine( startAt, maxWidth, wordArray, wordWidths, linesToProduce, expandOptions );

        // update state with our displayText
        this.setState({
            ...this.state,
            displayedText: displayText,
            expanded: false
        });
    }

    updateDisplayedText() {
        this.produceLines(
            this.startAt,
            this.container.offsetWidth,
            this.wordArray,
            this.wordWidths,
            this.linesToProduce,
            this.controls.expandOptions
        );
    }

    handleResize() {
        // call this.updateDisplayedText() if not expanded
        if ( !this.state.expanded ) {
            this.updateDisplayedText();
        }
    }

    handleExpand() {
        this.setState({
            ...this.state,
            expanded: true,
            displayedText: <span>{ this.wordArray.join( " " ) } - <button
                className="_text_clamp_collapse"
                type="button"
                onClick={ this.handleCollapse }>
                    { this.controls.collapseOptions.text }
                </button>
            </span>
        });
    }

    handleCollapse() {
        this.updateDisplayedText();
    }

    ponentWillUnmount() {
        // unsubscribe to resize event if resize is enabled
        if ( this.resize ) {
            window.removeEventListener( "resize", this.handleResize, false );
        }
    }

    render() {
        // render the displayText
        const { displayedText } = this.state;
        return (
            <div id={ this.id } className="_text_clamp_container">
                <span className="_clamped_text" ref={ ( el ) => { this.textDisplayEl = el } }>{ displayedText }</span>
            </div>
        );
    }
}

TextClamp.propTypes = {
    text: PropTypes.string.isRequired,
    lines: PropTypes.number.isRequired,
    resize: PropTypes.bool,
    controls: PropTypes.shape({
        expandOptions: PropTypes.shape({
            text: PropTypes.string,
            func: PropTypes.func
        }),
        collapseOptions: PropTypes.shape({
            text: PropTypes.string,
            func: PropTypes.func
        })
    })
}

function mergedControlOptions( defaults, provided ) {
    let key, subKey, controls = defaults;
    for ( key in defaults ) {
        if ( provided[ key ] ) {
            for ( subKey in provided[ key ] ) {
                controls[ key ][ subKey ] = provided[ key ][ subKey ];
            }
        }
    }

    return controls;
}

function _produceLine( startAt, maxWidth, wordArray, wordWidths, linesToProduce, expandOptions, lines ) {
    let i, width = 0;
    // format and return displayText if all lines produces
    if ( !( linesToProduce > 0 ) ) {

        let lastLineArray = lines[ lines.length - 1 ].split( " " );
        lastLineArray.push( expandOptions.text );

        width = _getWidthOfLastLine( wordWidths, lastLineArray );

        width - wordWidths[ "WHITESPACE" ];

        lastLineArray = _trimResponseAsNeeded( width, maxWidth, wordWidths, lastLineArray, expandOptions );

        lastLineArray.pop();

        lines[ lines.length - 1 ] = lastLineArray.join( " " );

        let formattedDisplay = <span>{ lines.join( " " ) } - <button
            className="_text_clamp_show_all"
            type="button"
            onClick={ expandOptions.func }>{ expandOptions.text }</button></span>

        return formattedDisplay;
    }

    // increment i until width is > maxWidth
    for ( i = startAt; width < maxWidth; i++ ) {
        width += wordWidths[ wordArray[ i ] ] + wordWidths[ "WHITESPACE" ];
    }

    // remove last whitespace width
    width - wordWidths[ "WHITESPACE" ];

    // use wordArray.slice with the startAt and i - 1 to get the words for the line and
    // turn them into a string with .join
    let newLine = wordArray.slice( startAt, i - 1 ).join( " " );

    // return the production of the next line adding the lines argument
    return _produceLine(
        i - 1,
        maxWidth,
        wordArray,
        wordWidths,
        linesToProduce - 1,
        expandOptions,
        lines ? [ ...lines, newLine ] : [ newLine ],
    );
}

function _getWidthOfLastLine( wordWidths, lastLine ) {
    let _width = 0, length = lastLine.length, i;
    _width = ( wordWidths[ "WHITESPACE" ] * 2 )
    for ( i = 0; i < length; i++ ) {
        _width += wordWidths[ lastLine[ i ] ] + wordWidths[ "WHITESPACE" ];
    }

    return _width;
}

function _trimResponseAsNeeded( width, maxWidth, wordWidths, lastLine, expandOptions ) {
    let _width = width,
        _maxWidth = maxWidth,
        _lastLine = lastLine;

    if ( _width > _maxWidth ) {
        _lastLine.splice( length - 2, 2 );
        _width = _getWidthOfLastLine( wordWidths, _lastLine );
        if ( _width > _maxWidth ) {
            _lastLine.push( expandOptions.text );
            return _trimResponseAsNeeded( _width, _maxWidth, wordWidths, _lastLine, expandOptions );
        } else {
            _lastLine.splice( length - 2, 2 );
            _lastLine.push( expandOptions.text );
            if ( _getWidthOfLastLine( wordWidths, lastLine ) > maxWidth ) {
                return _trimResponseAsNeeded( _width, _maxWidth, wordWidths, _lastLine, expandOptions );
            }
        }
    } else {
        _lastLine.splice( length - 1, 1 );
    }

    return _lastLine;
}

Text-clamp.scss

._text_clamp_container {
    ._clamped_text {
        ._text_clamp_show_all, ._text_clamp_collapse {
            background-color: transparent;
            padding: 0px;
            margin: 0px;
            border: none;
            color: #2369aa;
            cursor: pointer;
            &:focus {
                outline: none;
                text-decoration: underline;
            }
            &:hover {
                text-decoration: underline;
            }
        }
    }
}
发布评论

评论列表(0)

  1. 暂无评论