2026最新实战:画一条直线变成两个三角形项目搭建指南
很多兄弟刚学完语法,满脑子都是 for 循环和变量定义,但一到动手搭项目就懵了。不知道文件怎么放,不知道依赖怎么装,更不知道代码逻辑该怎么串联。这种“学会语法却不知怎么搭项目”的断层,是 2026 最新技术栈下新人最大的拦路虎。
今天不讲虚的,直接上实战。我们用一个极简但完整的案例——“画一条直线变成两个三角形”,来打通从初始化到部署的全流程。别看题目简单,这里面藏着工程化的核心逻辑:目录规范、依赖管理、渲染逻辑、状态控制。搞定这个,你就有了自己第一个能拿得出手的 Demo。
项目目标与思维转换
在敲第一行代码前,先明确我们要做什么。
表面上看,这是一个图形题:画一条线段,然后以这条线段为底,分别向上、向下画两个三角形。但作为工程化项目,我们的目标是:
- 环境标准化:确保任何人克隆代码后,执行两条命令就能跑起来。
- 逻辑解耦:几何计算与渲染展示分离,方便后续替换渲染引擎(比如从 Canvas 换成 SVG 或 Three.js)。
- 交互闭环:用户能拖动直线端点,三角形实时跟随变化,体现“动态”而非“静态”。
很多新人喜欢一上来就 import 一堆库,结果包冲突、版本报错。2026 年的前端趋势是“轻依赖、重标准”。我们只使用原生 JS + HTML5 Canvas,不引入 React 或 Vue,目的是让你看清底层数据流向。
核心痛点破解: 别把“画三角形”当成美术任务,要当成数据流任务。
- 输入:直线的两个端点坐标
(x1, y1)和(x2, y2)。 - 处理:根据中点垂直向量,计算上方三角形第三个顶点,下方同理。
- 输出:Canvas 路径指令。
想通这一层,代码只是把数学公式翻译成 API 调用而已。
目录结构与初始化
工程化的第一步,不是写代码,是搭架子。乱堆文件是项目烂掉的开始。
我们采用最经典的前端单体应用结构,兼顾清晰与简洁:
triangle-project/
├── index.html # 入口文件
├── style.css # 样式隔离,避免污染全局
├── src/
│ ├── main.js # 入口逻辑,初始化画布
│ ├── geometry.js # 纯几何计算模块(无 DOM 操作)
│ └── renderer.js # Canvas 渲染模块(只负责画图)
├── package.json # 依赖声明,即使无依赖也保留
└── README.md # 项目说明
为什么这么分?
geometry.js是纯函数库:它不关心屏幕,只关心数学。输入坐标,输出坐标。这样你可以单独写单元测试,不用开浏览器就能验证算法对不对。renderer.js是视图层:它不知道三角形怎么算的,它只接收“画这个点、连那条线”的指令。main.js是控制器:监听鼠标事件,调用geometry.js算出顶点,再传给renderer.js去画。
初始化步骤(2026 最新工作流)
虽然本项目无第三方依赖,但我们仍推荐初始化 package.json,为后续扩展(如引入 mathjs 或 d3-shape)做准备。在终端执行:
mkdir triangle-project && cd triangle-project
npm init -y
打开 index.html,引入我们的三个 JS 文件。注意顺序:先引入工具模块,再引入入口。
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>直线变双三角 - 2026 工程化实战</title><link rel="stylesheet" href="style.css">
</head>
<body><canvas id="main-canvas" width="800" height="600"></canvas><!-- 引入顺序至关重要:依赖在前,入口在后 --><script src="src/geometry.js"></script><script src="src/renderer.js"></script><script src="src/main.js"></script>
</body>
</html>
避坑提示:
很多新手喜欢把所有代码塞在 index.html 的 <script> 标签里。这在 Demo 阶段可以,但一旦代码超过 200 行,维护就是灾难。现在养成模块化习惯,未来接手大型项目时你会感谢自己。
核心代码实现:从数学到像素
这是最核心的部分。我们将逻辑拆解为三个文件,逐一讲解。
1. 几何计算模块 (src/geometry.js)
这个文件严禁出现 document、canvas 等 DOM 对象。它只处理数字。
核心任务:给定直线两点 A、B,求上方三角形顶点 C_up 和下方三角形顶点 C_down。
/*** 几何计算工具库* 纯函数设计,无副作用,易于测试*/
const Geometry = (() => {// 计算两点中点function getMidpoint(p1, p2) {return {x: (p1.x + p2.x) / 2,y: (p1.y + p2.y) / 2};}// 计算向量 (p2 - p1)function getVector(p1, p2) {return {x: p2.x - p1.x,y: p2.y - p1.y};}// 计算垂直向量 (逆时针旋转90度)// 向量 (x, y) 的垂直向量是 (-y, x)function getPerpendicularVector(vec) {return {x: -vec.y,y: vec.x};}/*** 核心算法:根据底边两端点,计算等腰三角形的第三个顶点* @param {Object} p1 底边起点* @param {Object} p2 底边终点* @param {number} heightRatio 高宽比,控制三角形尖锐程度* @param {boolean} isUp 是否向上方生成* @returns {Object} 第三个顶点坐标*/function calculateApex(p1, p2, heightRatio = 0.5, isUp = true) {const mid = getMidpoint(p1, p2);const baseVec = getVector(p1, p2);const perpVec = getPerpendicularVector(baseVec);// 归一化垂直向量,确保方向一致且长度为1const length = Math.sqrt(perpVec.x * perpVec.x + perpVec.y * perpVec.y);if (length === 0) return mid; // 防止除零错误const normPerp = {x: perpVec.x / length,y: perpVec.y / length};// 计算底边长度,用于确定高度const baseLength = Math.sqrt(baseVec.x * baseVec.x + baseVec.y * baseVec.y);const height = baseLength * heightRatio;// 根据 isUp 决定方向const direction = isUp ? 1 : -1;return {x: mid.x + normPerp.x * height * direction,y: mid.y + normPerp.y * height * direction};}// 导出公共 APIreturn {calculateApex};
})();
逐行解析关键逻辑:
- 垂直向量旋转:这是平面几何的精髓。向量
(dx, dy)旋转 90 度后变为(-dy, dx)。很多新手在这里容易搞反符号,导致三角形画反方向。 - 归一化:垂直向量的长度并不等于 1,如果不除以
length,高度计算会受底边长度影响,导致三角形比例失调。 - 方向控制:
isUp参数通过direction变量控制顶点在中点上方还是下方,实现“两个三角形”的效果。
2. 渲染模块 (src/renderer.js)
这个文件只负责“画”。它接收坐标,调用 Canvas API。
/*** Canvas 渲染引擎* 封装绘图细节,对外提供简洁接口*/
const Renderer = (() => {let ctx;// 初始化上下文function init(canvas) {ctx = canvas.getContext('2d');}// 清空画布function clear() {if (!ctx) return;ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);// 绘制网格背景,提升视觉效果drawGrid();}// 绘制简单网格function drawGrid() {ctx.strokeStyle = '#eee';ctx.lineWidth = 1;const step = 50;for (let i = 0; i <= ctx.canvas.width; i += step) {ctx.beginPath();ctx.moveTo(i, 0);ctx.lineTo(i, ctx.canvas.height);ctx.stroke();}for (let j = 0; j <= ctx.canvas.height; j += step) {ctx.beginPath();ctx.moveTo(0, j);ctx.lineTo(ctx.canvas.width, j);ctx.stroke();}}/*** 绘制三角形* @param {Object} p1 顶点1* @param {Object} p2 顶点2* @param {Object} p3 顶点3* @param {string} color 填充颜色* @param {string} strokeColor 边框颜色*/function drawTriangle(p1, p2, p3, color = 'rgba(0, 123, 255, 0.3)', strokeColor = '#007bff') {if (!ctx) return;ctx.beginPath();ctx.moveTo(p1.x, p1.y);ctx.lineTo(p2.x, p2.y);ctx.lineTo(p3.x, p3.y);ctx.closePath();ctx.fillStyle = color;ctx.fill();ctx.strokeStyle = strokeColor;ctx.lineWidth = 2;ctx.stroke();}// 绘制直线function drawLine(p1, p2, color = '#333') {if (!ctx) return;ctx.beginPath();ctx.moveTo(p1.x, p1.y);ctx.lineTo(p2.x, p2.y);ctx.strokeStyle = color;ctx.lineWidth = 3;ctx.stroke();}return {init,clear,drawTriangle,drawLine};
})();
3. 主控制器 (src/main.js)
这里将几何、渲染与用户交互连接起来。
/*** 应用入口* 负责状态管理与事件绑定*/
(function() {const canvas = document.getElementById('main-canvas');Renderer.init(canvas);// 状态管理:存储当前直线的两个端点let state = {p1: { x: 300, y: 300 },p2: { x: 500, y: 300 }};let isDragging = null; // null, 'p1', 'p2'// 渲染循环function render() {Renderer.clear();// 1. 计算两个三角形的顶点const apexUp = Geometry.calculateApex(state.p1, state.p2, 0.5, true);const apexDown = Geometry.calculateApex(state.p1, state.p2, 0.5, false);// 2. 绘制直线Renderer.drawLine(state.p1, state.p2);// 3. 绘制上方三角形 (蓝色系)Renderer.drawTriangle(state.p1, state.p2, apexUp, 'rgba(0, 123, 255, 0.2)', '#007bff');// 4. 绘制下方三角形 (红色系)Renderer.drawTriangle(state.p1, state.p2, apexDown, 'rgba(220, 53, 69, 0.2)', '#dc3545');// 5. 绘制端点手柄,提示用户可拖动drawHandle(state.p1);drawHandle(state.p2);}// 绘制可拖动的圆形手柄function drawHandle(point) {if (!Renderer) return; // 简单判断const ctx = canvas.getContext('2d');ctx.beginPath();ctx.arc(point.x, point.y, 10, 0, Math.PI * 2);ctx.fillStyle = '#fff';ctx.fill();ctx.strokeStyle = '#333';ctx.lineWidth = 2;ctx.stroke();}// 事件处理:判断鼠标是否点击在端点上function isPointInCircle(point, mouse, radius = 15) {const dx = mouse.x - point.x;const dy = mouse.y - point.y;return (dx * dx + dy * dy) < (radius * radius);}canvas.addEventListener('mousedown', (e) => {const rect = canvas.getBoundingClientRect();const mouse = {x: e.clientX - rect.left,y: e.clientY - rect.top};if (isPointInCircle(state.p1, mouse)) {isDragging = 'p1';} else if (isPointInCircle(state.p2, mouse)) {isDragging = 'p2';}});canvas.addEventListener('mousemove', (e) => {if (!isDragging) return;const rect = canvas.getBoundingClientRect();const mouse = {x: e.clientX - rect.left,y: e.clientY - rect.top};if (isDragging === 'p1') {state.p1.x = mouse.x;state.p1.y = mouse.y;} else if (isDragging === 'p2') {state.p2.x = mouse.x;state.p2.y = mouse.y;}render(); // 状态变化,立即重绘});window.addEventListener('mouseup', () => {isDragging = null;});// 初始渲染render();
})();
关键点讲解:
- 状态驱动:
state对象是唯一真实数据源。任何 UI 变化都源于state的改变,然后触发render()。这是 2026 年即使不用框架也推荐的思维模式。 - 坐标转换:
e.clientX是相对于窗口的坐标,必须减去rect.left才能转为画布内部坐标。这是 Canvas 交互中最常见的 Bug 来源。 - 即时反馈:在
mousemove中直接调用render()。对于这种轻量级项目,无需请求动画帧(requestAnimationFrame),直接重绘性能完全足够,且逻辑更简单。
运行与测试
项目搭建完成后,验证环节不能少。
启动服务: 不要直接用
file://协议打开index.html,因为模块化脚本在某些浏览器下可能有跨域限制。推荐使用 VS Code 的 Live Server 插件,或执行:npx serve .访问
http://localhost:3000。功能测试:
- 拖动测试:点击蓝色或红色三角形底部的端点,拖动时,两个三角形应实时变形,保持以直线为底。
- 极端情况:将两点拖到非常近的位置,观察三角形是否退化或报错。代码中
length === 0的判断应能防止除零异常。 - 边界测试:将端点拖出画布范围,确认 Canvas 不会崩溃,只是图形部分不可见。
性能检查: 打开浏览器开发者工具的 Performance 面板,拖动鼠标时,Frame 时间应稳定在 16ms 以内。如果出现掉帧,检查
drawGrid是否在每帧都重新计算,可考虑优化为静态背景图。
优化扩展与避坑指南
项目能跑起来只是及格,以下是进阶建议,让你的代码更具“2026 最新”的工程质感。
1. 引入 TypeScript(推荐)
纯 JS 虽然灵活,但在团队协作中容易出错。将 geometry.js 改为 geometry.ts,添加类型定义:
interface Point {x: number;y: number;
}function calculateApex(p1: Point, p2: Point, heightRatio?: number, isUp?: boolean): Point {// ...
}
这样,如果在 main.js 中误传字符串给 calculateApex,编辑器会立即报错,而不是等到运行时才发现。
2. 依赖管理细节
虽然本项目无外部依赖,但如果未来需要引入 mathjs 进行复杂计算,务必注意 NPM/PyPI 官方包的版本锁定。
- 不要在
package.json中写"mathjs": "^11.0.0"。 - 要使用
npm install mathjs --save-exact,锁定为"mathjs": "11.0.1"。
2026 年的依赖地狱大多源于版本漂移。精确版本能确保你的代码在一年后依然能正常构建。
3. 常见坑点
- Canvas 高清屏模糊:
在 Retina 屏上,Canvas 默认 1 像素对应 2 物理像素,导致线条模糊。解决方案:
const dpr = window.devicePixelRatio || 1; canvas.width = 800 * dpr; canvas.height = 600 * dpr; canvas.style.width = '800px'; canvas.style.height = '600px'; ctx.scale(dpr, dpr); - 内存泄漏:
如果在
mousemove中频繁创建对象(如新的 Point 对象),会导致 GC 压力。尽量复用对象,或确保拖拽结束时正确清理事件监听器。
4. 单元测试示例
为 geometry.js 写一个简单的测试(无需 Jest,用原生 assert):
// test/geometry.test.js
const assert = require('assert');
const Geometry = require('../src/geometry.js');// 测试水平直线
const p1 = { x: 0, y: 0 };
const p2 = { x: 10, y: 0 };
const apex = Geometry.calculateApex(p1, p2, 0.5, true);// 预期顶点应在 (5, -5) 或 (5, 5),取决于垂直向量方向定义
// 这里根据代码逻辑,垂直向量 (0, 10) -> (-10, 0) 归一化后 (-1, 0)
// 所以 apex.x = 5 + (-1)*5*1 = 0? 需根据具体旋转方向调整测试期望值
assert.strictEqual(apex.y, -5, 'Y 坐标应为 -5');console.log('✅ 几何测试通过');
小结
通过这个“画一条直线变成两个三角形”的项目,我们走完了从环境初始化、目录规划、模块化代码编写、交互逻辑绑定到测试优化的完整闭环。
你会发现,代码本身并不复杂,复杂的是如何组织代码。
- 几何模块保证了算法的纯净与可测试性。
- 渲染模块隔离了图形 API 的变更风险。
- 主控制器串联了数据流与用户交互。
这种分层思维,是区分“脚本小子”和“工程师”的分水岭。无论你未来是做前端、后端还是全栈,这种关注点分离(Separation of Concerns)的原则永远适用。
别小看这个小 Demo。把它推到 GitHub,写一份清晰的 README,配上动图演示。这就是你简历里第一个真实的项目经历。
你更常用哪种写法?是喜欢把所有逻辑塞在一个文件里追求极致简单,还是像我这样严格分层追求可维护性?评论区交流,看看大家的工程化思路。