写代码这几年,我被问得最多的一句话就是:“for 循环我好像看懂了,但一到自己写就懵。” 每次遇到这种情况,我都会让对方去用 for 循环在控制台打印星星模型。一个由星号和空格拼出来的三角形、菱形,看着不起眼,实际上把循环该考的东西全考了一遍:外层循环管行、内层循环管列、循环变量怎么参与计算、边界条件怎么卡、字符串怎么拼接。所以这篇我就把平时最常练的几类星星模型从易到难拆开讲,顺便把新手写循环时最容易翻车的几个点一并整理出来。不管是刚开始学 JavaScript 的人,还是想找循环练习给新人练手的,照着敲一遍应该会有收获。
1. 先搞清楚 for 循环和星星模型之间的关系
1.1 为什么偏偏用 for 循环画星星
很多语言教程在讲循环时都绕不开星星图形,JavaScript 也一样。for 循环天生适合“做固定次数”的事情,而画星星模型本质上就是“有确定行数、有确定列数”的重复输出。比如我要画五行星星,每行五颗,这就是 5 次外层循环,每次外层循环里再做 5 次内层循环。整个过程和数格子一模一样。
for 循环的语法长这样:
for (let i = 0; i < 5; i++) { // 循环体 }这里有一个新手容易忽略的细节:i 从 0 开始,到 i < 5 成立,i 依次取 0、1、2、3、4,一共执行 5 次。你要习惯这种“半开区间”写法,也就是用小于号,而不是小于等于号。很多星星图形多一行少一行,问题就出在把 i < 5 写成了 i <= 5。
画星星的时候,外层循环变量 i 通常代表行号,内层循环变量 j 通常代表列号。所谓“模型”,就是行和列之间满足什么样的大小关系。你把行号列号当作坐标,星星图形就是一堆符合条件的坐标点。
1.2 先把“画纸”准备好:控制台输出方式
在浏览器控制台和 Node.js 环境中,最直接的输出方式是 console.log。但 console.log 有一个脾气:每次调用都会自动换行。所以你不能天真地这样做:
for (let i = 0; i < 5; i++) { console.log('*'); }这段代码会输出五颗星星,但每颗独占一行,根本拼不成一行五颗的效果。正确思路是先准备好一个字符串变量,比如叫 row,在内层循环里不断追加星星,等这一行拼完了,再 console.log 一次。
let row = ''; for (let i = 0; i < 5; i++) { row += '*'; } console.log(row); // 输出 *****这个“先拼一行,再输出一行”的习惯,是所有星星模型的基础。后面的三角形、金字塔、菱形,不管图形多复杂,采用的都是一样的思路:每一行都是一个字符串,内层循环负责把这一行拼好,外层循环负责把每一行打印出去。
1.3 嵌套循环的核心心法:外层管行,内层管列
有了单行输出的基础,接下来就是嵌套循环。先看一个最朴素的例子,打印一个五行五列的星星矩形:
function printRectangle(rows, cols) { for (let i = 0; i < rows; i++) { let row = ''; for (let j = 0; j < cols; j++) { row += '*'; } console.log(row); } } printRectangle(5, 5);你仔细观察这段代码的执行顺序:外层循环 i 等于 0 时,进入内层循环,内层循环 j 从 0 到 4 各执行一次,把 5 颗星星拼到 row 上,然后 console.log 输出第一行;接着外层 i 变成 1,重新定义一个新的空 row,再走一遍内层循环,输出第二行。
这里有个特别关键的细节:row 的定义必须放在外层循环里面。如果把 row 放在外层循环外面,比如写成这样:
let row = ''; for (let i = 0; i < 5; i++) { for (let j = 0; j < 5; j++) { row += '*'; } console.log(row); }后果是第一行输出 5 颗星,第二行输出 10 颗星,第三行输出 15 颗星,因为 row 一直在累积没有被清空。我见过很多新手卡在这个问题上,所以在这里先强调一遍:每次开始新的一行,row 都要重新初始化为空字符串。
2. 从基础图形开始:矩形与三角形
2.1 矩形模型:把行列数变成参数
矩形是所有图形里最友好的,因为它不涉及任何行列关系,内层循环的次数固定,外层循环的次数也固定。上面的 printRectangle 函数已经把矩形封装好了。这里我建议把行数和列数设计成函数参数,而不是写死在代码里:
function printRectangle(rows, cols) { for (let i = 0; i < rows; i++) { let row = ''; for (let j = 0; j < cols; j++) { row += '* '; } console.log(row); } } printRectangle(4, 8);我习惯在星星之间加一个空格,也就是每颗星后面跟一个空格,这样输出到控制台以后看起来更像网格。如果想要星号紧挨着,就把加号后面的空格去掉。做这种小图形时,加不加空格纯看个人审美,关键是理解循环结构。
写完这个函数后,你可以试着改变 rows 和 cols 的值,观察输出变化。参数化思维很重要,后面所有图形都会做成函数,传不同的 n 就能得到不同尺寸。
2.2 直角三角形模型:内层循环次数跟着外层变量走
矩形是固定列数,直角三角形就不一样了。它每一行的星星数量都不同:第 0 行 1 颗,第 1 行 2 颗,第 2 行 3 颗,依此类推。也就是说,内层循环的结束条件要跟外层循环变量 i 挂钩。
function printRightTriangle(n) { for (let i = 0; i < n; i++) { let row = ''; for (let j = 0; j <= i; j++) { row += '*'; } console.log(row); } } printRightTriangle(5);输出效果:
* ** *** **** *****这里最容易被坑的是内层条件写成 j < i。我们推导一下:当 i 等于 0 时,如果条件是 j < 0,那么 j 从 0 开始,0 < 0 不成立,内层循环一次都不执行,第一行就空了,这显然不对。当 i 等于 0 时,我们希望输出 1 颗星星,所以要满足 j <= 0,也就是条件写成 j <= i;或者写成 j < i + 1。记住这个加 1 的转换,在很多图形推导里你会反复用到。
2.3 反向三角形模型:两种思路都可以
反向三角形输出效果是倒过来的直角三角形,第一行 n 颗星,最后一行 1 颗星。实现方式有两种,我都列出来,你感受一下哪种更适合自己的思维习惯。
第一种,外层循环 i 依然从 0 递增,内层循环的次数用 n - i 控制:
function printInvertedTriangle(n) { for (let i = 0; i < n; i++) { let row = ''; for (let j = 0; j < n - i; j++) { row += '*'; } console.log(row); } }当 n 等于 5 时,i 等于 0,内层执行 5 次;i 等于 1,内层执行 4 次;最后 i 等于 4,内层执行 1 次。效果完全正确。
第二种,外层循环 i 直接从 n 递减到 1:
function printInvertedTriangle(n) { for (let i = n; i > 0; i--) { let row = ''; for (let j = 0; j < i; j++) { row += '*'; } console.log(row); } }两种写法输出结果一样。我个人更喜欢第一种,因为所有变量的变化方向最直觉:行号往下走,列数跟着减,循环边界不需要在脑子里“倒带”。做图形推导时,你只要记住一点:内层循环的次数等于当前行希望输出的星星个数,具体用 i、n-i、还是 i+1 表示,画一张小表马上就能看出来。
2.4 数字模型和字母模型:循环变量不只是用来控制次数
把星星换成数字,图形突然就变成了另一种练习题,也能帮你建立“循环变量不只会控制次数,还能作为输出内容”的意识。比如打印这样的三角形:
1 22 333 4444 55555代码很简单,只需要把拼接的星号换成 i + 1:
function printNumberTriangle(n) { for (let i = 0; i < n; i++) { let row = ''; for (let j = 0; j <= i; j++) { row += i + 1; } console.log(row); } } printNumberTriangle(5);同理,想打印字母三角形,就把内容换成 String.fromCharCode(65 + i),65 是字母 A 的 Unicode 码,i 是当前行号:
function printLetterTriangle(n) { for (let i = 0; i < n; i++) { let row = ''; for (let j = 0; j <= i; j++) { row += String.fromCharCode(65 + i); } console.log(row); } }这个拓展的价值在于:循环内部拼什么内容,完全由你的业务决定。图形只是载体,真正训练的是你对循环变量 i、j 的敏感度。
3. 金字塔、空心图形与菱形
3.1 金字塔模型:空格和星号的完美配合
直角三角形只需要考虑星星数量,金字塔则多了一个角色:空格。一个标准的金字塔,每一行由“空格 + 星号”组成,而且空格数和星号数都跟行号有关。
假设总行数是 n,第 i 行(i 从 0 开始):
- 前面的空格数量是 n - i - 1
- 星号数量是 2 * i + 1
为什么星号数量是 2 * i + 1?因为金字塔是对称的,第一行 1 颗,之后每往下走一行,左右各多一颗,所以每行多 2 颗。推导出来就是 2*i + 1。
代码:
function printPyramid(n) { for (let i = 0; i < n; i++) { let row = ''; for (let j = 0; j < n - i - 1; j++) { row += ' '; } for (let j = 0; j < 2 * i + 1; j++) { row += '*'; } console.log(row); } } printPyramid(5);输出效果:
* *** ***** ******* *********这里要注意循环顺序:必须先拼空格,再拼星号。你想想金字塔的样子,星星往右凸出,左边留白;如果先拼星号再拼空格,控制台会把星号顶到最左边,空格全跑到右边去,图形就变成另一个样子了。控制台看不到每一行的尾部,所以很多人犯这个错后还挺困惑,觉得自己明明加空格了,怎么图形还是不对。调试的时候可以把空格换成下划线,比如 row += '_',这样能直观看到每一行前面有多少个占位符,排完错再改回来。这个技巧后面还会细说。
3.2 空心金字塔模型:学会画“边界”
实心金字塔会了,空心金字塔就是只画轮廓,不填充内部。第 i 行的星号数量还是 2 * i + 1,但除了最后一行外,每一行只在第一个位置和最后一个位置放星星,中间全部放空格。
function printHollowPyramid(n) { for (let i = 0; i < n; i++) { let row = ''; const starCount = 2 * i + 1; for (let j = 0; j < n - i - 1; j++) { row += ' '; } for (let j = 0; j < starCount; j++) { if (i === n - 1 || j === 0 || j === starCount - 1) { row += '*'; } else { row += ' '; } } console.log(row); } } printHollowPyramid(6);这段代码的关键在于边界判断。i === n - 1 表示最后一行,最后一行要全部用星号封底;j === 0 表示当前行的第一个星号;j === starCount - 1 表示当前行的最后一个星号。中间的部分用空格填充。
空心图形训练的是 if 和循环的配合能力。在二维网格里,你要能用某个点的“坐标” i 和 j 判断出它是否落在边界上。这个思路在后续做游戏地图、处理二维数组时都会用到。
3.3 菱形模型:拆成上下两半,别想一口气画完
菱形是最容易让人望而生畏的图形,但它的本质很简单:上半部分是正金字塔,下半部分是倒金字塔。唯一需要注意的是中间那一行不能重复。
假设菱形总共有 2n - 1 行,那么上半部分用 n 行正金字塔,下半部分用 n - 1 行倒金字塔。
function printDiamond(n) { // 上半部分:正金字塔,共 n 行 for (let i = 0; i < n; i++) { let row = ''; for (let j = 0; j < n - i - 1; j++) { row += ' '; } for (let j = 0; j < 2 * i + 1; j++) { row += '*'; } console.log(row); } // 下半部分:倒金字塔,共 n - 1 行 for (let i = n - 2; i >= 0; i--) { let row = ''; for (let j = 0; j < n - i - 1; j++) { row += ' '; } for (let j = 0; j < 2 * i + 1; j++) { row += '*'; } console.log(row); } } printDiamond(5);输出效果:
* *** ***** ******* ********* ******* ***** *** *下半部分循环为什么从 n - 2 开始?因为正三角已经输出了中间那一行,也就是最大的一行。如果下半部分从 n - 1 开始,中间那一行就会被输出两次,菱形中间会出现一条明显的“胖”线。你可以自己试一下,把从 n - 2 改成从 n - 1 开始,输出结果会让你立刻理解边界的重要性。
菱形还可以推广成空心菱形,思路和空心金字塔一样,把每一行的首尾和最后一行作为边界即可。这里我就不单独贴代码了,你可以先尝试自己改。
3.4 沙漏模型:把菱形反过来
沙漏本质上就是先把倒金字塔放前面,再把正金字塔放后面。理解了菱形,沙漏几乎不需要新知识:
function printHourglass(n) { // 上半部分:倒金字塔 for (let i = 0; i < n; i++) { let row = ''; for (let j = 0; j < i; j++) { row += ' '; } for (let j = 0; j < 2 * (n - i) - 1; j++) { row += '*'; } console.log(row); } // 下半部分:正金字塔,少一行 for (let i = n - 2; i >= 0; i--) { let row = ''; for (let j = 0; j < i; j++) { row += ' '; } for (let j = 0; j < 2 * (n - i) - 1; j++) { row += '*'; } console.log(row); } } printHourglass(5);写到这里,你应该已经发现规律了:不管图形怎么变,核心步骤都是先算空格数,再算星号数,然后套两层 for 循环。真正需要你动脑筋的,是每一行空格和星号关于 i 的表达式。把这些表达式想明白,图形就成功了一大半。
4. 把星星模型玩出花样:坐标思维、函数封装与页面渲染
4.1 X 形星星和十字星:开始用坐标判断
前面几类图形都是“前面若干空格,后面若干星星”,属于堆砌型拼图。但如果你把整个图形看成一个二维网格,用 i 表示行坐标、j 表示列坐标,就能画出很多更有意思的图案。
比如画一个大写的 X:
function printX(n) { for (let i = 0; i < n; i++) { let row = ''; for (let j = 0; j < n; j++) { if (i === j || i + j === n - 1) { row += '*'; } else { row += ' '; } } console.log(row); } } printX(7);这段代码的逻辑是:行号等于列号,也就是 i === j,代表从左上到右下的对角线;i + j === n - 1 代表从右上到左下的另一条对角线。两条对角线上放星号,其余位置放空格,X 就出来了。
同理,十字星只需要找到中心点,让中心点所在的行和列都放星号:
function printCross(n) { const center = Math.floor(n / 2); for (let i = 0; i < n; i++) { let row = ''; for (let j = 0; j < n; j++) { if (i === center || j === center) { row += '*'; } else { row += ' '; } } console.log(row); } } printCross(7);这种“坐标判断法”比单纯数空格更灵活。很多字符画、心形图案、网格地图都是这个思路,只是判断条件更复杂。等你熟练了,可以尝试画空心菱形、五角星轮廓,都是同一个套路。
4.2 封装成通用函数:一套代码管理多种模型
写了这么多 print 函数后,你会发现自己写了大量重复的循环结构。这时候就可以把“打印星星模型”这件事抽象成一个通用函数。比如用配置参数指定模型类型和大小:
function printStarModel(type, size) { switch (type) { case 'rectangle': printRectangle(size, size); break; case 'triangle': printRightTriangle(size); break; case 'pyramid': printPyramid(size); break; case 'diamond': printDiamond(size); break; case 'x': printX(size); break; default: console.log('暂不支持该模型'); } } printStarModel('diamond', 7);这种做法非常适合给新人做练习工具,或者用来快速验证不同尺寸下图形是否正常。在实际项目里,这种“分发”结构也很常见,它把调用方和实现细节解耦,后面想加新模型,只需要在 switch 里加一个 case。
4.3 把计算和打印分开:用二维数组存星星
前面所有函数都是边拼字符串边 console.log。这个方式在图形简单时没问题,但如果后续要对图形做旋转、镜像、拼接,直接打印就很麻烦。更好的做法是先用二维数组把图形存下来,最后一次性渲染。
下面的代码创建一个二维数组,然后把金字塔的星星坐标填充进去:
function createPyramidGrid(n) { const grid = []; for (let i = 0; i < n; i++) { const row = new Array(2 * n - 1).fill(' '); const start = n - i - 1; const end = n + i - 1; for (let j = start; j <= end; j++) { row[j] = '*'; } grid.push(row); } return grid; } const pyramid = createPyramidGrid(5); console.log(pyramid.map(row => row.join('')).join('\n'));new Array(2 * n - 1) 创建了一个长度为 2n - 1 的数组,并全部填充为空格。每一行从 start 到 end 的位置放星号。最后用 map 把每个数组元素拼接成字符串,再用 join('\n') 拼接成最终的图形文本。
这种做法的好处是,图形在内存中是一个可以继续操作的数据结构。你想镜像、旋转,甚至逐帧生成动画,都是在二维数组上做数学变换,而不是重新拼字符串。等以后用 canvas 画星星、做小游戏,这种网格思维会非常有用。
4.4 从控制台到网页:用 pre 标签在页面上显示星星
如果你想把星星模型放到网页上展示,直接使用 innerText 往元素里塞带换行符的字符串,会遇到一个问题:浏览器默认会合并连续空格和换行,金字塔会变成一堆乱七八糟的星号。
解决方案是使用 pre 标签,或者给普通元素加 CSS white-space: pre。因为星星图形依赖空格对齐,必须让浏览器保留这些空白字符。
<pre id="starBox"></pre> <script> function createPyramidText(n) { const lines = []; for (let i = 0; i < n; i++) { let row = ''; for (let j = 0; j < n - i - 1; j++) { row += ' '; } for (let j = 0; j < 2 * i + 1; j++) { row += '*'; } lines.push(row); } return lines.join('\n'); } document.getElementById('starBox').innerText = createPyramidText(8); </script>这里把打印逻辑改成了返回字符串数组,再通过 join('\n') 合成带换行的字符串,最后用 innerText 赋值。虽然只是把 console.log 改成了 return,但这一步是由“控制台练习”进入“网页应用”的关键跨越。你也可以进一步把每个星星变成一个 span 元素,设置不同颜色和大小,做一个真正像夜空一样的星星模型页面。
5. 常见问题与排查技巧实录
5.1 图形多一行少一行:边界条件必背口诀
写 for 循环画星星,最常见的错误就是多一行或者少一行。比如想输出 5 行,条件却写成 i <= 5,结果输出 6 行;想输出第 i 行有 i+1 个星,条件却写成 j < i,结果第一行就空了。
我的建议是:每一段循环都先拿前两行做手算推演。比如 n = 5,外层条件是 i < 5,那么 i 取 0、1、2、3、4,恰好 5 行。如果你看到“最后一行没有输出”或“最后多了一行”,优先检查小于号和小于等于号。下面的速查表可以帮你快速对照:
| 期望 | 写法 1 | 写法 2 |
|---|---|---|
| 行号从 0 开始,共 n 行 | i < n | i <= n - 1 |
| 行号从 1 开始,共 n 行 | i <= n | i < n + 1 |
| 当前行输出 i + 1 个星星 | j <= i | j < i + 1 |
| 当前行输出 n - i 个星星 | j < n - i | j <= n - i - 1 |
这些不是要你背下来,而是当你发现图形不对时,照着这个思路去检查,很快就能定位问题。
5.2 图形歪了:空格和星号顺序反了
金字塔、菱形这类图形,空格必须加在星号前面。有人会想:“我明明加了空格,为什么图形看起来左边还是齐的?”因为控制台每一行按从左到右输出,如果先输出星号再输出空格,那么星号都在最左边,空格全在行尾,而终端很难看出行尾发生了什么。结果整个图形就没有金字塔的错落感。
排查办法很直接:把空格替换成下划线,或者某个可见字符。假设你打印金字塔时用 '_' 代替空格:
____* ___*** __***** _******* *********这样你就一眼能看出每行前面的占位符够不够。发现问题后,把下划线改回空格即可。这个方法我到现在还在用,尤其是调试复杂字符画的时候,可视化占位符能救命。
5.3 变量污染:忘记重置行字符串
前面已经提到过,row 必须在外层循环内部初始化为空字符串。但还有一种更隐蔽的错误:内层循环的计数变量和外层循环变量同名。
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) { // ... } }在 var 时代,重复声明同名变量会相互覆盖,导致外层循环被内层循环干扰,这是一个非常经典的坑。现在用 let 声明可以避免一部分问题,因为内层 i 会遮蔽外层 i,但为了可读性,绝对不建议内外层使用同名变量。我习惯外层用 i,内层用 j,如果还有第三层就用 k,这已经是约定俗成的做法。
5.4 网页上显示不对:空格被浏览器吃掉
很多人在控制台写得好好的星星图形,一放到网页上就变形,原因在前面的 pre 标签部分已经解释过。这里再补充一个判断经验:如果你的页面元素里存了字符串,检查一下元素样式是否设置了 white-space: pre。没有这个样式,HTML 会把连续空格合并成一个,金字塔自然就塌了。
pre { white-space: pre; }如果不用 pre 标签,给任意 div 加上这个样式也可以。还有一点,使用 innerText 赋值时,换行符 \n 会被保留;但如果你用 innerHTML,换行和空格同样是可能被折叠的。简单来说,展示文本就用 innerText,不要用 innerHTML。
5.5 性能问题:字符串拼接 versus 数组 join
画星星模型规模一般很小,性能差异可以忽略。但如果哪一天你想生成一个 1000 行的大星星图形,频繁使用 row += '*' 会在内存中反复创建新的字符串对象。JavaScript 字符串是不可变的,每次拼接都生成一个新字符串,旧字符串等待垃圾回收。这种情况下,用数组收集元素,最后 join 是更稳妥的做法。
function createLargeTriangle(n) { const result = []; for (let i = 0; i < n; i++) { const line = []; for (let j = 0; j <= i; j++) { line.push('*'); } result.push(line.join('')); } return result.join('\n'); }对初学者而言,不需要过度纠结性能,但要建立这个意识:凡是循环里不断增长的字符串,都可以考虑用数组代替。先保证正确,再谈优化。
5.6 我的调试习惯:先写一行,再写全部
每当我需要画一个新的星星模型,我不会直接写完整的两层循环。我会先写一个 buildLine(spaces, stars) 函数,专门负责生成一行内容:
function buildLine(spaces, stars) { return ' '.repeat(spaces) + '*'.repeat(stars); } console.log(buildLine(4, 1)); // 金字塔第一行,前面 4 个空格 console.log(buildLine(3, 3)); console.log(buildLine(2, 5));先用几个固定参数验证 buildLine 是否正确,再把 buildLine 放进外层循环,把空格数和星号数替换成表达式。这样做的好处是,如果最终图形出问题,你能立刻确定是 buildLine 出错,还是外层循环的行数计算出错,不用对着两三层嵌套的代码发呆。
这个习惯也适用于任何更复杂的逻辑。写一个几十行的函数前,先拆出一个纯函数测一测,能省掉大量排查时间。星星模型虽小,但它真的能帮你养成好的编码习惯。后面你再去做冒泡排序的可视化、canvas 画图,或者页面里的动态星空背景,都会发现循环加坐标的思路是一样的。把基础图形吃透,后面的路会顺很多。