给个人博客做一次大手术:剥离冗余、模块解耦与 SPA 级原生交互

在博客稳定运行并成功接入 900+ 条豆瓣全量书影音记录后,站点的内容和功能日趋丰富。然而,随着功能模块的增加,一些在初期为了“快速跑通”而妥协的隐蔽缺陷也逐渐暴露出来:样式臃肿耦合、多页面跳转带来的白屏割裂、移动端手势卡顿以及命名规范碎片化。

代码的腐化往往始于细微的妥协。为了让博客重归极致纯粹的禅意架构(Zen Architecture),我决定给全站进行一次深度重构与彻底“大手术”:将混合揉杂的模块完全解耦自闭环、全局 CSS 极致瘦身、打造单页 SPA 级的无刷新 Tab 原生交互,并完成全站规范化与移动端像素级打磨

本文将深入底层代码与架构设计,完整复盘这次重构的核心考量与实现细节。


1. 架构病灶复盘:为什么必须动这次大手术?

在动手重构之前,深入梳理全站代码后发现了四大典型“病灶”:

graph TD subgraph 痛点一["1. 全局 CSS 严重耦合"] A1["main.css (1800+ 行)"] --> A2["包含 450+ 行海报墙专有样式"] A2 --> A3["拖慢所有 71+ 篇日常文章加载速度"] end subgraph 痛点二["2. 多页面割裂与白屏跳动"] B1["pages/ 目录"] --> B2["tools.html / phone.html / home.html"] B2 --> B3["80% 骨架代码重复"] B2 --> B4["每次分类切换触发整页白屏刷新"] end subgraph 痛点三["3. 移动端触控与布局缺陷"] C1["悬浮过滤胶囊在超小屏折行变形"] C2["海报卡片内边距与外边距不对称"] C3["触摸滑动未分离 Tap 与 Scroll 导致误触"] end subgraph 痛点四["4. 规范与命名碎片化"] D1["旧 URL: /pages/tools.html 与 /archive.html"] D2["各页面 <title> 命名规范风格不一"] D3["Sitemap 遗漏自定义动态路由"] end

