你现在看到的这个站点,就是按下面这套流程造出来的。全文分五段:技术选型 → 页面结构 → 动效系统 → 响应式与性能 → 构建部署。每一段都给具体文件、具体代码、具体命令。
一句话概括本站的技术形态:内容用 Markdown 写,构建交给 Vite,动效全部是原生 CSS / 原生 JS / SVG。产出物是纯静态文件,可以扔到任何地方。
一、技术选型:只要 4 个构建期依赖
静态博客的本质是「Markdown 进,HTML 出」。这件事有很多种做法,选之前先想清楚三条硬指标:
- 首屏够快:赛博朋克视觉元素多,如果再加上框架运行时,第一屏很容易掉到 1 秒以外。
- 动效可控:霓虹、故障、扫描线都必须能精确控制生命周期,不能交给某个 UI 库的主题。
- 十年后还能构建:博客是长期资产,工具链越薄,腐坏概率越低。
按这三条,可选方案大致三类:
| 方案 | 代表 | 构建耗时 | 依赖体积 | 运行时开销 | 适合谁 |
|---|---|---|---|---|---|
| 重型框架 | Next.js / Nuxt | 5~30s | 200MB+ | 有 hydration | 需要 SSR、API 路由 |
| 轻量 SSG | Astro / Hugo / Eleventy | 0.3~3s | 30~200MB | 通常为 0 | 内容多、要插件生态 |
| 自写构建脚本 | 纯 Node / Python | < 0.1s | 0~2MB | 0 | 追求完全掌控 |
| 本项目:Vite + markdown-it | 本仓库 | < 0.5s | ~10MB | 0 | 标准工具链 + 零运行时 |
本项目选最后一档,原因很具体:
- 构建期干活,运行期不干活:Markdown 解析、语法高亮、模板拼装全部在
npm run build时完成,浏览器拿到的就是 HTML 字符串。产物里没有解析器,也没有 hydration。 - Vite 只做两件事:开发服务器(HMR)和打包(CSS/JS 压缩 + 文件名 hash)。它不参与「内容」,所以换掉它也不会伤筋动骨。
- 依赖只有 4 个,全部是
devDependencies:
| 依赖 | 作用 | 是否进入产物 |
|---|---|---|
vite |
开发服务器 + 打包 | 否 |
markdown-it |
Markdown → HTML | 否 |
gray-matter |
解析 front matter | 否 |
highlight.js |
代码块语法高亮(构建期) | 否(只留 CSS 类名) |
注意「构建期」和「运行期」这两个词。
npm run build之后,dist/里只有 HTML、一个 CSS、一个 JS 和几个 SVG。没有 node_modules,没有框架,没有第三方脚本。
流水线只有四步,全部发生在构建期:
content/*.md
│ gray-matter → title / date / tags / summary
│ markdown-it → <p> <h2> <pre><code class="hljs">...
▼
scripts/templates.mjs → layout() 套骨架 + 卡片 + 目录
│
▼
index.html / posts/*.html / about.html / 404.html ← 磁盘上的真实文件
│ Vite 打包(CSS 压缩、JS 压缩、文件名加 hash)
▼
dist/ → sitemap.xml / feed.xml / robots.txt / .nojekyll (writeBundle 阶段补写)二、目录结构:源与产物彻底分开
cyberblog/
├── package.json # 4 个 devDependencies + 4 条 npm script
├── vite.config.mjs # base / 多页 / 打包策略
├── content/ # 唯一需要你天天动的地方
│ ├── 2026-09-28-hello-neon.md
│ ├── 2026-10-12-canvas-particles.md
│ └── 2026-10-18-cyberpunk-blog-tutorial.md
├── scripts/ # 构建器,六个文件各管一件事
│ ├── site.config.mjs # 站点标题 / 作者 / 域名 / 配色轮换
│ ├── markdown.mjs # md 渲染:锚点、目录、代码块外壳、图片懒加载
│ ├── content.mjs # 读 md → 结构化对象 + 阅读时长 + 标签统计
│ ├── templates.mjs # 纯函数模板:layout / card / 五个页面
│ ├── generate.mjs # 写页面到磁盘 + sitemap / robots / RSS
│ └── vite-plugin-blog.mjs # 把上面这套接进 Vite 的三个生命周期钩子
├── src/
│ ├── main.js # 交互层:粒子 / 打字机 / 进场 / 倾斜 / 过滤
│ └── styles/cyber.css # 设计系统 + 全部 CSS 动画 + 高亮配色
├── public/assets/ # 原样拷贝到 dist 的静态资源
│ ├── holo-core.svg # 首页全息核心(SMIL 动图,6KB)
│ ├── data-stream.svg # 数据流(SMIL 动图,5KB)
│ ├── mark.svg # 品牌标志
│ └── favicon.svg
├── dist/ # 构建产物(git 忽略,可直接部署)
└── *.html # 构建器生成的页面(git 忽略)三个边界必须清楚,否则后面全是坑:
content/是内容,scripts/是规则,src/是表现。 改文章只碰content/,改视觉只碰src/styles/cyber.css。dist/每次构建都会被覆盖,任何手工修改都会丢失。- 根目录那几个
.html是生成的中间产物,同时充当 Vite 的多页入口(build.rollupOptions.input)。所以它们要进.gitignore,但不能删。
为什么页面要「先落盘,再交给 Vite」
Vite 打包多页应用时,要求每个入口是一个真实存在的 HTML 文件。所以流程是:
// scripts/vite-plugin-blog.mjs
config() {
const { input } = generateSite() // 先把 md 渲染成 *.html 写到磁盘
return { build: { rollupOptions: { input } } } // 再把它们注册成打包入口
}config() 钩子在 Vite 读完配置之后、开始打包之前执行,是唯一能「既生成文件、又改打包入口」的位置。开发服务器同样跑这个钩子,于是 npm run dev 打开的就是同一份 HTML,不存在「开发能看、构建后错位」的问题。
三、页面结构设计与职责划分
整站 5 个页面,每个页面只负责一件事:
| 页面 | 产物路径 | 构建函数 | 核心职责 | 主力动效 |
|---|---|---|---|---|
| 首页 | /index.html |
buildIndex() |
3 秒内说清「这里是什么」,导流到最新 3 篇 | 故障标题、打字机、全息核心、粒子星网、数据流 |
| 归档页 | /posts/index.html |
buildList() |
全量索引、标签过滤、关键词检索 | 卡片入场、3D 倾斜、扫光 |
| 详情页 | /posts/<slug>.html |
buildPost() |
沉浸阅读、目录导航、代码复制、上下篇 | 阅读进度条、目录高亮、头图数据流 |
| 关于页 | /about.html |
buildAbout() |
作者身份、技术栈、时间线、联系方式 | 头像环旋转、时间线脉冲点 |
| 404 | /404.html |
buildNotFound() |
兜住错误路径,把人送回首页 | 故障标题 |
1. 骨架层:所有页面共用的 5 件事
layout() 是唯一的页面外壳,它只负责塞五类东西,多一件都不塞:
- 元信息:
title、description、og:*、theme-color、favicon、RSS 自动发现 - 氛围层:
.fx--scanlines(扫描线)、.fx--vignette(暗角)、.fx--grain(噪点)三个固定定位的空 div - 背景画布:
<canvas id="bg-canvas"> - 全局导航:logo + 三个链接 + 状态灯
- 样式与脚本:
/src/styles/cyber.css与<script type="module" src="/src/main.js">
模板参数只有 6 个:title、desc、body、root、pageId、boot。
其中 root 是相对根路径:一级页传 '',/posts/ 下的页面传 '../'。导航链接写成 root + 'posts/index.html',无论部署在域名根目录还是 https://user.github.io/repo/ 这种子路径下,相对链接永远正确,不需要任何运行时拼接。
layout({ title, desc, body, root: '', pageId: 'index', boot: true }) // /index.html
layout({ title, desc, body, root: '../', pageId: 'post' }) // /posts/xxx.html再强调一遍这条纪律:页面内的站内链接一律相对,静态资源一律绝对(/assets/...)。 前者保证子路径部署不炸,后者交给 Vite 的 base 统一改写。
2. 首页:一次性说服
首页只有一个任务:让访客 3 秒内知道这里是什么,并且愿意点进去。所以信息密度刻意做低,按 F 型视线排布:
- 左侧:状态徽章 → 主标题(故障字)→ 打字机副标题 → 一句定位 → 两个 CTA → 四个数字
- 右侧:全息核心动图——视觉锚点,也是「这里能动」的第一个信号
- 下方:最新日志三卡 → 终端面板 + 信号面板 → 收尾 CTA
终端面板是氛围组件而非功能组件:等宽字体、假命令、绿色 ✔,成本几乎为零,但「赛博 / 工程」的气质全靠它撑。注意它是静态 HTML,不是 JS 打出来的——能用静态标记表达的,就不要用脚本。
3. 归档页:可检索
过滤与搜索全部在前端完成,不需要后端、不需要索引文件:
// 构建时:front matter 注入 data 属性
// <article class="card" data-tags="CSS,动效" data-title="纯 CSS 故障艺术">
// 运行时:只做字符串匹配 + hidden 切换,不重建 DOM
var okTag = activeTag === 'all' || tags.indexOf(activeTag) !== -1
card.hidden = !(okTag && okText)hidden 属性是最便宜的「隐藏」:不销毁节点、不触发重排到 0 高度,恢复时也不需要重建。5 篇文章看不出差别,500 篇也不会卡。
4. 详情页:阅读优先
详情页有严格层级:进度条(fixed)→ 面包屑 → 标签 → 标题 → 元信息 → 头图 → 正文 + 目录 → 上下篇。
正文是两栏网格:
.post__layout {
display: grid;
grid-template-columns: minmax(0, 1fr) auto; /* 右边 210px 的 sticky 目录 */
gap: 56px;
}minmax(0, 1fr) 里的 0 是必须的。默认的 1fr 等价于 minmax(auto, 1fr),而 auto 的最小尺寸由内容决定——<pre> 里一行 200 字符的代码会把栅格撑破,整页出现横向滚动条。这是 CSS Grid 最常见的坑,配代码高亮的博客 100% 会遇到。
目录只在 H2 数量 ≥ 2 时渲染,避免短文章出现一个孤零零的目录框。
5. 关于页 & 404
关于页回答「你是谁 / 你做过什么 / 怎么联系」,用三种载体:事实列表(LOCATION / STACK / STATUS / POSTS)、README 式自述、时间线。时间线左边线用 border-left,节点是 ::before 的发光圆点,最后一项把边框设成透明,视觉上自然收尾。
404 复用 page-head 的排版,只把标题换成 404,再加一个回首页的按钮。不要为 404 单独设计一套样式——它的访问量低,不值得维护成本。
四、动图与动画系统
赛博朋克最容易翻车的地方:把所有特效同时打开,页面从头到尾都在动,读者三秒就烦。
本站的规矩是分三层,三层不能混:
| 层级 | 定位 | 数量 | 例子 |
|---|---|---|---|
| L0 氛围 | 常驻、低成本、不抢注意力 | ≤ 4 | 粒子星网、扫描线、暗角、噪点、网格地面 |
| L1 信息 | 随滚动/时间触发,服务于内容 | ≤ 6 | 故障标题、打字机、全息核心、数据流、卡片入场、数字滚动 |
| L2 反馈 | 只在交互时出现 | 不限 | 3D 倾斜、扫光、光标光晕、阅读进度、复制反馈 |
1. 完整动效清单(位置 / 触发 / 实现 / 成本)
| 层 | 动效 | 位置 | 触发时机 | 实现 | 成本 |
|---|---|---|---|---|---|
| L0 | 粒子星网 | 全站固定底层 #bg-canvas |
空闲回调后常驻 | Canvas 2D + rAF | 中 |
| L0 | CRT 扫描线 | 全站覆盖层 | 常驻,7s 循环 | repeating-linear-gradient + translate3d |
极低 |
| L0 | 暗角 + 噪点 | 全站覆盖层 | 静态 | radial-gradient + SVG feTurbulence |
零 |
| L0 | 网格地面 | 首页 hero 底部 | 常驻,6s 流动 | rotateX(74deg) + 背景位移 |
低 |
| L1 | 故障标题 | 首页 / 归档 / 关于 / 404 的 H1 | 每 5.5~6.5s 爆发约 40ms | 双伪元素 + clip-path + steps(1) |
低 |
| L1 | 打字机 | 首页副标题 | load 后 0.9s,循环 3 句 |
setTimeout 逐字 |
低 |
| L1 | 全息核心 | 首页右侧 | 常驻,7 组不同速率 | SVG SMIL animateTransform |
中 |
| L1 | 数据流 | 首页信号面板 + 详情页头图 | 常驻 | SVG SMIL stroke-dashoffset |
低 |
| L1 | 卡片入场 | 首页 / 归档 | 交叉视口 8% 时触发一次 | IntersectionObserver + transform | 低 |
| L1 | 数字滚动 | 首页 stats | 进入视口时 | rAF + easeOutCubic | 极低 |
| L2 | 3D 倾斜 | 卡片 hover | pointermove(rAF 节流) |
CSS 变量 + rotateX/Y |
低 |
| L2 | 扫光 | 卡片 hover | hover | translateX(-130% → 130%) |
低 |
| L2 | 光标光晕 | 全站 | pointermove,0.14 阻尼跟随 |
单 div transform |
低 |
| L2 | 阅读进度 | 详情页顶部 | scroll(rAF 节流) |
scaleX() |
极低 |
| L2 | 复制反馈 | 代码块按钮 | click | 文案切换 + 颜色 | 极低 |
| L2 | 开机遮罩 | 首页 | load 后 620ms 淡出 |
opacity + visibility |
一次性 |
2. 动图选型:为什么不用 GIF
「有动图」这件事,很多人第一反应是塞一张 .gif。本站首页那个旋转的核心就是一张动图,但它不是 GIF,是一个 6KB 的 SVG:
| 指标 | 动画 GIF(480px) | SVG + SMIL | WebM / MP4 |
|---|---|---|---|
| 体积 | 800KB ~ 2.5MB | 6KB | 200KB ~ 2MB |
| 缩放 | 放大发虚 | 任意分辨率锐利 | 放大发虚 |
| 改颜色 | 逐帧重做 | 改一个 stop-color |
重编码 |
支持 prefers-reduced-motion |
否 | 是(CSS 可禁) | 是(暂停播) |
| CPU | 每帧解码位图 | 矢量重绘 | 硬解,很省 |
| 适用场景 | 真人实拍 / 复杂纹理 | 图标、界面、数据可视化 | 实拍视频 |
结论按这个顺序选:
- 能用 SVG/CSS 表达的,就用 SVG/CSS。它天然矢量、可换色、可被 CSS 控制,体积常常只有 GIF 的 1%。
- 必须是实拍或复杂纹理动画 → 用 WebM/MP4 +
video autoplay muted loop playsinline,不要 GIF。 - 非要用栅格动图 → 用动图 WebP 或 AVIF,同画质约为 GIF 的 1/5。
- 实在要用 GIF:限制在 480px 宽、控制帧数、
loading="lazy",并且永远别放在首屏。
一个容易忽略的细节:SMIL 动画写在 .svg 文件内部,用 <img src="holo-core.svg"> 引用时依然会播放(脚本不执行,但声明式动画会)。所以它既能当图片用,又天然是「动图」,还能被懒加载、被浏览器缓存、被 CDN 压缩。
<img src="/assets/holo-core.svg" alt="全息核心:旋转光环与脉冲波形"
width="560" height="560" fetchpriority="high" decoding="async">width / height 必须写死。SVG 在加载完成前高度是 0,会造成明显的布局偏移(CLS)——赛博朋克页面本来元素就多,这一下能直接把 CLS 从 0 推到 0.2。
3. 故障标题:两个伪元素伪造信号断裂
思路:真文字保持白色,两个伪元素复制同一段文字(content: attr(data-text)),分别染青与品红,用 mix-blend-mode: screen 叠加,再用 clip-path: inset() 在时间轴上抖动切片。
.glitch { position: relative; color: #f2f8ff; }
.glitch::before,
.glitch::after {
content: attr(data-text);
position: absolute; inset: 0;
pointer-events: none;
mix-blend-mode: screen; /* 叠加而非遮挡,保留底层的品红斜杠 */
}
.glitch::before { color: var(--cyan); text-shadow: -2px 0 var(--magenta); animation: glitch-a 6.5s steps(1) infinite; }
.glitch::after { color: var(--magenta); text-shadow: 2px 0 var(--cyan); animation: glitch-b 5.5s steps(1) infinite; }
@keyframes glitch-a {
0%, 88%, 100% { transform: translate3d(0,0,0); clip-path: inset(0 0 100% 0); }
89% { transform: translate3d(-5px,0,0); clip-path: inset(8% 0 62% 0); }
90% { transform: translate3d( 5px,0,0); clip-path: inset(58% 0 18% 0); }
91% { transform: translate3d(-3px,0,0); clip-path: inset(32% 0 46% 0); }
}三个关键点:
steps(1)而不是ease:故障是「跳变」不是「渐变」,缓动会让它看起来像卡顿。- 静止态用
clip-path: inset(0 0 100% 0)把伪元素完全裁掉:只有爆发那几十毫秒才露出来,其余时间文字是绝对干净的。否则长期重影会让标题发虚。 - 两个伪元素的周期故意错开(6.5s / 5.5s):同频会显得像固定模板,错开后每次爆发的位置组合都不同。
4. 扫描线:3px 位移的完美循环
.fx--scanlines {
position: fixed; inset: -4px 0; /* 上下各多留 4px,避免位移时露边 */
background: repeating-linear-gradient(to bottom,
rgba(190,240,255,.05) 0 1px, transparent 1px 3px);
mix-blend-mode: overlay;
animation: scan-drift 7s linear infinite;
}
@keyframes scan-drift {
from { transform: translate3d(0,-3px,0); }
to { transform: translate3d(0, 0,0); }
}位移距离必须等于图案周期(3px),最后一帧才能和第一帧完全重合。写成 -10px 你会看到每 7 秒画面「跳」一下——这是最常见的循环动画瑕疵。
5. 粒子星网:一个 canvas 撑起全站氛围
initCanvas() 做五件事:
- 按面积算节点数:
clamp(w*h/20000, 26, 90)。小屏省电,大屏不空。 - 连线只比距离平方:
d2 < 19600(即 140px),避免每个配对都开方。 - 鼠标排斥:指针 160px 内的节点被轻推开,制造「有生命」的错觉。
- DPR 上限 2:4K 屏不画 4 倍像素。
- 标签页隐藏时跳过绘制:
document.hidden为真直接 return。
var dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 用 CSS 像素坐标绘制,缩放交给矩阵不要用 ctx.shadowBlur 画 90 个发光点。 那是 canvas 里最贵的操作之一,中端笔记本能直接把帧率从 60 拖到 25。本项目的发光感来自 CSS 覆盖层的暗角与噪点,而不是逐点高斯模糊。
另外一个容易被忽略的点:粒子画布通过 requestIdleCallback 延后启动,不和首屏 LCP 抢主线程。
var idle = window.requestIdleCallback || function (fn) { return setTimeout(fn, 260); };
idle(function () { initCanvas(); }, { timeout: 1400 });6. 进场动画:只动 opacity 和 transform,且只触发一次
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
entry.target.classList.add('is-visible');
io.unobserve(entry.target); // 触发一次就解绑,避免反复计算
});
}, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 });配套 CSS 只动两个属性:
html.js [data-reveal] {
opacity: 0;
transform: translate3d(0, 22px, 0);
transition: opacity .75s var(--ease), transform .75s var(--ease);
}
html.js [data-reveal].is-visible { opacity: 1; transform: none; }注意前缀 html.js:这段样式只在 JS 可用时生效。否则禁用 JS 的用户会看到一整页空白——这是「用 JS 做动画」最经典的翻车方式。<head> 里那行内联脚本负责第一时间打标记:
<script>document.documentElement.classList.add("js");</script>它必须内联、必须在 <head>、必须早于任何样式表生效,否则会出现「先正常显示,再突然隐藏,再淡入」的闪屏。
7. 3D 倾斜:把鼠标坐标写进 CSS 变量
card.addEventListener('pointermove', function (e) {
if (frame) return; // rAF 节流:120Hz 鼠标每秒触发 120 次
frame = requestAnimationFrame(function () {
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - 0.5;
var py = (e.clientY - r.top) / r.height - 0.5;
inner.style.setProperty('--ry', (px * 7).toFixed(2) + 'deg');
inner.style.setProperty('--rx', (-py * 7).toFixed(2) + 'deg');
frame = null;
});
});.card__link {
transform: perspective(900px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transition: transform .35s var(--ease);
}7 度是经验上限:再大就显得廉价,而且文字会因为透视而模糊。倾斜只在 (hover: hover) and (pointer: fine) 下初始化——触屏设备上 pointermove 会和滚动打架。
8. 触发时机的三条纪律
- 首屏常驻动画不超过 3 个:本站是粒子 + 扫描线 + 全息核心。再多,LCP 和注意力都会被吃掉。
- 滚动动画只播一次:
unobserve之后不再重播。反复播放的入场动画会让长文阅读变得焦躁。 - 交互动画只做增强:hover 效果必须在没有 hover 的设备上完全不影响可用性;进度条、目录高亮属于「有更好,没有也能读」。
五、响应式与性能
1. 断点策略:三个就够
| 断点 | 变化 |
|---|---|
≤ 1024px |
Hero 变单列、视觉元素提到标题上方;目录隐藏;关于页单列 |
≤ 820px |
导航收进汉堡菜单;工具栏纵向排列;头图降为 96px 高 |
≤ 520px |
容器边距 18px;数字区 2×2 网格;信号面板单列;扫描线透明度降到 .35 |
断点不是「设备型号」,而是「内容开始挤不下」的位置。判断方法很简单:把浏览器拉到 1440 / 1024 / 820 / 520 / 375 五档,看哪一档开始出现换行难看、文字压边。
移动端刻意做的降级清单:
- 粒子数量随面积下降(下限 26 个)
- 3D 倾斜、光标光晕只在支持 hover 的精细指针设备启用
- 扫描线透明度降低(小屏像素密度高,同透明度会更糊)
- hero 最小高度改
auto,避免移动端地址栏收缩导致高度跳动 - 详情页目录隐藏(移动端有原生滚动条就够)
2. 性能预算:给自己定死红线
| 指标 | 预算 | 说明 |
|---|---|---|
| 首页 HTML | < 30KB | 含内联骨架,不压缩 |
| CSS | < 60KB | 单文件,gzip 后约 9KB |
| JS | < 30KB | 单文件,gzip 后约 5KB |
| SVG 资源合计 | < 40KB | 4 个文件 |
| 首屏请求数 | ≤ 6 | HTML / CSS / JS / 2 个 SVG / 字体 |
| LCP | < 1.5s | 本地静态预览约 0.5~0.7s |
| CLS | < 0.05 | 图片写死宽高,实测 0 |
| 单帧 JS 执行 | < 4ms | 粒子循环约 1ms |
| Lighthouse Performance | ≥ 95 | 关闭节流后测 |
预算超标就优化,不要用「看起来还行」自我安慰。
3. 资源加载顺序
<!-- 1. 字体:先建连,再用 print 技巧异步加载,不阻塞首屏渲染 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Orbitron:wght@600;800;900&display=swap"
media="print" onload="this.media='all'">
<!-- 2. 关键 CSS:同步加载。单文件且体积小,内联反而破坏缓存 -->
<link rel="stylesheet" href="/src/styles/cyber.css">
<!-- 3. 首屏 SVG 提高优先级 -->
<img src="/assets/holo-core.svg" width="560" height="560" fetchpriority="high">
<!-- 4. 首屏以下的图懒加载 -->
<img src="/assets/data-stream.svg" width="640" height="180" loading="lazy">
<!-- 5. JS 用 module(自带 defer),不阻塞解析 -->
<script type="module" src="/src/main.js"></script>media="print" onload="this.media='all'" 是经典的「不阻塞 CSS」技巧:浏览器把它当打印样式表,不等它就开始渲染首屏,加载完成后切回 all。即使 Google Fonts 被墙,--font-display / --font-mono 的 fallback 栈(Bahnschrift / Consolas / 系统字体)也能保证版面不塌——赛博感有 60% 来自字号、字距和字重,而不是具体哪款字体。
4. 动画性能的三条铁律
- 只动
transform和opacity。这两个属性走合成器线程,不触发 layout / paint。动width、top、margin、box-shadow会每帧重排或重绘,必掉帧。 will-change要克制。只在动画期间加,结束就移除。给几十个元素常驻will-change: transform,等于给显存开几十个合成层。- 短爆发优于常驻抖动。故障效果 6 秒才爆发 40ms,其余时间伪元素被
clip-path完全裁掉,几乎零成本。让它一直抖,既伤眼也伤 GPU。
5. 无障碍与降级:不是可选项
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after {
animation-duration: .001ms !important;
animation-iteration-count: 1 !important;
transition-duration: .001ms !important;
}
.fx--scanlines,
.hero__grid::before { animation: none; }
.cursor-glow { display: none; }
.boot { display: none; }
html.js [data-reveal] { opacity: 1; transform: none; }
}JS 侧同步尊重这个信号:reduceMotion 为真时,粒子只画一帧静态图、打字机直接显示整句、倾斜与光晕不初始化、开机遮罩直接移除。
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;系统设置里打开「减弱动态效果」的用户,通常是前庭功能敏感人群。降级不是体贴,是及格线。
六、从环境搭建到部署:完整步骤
步骤 1:检查环境
只需要 Node.js 18+(推荐 20 / 22 LTS)。Vite 7 要求 Node 20.19+ 或 22.12+。
node -v # v22.22.2
npm -v # 10.9.7没有 Node 的话,去 nodejs.org 装 LTS 版本,或者用版本管理器:
# macOS / Linux
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
nvm install 22 && nvm use 22
# Windows(PowerShell)
winget install OpenJS.NodeJS.LTS步骤 2:拿到项目并安装依赖
git clone <your-repo> cyberblog
cd cyberblog
npm install国内网络慢的话换镜像(只影响下载速度,不改产物):
npm install --registry=https://registry.npmmirror.com安装完成后你会有 node_modules/(约 10MB)和 package-lock.json。node_modules 不提交,package-lock.json 必须提交——后者锁定版本,保证半年后 CI 构建出的产物和今天一致。
步骤 3:本地开发
npm run dev
# ➜ http://localhost:4321/开发服务器的行为要点:
- 改
src/styles/cyber.css、src/main.js:Vite 热更新(HMR),样式改了不刷新页面,JS 改了自动 reload。 - 改
content/*.md:插件监听content/目录,重新生成 HTML 并整页刷新,控制台打印[cyberblog] content\xxx.md → rebuilt in 90ms (full reload)。 这里有个坑值得说:chokidar 回调拿到的可能是相对路径(content\x.md),直接和绝对路径startsWith比较会永远不成立,监听看起来「没反应」。所以插件里先path.resolve归一化再比较,顺便把反斜杠统一成正斜杠,Windows 上才不会踩雷。 - 新增 md 文件:需要重启一次 dev 服务器(新页面要进 Vite 的入口表)。
- 改
scripts/templates.mjs:重启 dev 服务器(模板在config()阶段执行)。
步骤 4:构建
npm run build输出大致如下:
vite v7.x building for production...
✓ 9 modules transformed.
dist/index.html 18.4 kB
dist/posts/index.html 12.1 kB
dist/posts/cyberpunk-blog-tutorial.html 41.3 kB
...
dist/assets/index-<hash>.css 38.9 kB │ gzip: 8.7 kB
dist/assets/index-<hash>.js 13.9 kB │ gzip: 4.8 kB
dist/assets/holo-core.svg 6.6 kB
✓ built in ~400ms值得注意的三件事:
- HTML 是「每个页面一个文件」,体积就是内容本身,没有运行时解析成本。
- CSS / JS 文件名带 hash,可以放心设一年缓存:内容变了文件名就变了。
dist/里没有node_modules,也没有 markdown-it / highlight.js——它们只在构建机器上存在。
步骤 5:本地验收
npm run preview
# ➜ http://localhost:4321/ (serve 的是 dist/,和线上完全一致)必须用 preview 而不是直接双击 dist/index.html:file:// 协议下模块脚本会被 CORS 拦住。
验收时打开 DevTools 三个面板:
- Performance:录 5 秒,FPS 应稳定在 60,长任务(红线)不超过 50ms
- Rendering → Paint flashing:不应出现大面积绿色闪烁(说明没有持续重绘)
- Lighthouse:Performance / Accessibility / Best Practices / SEO 四项
步骤 6:部署
产物是纯静态文件,任选一种。
A. GitHub Pages
新建 .github/workflows/deploy.yml(仓库已自带):
name: deploy
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
env:
BASE: ${{ github.event.repository.name }}
- uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment: github-pages
steps:
- uses: actions/deploy-pages@v4关键点:BASE=<repo>(只写仓库名,不带斜杠)。因为 GitHub Pages 的项目站点跑在 https://user.github.io/<repo>/ 子路径下,必须让 Vite 用 /repo/ 前缀改写静态资源 URL,否则 CSS 会 404。vite.config.mjs 会自动把它补成 /repo/。
然后到仓库 Settings → Pages,Source 选 GitHub Actions。第一次 push 后自动上线。
B. Netlify
仓库根目录的 netlify.toml 已经写好:
[build]
command = "npm run build"
publish = "dist"
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"接 Git 仓库即可;也可以直接把 dist/ 拖到 Netlify Drop。
C. Vercel
npm i -g vercel
vercel --prod
# Framework Preset: Other,Output Directory: dist,Build Command: npm run buildD. Cloudflare Pages
Build command npm run build,Output directory dist,其余留空。全球边缘节点,国内访问通常比 Vercel 稳。
E. 自己的服务器(Nginx)
rsync -avz --delete dist/ user@server:/var/www/neon-edge/server {
listen 80;
server_name blog.example.com;
root /var/www/neon-edge;
index index.html;
location / {
try_files $uri $uri/ $uri.html =404;
}
error_page 404 /404.html;
# 带 hash 的资源可以永久缓存
location /assets/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# HTML 必须回源,否则更新后用户看不到新内容
location ~* \.html$ {
add_header Cache-Control "no-cache";
}
gzip on;
gzip_types text/css application/javascript image/svg+xml application/xml;
}步骤 7:上线后收尾
- 把
scripts/site.config.mjs里的url改成真实域名(sitemap.xml、feed.xml、robots.txt都用它) - 到 Google Search Console / 百度站长提交
sitemap.xml - 把
feed.xml塞进 RSS 阅读器,确认能正常订阅 - 用 PageSpeed Insights 测线上地址(本地分数会偏高,因为线上有网络延迟)
七、日常写作流程
新增一篇文章 = 新建一个 md 文件,然后 git push。
cat > content/2026-10-20-my-post.md <<'EOF'
---
title: 文章标题
slug: my-post
date: 2026-10-20
tags: 随笔, 动效
summary: 出现在卡片上的一句话摘要。
---
正文用标准 Markdown 写。代码块用三个反引号包裹并标注语言,构建期会自动高亮。
EOF
npm run dev # 本地看效果
git add . && git commit -m "post: my-post" && git pushfront matter 字段说明:
| 字段 | 必填 | 作用 |
|---|---|---|
title |
是 | 页面标题、卡片标题、<title> |
slug |
否 | 产物文件名与 URL;缺省时从文件名去掉日期前缀推断 |
date |
是 | 排序依据(YYYY-MM-DD 字符串倒序) |
tags |
否 | 逗号分隔;第一个作为卡片角标,全部用于过滤 |
summary |
否 | 卡片摘要 + meta description |
阅读时长由构建器自动估算:中文汉字数 / 350 + 英文词数 / 200。
在文章里放动图/图片,两条路径:
<!-- 1. 普通图片:放 public/assets/,构建时原样拷贝 -->
<img src="/assets/screenshot.png" alt="描述" width="1200" height="630" loading="lazy">
<!-- 2. 动图:优先 SVG(SMIL)或视频 -->
<img src="/assets/loop.svg" alt="描述" width="640" height="360" loading="lazy">
<video src="/assets/loop.webm" width="640" height="360" autoplay muted loop playsinline></video>Markdown 里直接写 <img> / <video> 是可行的,因为 markdown-it 开了 html: true。
八、常见问题排查
| 现象 | 原因 | 修复 |
|---|---|---|
| 页面空白,元素全不见 | html.js [data-reveal] 生效了但 IO 没跑 |
检查 /src/main.js 是否 404;或临时删掉 html 上的 js 类验证 |
| 整篇正文永远不显示 | data-reveal 加在比视口高几十倍的元素上,threshold: 0.08 的可见比例永远达不到 |
threshold 改 0,或干脆不给正文加 reveal(本站就是不给) |
Windows 下构建报 fileName ... must not be absolute |
项目放在目录联结 / 软链下,root 与入口文件不在同一盘符,path.relative 返回绝对路径 |
配置里用 realpathSync 统一 root(vite.config.mjs 已处理) |
构建报 Could not resolve entry |
生成的 HTML 不存在 | 确认 config() 里 generateSite() 先跑且没抛错;content/ 至少要有 1 篇 md |
| 部署到子路径后样式丢失 | 静态资源没走 base |
BASE=repo npm run build,并检查是否有硬编码的绝对路径 |
| 代码块撑破布局 | 栅格列没写 minmax(0, 1fr) |
改成 grid-template-columns: minmax(0,1fr) auto |
| 扫描线有接缝 | 位移距离 ≠ 图案周期 | 3px 图案必须位移 3px |
| 字体没生效 | Google Fonts 被墙 | 换系统字体栈,或自托管 woff2 到 public/fonts/ |
| SVG 动图不动 | 用了 background-image 或内含脚本 |
SMIL 在 <img> 里可用,脚本不行;改 <img> 引用 |
| 改 md 不生效(dev) | 新增文件没进入口表 | 重启 npm run dev |
| 动画在低端机卡顿 | 用 box-shadow / filter 做逐帧动画 |
改用 transform/opacity,或按媒体查询降级 |
| 每篇文章标题都一样 | <title> 没走模板变量 |
检查 layout() 的 title 是否传了 post.title |
| 中文锚点链接失效 | slugify 把中文过滤掉了 |
本项目用 \p{L} 保留中英文,确认没改成 [a-z0-9] |
九、验收清单
上线前逐项打勾:
npm run build无报错,dist/里 HTML 数量 = 文章数 + 4npm run preview下 320 / 768 / 1024 / 1440 四档宽度无横向滚动条- 键盘 Tab 能走完「导航 → 卡片 → 正文链接」,焦点框可见
- 系统开启「减弱动态效果」后,页面无持续动画,内容完整可读
- DevTools 断网后刷新,字体回退但版面不塌
- Lighthouse Performance ≥ 95,CLS = 0
- 每篇文章
title/description/og:title唯一 sitemap.xml/feed.xml/robots.txt/.nojekyll均已生成- 手机真机上看一眼首屏——模拟器看不出滚动惯性带来的抖动
赛博朋克的视觉内核不是「东西多」,而是「信息在噪声中依然能被读清」。所有霓虹、故障、扫描线,都服务于这一件事。动画是修辞,不是内容。
到这里你已经有一套可复用的模板:改 src/styles/cyber.css 里的 --cyan / --magenta 就是另一套冷色或毒性绿视觉,换掉 content/ 就是另一个博客。剩下的,就是持续往里写东西。
十、加餐:三套皮肤与一键切换(赛博 / 卡通 / 二次元)
暗色看久了会想换个口味,所以站里加了一个右下角的切换按钮:赛博 → 卡通 → 二次元 三态轮转(默认赛博),点一下 0.55 秒交叉淡入,不刷新页面,选择记在 localStorage。
实现上只解决三个问题:动图怎么来、怎么铺满还不挡内容、亮色主题怎么不重写一遍 CSS。
1. 动图仍然是 SVG,不是 GIF
public/assets/anime-bg.svg 是一个 24KB 的 SMIL 动图:渐变天空、漂移的云、飘落的樱花(30 片,各自不同的时长与旋转速度)、会呼吸的太阳、闪烁的星芒。同等观感的 GIF 大概要 3MB,而且放大就糊。
2. 一个固定层 + 一层白纱
<div class="bg-anime" aria-hidden="true">
<img class="bg-anime__art" src="/assets/anime-bg.svg" alt="" width="1600" height="1000">
<span class="bg-anime__veil"></span>
</div>.bg-anime {
position: fixed; inset: 0; z-index: 0;
opacity: 0; visibility: hidden;
pointer-events: none; /* 关键:不拦截滚动和点击 */
transition: opacity .55s var(--ease), visibility .55s;
}
.bg-anime__art { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.bg-anime__veil { /* 把动图压成浅底,保证文字对比度 */
position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(255,255,255,.24), rgba(255,250,252,.42) 46%, rgba(255,255,255,.64));
}
html[data-theme="anime"] .bg-anime { opacity: 1; visibility: visible; }
html[data-theme="anime"] #bg-canvas { opacity: 0; } /* 粒子星网在亮色下会变脏,淡出 */三个细节:
object-fit: cover保证任意屏幕比例都铺满,不拉伸、留白;object-position: 50% 42%让视觉重心落在太阳与天空,而不是被裁在底部。pointer-events: none是必须的。背景层一旦能接收事件,整页的 hover、点击、滚动都会被它吃掉。- 白色柔化层是可读性的保险:亮色主题下正文直接用深灰
#33374f,配 24%~64% 的白纱,对比度稳定在 7:1 以上。
3. 主题 = 一套 CSS 变量 + 一段临时过渡
亮色主题没有重写组件,只覆盖令牌:
html[data-theme="anime"] {
--text: #2c2f45;
--text-dim: #4b4f68;
--muted: #6d7190;
--cyan: #0b93a8; /* 霓虹青在浅底上会糊,需要压深 */
--magenta: #d9366e;
--panel: rgba(255, 255, 255, .76);
}然后是几十条 html[data-theme="anime"] .xxx 的定点修正——因为暗色主题里有些颜色是写死的(.card 的渐变、.prose 的 #c9d6e8、.post__title 的 #f4f9ff)。这是纯暗色设计切亮色必然要付的成本:变量能覆盖的用变量,写死的老老实实补一条。
切换的平滑感来自一个「临时全局过渡」:
root.classList.add('is-theme-switching');
setTimeout(() => root.classList.remove('is-theme-switching'), 620);html.is-theme-switching *,
html.is-theme-switching *::before,
html.is-theme-switching *::after {
transition: background-color .55s var(--ease), color .55s var(--ease),
border-color .55s var(--ease), opacity .55s var(--ease) !important;
}只在切换的那 620ms 里给所有元素挂上颜色过渡,之后立刻摘掉。这样既拿到了「整页一起渐变」的效果,又不会让 hover、折叠、倾斜这些常规动画被 transition: all 拖慢。
4. 两个必须踩过的坑
-
刷新会闪回暗色:
data-theme是 JS 加的,而 JS 在<body>末尾才执行,首帧一定是暗的。解法是把读取localStorage的脚本内联进<head>,早于 CSS 生效:<script>(function(){try{if(localStorage.getItem("neon-theme")==="anime")document.documentElement.setAttribute("data-theme","anime")}catch(e){}})();</script> -
减少了动效的用户不该看到淡入:
prefers-reduced-motion: reduce时把.bg-anime的transition直接关掉,切换变成立即生效。功能不减,只是不再渐变。
按钮本身也做了三件事:文案跟着当前主题走(赛博 / 卡通 / 二次元)、aria-label 同时说明「当前是什么 + 点了会去哪」、mobile 下缩到 14px 边距并在 380px 以下退化成纯图标圆钮。
// COMMENTS
评论尚未启用 · 见下方说明