正如我们所知道的 textarea 是一个行内块元素 display: inline-block
并且它的默认宽高由 cols & rows 决定, 也就是说 textarea 的 height 并不会自适应于内容长度.
textarea 的宽高是如何决定的?
参考文章: http://www.zhangxinxu.com/wordpress/2016/02/html-textarea-rows-height/
那么, 我们今天的任务就是来思考如何创建一个 高度内容自适应的 textarea 组件.
我将介绍三种思路实现 高度内容自适应的 textarea.
这三种思路的 React 组件实现代码如下:
https://github.com/teeeemoji/textareaAutoSizeSolutions
所有参考链接(锚点失效, 参考链接在最后)
这是三种方案的概述和实现思路的简介, 实现方案 & 遇到的坑 & 拓展知识点, 点击 (查看更多) or 直接看 teeeemoji 的 demo.
方案一: 两次调整 textarea.style.height
textarea 的 onchange 触发 resize 方法
下面是 resize 方法的逻辑
① textarea.style.height = 'auto';// 让 textarea 的高度恢复默认
② textarea.style.height = textarea.scrollHeight + 'px';// textarea.scrollHeight 表示 textarea 内容的实际高度
方案二: 利用一个 ghostTextarea 获得输入框内容高度, 再将这个高度设置给真实的 textarea
textarea 构建时创建 ghostTextarea, onchange 触发 resize 方法
① 创建 textarea 的时候, 同时创建一个一模一样的隐藏 ghostTextarea;
② ghostTextarea 的属性全部克隆自 textarea, 但是 ghostTextarea 是隐藏的, 并且 ghostTextarea.style.height = 0; 也就是说 ghostTextarea.scrollHeight 就是 textarea 中内容的真是高度
resize 方法处理流程
- step-1: textarea.value 先设置给 ghostTextarea,
- step-2: 拿到 ghostTextarea.scrollHeight
- step-2: 将 textarea.style.height = ghostTextarea.scrollHeight
方案三: 使用 (div | p | ...).contenteditable 代替 textarea 作为输入框
div 是块级元素, 高度本身就是内容自适应的(除非设置
max-width
ormin-widht
使用contenteditable 让 div 代替 textarea, 省去各种计算高度的逻辑
满分3分, 三种方案通过优化, 在用户体验和兼容性上都能达到满分. 因此差别仅仅在于这几个方案的实现难度. (仅仅是基于 react 组件的实现复杂度).
用户体验对比(在最后面, 简书对 markdown 内嵌 html 支持不友好, 锚点都不能用了)
方案 | 用户体验 | 兼容性 | 易用性 | 综合评价 |
---|---|---|---|---|
方案一 | 3 | 3 | 3 | 10 |
方案二 | 3 | 3 | 1 | 7 |
方案三 | 3 | 3 | 2 | 8 |
毫无疑问方案一是最优选择, 多加1分以示奖励;
handleChange(e) {
this.props.onChange(e.target.value);
this.resize(); // 看这里
}
// 重新计算 textarea 的高度
resize() {
if (this.inputRef) {
console.log('resizing...')
this.inputRef.style.height = 'auto';
this.inputRef.style.height = this.inputRef.scrollHeight + 'px';
}
}
避免两次渲染,造成内容抖动
在 react 中, 组件 receiveProps 的时候会 render一次, 直接调整 textarea 的 height 也会浏览器的重绘.那么就会造成两次重绘, 并且两次重绘的时候, textarea 的内容可能会发生抖动.
优化思路
先触发 resize 后触发 render **用最简单的思路完美解决问题
return (
)
初始化的时候拷贝属性
初始化必须使用工具方法将 textarea 的属性拷贝到 ghostTextarea 去. 因为 textarea 的样式再组件外也能控制, 因此初始化的时候 copy style 是最安全的
这是所以要拷贝的属性的列表
'letter-spacing', 'line-height', 'font-family', 'font-weight', 'font-size', 'font-style', 'tab-size', 'text-rendering', 'text-transform', 'width', 'text-indent', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', 'box-sizing' ];
这是 ghostTextarea 的隐藏属性列表
'min-height': '0', 'max-height': 'none', height: '0', visibility: 'hidden', overflow: 'hidden', position: 'absolute', 'z-index': '-1000', top: '0', right: '0', };
这是拷贝 style 的工具方法
// 拿到真实 textarea 的所有 style function calculateNodeStyling(node) { const style = window.getComputedStyle(node); if (style === null) { return null; } return SIZING_STYLE.reduce((obj, name) => { obj[name] = style.getPropertyValue(name); return obj; }, {}); } // 拷贝 真实 textarea 的 style 到 ghostTextarea export const copyStyle = function (toNode, fromNode) { const nodeStyling = calculateNodeStyling(fromNode); if (nodeStyling === null) { return null; } Object.keys(nodeStyling).forEach(key => { toNode.style[key] = nodeStyling[key]; }); Object.keys(HIDDEN_TEXTAREA_STYLE).forEach(key => { toNode.style.setProperty( key, HIDDEN_TEXTAREA_STYLE[key], 'important', ); }); }
textarea 的 onChange 事件
先 reize 再触发 change 事件
this.resize(); let value = e.target.value; this.props.onChange(value); }
textarea 的 resize 方法
resize() { console.log('resizing...') const height = calculateGhostTextareaHeight(this.ghostRef, this.inputRef); this.setState({height}); }
calculateGhostTextareaHeight 工具方法
先将内容设置进 ghostTextarea, 再拿到 ghostTextarea.scrollHeight
export const calculateGhostTextareaHeight = function (ghostTextarea, textarea) { if (!ghostTextarea) { return; } ghostTextarea.value = textarea.value || textarea.placeholder || 'x' return ghostTextarea.scrollHeight; }
避免两次渲染,造成内容抖动
在 react 中, 组件 receiveProps 的时候会 render一次, 给 textarea 设置 height 属性也会浏览器的重绘.那么就会造成两次重绘, 并且两次重绘的时候, textarea 的内容可能会发生抖动.
下面两种思路, 再 demo 中均有体现
优化思路一: 合并祯渲染
使用 window.requestAnimationFrame & window.cancelAnimationFrame 来取消第一祯的渲染, 而直接渲染高度已经调整好的 textarea;
优化思路二: 减少渲染次数
利用 react 批处理 setState 方法, 减少 rerender 的特性;
在 textarea onChange 方法中同时触发两个 setState;
123.png
渲染一个 div.contenteditable=true
return (
)
获取 & 设置 编辑去呀的内容
textarea 通过 textarea.value 来取值 or 设置值, 但换成了 div 之后, 就要使用 div.innerHTML or div.innerText 来取值 or 设置值.
使用 div.innerHTML 会出现以下两种问题:
- & 会被转码成 & ;
- 空白符合并
使用 div.innerText 在低版本 firfox 上要做兼容处理.
因此使用哪种方式主要看需求.
placeholder 的实现
div 的 placeholder 属性是无效, 不会显示出来的, 现存一种最简单的方式, 使用纯 css 的方式实现 div 的 placeholder
.textarea[placeholder]:empty:before { /*empty & before 两个伪类*/ content: attr(placeholder); /*attr 函数*/ color: #555; }
去除支持富文本
div.contenteditable 是默认支持富文本的, 可能会以 粘贴 or 拖拽 让输入框出现富文本;
234.png
监听 div 的 onPaste 事件
handlePaste(e) {
e.preventDefault();
let text = e.clipboardData.getData('text/plain'); // 拿到纯文本
document.execCommand('insertText', false, text); // 让浏览器执行插入文本操作
}
handlePaste 的更多兼容性处理
我分别查看了微博, ant.design组件库, 知乎 的自适应输入框的实现.
未输入时
5aa4b41fdf0082f1c9.png
输入后
5aa4b4517a668254df.png
但是微博的实现存在用户体验上的缺陷, 会抖动!!!
weibo.git.gif
体验超级棒哦
antd.gif
看上去竟然存在 bug , 其实上面的截图也有
zhih.gif