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

javascript - Possible to have a dynamically height adjusted textarea without constant reflows? - Stack Overflow

programmeradmin1浏览0评论

Note: This is not a duplicate as far as I can tell, as using a contentEditable div doesn't seem to be a good alternative. It has numerous problems (no placeholder text, need to use the dangerouslySetInnerHTML hack to update text, selection cursor is finicky, other browser issues, etc.) I would like to use a textarea.

I'm currently doing something this for my React textarea component:

componentDidUpdate() {
  let target = this.textBoxRef.current;

  target.style.height = 'inherit';
  target.style.height = `${target.scrollHeight + 1}px`; 
}

This works and allows the textarea to dynamically grow and shrink in height as line breaks are added and removed.

The problem is that on every text change there is a reflow occurring. This causes a lot of lag in the application. If I hold down a key in the textarea there is delay and lag as the characters are appended.

If I remove the target.style.height = 'inherit'; line the lag goes away, so I know it's being caused by this constant reflow.

I heard that setting overflow-y: hidden might get rid of the constant reflow, but it did not in my case. Likewise, setting target.style.height = 'auto'; did not allow for dynamic resize.

I currently have developed a solution to this which works, but I don't like it, as it is an O(n) operation for every time the text changes. I just count the number of line breaks and set the size accordingly, like this:

// In a React Component

handleMessageChange = e => { 
  let breakCount = e.target.value.split("\n").length - 1;

  this.setState({ breakCount: breakCount });
}

render() {
  let style = { height: (41 + (this.state.breakCount * 21)) + "px" };

  return (
    <textarea onChange={this.handleMessageChange} style={style}></textarea>
  );
}

Note: This is not a duplicate as far as I can tell, as using a contentEditable div doesn't seem to be a good alternative. It has numerous problems (no placeholder text, need to use the dangerouslySetInnerHTML hack to update text, selection cursor is finicky, other browser issues, etc.) I would like to use a textarea.

I'm currently doing something this for my React textarea component:

componentDidUpdate() {
  let target = this.textBoxRef.current;

  target.style.height = 'inherit';
  target.style.height = `${target.scrollHeight + 1}px`; 
}

This works and allows the textarea to dynamically grow and shrink in height as line breaks are added and removed.

The problem is that on every text change there is a reflow occurring. This causes a lot of lag in the application. If I hold down a key in the textarea there is delay and lag as the characters are appended.

If I remove the target.style.height = 'inherit'; line the lag goes away, so I know it's being caused by this constant reflow.

I heard that setting overflow-y: hidden might get rid of the constant reflow, but it did not in my case. Likewise, setting target.style.height = 'auto'; did not allow for dynamic resize.

I currently have developed a solution to this which works, but I don't like it, as it is an O(n) operation for every time the text changes. I just count the number of line breaks and set the size accordingly, like this:

// In a React Component

handleMessageChange = e => { 
  let breakCount = e.target.value.split("\n").length - 1;

  this.setState({ breakCount: breakCount });
}

render() {
  let style = { height: (41 + (this.state.breakCount * 21)) + "px" };

  return (
    <textarea onChange={this.handleMessageChange} style={style}></textarea>
  );
}
Share Improve this question edited Sep 16, 2019 at 22:56 Ryan Peschel asked Sep 16, 2019 at 22:48 Ryan PeschelRyan Peschel 11.8k22 gold badges87 silver badges161 bronze badges 8
  • 3 Look at how any of the existing libraries do it (or use one of them). For example (demo). An important part of that is the debounce with a wait of 166ms, so it doesn't reflow constantly. And the hidden "shadow" <textarea>. – thirtydot Commented Sep 16, 2019 at 23:14
  • what do you mean by constant reflows? – ngShravil.py Commented Sep 19, 2019 at 6:00
  • @ngShravil.py I mean the browser does a reflow every time the text in the textarea changes (due to accessing target.style.height) – Ryan Peschel Commented Sep 19, 2019 at 6:13
  • 1 unfortunately that won't work either. for example, if you hold down a key and cause the message to go to the next line while doing so, the textarea should expand while the key is being held down. @apachuilo that still involves counting the line breaks on every text change. – Ryan Peschel Commented Sep 21, 2019 at 3:19
  • 1 I've stumbled upon this question again. At this point just directly use the Material-UI code. You can literally copy the single (2.1kB gzipped) file I linked to within 30 mins of your question being asked. You don't need to import Material-UI at all, if you don't want to. It makes little sense to "hack together" your own version in this way. You might be suffering from "not invented here syndrome " or "reinventing the wheel". It can be good to code it yourself to understand, but you should use the existing solution in the end. – thirtydot Commented Sep 24, 2019 at 2:05
 |  Show 3 more comments

5 Answers 5

Reset to default 8

I think thirtydot's recommendation may be the best. The Material UI textarea he linked has a pretty clever solution.

They create a hidden absolutely positioned textarea that mimics the style and width of the actual textarea. Then they insert the text you type into that textarea and retrieve the height of it. Because it is absolutely positioned there is no reflow calculation. They then use that height for the height of the actual textarea.

I don't fully understand all of what their code is doing, but I've hacked together a minimal repurposement for my needs, and it seems to work well enough. Here are some snippets:

.shadow-textarea {
  visibility: hidden;
  position: absolute;
  overflow: hidden;
  height: 0;
  top: 0;
  left: 0
}
<textarea ref={this.chatTextBoxRef} style={{ height: this.state.heightInPx + "px" }}
          onChange={this.handleMessageChange} value={this.props.value}>
</textarea>

<textarea ref={this.shadowTextBoxRef} className="shadow-textarea" />
componentDidUpdate() {
  this.autoSize();
}

