Улучшение производительности для устранения текстового зажима?

Я пытаюсь сделать многоразовый компонент React text-clamp. Пользователь передает количество строк для рендеринга и текст, который они хотят отобразить, и компонент отображает их текст, отсекая его по указанному количеству строк и вставляя в конец многоточие (...).

Как я вычисляю, где отрезать текст и вставить многоточие, нужно добавить одно слово за раз, пока текст clientHeight больше, чем clientHeight контейнера div.

Пока он работает, я вижу в инструментах chrome dev следующее:

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

Это, вероятно, связано с тем, что чтение clientHeight принудительно завершает.

Здесь мой код:

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>
        );
    }
}

Таким образом, основной рабочей лошадкой компонента является функция renderText(). Там я добавляю одно слово за раз, пока высота текста больше, чем высота его контейнера. Оттуда я удаляю последнее слово и добавляю многоточие.

Оптимизации, которые я сделал, следующие:

  • evaluationWordCount позволяет циклу добавлять одно слово в то время, когда ему не нужно начинать с самого начала каждый раз.

  • Я вычисляю текст, который должен отображаться, копируя размеры фактического контейнера div на заставку, position:absolute div, чтобы он не взаимодействовал с другими элементами DOM.

Однако даже с моей оптимизацией хром все еще жалуется, что переполнение из-за javascript занимает слишком много времени.

Есть ли какие-либо оптимизации для моей функции renderText(), которую я могу сделать, чтобы не часто читать clientHeight?

Ответ 1

Вот очень быстрое решение этой проблемы, которое использует технику для хранения ширины каждого слова в тексте, а затем строит каждую строку на основе максимальной ширины и накопленной ширины слов в строке. Очень мало манипуляций с DOM так быстро. Даже работает с параметром изменения размера без дросселирования и выглядит великолепно:)

Только одно манипулирование DOM за обновление! Автоматические зажимы при изменении размера! Все, что вам нужно сделать, это предоставить ему 2 свойства. Текстовое свойство текста, который вы хотите зафиксировать, и свойство numi lines, обозначающее, сколько строк вы хотите отобразить. Вы можете установить reset= {false}, если хотите, но я действительно не вижу необходимости. Он очень быстро изменяется.

Надеюсь, вам понравится и вы можете задать любой вопрос, который у вас есть! Ниже приведен код es6, и здесь работает Codepen, который был слегка адаптирован для работы с Codepen.io.

Я рекомендую загрузить код и изменить размер вашего окна, чтобы узнать, как быстро он пересчитывает.

EDIT: я обновил этот компонент, чтобы вы могли добавлять пользовательские функции для расширения и сглаживания. Они полностью необязательны, и вы можете предоставить любую часть объекта управления, который вы хотите. И.Е. только предоставить текст для опции свернуть.

Теперь вы можете предоставить объект управления как <TextClamp controls={ ... }. Вот позор объекта управления:

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

Оба text и lines нуждаются в реквизитах.

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

Текст 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;
            }
        }
    }
}

Ответ 2

Отключение требований, как указано:

Пользователь передает количество строк для рендеринга и текст, который они хотят отобразить, а компонент отображает их текст, вырезая его с указанным количеством строк и вставляя в конце многоточие (...).

Один из способов состоит в том, чтобы отклонять вычисления высоты и беспокоиться только о ширине, добавляя слова до тех пор, пока наша ширина линии не станет удачной с ее контейнером и не будет отслеживать добавленные строки до тех пор, пока не будет достигнуто максимальное количество указанных строк.

Этот подход дает большое ускорение, поскольку он позволяет избежать доступа к DOM так же. Анекдотически я вижу 3-кратное ускорение в времени рендеринга. Используя этот подход и несколько других оптимизаций, см. Встроенные комментарии для большего.

Посмотрите на этот компонент, который я закодировал, перечисленный здесь для контекста. Также посмотрите пример использования ниже.

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 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 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: ""

};

Использование:

const exampleText = "This is an example piece of text. It should properly break lines at the correct width of it 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} />