可以在没有持续回流的情况下动态调整高度的文本区域吗?

Posted

技术标签:

【中文标题】可以在没有持续回流的情况下动态调整高度的文本区域吗?【英文标题】:Possible to have a dynamically height adjusted textarea without constant reflows? 【发布时间】:2020-01-17 18:54:35 【问题描述】:

注意:据我所知,这不是重复的,因为使用 contentEditable div 似乎不是一个好的选择。它有很多问题(没有占位符文本,需要使用dangerouslySetInnerhtml hack 来更新文本,选择光标很挑剔,其他浏览器问题等)我想使用文本区域。

我目前正在为我的 React textarea 组件做一些事情:

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

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

这很有效,并且允许 textarea 在添加和删除换行符时动态增长和缩小高度。

问题是每次文本更改都会发生重排。这会导致应用程序出现很多延迟。如果我在 textarea 中按住一个键,则会在添加字符时出现延迟和滞后。

如果我删除 target.style.height = 'inherit'; 行,滞后就会消失,所以我知道这是由这种不断的回流引起的。

我听说设置overflow-y: hidden 可能会摆脱持续回流,但在我的情况下并没有。同样,设置 target.style.height = 'auto'; 不允许动态调整大小。

我目前已经为此开发了 a 解决方案,但我不喜欢它,因为每次文本更改时它都是 O(n) 操作。我只是计算换行符的数量并相应地设置大小,如下所示:

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

【问题讨论】:

