Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Performance improvement to React Text Clamp?

I'm trying to make a reusable React text-clamp component. The user passes in the number of lines to render and the text they want to display, and the component 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(' '),
        };
    }

    componentDidMount() {
        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 component 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 complaining 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?

like image 973
MarksCode Avatar asked Jul 13 '17 22:07

MarksCode


People also ask

What makes Reactjs performance faster?

Immutable data is the answer to this problem. By definition, immutable data structures never change. Immutable data allows you to compare direct object references instead of doing deep-tree comparisons. This makes a React app faster.

How do you test the performance of a reaction?

Using React Developer Tools​In the React Developer Tools tab, there will be a tab called "Profiler". Click the record button to start recording performance data and, after using your app, click the "Stop" button.

Why is my React app so slow?

Basically always when your React application is slow it's due to too many components re-rendering or too often. React have very useful Developer Tools, where you can profile your App and then find out which components are taking most of the time to render.


2 Answers

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 component 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 comments for more.

Take a look at this component 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: []
        }
    }

    computeText = () => {

        // 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 computeText 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 complete 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,
        })
    };

    componentDidMount() {
        this.computeText();
    }

    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} />
like image 62
enjoylife Avatar answered Sep 30 '22 22:09

enjoylife


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 recommend loading the codepen and resizing your window to see how fast it recalculates.

EDIT: I updated this component so that you could add custom functionality for both expand and collapse. These are completely 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;
        }
    }

    componentDidMount() {
        // 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 computedStyles so we ensure we measure with the correct font-size and letter-spacing
        const computedStyles = window.getComputedStyle( this.textDisplayEl, null );
        measurementEl.style.fontSize = computedStyles.getPropertyValue( "font-size" );
        measurementEl.style.letterSpacing = computedStyles.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();
    }

    componentWillUnmount() {
        // 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;
            }
        }
    }
}
like image 23
Kyle Richardson Avatar answered Sep 30 '22 23:09

Kyle Richardson