1.1 核心重构目标

  1. 彻底解耦(Decoupled Modules):每个独立功能(/archive//tool/)拥有专属模板、样式、脚本与数据源,彻底自闭环。
  2. 全局瘦身(Slim Stylesheet):从全局 main.css 彻底剥离海报墙专有代码,日常博文保持极致轻量。
  3. 原生无刷新交互(Zero-Reload SPA):用最纯粹的原生 JavaScript + CSS Grid 实现分类秒切,动态更新标题与 Hash 锚点。
  4. 全端一致与 120Hz 丝滑触控:统一 4px 流体网格与 Passive 原生触摸手势,悬浮胶囊永不折行。

2. 第一刀:模块解耦与全局 CSS 极致瘦身

2.1 书影音归档模块自闭环(/archive/

在此之前,书影音海报墙的样式直接堆砌在 theme/public/css/main.css 底部。这意味着访问任意一篇普通的纯文字博文时,浏览器都必须下载并解析几百行与该文章完全无关的海报动画和毛玻璃样式。

我们将书影音相关的逻辑完整剥离,建立专有的独立模块体系:

theme/
├── layouts/
│   └── archive.ejs              # 专属 EJS 模板(输出为 build/archive/index.html)
└── public/
    └── archive/
        ├── archive.css          # 独立海报墙专属样式(450 行)
        └── archive.js           # 独立状态机与手势控制器

在构建管线 core/build.js 中,将数据源也完全收归到模块内部:

// core/build.js: 数据源与页面完全内聚
const archiveDataDir = path.join(paths.BUILD, 'archive', 'data');
await fs.mkdir(archiveDataDir, { recursive: true });
await fs.copyFile(path.join(paths.ROOT, 'data', 'movies.json'), path.join(archiveDataDir, 'movies.json'));
await fs.copyFile(path.join(paths.ROOT, 'data', 'books.json'), path.join(archiveDataDir, 'books.json'));

let archiveHtml = await ejs.renderFile(path.join(paths.LAYOUTS, 'archive.ejs'), {
  title: 'Archive',
  url: '/archive/',
  config
});
// 自动提取并压缩专属 archive.css 与 archive.js 并打上哈希指纹
archiveHtml = await processResources(archiveHtml, resourceCache, processedSet);

瘦身成果:

  • main.css1825 行精简至 1371 行(削减超 25%)。
  • 博客所有 71 篇博文的首屏 CSS 传输体积与解析耗时大幅降低。

3. 第二刀:好物工具单页 SPA 级 Tab 无刷新架构

3.1 摒弃 3 个静态 HTML,重构为单一 SPA 入口

过去的好物分享页面被割裂在 theme/public/pages/ 目录下,包含 tools.htmlphone.htmlhome.html 三个物理文件。用户在点击分类时,浏览器必须重新发起 HTTP 请求,伴随白屏、重绘与重解析,体验非常生硬。

重构后,我们将原本分散的文件整合为全新的独立模块 /tool/

flowchart LR subgraph 旧方案["旧方案:多页面跳转"] O1["tools.html (23KB)"] -->|整页跳转白屏| O2["phone.html (13KB)"] O2 -->|整页跳转白屏| O3["home.html (7KB)"] end subgraph 新方案["新方案:单页 SPA 级 Tab 架构 (/tool/)"] N1["index.html (整合骨架)"] N1 --> P1["Tab 1: #panel-tools"] N1 --> P2["Tab 2: #panel-phone"] N1 --> P3["Tab 3: #panel-home"] N2["tool.js (状态控制器)"] -->|秒级无刷新切换| N1 end

3.2 核心黑科技:display: contents; 实现原生 CSS Grid 完美编排

在传统的 Tab 切换中,如果外层有统一的 Grid 容器(例如第一格是 Hero 介绍卡片,后续是商品卡片),直接在 Grid 内部包裹 <div> 作为 Tab Panel 会破坏父级 Grid 的网格流。

我们巧妙地利用了现代 CSS 的 display: contents;特性:

/* theme/public/tool/tool.css */
.grid {
  display: grid;
  align-items: flex-start;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--gap);
}

/* 核心:将 Tab Panel 容器作为抽象无框容器,让其子项直接参与父级 Grid 布局 */
.tab-panel {
  display: contents;
}

.tab-panel.hidden {
  display: none !important;
}

效果:当 Panel 处于激活状态时,内部所有的 .tools-item 就像直接位于 .grid 根下一样,与左上角的 .hero 卡片浑然一体;当切换为 .hidden 时,整组卡片瞬间被隐藏,且完全不产生多余的 DOM 盒模型布局计算!

3.3 动态标题、Tagline 与精准 Hash 锚点分享

为了保证单页切换后依然具备传统网页的 SEO 友好度和外链分享能力,tool.js 实现了完整的状态机联动:

// theme/public/tool/tool.js
const TAB_CONFIG = {
  tools: {
    title: 'Tools · songlin.me',
    tagline: '<em>Fewer, better things.</em><br>之前使用过和正在使用的产品'
  },
  phone: {
    title: 'Phone · songlin.me',
    tagline: '<em>Fewer, better things.</em><br>个人使用过的手机'
  },
  home: {
    title: 'Home · songlin.me',
    tagline: '<em>Fewer, better things.</em><br>在用且喜欢的家居产品'
  }
};

function switchTab(tabKey, updateHash = true) {
  if (!TAB_CONFIG[tabKey] || !panels[tabKey]) return;

  // 1. 切换导航高亮
  navLinks.forEach(link => {
    link.classList.toggle('active', link.dataset.tab === tabKey);
  });

  // 2. 切换内容面板
  Object.keys(panels).forEach(key => {
    panels[key].classList.toggle('hidden', key !== tabKey);
  });

  // 3. 动态更新网页标题与 Tagline
  document.title = TAB_CONFIG[tabKey].title;
  taglineEl.innerHTML = TAB_CONFIG[tabKey].tagline;

  // 4. 更新 Hash 锚点(支持刷新与外链直达)
  if (updateHash) {
    history.replaceState(null, '', '#' + tabKey);
  }
}

访问 https://songlin.me/tool/#phone 即可精准直达手机历史面板,在站内点击切换时页面 URL 变为对应锚点,但全程 0 毫秒延迟、零白屏刷新


4. 第三刀:移动端体验与 4px 流体网格打磨

4.1 4px 像素级无缝流体海报墙

在移动端和桌面端,原本海报之间的间距和外边距存在微小偏差。本次重构制定了统一的流体网格标准:

/* theme/public/archive/archive.css */
.movies-container {
  width: 100%;
  padding: 4px; /* 外边距与网格间隙严格一致 */
  box-sizing: border-box;
}

.movie-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 4px; /* 统一 4px 紧凑无缝排列 */
}
  • 移动端竖屏(375px ~ 430px):自适应恒定为 双列网格(单卡宽度181px209px181\text{px} \sim 209\text{px}),沉浸感极强。
  • 桌面与宽屏:自适应呈现 4 ~ 6 列流体海报墙。

