TECH · 3 MIN READ · 1415 字

没有框架的博客 UI:搜索、筛选、覆盖链接与目录

没有 React,也没有组件库。博客需要搜索、分类筛选、整卡链接、阅读目录和代码复制,这些都可以用浏览器原生能力完成。

没有 React,也没有组件库。博客需要搜索、分类筛选、整卡链接、阅读目录和代码复制,这些都可以用浏览器原生能力完成。

这个博客的交互层只有一个 main.js。它不负责渲染内容,只负责在已有 HTML 上补行为:打开搜索、过滤卡片、复制代码、跟随目录。

搜索:构建时生成一份小索引

搜索不查数据库。build.mjs 在构建时把标题、摘要、标签、分类和正文片段写进 search-index.json:

[
  {
    "title": "把封面做成一层会退场的舞台",
    "url": "/posts/cover-scroll-ui.html",
    "categoryName": "技术笔记",
    "tags": ["UI", "CSS"],
    "excerpt": "…",
    "text": "…"
  }
]

用户第一次打开搜索面板时才 fetch 这份 JSON,之后缓存在内存里。没有第三方搜索服务,也没有额外的后端。

排序是一个简单的加权:

if (title.includes(term)) score += 80;
if (tags.includes(term)) score += 42;
if (category.includes(term)) score += 24;
if (excerpt.includes(term)) score += 20;
if (text.includes(term)) score += 5;

标题命中权重最高,正文只做兜底。输入多个词时,要求每个词都有命中,避免“搜到一堆不相关的东西”。

这里踩过一个真实的竞态问题:搜索面板打开后开始加载 JSON,如果用户在加载完成前就输入关键词,索引返回时会把结果清空。修复方式是加载完成后再检查一次当前输入:

loadIndex().then(() => {
  if (input.value.trim()) renderSearch(input.value);
  else showHint();
});

筛选:把状态放进 URL

文章库的分类和标签不是服务端过滤,而是给每张卡片写 data-category 与 data-tags:

<article class="post-card"
  data-category="tech"
  data-tags="UI|CSS|性能">

点击分类或标签时:

const params = new URLSearchParams(location.search);
params.set("category", category);
history.replaceState(null, "", `?${params}`);

然后遍历卡片,按分类、标签、关键词三项匹配,切换 hidden。这样刷新页面、复制链接、前进后退都还能保留筛选状态。

标签使用 | 分隔而不是数组字符串,比较时精确匹配,避免“UI”误命中“UI 细节”。

整卡链接:不要嵌套 <a>

最初的文章卡片是一个大 <a>,里面又放了标签 <a>。浏览器遇到嵌套链接时会自动闭合外层 <a>,结果标题、摘要和标签全被甩出卡片,布局直接坏掉。

正确做法是“标题链接 + 覆盖层”:

.post-card {
  position: relative;
  isolation: isolate;
}

.post-card__title::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
}

.post-card .tag-row {
  position: relative;
  z-index: 2;
}

标题本身是链接,::after 覆盖整张卡片;标签放在更高层级,仍然可以单独点击。HTML 里没有嵌套链接,键盘聚焦也只有一个主要入口。

这个模式同样用在首页的精选卡和最新文章列表上。验收时直接检查 document.querySelectorAll("a a").length === 0。

目录:标题 ID 在构建时生成

Markdown 转 HTML 后,构建器会给每个 h2 / h3 加 ID,并收集成目录。阅读时用滚动位置计算当前章节,而不是一次性监听所有标题:

headings.forEach((heading) => {
  if (heading.getBoundingClientRect().top <= 140) current = heading.id;
});

右侧目录用 position: sticky 跟随。移动到手机端后,目录变成一条横向滚动的胶囊条,避免在正文前堆一长列链接。

表格与代码块

Markdown 里的表格在手机上很容易撑破布局。两层处理:

.post-layout {
  grid-template-columns: minmax(0, 1fr);
}

.prose > *,
.table-wrap {
  min-width: 0;
}

.table-wrap {
  overflow-x: auto;
}

.table-wrap table {
  min-width: 620px;
}

只写 overflow-x: auto 还不够。如果父级网格是 1fr,表格的 min-content 会把整列撑到 1000px 以上。把网格改成 minmax(0, 1fr),再给表格容器明确宽度,才是完整的修复。

代码块则相反:pre 自己横向滚动,不参与页面宽度计算。

复制与反馈

代码复制优先使用 navigator.clipboard,失败时回退到临时 textarea + execCommand。复制链接按钮也走同一套逻辑。

这不是核心功能,但没有它,技术文章的阅读体验会差一截。

结果

  • 搜索、分类、标签筛选都不需要后端。
  • 整卡链接和标签点击可以共存,嵌套链接为 0。
  • 手机端表格在 350px 容器里滚动,页面本身不横向溢出。
  • 目录在桌面端跟随,在手机端折叠成横向条。

没有框架也可以有完整的交互,前提是把 HTML 结构、层级和状态边界想清楚。