写在开头
哈喽,各位好呀!😀
此时此刻,小编正在广州经历最恐怖的天气,回南天🤢!!!
不怕冷不怕热,就怕这回南天,到处湿漉漉的,租房直接变成水帘洞。。。
唉,不扯远了,如下是本次分享的最终效果:

各位按需食用。🤡
正文
布局与样式
页面布局和样式比较简单,随便瞧瞧就行,不是重点。😋
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文本域显示行号</title>
<style>
.container {
display: flex;
border: 1px solid rgb(203, 213, 225);
overflow: hidden;
width: 400px;
}
.numbers {
/** 尽量不要给行号的容器设置属于,它的属性可以从textarea身上取 **/
border-right: 1px solid rgb(203, 213, 225);
overflow: hidden;
text-align: center;
box-sizing: border-box;
}
.textarea {
width: 370px;
padding: 5px;
border: none;
outline: none;
width: 100%;
font-size: 20px;
box-sizing: border-box;
/* 很关键: 可以看看下面的补充说明 */
resize: vertical;
min-height: 10rem;
max-height: 20rem;
}
/** 美化滚动条 **/
::-webkit-scrollbar {
background: transparent;
height: 8px;
width: 8px;
}
::-webkit-scrollbar-thumb {
border-radius: 1;
background: rgb(148 163 184);
}
::-webkit-scrollbar-track {
background: transparent;
}
</style>
</head>
<body>
<div class="container">
<div class="numbers"></div>
<textarea class="textarea"></textarea>
</div>
</body>
</html>
我们尽量不要去给行号的容器(numbers
)设置样式,可能会影响内容与行号的对齐,它的样式会从 textarea
元素身上同步过来,比如字体、字体大小、内边距(padding
)等等。
如果,你还发现有细微的内容和行号对不齐的情况,欢迎留言,或者请去检查相关元素的 border
或 box-sizing
等样式属性。(别问我怎么知道的🙉)
关于文本域(
textarea
)禁止拖动调整大小的说明:众所周知,
textarea
元素是允许用户手动拖动调整大小的,但是,有时这一操作可能会破坏页面的整体布局,为此我们又不得不对其进行一些拖动上的限制。禁止
textarea
元素拖动可以使用resize
属性,它主要有none
、vertical
、horizontal
等值。为什么在这里说这个呢?主要是很多人可能会错误使用该属性,动不动上来就是
resize: none
属性,要么造成用户完全无法调整大小,要么造成回显文案无法完整显示等等糟糕的用户体验。正确做法💡:
javascripttextarea { resize: vertical; /* 根据实际的业务选择属性值 */ /* 控制文本域的高度,根据实际需求调整 */ min-height: 5rem; max-height: 20rem; }
显示行号
第二步,我们来初始化行号的显示。为了测试效果,我们先写死一些内容在 textarea
元素中,这里小编找了李白的将进酒作为测试内容。
(卑微的打工人要及时行乐,劳逸结合,人生得意须尽欢,莫使金樽空对月,爱咋滴咋滴。😆)
html
<textarea class="textarea">
将进酒
唐代:李白
君不见,黄河之水天上来,奔流到海不复回。
君不见,高堂明镜悲白发,朝如青丝暮成雪。
人生得意须尽欢,莫使金樽空对月。
天生我材必有用,千金散尽还复来。
烹羊宰牛且为乐,会须一饮三百杯。
岑夫子,丹丘生,将进酒,杯莫停。
与君歌一曲,请君为我倾耳听。(倾耳听 一作:侧耳听)
钟鼓馔玉不足贵,但愿长醉不愿醒。(不足贵 一作:何足贵;不愿醒 一作:不复醒)
古来圣贤皆寂寞,惟有饮者留其名。(古来 一作:自古;惟 通:唯)
陈王昔时宴平乐,斗酒十千恣欢谑。
主人何为言少钱,径须沽取对君酌。
五花马,千金裘,呼儿将出换美酒,与尔同销万古愁。
</textarea>
显示行号关键点在于要确定内容有多少行数,然后通过行数去动态渲染出行号的数量。
为此,我们需要通过将内容拆分为不同的行来确定文本域中的行数,这里我们先粗略以"换行符"来拆分:
html
<script>
// 将逻辑都放在DOMContentLoaded事件下,防止操作DOM出现错误
document.addEventListener('DOMContentLoaded', () => {
const textarea = document.querySelector('.textarea');
const numbers = document.querySelector('.numbers');
function initLineNumbers() {
// 把内容按换行符划分行数
const lines = textarea.value.split('\n');
// 根据行数动态生成行号的数量
const lineDoms = Array.from({
length: lines.length,
}, (_, i) => `<div>${i + 1}</div>`);
numbers.innerHTML = lineDoms.join('');
}
initLineNumbers();
});
</script>