componentDidMount() {
  this.autoSize();
}
autoSize = () => {
  let computedStyle = window.getComputedStyle(this.chatTextBoxRef.current); // this is fine apparently..?

  this.shadowTextBoxRef.current.style.width = computedStyle.width; // apparently width retrievals are fine
  this.shadowTextBoxRef.current.value = this.chatTextBoxRef.current.value || 'x';

  let innerHeight = this.shadowTextBoxRef.current.scrollHeight; // avoiding reflow because we are retrieving the height from the absolutely positioned shadow clone

  if (this.state.heightInPx !== innerHeight) { // avoids infinite recursive loop
    this.setState({ heightInPx: innerHeight });
  }
}

A bit hacky but it seems to work well enough. If anyone can decently improve this or clean it up with a more elegant approach I'll accept their answer instead. But this seems to be the best approach considering Material UI uses it and it is the only one I've tried so far that eliminates the expensive reflow calculations that cause lag in a sufficiently complex application.

Chrome is only reporting reflow occurring once when the height changes, as opposed to on every keypress. So there is still a single 30ms lag when the textarea grows or shrinks, but this is much better than on every key stroke or text change. The lag is 99% gone with this approach.

NOTE: Ryan Peschel's answer is better.

Original Post: I have heavily modified apachuilo's code to achieve the desired result. It adjusts the height based on the scrollHeight of the textarea. When the text in the box is changed, it sets the box's number of rows to the value of minRows and measures the scrollHeight. Then, it calculates the number of rows of text and changes the textarea's rows attribute to match the number of rows. The box does not "flash" while calculating.

render() is only called once, and only the rows attribute is changed.

It took about 500ms to add a character when I put in 1000000 (a million) lines of at least 1 character each. Tested it in Chrome 77.

CodeSandbox: https://codesandbox.io/s/great-cherry-x1zrz

import React, { Component } from "react";

class TextBox extends Component {
  textLineHeight = 19;
  minRows = 3;

  style = {
    minHeight: this.textLineHeight * this.minRows + "px",
    resize: "none",
    lineHeight: this.textLineHeight + "px",
    overflow: "hidden"
  };

  update = e => {
    e.target.rows = 0;
    e.target.rows = ~~(e.target.scrollHeight / this.textLineHeight);
  };

  render() {
    return (
      <textarea rows={this.minRows} onChange={this.update} style={this.style} />
    );
  }
}

export default TextBox;

While it's not possible to eliminate all reflows — the browser has to calculate the height at some point — it is possible to reduce them significantly.

Per Paul Irish (a Chrome developer), elem.scrollHeight is among the property accesses & methods that cause a reflow. However, there is a significant note:

Reflow only has a cost if the document has changed and invalidated the style or layout. Typically, this is because the DOM was changed (classes modified, nodes added/removed, even adding a psuedo-class like :focus).

This is where, for plain text, a textarea is actually superior to a <div contenteditable>. For a div, typing changes the innerHTML, which is actually a Text node. As such, modifying the text in any way also modifies the DOM, causing a reflow. In the case of a textarea, typing only changes its value property — nothing touches the DOM, all that's required is repainting, which is (comparatively) very cheap. This allows the rendering engine to cache the value as indicated by the above quote.

Because of the browser's cacheing of scrollHeight, you can use the "classic" advice — fetch that value and immediately set it to the actual height.

function resizeTextarea(textarea) {
    textarea.style.height = 'auto';
    textarea.style.height = `${textarea.style.scrollHeight}px`;
}

Use that method any time the value changes, which will ensure the textarea remains at a height that does not scroll. Don't worry about the consecutive setting of the property, as the browser executes these together (similar to requestAnimationFrame).

This is true in all WebKit-based browsers, which are currently Chrome and Opera, and soon to be Edge as well. I presume Firefox and Safari have similar implementations.

Personally I couldn't image reading all those line breaks like that being too much of a problem unless your writing a novel, but I don't know. You could try adjusting the number of breaks based on keystroke.

Sandbox here.

import React, { Component } from "react";

class TextBox extends Component {
  state = {
    breakCount: 0
  };

  handleKeyDown = e => {
    if (e.key === "Enter") {
      this.setState({ breakCount: this.state.breakCount + 1 });
    }

    // Note you will want something to better detect if a newline is being deleted. Could do this with more logic
    // For quick testing of spamming enter/backspace key though this works.
    if (e.key === "Backspace" && this.state.breakCount > 0) {
      this.setState({ breakCount: this.state.breakCount - 1 });
    }
  };

  render() {
    const style = { height: 41 + this.state.breakCount * 21 + "px" };

    return <textarea onKeyDown={this.handleKeyDown} style={style} />;
  }
}

export default TextBox;

A "modern" hooks-apporach only using built-in features of react would be useRef and useLayoutEffects. This approach updates the height of the textarea triggered by the change of value before any rendering in the browser and therefor avoids any flickering/jumping of the textarea.

import React from "react";

const MIN_TEXTAREA_HEIGHT = 32;

export default function App() {
  const textareaRef = React.useRef(null);
  const [value, setValue] = React.useState("");
  const onChange = (event) => setValue(event.target.value);

  React.useLayoutEffect(() => {
    // Reset height - important to shrink on delete
    textareaRef.current.style.height = "inherit";
    // Set height
    textareaRef.current.style.height = `${Math.max(
      textareaRef.current.scrollHeight,
      MIN_TEXTAREA_HEIGHT
    )}px`;
  }, [value]);

  return (
    <textarea
      onChange={onChange}
      ref={textareaRef}
      style={{
        minHeight: MIN_TEXTAREA_HEIGHT,
        resize: "none"
      }}
      value={value}
    />
  );
}

https://codesandbox.io/s/react-textarea-auto-height-s96b2

发布评论

评论列表(0)

  1. 暂无评论