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:
estimatedWordCount allows the loop that adds one word a time to not have to start at the beginning each time.
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?
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.
Using React Developer ToolsIn 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.
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.
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} />
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 = " ";
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 love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With