虽然还有点丑😁,但是没关系我们继续来美化、完善它。
内容与行号对齐
第三步,我们需要来给行号的容器(numbers
)设置样式,让行号与内容齐平,而需要设置的这些样式可以从 textarea
元素身上动态取,这样能避免我们去维护两个元素的基础样式一致。
javascript
// 获取文本域的所有样式
const textareaStyles = window.getComputedStyle(textarea);
// 取我们需要的样式
[
'fontFamily', 'fontSize', 'fontWeight',
'letterSpacing', 'lineHeight', 'padding',
].forEach(property => {
numbers.style[property] = textareaStyles[property];
});

对齐后,就稍微好看一点啦。😀
内容拆分🍊
前面,我们说过只是粗略以"换行符"来拆分内容,为什么说是"粗略"呢❓
可以仔细看上面截图,"将进酒"的内容大概能让我们生成15个行号,但这明显和内容行数是对应不上的,里面有一些句子是占据了多行的,这就造成了内容多,行号少的尴尬局面了。😳
那么,如何来解决这个问题呢❓
内容是由句子来组成的,如果我们能知道每个句子占据的行数,再全部累加起来,用这个总数再去生成全部行号,这个结果才是准确的。
根据这个想法,我们看看在代码中是如何来解决的:
javascript
function initLineNumbers() {
// 获取全部的行号
const lines = calcLines();
// 空行用' '占位
const lineDoms = Array.from({
length: lines.length,
}, (_, i) => `<div>${lines[i] || ' '}</div>`);
numbers.innerHTML = lineDoms.join('');
}
/* 创建canvas元素,来辅助计算,主要是使用canvas.measureText方法 */
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// 保持canvas的画笔与字体设置一致
const font = `${textareaStyles.fontSize} ${textareaStyles.fontFamily}`;
context.font = font;
/**
* @name 计算一个句子在目标容器中占据多少行
* @param { string } sentence
* @param { number } width 目标容器的宽度 --- 文本域的内容宽度
* @returns { number }
*/
function calcStringLines(sentence, width) {
if (!width) return 0;
// 将句子拆分,如果是纯英文句子可以使用.split(' ')进行拆分提升效率
const words = sentence.split('');
// 一个句子占据的行数
let lineCount = 0;
let currentLine = '';
for (let i = 0; i < words.length; i++) {
// 获取被测量文本的TextMetrics对象,主要拿width
const wordWidth = context.measureText(words[i]).width;
const lineWidth = context.measureText(currentLine).width;
if (lineWidth + wordWidth > width) {
// 当前的句子累计的词语已经大于目标容器的宽度了,要多占一行
lineCount++;
// 已经占一行了,要清空重新累加
currentLine = words[i];
} else {
currentLine += words[i];
}
}
// 最后剩余的占一行
if (currentLine.trim() !== '') lineCount++;
return lineCount;
}
function calcLines() {
// 按"换行符"分隔句子
const lines = textarea.value.split('\n');
// textarea宽度
const textareaWidth = textarea.getBoundingClientRect().width;
// textarea滚动条宽度,没有则为0
const textareaScrollWidth = textareaWidth - textarea.clientWidth;
// textarea内边距,转成数字
const parseNumber = (v) => v.endsWith('px') ? parseInt(v.slice(0, -2), 10) : 0;
const textareaPaddingLeft = parseNumber(textareaStyles.paddingLeft);
const textareaPaddingRight = parseNumber(textareaStyles.paddingRight);
// textarea的内容宽度
const textareaContentWidth = textareaWidth - textareaPaddingLeft - textareaPaddingRight - textareaScrollWidth;
// 获取每个句子占多少行,数组形式记录
const numLines = lines.map(lineString => calcStringLines(lineString, textareaContentWidth));
// 将每个句子占据的行数转成对应的行号与空格
let lineNumbers = [];
let i = 1;
while (numLines.length > 0) {
const numLinesOfSentence = numLines.shift();
lineNumbers.push(i);
if (numLinesOfSentence > 1) {
Array(numLinesOfSentence - 1)
.fill('')
.forEach((_) => lineNumbers.push(''));
}
i++;
}
return lineNumbers;
}
// 注意放在最下面调用,上面有dom的操作
initLineNumbers();
calcStringLines
函数:
- 原理就是把一个句子拆成多个词语,再去检测每个词语的宽度,再慢慢去叠加宽度,然后与目标容器的宽度作比较。
- 测量文本的宽度使用了 context.measureText API,获取的
TextMetrics
对象如下:
calcLines
函数:
- 主要作用是确定
textarea
的"内容宽度",最开始我们设置了它的宽度是width: 370px;
,但它的内容宽度需要减去内边距与滚动条的宽度。 - 还有就是把生成的每个句子占据的行数数组(
[1, 1, 2, 2, 2, ...]
)转成行号的数组([1, 2, 3, '', 4, '', 5, ...]
)的形式。
总的是增加两个函数,全部代码小编都写上详细的注释了,可不能说看不懂啦。👻
这下就能完美让行号根据内容行数来渲染了,当然,你也可以不要空的行号(' '
)来占位,顺序排列行号也可以,这点就你自己琢磨囖。💀
文本域尺寸变化
然而,还没完,现在整体布局看来很奇怪,一边长一边短的,原因是两边的高度没有同步好,让我们继续来完善它。
javascript
const ro = new ResizeObserver(() => {
const rect = textarea.getBoundingClientRect();
numbers.style.height = `${rect.height}px`;
initLineNumbers();
});
ro.observe(textarea);
由于文本域能手动调整大小,我们使用 ResizeObserver API来监听它的变化,然后把高度同步给行号的容器。
在外面的 initLineNumbers()
方法不需要调用了,ResizeObserver
API初始化会调用一次,而且,两者产生的文本域高度是不一样的结果❗
滚动同步
从上面动图可以看到内容滚动的时候行号还不能同步,我们再来瞧瞧这个问题要如何解决。
javascript
textarea.addEventListener('scroll', () => {
numbers.scrollTop = textarea.scrollTop;
});
很简单,就加一个监听滚动即可,So Easy 👻👻👻
内容修改
接下来还有最后一个问题,当增加或者删除文本域内容的时候,如何保持行号的同步呢❓
其实也简单,就不卖关子了,直接上代码:
javascript
textarea.addEventListener('input', () => {
initLineNumbers();
});
完整源码
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文本域显示行号</title>
<style>
.container {
display: flex;
border: 1px solid rgb(203 213 225);
overflow: hidden;
width: 400px;
}
.numbers {
flex: 1;
border-right: 1px solid rgb(203 213 225);
overflow: hidden;
text-align: center;
box-sizing: border-box;
}
.textarea {
width: 370px;
padding: 5px;
border: none;
outline: none;
font-size: 20px;
resize: vertical;
min-height: 10rem;
max-height: 20rem;
overflow-x: hidden;
box-sizing: border-box;
}
::-webkit-scrollbar {
background: transparent;
height: 8px;
width: 8px;
}
::-webkit-scrollbar-thumb {
border-radius: 1;
background: rgb(148 163 184);
}
::-webkit-scrollbar-track {
background: transparent;
}
</style>
</head>
<body>
<div class="container">
<div class="numbers"></div>
<textarea class="textarea"></textarea>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const textarea = document.querySelector('.textarea');
const numbers = document.querySelector('.numbers');
function initLineNumbers() {
const lines = calcLines();
const lineDoms = Array.from({
length: lines.length,
}, (_, i) => `<div>${lines[i] || ' '}</div>`);
numbers.innerHTML = lineDoms.join('');
}
const textareaStyles = window.getComputedStyle(textarea);
[
'fontFamily', 'fontSize', 'fontWeight',
'letterSpacing', 'lineHeight', 'padding',
].forEach((property) => {
numbers.style[property] = textareaStyles[property];
});
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const font = `${textareaStyles.fontSize} ${textareaStyles.fontFamily}`;
context.font = font;
function calcStringLines(sentence, width) {
if (!width) return 0;
const words = sentence.split('');
let lineCount = 0;
let currentLine = '';
for (let i = 0; i < words.length; i++) {
const wordWidth = context.measureText(words[i]).width;
const lineWidth = context.measureText(currentLine).width;
if (lineWidth + wordWidth > width) {
lineCount++;
currentLine = words[i];
} else {
currentLine += words[i];
}
}
if (currentLine.trim() !== '') lineCount++;
return lineCount;
}
function calcLines() {
const lines = textarea.value.split('\n');
const textareaWidth = textarea.getBoundingClientRect().width;
const textareaScrollWidth = textareaWidth - textarea.clientWidth;
const parseNumber = (v) => v.endsWith('px') ? parseInt(v.slice(0, -2), 10) : 0;
const textareaPaddingLeft = parseNumber(textareaStyles.paddingLeft);
const textareaPaddingRight = parseNumber(textareaStyles.paddingRight);
const textareaContentWidth = textareaWidth - textareaPaddingLeft - textareaPaddingRight - textareaScrollWidth;
const numLines = lines.map(lineString => calcStringLines(lineString, textareaContentWidth));
let lineNumbers = [];
let i = 1;
while (numLines.length > 0) {
const numLinesOfSentence = numLines.shift();
lineNumbers.push(i);
if (numLinesOfSentence > 1) {
Array(numLinesOfSentence - 1)
.fill('')
.forEach((_) => lineNumbers.push(''));
}
i++;
}
return lineNumbers;
}
const ro = new ResizeObserver(() => {
const rect = textarea.getBoundingClientRect();
numbers.style.height = `${rect.height}px`;
initLineNumbers();
});
ro.observe(textarea);
textarea.addEventListener('scroll', () => {
numbers.scrollTop = textarea.scrollTop;
});
textarea.addEventListener('input', () => {
initLineNumbers();
});
});
</script>
</body>
</html>
至此,本篇文章就写完啦,撒花撒花。

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。