
前阵子帮一个学弟改他的个人展示页面他用 html 写了一份简历页效果很素白底黑字加两张截图朋友打开不到三秒就关掉了。后来我给他加了一套基于 Canvas 的线条交互背景——鼠标滑过时页面里的线条会被“吸引”过去形成一张会动的网。改完以后他自己玩了一下午还截图发朋友圈说“像在操控星空”。这篇文章就把这个项目的完整做法记录下来线条交互背后的粒子连线原理、不用框架的纯 HTML/CSS/JS 实现、内容排版怎么和动态背景配合以及我在不同设备上实测时踩过的坑。适合刚学完基础标签、想做一个拿得出手的个人主页的人参考也适合已经会写静态页面、想给页面加一点“活”元素的前端初学者。1. 这个页面到底在做什么一个带“活”背景的个人名片1.1 它解决的是“看三秒就关掉”的问题个人展示页的本质不是把简历搬上网。访客打开你的页面默认心态是“快速判断这里有没有我想看的东西”。如果你只放一张头像、几行字没有视觉焦点没有让人停留的理由三秒后就关掉是正常的。线条交互的作用就是制造一个“视觉焦点 互动钩子”。当访客移动鼠标背景里的线条会跟着光标变化他本能地会多玩一会儿想看看这个页面还有什么反应。这个“多玩一会儿”的时间足够他读完你的名字、看明白你是做什么的、再决定点不点你的作品链接。我见过很多个人页问题不是信息不够而是没有给访客一个“留在页面上的理由”。动态背景不解决内容问题它解决的是注意力问题。1.2 为什么用纯 HTML/CSS/JS而不是框架这个项目从头到尾只用原生的 HTML、CSS、JavaScript没有引入 Vue、React也没有依赖 Three.js 之类的图形库。不是因为框架不好而是个人展示页这个场景根本不需要。单个页面、内容固定、没有复杂状态管理用框架等于背着一台发电机给一盏台灯供电。原生实现的好处很实在双击 index.html 就能打开不需要 npm install不需要构建工具。部署成本极低扔到任意静态托管或者服务器的一个目录里就完事。后续想改内容打开 HTML 文件改几行文字就行不会牵出编译报错。线条交互本身也没有复杂到需要引库的程度。Canvas 2D API 加一段粒子系统一百行 JavaScript 就能跑起来。自己写一遍你对“动画循环”“粒子运动”“坐标变换”这些概念的理解会比调用任何库都深。1.3 线条交互在个人页里的正确位置这是这个项目里最重要的一条设计原则动态的是背景安静的是内容。线条交互应该作为背景层存在它负责制造氛围不负责传递信息。个人展示页的核心信息——名字、职业、简介、链接——必须放在一个稳定、清晰的内容层里。如果背景和内容都在动页面会显得很躁访客根本不知道该看哪里。所以这个页面在结构上被明确分成两层第一层是 Canvas 画布铺满整个视口承载粒子线条动画放在最底层。第二层是内容卡片居中显示个人信息放在画布上方。两者之间用半透明背景和模糊效果拉开层次背景再活跃也不会干扰阅读。我在这个项目里给学弟定的目标很简单背景让页面“活”起来内容让页面“可信”起来两者各做各的事。2. 线条交互的核心原理粒子、连线和光标引力2.1 从“一堆点”到“一张网”线条交互看起来神秘拆开其实就三件事粒子、连线规则、鼠标参与。先说粒子。我在 Canvas 上随机生成几十个点每个点有坐标x、y有速度vx、vy。每一帧动画里点的坐标加上速度点就开始移动。碰到画布边缘时反向就像台球撞库一样弹回来。只有粒子还不行一根根孤立的点看不出什么效果。关键在第二条规则当两个粒子之间的距离小于某个阈值时就在它们之间画一条半透明的线。距离越近线越亮距离越远接近阈值线越淡直到消失。这样画出来的画面不是一堆凌乱的点而是一张不断变化的网。粒子在动粒子间的连线关系也在跟着变视觉上就产生了“这张网是活的”的感觉。透明度衰减的公式是这个效果的灵魂我常用的是alpha 1 - dist / LINK_DISTdist 是两个粒子的实际距离LINK_DIST 是连线的最大距离。dist 越接近 0alpha 越接近 1线越清晰dist 越接近 LINK_DISTalpha 越接近 0线越淡。这个线性衰减不需要多么精确只要让画面的线条有远近层次视觉就舒服。2.2 鼠标/手指是如何参与进来的光有粒子自己运动页面确实会动但访客觉得自己只是个观众。线条交互真正让人上头的地方是鼠标可以成为画面里的一个“引力点”。实现方法不复杂——把鼠标的坐标当成一个虚拟粒子每一帧都判断这个虚拟粒子和普通粒子之间的距离。如果小于影响范围就在鼠标和粒子之间画一条线。鼠标移动时周围一圈粒子会“追”着鼠标连出线来视觉上就像鼠标在拖动一张网。有些实现会给鼠标加排斥力粒子碰到鼠标会被弹开画面会留下一个“空洞”。我的个人经验是排斥效果视觉上很酷但交互上容易让人不知所措吸引连线更直观鼠标划过去线条跟过来访客马上就知道“这个页面会回应我”。个人展示页需要的恰恰是这个“回应感”。手指触摸设备同理把 touchmove 回调里的触点坐标赋给鼠标变量就行。2.3 几个关键参数到底怎么调这个活动页面能不能出效果参数占了很大比重。我给学弟初始配置是这样一组参数初值作用调整方向粒子数量 COUNT90决定画面密度数量越多越稠密但计算量翻倍连线距离 LINK_DIST130决定网眼大小越大线越多越小线越稀疏鼠标影响范围 MOUSE_DIST180决定鼠标拖动的半径越大响应范围越广粒子速度0.3-0.6决定画面动态快慢越大越活跃越小越安静线条颜色rgba(100,180,255,alpha)决定整体色调配合页面主题色粒子数量是这里面最需要克制的一个参数。两个粒子之间的判断是双重循环90 个粒子每帧要做大约 4000 次距离计算完全没压力。但如果有人把数量加到 500那就是 12 万次计算低端设备直接卡成幻灯片。我习惯用一个经验公式粒子上限 屏幕面积宽乘高除以 12000。比如 1920x1080 的屏幕面积约 207 万除以 12000 大概是 170 个。宁可少一点也要保证帧率稳定这个项目里 90 个在绝大多数屏幕上已经够密了。3. 落地实现骨架、样式和三个关键 JS 模块3.1 页面骨架一个 canvas 层加一个内容层页面结构用最简单的方式一个 canvas 负责动态背景一个 main 卡片负责内容。下面是骨架代码也是这个项目 demo 的基础。!DOCTYPE html html langzh-cn head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title个人展示页面/title link relstylesheet hrefstyle.css /head body canvas idparticle-canvas/canvas main classcard img classavatar srcavatar.jpg alt头像 h1你的名字/h1 p classintro前端开发 / 设计爱好者 / 持续成长中/p div classlinks a href#GitHub/a a href#作品集/a a href#关于我/a /div /main script srcmain.js/script /body /html这里有几个基础但重要的点。langzh-cn告诉浏览器这个页面是简体中文内容影响屏幕阅读器的朗读方式meta charsetUTF-8保证中文不会乱码。这两个标签看着不起眼缺了任何一个后面都会有麻烦。viewport这个 meta 标签是移动端适配的关键没有它手机打开页面会按照桌面宽度缩放字小到看不清。个人展示页大概率会在手机上被看所以这个标签必须写。3.2 视觉层让卡片浮在动态背景上CSS 的核心任务是层级和层次感。canvas 用 fixed 定位铺满视口卡片用相对定位浮在上方垂直水平居中。* { margin: 0; padding: 0; box-sizing: border-box; } html, body { height: 100%; } body { overflow: hidden; font-family: PingFang SC, Microsoft YaHei, sans-serif; color: #e8eaf6; background: #0b0e14; } #particle-canvas { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 0; } .card { position: relative; z-index: 1; max-width: 620px; margin: 0 auto; transform: translateY(-50%); top: 50%; padding: 48px 40px; text-align: center; background: rgba(255, 255, 255, 0.07); backdrop-filter: blur(10px); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.12); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25); }注意 body 设置了overflow: hidden这是为了让页面不出现滚动条让 canvas 背景固定在一个干净的视口里。如果内容卡片很长比如要展示作品列表就不要用overflow: hidden可以改成让 canvas 仍然 fixed卡片正常处于文档流中向下滚动。我后面会在踩坑章节专门讲这个。backdrop-filter: blur(10px)给卡片加了毛玻璃效果让背后的线条动画透过来时是朦胧的内容区域自然就和背景分开。这个属性现在大部分现代浏览器都支持但为了保险我给卡片背景也留了一层rgba(255,255,255,0.07)就算浏览器不支持 blur半透明背景仍然能在线条上方撑住可读性。3.3 JS 模块一初始化粒子与尺寸适配JavaScript 部分分成三个模块第一个是画布尺寸适配和粒子初始化。const canvas document.getElementById(particle-canvas); const ctx canvas.getContext(2d); let width window.innerWidth; let height window.innerHeight; let particles []; const COUNT 90; const LINK_DIST 130; const MOUSE_DIST 180; function resizeCanvas() { const dpr window.devicePixelRatio || 1; width window.innerWidth; height window.innerHeight; canvas.width width * dpr; canvas.height height * dpr; canvas.style.width width px; canvas.style.height height px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } function initParticles() { particles []; for (let i 0; i COUNT; i) { particles.push({ x: Math.random() * width, y: Math.random() * height, vx: (Math.random() - 0.5) * 0.6, vy: (Math.random() - 0.5) * 0.6, }); } } resizeCanvas(); initParticles();这里必须解释一个大家容易漏掉的细节devicePixelRatio。普通屏幕上这个值是 1但在一台 Retina 屏笔记本上可能是 2。如果你直接用 CSS 像素设置 canvas.width在高分屏上画布会模糊线条边缘全是锯齿。正确处理是canvas 的实际像素尺寸width、height 属性乘以 dpr再通过ctx.setTransform(dpr, 0, 0, dpr, 0, 0)让后续所有绘图操作都按 CSS 坐标进行。这样我们写鼠标坐标和粒子坐标时不用做任何换算Canvas 会自动把坐标放大到物理像素线条自然就清晰了。3.4 JS 模块二动画循环和连线绘制第二个模块是动画循环的核心也是整个页面最“烧”的一步。function draw() { ctx.clearRect(0, 0, width, height); for (let i 0; i particles.length; i) { const p particles[i]; p.x p.vx; p.y p.vy; if (p.x 0 || p.x width) p.vx * -1; if (p.y 0 || p.y height) p.vy * -1; for (let j i 1; j particles.length; j) { const q particles[j]; const dx p.x - q.x; const dy p.y - q.y; const dist Math.sqrt(dx * dx dy * dy); if (dist LINK_DIST) { const alpha 1 - dist / LINK_DIST; ctx.strokeStyle rgba(100, 180, 255, ${alpha * 0.6}); ctx.lineWidth 1; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke(); } } } requestAnimationFrame(draw); } draw();动画循环不是用setInterval而是用requestAnimationFrame。这个 API 由浏览器统一调度会跟着屏幕刷新率走屏幕 60Hz 就每秒调用约 60 次屏幕 120Hz 就调用约 120 次不会在页面被切到后台时继续空转消耗 CPU。这里我把内层循环的 j 从i 1开始而不是从 0 开始是避免同一对粒子被判断两次。两个粒子之间只需要画一条线交换顺序再画一次是纯浪费。clearRect每帧先清空画布再重新绘制。线条的透明度通过alpha * 0.6控制0.6 这个系数是我调出来的让线条画出来是“淡蓝色细线”的效果而不是一条条刺眼的白线。具体系数可以根据喜好调整但原则上线条要能融入背景不能抢内容的风头。3.5 JS 模块三鼠标交互与事件绑定第三个模块是鼠标和触摸事件让访客能真正“上手”这个页面。const mouse { x: null, y: null }; window.addEventListener(mousemove, (e) { mouse.x e.clientX; mouse.y e.clientY; }); window.addEventListener(mouseleave, () { mouse.x null; mouse.y null; }); window.addEventListener(resize, () { resizeCanvas(); initParticles(); }); function draw() { ctx.clearRect(0, 0, width, height); for (let i 0; i particles.length; i) { const p particles[i]; p.x p.vx; p.y p.vy; if (p.x 0 || p.x width) p.vx * -1; if (p.y 0 || p.y height) p.vy * -1; for (let j i 1; j particles.length; j) { const q particles[j]; const dx p.x - q.x; const dy p.y - q.y; const dist Math.sqrt(dx * dx dy * dy); if (dist LINK_DIST) { const alpha 1 - dist / LINK_DIST; ctx.strokeStyle rgba(100, 180, 255, ${alpha * 0.6}); ctx.lineWidth 1; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y); ctx.stroke(); } } } if (mouse.x ! null) { for (const p of particles) { const dx p.x - mouse.x; const dy p.y - mouse.y; const dist Math.sqrt(dx * dx dy * dy); if (dist MOUSE_DIST) { const alpha 1 - dist / MOUSE_DIST; ctx.strokeStyle rgba(255, 255, 255, ${alpha * 0.5}); ctx.lineWidth 1; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(mouse.x, mouse.y); ctx.stroke(); } } } requestAnimationFrame(draw); } draw();这里mouseleave事件很容易被忽略。如果不写鼠标移出浏览器窗口后坐标还是停留在最后的位置画面里会一直有一团线条聚在一个边角看起来非常怪。监听mouseleave后把坐标置空鼠标不在页面里就不参与连线计算。resize事件要保持 canvas 尺寸和粒子数量跟窗口一致。窗口拉大后原先生成的粒子可能挤在左上角所以简单粗暴的做法是重新初始化所有粒子让它们均匀分布在新窗口里。移动端触摸事件我一般这样加window.addEventListener(touchmove, (e) { if (e.touches.length 0) { mouse.x e.touches[0].clientX; mouse.y e.touches[0].clientY; } }, { passive: true });passive: true告诉浏览器这个事件处理器不会调用 preventDefault可以让滚动更流畅。注意 touchmove 不是 mousemove同一根手指在屏幕上划动时触点会持续更新坐标效果和鼠标拖动一致。4. 从“会动”到“好看”内容排版与视觉节奏4.1 内容区怎么排一个信息层级清晰的卡片有了动态背景内容区的编排反而要更克制。一张合格的个人名片卡片信息层级应该一眼就能读完头像、名字、一句话简介、链接。不要在卡片里塞大段文字那会显得拥挤。我在卡片里用了一个简单的竖直排列头像在最上方圆形裁剪尺寸控制在 96 到 120 像素名字用 28 像素左右的字号加粗简介用 16 像素、颜色稍微暗一点最下面放三个链接用按钮样式做出来鼠标 hover 时背景色变亮。链接按钮的 hover 效果是这个页面上唯一被我允许的“内容层动画”因为它给访客明确的反馈这里可以点。其他内容一律静态。整个页面动的地方只有一个背景访客的注意力反而会更集中。4.2 动态背景和静态内容怎么平衡很多初学者做这个项目容易犯一个毛病背景线条调得很亮颜色很丰富结果内容根本看不清。我常用的方法是让背景在亮度上和内容拉开差距。深色底配淡色线的方案最保险。背景用#0b0e14这类接近黑色的深蓝灰线条用半透明蓝或者半透明白整体亮度压低卡片文字用接近白色的浅色。这样背景再怎么活跃也不会和白色文字抢对比度。浅色底方案也可以做但需要把线条颜色压得更淡比如浅灰#cbd5e1文字用深色。我测试下来的感受是浅色底的线条交互视觉冲击力弱一些深色底更有“动态星空”的质感更适合个人展示页想传达的“有设计感”的形象。还有一个细节是卡片的宽度。桌面端我限制最大宽度 620 像素加上 40 像素的内边距行宽在 540 像素左右中文阅读很舒服。移动端会自动收窄卡片两侧留出 24 像素左右的边距不会贴到屏幕边缘。4.3 进阶玩法点击粒子炸开、主题切换按钮如果基础版已经做完你可以在不破坏整体结构的前提下加两个小彩蛋。第一个是点击画布时粒子炸开。原理是监听 click 事件把鼠标点击位置附近半径 150 像素内的粒子赋予一个朝外的额外速度。实现上是给每个粒子加一个临时速度偏移量过几十帧再衰减掉。这个效果会让访客有“掌控感”比单纯静态吸引更惊喜。第二个是主题切换按钮。右上角放一个按钮点击后在深色主题和浅色主题之间切换。切换时不只是改页面底色还要改线条颜色和透明度这两部分可以封装成一个小配置对象const themes { dark: { bg: #0b0e14, lineColor: rgba(100, 180, 255, , text: #e8eaf6, }, light: { bg: #f8fafc, lineColor: rgba(100, 116, 139, , text: #1e293b, }, };每次绘制线条时读取当前主题的 lineColor 拼字符串避免把颜色写死在代码里。这个改动很小但对代码结构的提升很大后面想换配色只需要改配置。5. 实测中躲不开的坑性能、移动端与兜底体验5.1 坑高 DPI 屏幕模糊第一次跑起来时我在 MacBook 上看到的线条边缘全是毛刺像被压缩过的低清图。排查了一圈问题就出在 canvas 宽高没乘devicePixelRatio。解决方式前面已经写过核心是const dpr window.devicePixelRatio || 1; canvas.width width * dpr; canvas.height height * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);这里有个更隐蔽的坑resize事件里重复调用ctx.setTransform不会叠加所以我在 resize 里用的是setTransform而不是scale。如果用scale(dpr, dpr)每 resize 一次变换矩阵就乘一次画布会越画越“放大”最后线条跑出视野。排查时如果发现画面缩放比例越来越夸张十有八九是这个原因。5.2 坑粒子一多就卡我最早测试时把粒子数量拉到了 200桌面端流畅但把页面切到手机预览模式后帧率肉眼可见地往下掉。两重循环是罪魁祸首——200 个粒子的两两判断是 19900 次距离计算移动端 CPU 扛不住。排查思路不是优化算法而是先接受现实个人展示页不需要那么密的粒子。我把数量降回 90移动端再用屏幕面积动态降低function getParticleCount() { const area window.innerWidth * window.innerHeight; return Math.max(40, Math.min(120, Math.floor(area / 12000))); }小屏手机算出来大约 40 到 60 个画面没有稀疏到难看性能也稳住了。另外注意一帧内避免重复创建对象比如Math.sqrt的结果直接存变量不要写Math.sqrt(dx*dxdy*dy)三遍。这里建议大家多测试真机模拟器不能完全代表性能。我见过模拟器流畅但手机掉帧的情况粒子数量保守一点永远是对的。5.3 坑鼠标移出窗口连线停在原地自己用的时候没感觉别人一用就露馅了。窗口缩小后鼠标移出浏览器边缘页面上有一坨线条聚集在最后一个坐标像一群飞虫停在窗口边缘。排查过程很简单我先在鼠标事件里加 console.log 打印坐标发现鼠标移出后坐标不再更新但不是 null动画循环里判断mouse.x ! null永远成立所以会一直用旧坐标画线。修复就是加mouseleave监听把坐标重置为 null。还有一个边界情况鼠标从窗口移到浏览器开发者工具上也可能触发 mouseleave导致效果短暂消失但重新移动鼠标就回来了影响不大。5.4 坑内容卡片被线条干扰、点击失效线条是画在 canvas 上的canvas 又是position: fixed铺满全屏如果内容卡片的 z-index 没有比 canvas 高卡片就会被盖住连链接都点不了。这个坑的典型表现是能看见卡片但点击链接没反应。排查时第一件事就是打开开发者工具查元素的层级。解决方式我给卡片设了position: relative; z-index: 1;canvas 保持z-index: 0;。同时给 canvas 再加pointer-events: none确保鼠标事件穿透画布落在内容的链接上。这样背景层的交互只靠 mousemove 全局监听canvas 本身不拦截任何点击。移动端还有一个滚动冲突的问题。如果页面内容很长需要滚动body 不能设overflow: hidden而 canvas 是 fixed 的触摸滚动时 touchmove 会持续触发线条被手指拖着一路跑视觉上非常乱。我的处理方案是页面内容少时用overflow: hidden限制滚动内容多时直接把背景固定在视口不动触摸事件只更新坐标但不参与滚动冲突让 canvas 始终铺满视口内容区自然滚动。实际测试下来后者体验更好。5.5 兜底方案不支持 Canvas 时的体验Canvas 的浏览器支持已经非常广但极端环境比如某些老旧的 webview 或禁用了脚本的阅读器下画布会是空白的整个页面背景就变成了纯色也不算难看但少了设计感。我习惯在 CSS 里给 body 准备一个渐变背景作为兜底body { background: linear-gradient(135deg, #0b0e14 0%, #1e293b 100%); }这样就算 JavaScript 没执行、canvas 没画出来访客看到的也是一个有层次的深色渐变背景信息依然可读。这个兜底方案的逻辑是交互是增强体验的内容才是必须保证的。任何时候都要先确保内容能被看见、能被读完再考虑效果够不够炫。我在实际做的时候还在 JS 文件最外层包了一个判断const canvas document.getElementById(particle-canvas); if (canvas canvas.getContext) { // 启动粒子动画 }这样如果 Canvas 不可用页面直接跳过 JS 动画只显示静态背景和内容卡片。别小看这一行判断它能避免你在调试时被一个“为什么页面全白”的问题卡住半天。最后分享一个个人习惯做这种带实时动画的页面我会在完成基础功能后关掉自己的视觉疲劳找几个没见过的朋友来试玩一下观察他们的鼠标一开始会往哪里动。多数人第一次划过页面时都会下意识来回晃鼠标等他们意识到线条会跟着自己变时就已经被这个页面“留”住了。这个小互动正是个人展示页面最廉价也最有效的记忆点。如果以后你想把这个效果延伸到作品集页面只需要把粒子颜色改成品牌主色、把连线距离稍微缩短整站的设计语言就能统一起来。