Saturday, July 22, 2017

Performance improvement to React Text Clamp?

Leave a Comment

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?

2 Answers

Answers 1

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. With that approach and a few other optimizations, see the inline comments. Anecdotally I see a 3x improvement in render time.

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} /> 

Answers 2

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;             }         }     } } 
If You Enjoyed This, Take 5 Seconds To Share It

0 comments:

Post a Comment