看看现有的库是如何做到的(或使用其中一个)。对于example (demo)。其中一个重要部分是 debounce 等待 166 毫秒,因此它不会不断地回流。还有隐藏的“影子”&lt;textarea&gt; 持续回流是什么意思? @ngShravil.py 我的意思是每次textarea中的文本发生变化时浏览器都会进行重排(由于访问target.style.height 不幸的是,这也行不通。例如,如果您按住一个键并在此过程中使消息转到下一行,则在按住该键时 textarea 应该展开。 @apachuilo 仍然涉及计算每次文本更改时的换行符。 我又偶然发现了这个问题。此时只需directly use the Material-UI 代码。您可以在提出问题的 30 分钟内复制我链接到的单个(2.1kB gzipped)文件。如果您不想,则根本不需要导入 Material-UI。以这种方式“破解”您自己的版本毫无意义。您可能正遭受“非此处发明综合症”或“重新发明***”的困扰。自己编写代码来理解可能很好,但最终还是应该使用现有的解决方案。 【参考方案1】:

我认为三十点的推荐可能是最好的。他链接的Material UI textarea 有一个非常聪明的解决方案。

他们创建了一个隐藏的绝对定位的 textarea,它模仿了实际 textarea 的样式和宽度。然后他们将您键入的文本插入该文本区域并检索它的高度。因为它是绝对定位的,所以没有回流计算。然后他们使用该高度作为实际文本区域的高度。

我并不完全了解他们的代码在做什么,但我已经根据自己的需要进行了最小的重新调整,而且它似乎运行良好。以下是一些sn-ps:

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

有点hacky,但它似乎工作得很好。如果有人可以体面地改进它或用更优雅的方法清理它,我会接受他们的答案。但考虑到 Material UI 使用它,这似乎是最好的方法,而且它是迄今为止我尝试过的唯一一种方法,它消除了在足够复杂的应用程序中导致延迟的昂贵回流计算。

Chrome 仅在高度变化时报告回流一次,而不是在每次按键时发生。所以当 textarea 增长或缩小时仍然有一个 30ms 的延迟,但这比每次击键或文本更改要好得多。使用这种方法可以消除 99% 的延迟。

【讨论】:

这会在盒子改变时导致多次回流。您是正确的,获得宽度不会导致回流,但显然更改 .shadowbox 的宽度会导致回流。获取.shadowboxscrollHeight 也会导致回流。 (这也让我感到惊讶!)活动树:i.stack.imgur.com/dUk2a.png 标记代码:i.stack.imgur.com/Llf0B.png 带有您的代码的沙盒:codesandbox.io/s/epic-leakey-lqu27 我要睡觉了,所以我现在无法调查您的评论,但有些不同。 Material UI 使用这种复杂的方法是有原因的。此外,当我采用他们的解决方案时,所有回流延迟都消失了。 输入返回字符时,我的解决方案用了 3.0 毫秒,而这个用了 5.9 毫秒。 我之前尝试过您的解决方案,但它导致了与我原来的帖子中相同的卡顿,因为在每次文本更改时访问 textarea 元素上的 scrollHeight 会使 UI 冻结并重新流动,直到用户释放键。跨度> 我承认,我很困惑。我不认为 Material UI 解决方案会起作用,因为它也会导致回流(甚至显然是倍数!),但由于某种原因(浏览器特性?)它消除了滞后。 Material UI 开发人员必须知道一些我们不知道的事情。这对我来说是个谜。【参考方案2】:

注意:Ryan Peschel's answer 更好。

原帖:我已经大量修改了 apachuilo 的代码以达到预期的效果。它根据textareascrollHeight 调整高度。当框中的文本发生更改时,它将框的行数设置为minRows 的值并测量scrollHeight。然后,它计算文本的行数并更改textarearows 属性以匹配行数。计算时该框不会“闪烁”。

render() 只被调用一次,只改变了rows 属性。

当我输入 1000000 行(每行至少 1 个字符)时,添加一个字符大约需要 500 毫秒。在 Chrome 77 中进行了测试。

代码沙盒: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;

【讨论】:

不幸的是,这似乎与原始帖子中的第一个代码块没有太大区别,因为它也会在每次文本更改时访问 e.target.scrollHeight。您可以在 Chrome 团队的一位高级成员的this post 中看到,即使只是访问此属性也会导致重排。 我不确定当我原始帖子中的后一种代码解决方案做到这一点时,它怎么会被认为是不可能的,尽管通过计算换行符效率低下。 Your original code 不考虑自动换行。 不错,不错,这也是原始代码解决方案也不理想的另一个原因。 我已经发布了一个解决方案。在不引起回流的情况下确实可以做到这一点。【参考方案3】:

虽然不可能消除所有回流——浏览器必须在某个点计算高度——但可以显着减少它们。

Per Paul Irish(Chrome 开发人员),elem.scrollHeight 是导致回流的属性访问和方法之一。不过有significant note:

仅当文档更改并使样式或布局无效时,重排才会产生成本。通常,这是因为 DOM 已更改(修改了类,添加/删除了节点,甚至添加了 :focus 之类的伪类)。

这就是对于纯文本而言,textarea 实际上优于 &lt;div contenteditable&gt; 的地方。对于 div,键入会更改 innerHTML,实际上是 Text node。因此,以任何方式修改文本也会修改 DOM,从而导致重排。在 textarea 的情况下,键入只会改变它的value 属性——没有任何东西触及 DOM,所需要的只是重新绘制,这(相对)非常便宜。这允许渲染引擎缓存上面引用所指示的值。

由于浏览器缓存了scrollHeight,您可以使用“经典”建议——获取该值并立即将其设置为实际高度。

function resizeTextarea(textarea) 
    textarea.style.height = 'auto';
    textarea.style.height = `$textarea.style.scrollHeightpx`;

在值发生变化时使用该方法,这将确保文本区域保持在不滚动的高度。不用担心属性的连续设置,因为浏览器会一起执行这些设置(类似于requestAnimationFrame)。

在所有基于 WebKit 的浏览器中都是如此,目前是 Chrome 和 Opera,很快也会是 Edge。我认为 Firefox 和 Safari 有类似的实现。

【讨论】:

【参考方案4】:

除非你写小说,否则我无法想象阅读所有这些换行符是一个太大的问题,但我不知道。您可以尝试根据击键调整中断次数。

沙盒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;

【讨论】:

如果我们复制粘贴文本会怎样?还是自动填充? 是的,我的意思是你必须监听所有这些事件并相应地处理它们(在粘贴的情况下必须计算所有换行符)。老实说,imo 最好的解决方案就是谴责和调整大小。 我最初也想到了这个解决方案,但它不得不处理大量潜在的情况,导致解决方案非常脆弱【参考方案5】:

仅使用 react 内置功能的“现代”钩子方法将是 useRef 和 useLayoutEffects。这种方法会在浏览器中进行任何渲染之前更新由值更改触发的 textarea 的高度,从而避免 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

【讨论】:

以上是关于可以在没有持续回流的情况下动态调整高度的文本区域吗?的主要内容,如果未能解决你的问题,请参考以下文章

我可以用 CSS 选择空的文本区域吗?

spark文本区域高度调整为内容

为啥tinymce没有出现在动态添加的文本区域

如何在android中以编程方式查找TextView的文本区域(高度/宽度)

如何在 IE 中禁用的文本区域上启用滚动条

实现可调整大小的文本区域?