4.2 悬浮毛玻璃胶囊过滤栏(Floating Glass Capsule)防折行

在手机端,原本居中悬浮的过滤胶囊容易在窄屏下被挤压折行,变成丑陋的双行气泡。通过深度计算,将其默认宽度精准收窄至 284px 并强制禁止折行:

@media (max-width: 768px) {
  .glass-filter-bar {
    width: auto;
    max-width: calc(100vw - 24px);
    flex-wrap: nowrap; /* 强制单行 */
    bottom: 20px;
  }
}

4.3 原生 Passive 触摸手势分离(120Hz 丝滑滚动)

早期的移动端卡片点击逻辑在 touchstart / touchmove 中滥用了 e.preventDefault(),导致手机在上下滑动海报墙时主线程被频繁拦截,出现轻微掉帧卡顿。

我们将其彻底重构为基于位移差判定(Delta Thresholding)的精准分离算法

// 触摸手势精确分离:区分滑屏与轻触
let touchStartX = 0, touchStartY = 0, touchStartTime = 0;

card.addEventListener('touchstart', (e) => {
  touchStartX = e.touches[0].clientX;
  touchStartY = e.touches[0].clientY;
  touchStartTime = Date.now();
}, { passive: true }); // 声明 passive 恢复 120Hz 硬件加速自然滑动

card.addEventListener('touchend', (e) => {
  const deltaX = Math.abs(e.changedTouches[0].clientX - touchStartX);
  const deltaY = Math.abs(e.changedTouches[0].clientY - touchStartY);
  const duration = Date.now() - touchStartTime;

  // 仅在位移极小 (<10px) 且时间极短 (<400ms) 时判定为 Tap 轻触展开
  if (deltaX < 10 && deltaY < 10 && duration < 400) {
    toggleMobileOverlay(card);
  }
}, { passive: true });

5. 第四刀:全站规范化与 SEO 闭环

5.1 全站浏览器 <title> 统一规范(· songlin.me

在全局 theme/layouts/common/head.ejs 中收拢所有标题格式化逻辑,全面采用 · songlin.me 品牌标识:

<%
  // 基础变量处理
  let pageTitle = title + ' · ' + config.title;
  if (locals.url === '/about.html' || title === 'About') {
    pageTitle = 'About · ' + config.title;
  } else if (locals.url === '/archive/' || title === 'Archive') {
    pageTitle = 'Archive · ' + config.title;
  } else if (locals.url === '/search.html' || title === 'Search') {
    pageTitle = 'Search · ' + config.title;
  } else if (title === config.title || !title) {
    pageTitle = config.title + ' · 保持好奇,记录生活';
  }
%>
<title><%= pageTitle %></title>

5.2 Sitemap 顶级路由自动注入与 301 零死链重定向

在静态站点生成器中,往往容易漏掉非 Markdown 生成的纯静态路由。我们在 core/metadata.js 中将独立模块作为一级入口注入:

// core/metadata.js
const staticUrls = [
  `  <url>\n    <loc>${config.origin}/archive/</loc>\n    <lastmod>${today}T00:00:00+00:00</lastmod>\n  </url>`,
  `  <url>\n    <loc>${config.origin}/tool/</loc>\n    <lastmod>${today}T00:00:00+00:00</lastmod>\n  </url>`
];

同时,在 theme/public/_redirects 中为历史旧外链配置完整的 301 永久重定向映射:

/archive.html /archive/ 301
/pages/tools.html /tool/ 301
/pages/phone.html /tool/#phone 301
/pages/home.html /tool/#home 301
/pages/tools /tool/ 301
/pages/phone /tool/#phone 301
/pages/home /tool/#home 301

6. 结语:极简不是简陋,而是克制的精致

✅ Zen Architecture v9 静态构建统计:
- 渲染页面:71 篇 Markdown + 4 大独立系统 (Index, Search, Archive, Tool)
- 物理清理:彻底剔除 100% 废弃死代码与多余模板
- 构建总耗时:2.72 秒

这次针对博客的“大手术”,没有引入任何庞大的前端交互框架(如 React / Vue),也没有引入任何 CSS 预处理库。

我们仅凭最精纯的 HTML5 语义标签、现代 CSS 现代特性(display: contentsbackdrop-filter、流体网格)以及几十行原生状态机 JavaScript,就完成了从架构解耦、样式减重、单页无刷新交互到移动端丝滑触控的全面升级。

真正的极简,从来不是功能的简陋,而是对代码每一处的克制与对用户体验细节的极致打磨。