给 Astro 博客的 CategoryBar 组件添加“特定页面显示”及 Swup 无刷新跳转适配指南

给 Astro 博客的 CategoryBar 组件添加“特定页面显示”及 Swup 无刷新跳转适配指南
✨AI 摘要:
🤖 正在生成摘要...

在基于 Astro(配合 Swup 客户端无刷新跳转)的博客主题中,我们经常会遇到需要对某些特定组件(如分类栏 CategoryBar)进行路由控制的需求。

在实际开发中,我们往往不希望分类栏在全站所有页面无条件显示(例如在关于页、友链页等独立页面中显得多余),而是希望它只在特定页面显示。例如:

  • 首页(/)
  • 归档页(/archive/)
  • 分类汇总页(/categories/)
  • 标签页与系列页(/tags/、/series/)
  • 所有文章详情页(例如以 /posts/ 开头的所有路径:/posts/hello-world/、/posts/astro-tutorial/ 等)

本文将记录如何实现这一需求,并解决在无刷新跳转(Swup)环境下的状态同步与显隐控制问题。

核心痛点分析#

在实现“特定页面显示”时,会遇到两个典型问题:

  1. 直接服务端 return null 导致 Swup 状态失效:如果用传统的服务端判断,当处于非白名单页面时直接让组件不渲染,那么当用户通过 Swup 局部无刷新跳转到白名单页面时,由于 DOM 树中根本不存在该组件,导致分类栏无法显示,必须手动刷新网页。
  2. 动态路由匹配(如文章详情页 /posts/*):静态白名单无法枚举所有的文章路径,因此需要引入前缀匹配逻辑(startsWith)。

最终解决方案:CSS 显隐控制 + Swup 动态响应#

为了兼顾 SSR(服务端首屏渲染性能) 与 Swup 客户端无刷新跳转,最佳策略是:

  • 让组件正常渲染到 DOM 中,但在最外层套一个包装容器。
  • 初始根据白名单(支持固定路径与动态前缀匹配)决定是否直接设置 display: none。
  • 配合客户端脚本,在 Swup 页面切换(swup:contentReplaced / astro:page-load)时,实时检查当前新 URL 并动态控制容器显隐。

完整组件代码 (CategoryBar.astro)#

以下是完整实现,已内置具体的白名单路径(如 /tags、/series)以及动态文章详情页(/posts/*)的匹配逻辑:

---
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import { Icon } from "astro-icon/components";
import { getCategoryList, getSortedPostsList } from "@/utils/content-utils";
import { url } from "@/utils/url-utils";
interface Props {
currentPostCategory?: string;
}
const { currentPostCategory } = Astro.props as Props;
const categories = await getCategoryList();
const totalPosts = (await getSortedPostsList()).length;
const homeUrl = url("/");
const archiveUrl = url("/archive/");
const pathname = Astro.url.pathname.replace(/\/$/, "");
const homePath = homeUrl.replace(/\/$/, "");
const archivePath = archiveUrl.replace(/\/$/, "");
const isHome = pathname === homePath || pathname === "" || pathname === "/";
const isCategories = pathname === "/categories";
const isArchive = pathname === archivePath;
const postCategory = (currentPostCategory || "").trim();
function initialActive(categoryName: string): {
active?: boolean;
soft?: boolean;
} {
if (isHome) return categoryName === "" ? { active: true } : {};
if (isCategories)
return categoryName === "__categories__" ? { active: true } : {};
if (isArchive) return categoryName === "__archive__" ? { active: true } : {};
if (postCategory && categoryName === postCategory) return { soft: true };
return {};
}
// ==================== 具体白名单与路由匹配配置 ====================
// 在这里列出你需要显示分类栏的具体路径
const allowedPaths = [
homePath, // 首页
archivePath, // 归档页
"/categories", // 分类汇总页
"/tags", // 标签页
"/series", // 系列页
"",
"/"
];
// 判断当前路径是否在白名单中,或者是否以 /posts/ 开头(匹配所有文章详情页)
const isAllowed = allowedPaths.includes(pathname) || pathname.startsWith("/posts/");
---
<!-- 外层包裹容器:用动态 style 初始控制显隐,避免 SSR 闪烁 -->
<div
id="category-bar-wrapper"
style={isAllowed ? "" : "display: none;"}
data-home-path={homeUrl}
data-archive-path={archiveUrl}
>
<div
class="card-base category-bar p-3 onload-animation"
id="category-bar"
data-home-path={homeUrl}
data-archive-path={archiveUrl}
data-current-post-category={currentPostCategory || ""}
>
<div class="category-bar-inner flex gap-2">
<a
href={homeUrl}
class="category-pill focus-ring-inset text-sm px-3 py-1.5 shrink-0
transition-colors duration-150 ease-out flex items-center justify-center"
data-category-name=""
data-active={initialActive("").active ? "" : undefined}
aria-label={i18n(I18nKey.home)}
>
<Icon class="text-lg" name="material-symbols:home"/>
</a>
<a
href={archiveUrl}
class="category-pill focus-ring-inset text-sm px-4 py-1.5 whitespace-nowrap shrink-0
transition-colors duration-150 ease-out flex items-center justify-center"
data-category-name="__archive__"
data-active={initialActive("__archive__").active ? "" : undefined}
>
{i18n(I18nKey.archive)}
<span class="pill-count">{totalPosts}</span>
</a>
<div class="category-divider shrink-0"></div>
<div class="scroll-area relative">
<div class="scroll-fade scroll-fade-left" aria-hidden="true"></div>
<div class="category-scroll flex gap-2 overflow-x-auto">
{
categories.map((cat) => {
const active = initialActive(cat.name);
return (
<a
href={cat.url}
class="category-pill focus-ring-inset text-sm px-4 py-1.5 whitespace-nowrap
transition-colors duration-150 ease-out flex items-center justify-center"
data-category-name={cat.name}
data-active={active.active ? "" : undefined}
data-soft-active={active.soft ? "" : undefined}
>
{cat.name}
<span class="pill-count">{cat.count}</span>
</a>
);
})
}
</div>
<div class="scroll-fade scroll-fade-right" aria-hidden="true"></div>
</div>
<div class="category-divider shrink-0 more-divider" aria-hidden="true"></div>
<a
href={url("/categories/")}
class="category-pill focus-ring-inset text-sm px-3 py-1.5 shrink-0
transition-colors duration-150 ease-out flex items-center justify-center gap-1"
data-category-name="__categories__"
data-active={initialActive("__categories__").active ? "" : undefined}
aria-label={i18n(I18nKey.more)}
>
<span>{i18n(I18nKey.more)}</span>
<Icon class="text-sm" name="fa7-solid:chevron-right"/>
</a>
</div>
</div>
</div>
<style>
.category-bar .category-pill {
border: none;
}
.category-bar {
margin-bottom: 1rem;
}
.category-divider {
width: 1px;
align-self: stretch;
background: var(--line-divider);
opacity: 0.5;
}
.more-divider {
opacity: 0;
transition: opacity 300ms ease-in-out;
}
.more-divider[data-visible] {
opacity: 0.5;
}
.scroll-area {
flex: 1;
min-width: 0;
position: relative;
}
.scroll-fade {
position: absolute;
top: 0;
bottom: 0;
width: 2.5rem;
pointer-events: none;
opacity: 0;
transition: opacity 300ms ease-in-out;
z-index: 1;
}
.scroll-fade[data-visible] {
opacity: 1;
}
.scroll-fade-left {
left: 0;
background: linear-gradient(to left, transparent, var(--card-bg));
}
.scroll-fade-right {
right: 0;
background: linear-gradient(to right, transparent, var(--card-bg));
}
:global(body.wallpaper-transparent) .scroll-fade {
display: none;
}
.category-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
.category-scroll::-webkit-scrollbar {
display: none;
}
</style>
<script>
let delayedCategoryBarScrollTimer: number | undefined;
// 检查当前 URL 是否在白名单中,并动态控制显隐
function checkRouteVisibility() {
const wrapper = document.getElementById("category-bar-wrapper");
if (!wrapper) return;
const pathname = window.location.pathname.replace(/\/$/, "");
const homePath = (wrapper.getAttribute("data-home-path") || "/").replace(/\/$/, "");
const archivePath = (wrapper.getAttribute("data-archive-path") || "/archive").replace(/\/$/, "");
// 客户端同步白名单路径
const allowedPaths = [homePath, archivePath, "/categories", "/tags", "/series", "", "/"];
const isAllowed = allowedPaths.includes(pathname) || pathname.startsWith("/posts/");
if (isAllowed) {
wrapper.style.display = "";
} else {
wrapper.style.display = "none";
}
}
function getCategoryBarState(bar: HTMLElement, currentUrl: URL) {
const pathname = currentUrl.pathname.replace(/\/$/, "");
const homePath = (bar.getAttribute("data-home-path") || "/").replace(/\/$/, "");
const archivePath = (bar.getAttribute("data-archive-path") || "/archive").replace(/\/$/, "");
const isHome = pathname === homePath || pathname === "" || pathname === "/";
const isArchive = pathname === archivePath;
const isCategories = pathname === "/categories";
return {
isHome,
isArchive,
isCategories,
activeCategory: currentUrl.searchParams.get("category") || "",
hasTag: currentUrl.searchParams.has("tag"),
hasUncategorized: currentUrl.searchParams.has("uncategorized"),
};
}
function setActiveCategory(bar: HTMLElement, currentUrl: URL) {
const { isHome, isArchive, isCategories, activeCategory, hasTag, hasUncategorized } =
getCategoryBarState(bar, currentUrl);
const postCategory = (bar.getAttribute("data-current-post-category") || "").trim();
const pills = bar.querySelectorAll<HTMLAnchorElement>(".category-pill");
pills.forEach((pill) => {
pill.removeAttribute("data-active");
pill.removeAttribute("data-soft-active");
});
if (isHome) {
bar.querySelector<HTMLAnchorElement>('.category-pill[data-category-name=""]')
?.setAttribute("data-active", "");
return;
}
if (isCategories) {
bar.querySelector<HTMLAnchorElement>('.category-pill[data-category-name="__categories__"]')
?.setAttribute("data-active", "");
return;
}
if (activeCategory) {
pills.forEach((pill) => {
if (pill.getAttribute("data-category-name") === activeCategory) {
pill.setAttribute("data-active", "");
}
});
return;
}
if (isArchive && !hasTag && !hasUncategorized) {
bar.querySelector<HTMLAnchorElement>('.category-pill[data-category-name="__archive__"]')
?.setAttribute("data-active", "");
return;
}
if (postCategory) {
pills.forEach((pill) => {
if (pill.getAttribute("data-category-name") === postCategory) {
pill.setAttribute("data-soft-active", "");
}
});
}
}
function scrollActiveCategoryIntoView(bar: HTMLElement, behavior: ScrollBehavior = "smooth") {
const activePill =
bar.querySelector<HTMLAnchorElement>('.category-pill[data-active]') ||
bar.querySelector<HTMLAnchorElement>('.category-pill[data-soft-active]');
const scroll = bar.querySelector<HTMLElement>(".category-scroll");
if (!activePill || !scroll) return;
const scrollLeft = activePill.offsetLeft - scroll.offsetLeft - (scroll.clientWidth - activePill.offsetWidth) / 2;
scroll.scrollTo({ left: Math.max(0, scrollLeft), behavior });
}
function shouldDeferCategoryScroll(bar: HTMLElement, currentUrl: URL) {
const { isHome } = getCategoryBarState(bar, currentUrl);
const isMobile = window.innerWidth < 1024;
const isBannerMode =
document.documentElement.getAttribute("data-wallpaper-mode") === "banner";
return isMobile && isBannerMode && !isHome;
}
function updateCategoryBar(
currentUrl = new URL(window.location.href),
options: { scrollBehavior?: ScrollBehavior; deferScroll?: boolean } = {},
) {
checkRouteVisibility(); // 每次更新时同步显隐状态
const bar = document.getElementById("category-bar");
if (!bar) return;
const swupContainer = document.getElementById("swup-container");
if (swupContainer) {
const currentPostCategory = swupContainer.getAttribute("data-current-post-category") || "";
bar.setAttribute("data-current-post-category", currentPostCategory);
}
setActiveCategory(bar, currentUrl);
const deferScroll = options.deferScroll ?? shouldDeferCategoryScroll(bar, currentUrl);
const scrollBehavior = options.scrollBehavior ?? (deferScroll ? "auto" : "smooth");
if (delayedCategoryBarScrollTimer) {
window.clearTimeout(delayedCategoryBarScrollTimer);
delayedCategoryBarScrollTimer = undefined;
}
if (deferScroll) {
delayedCategoryBarScrollTimer = window.setTimeout(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollActiveCategoryIntoView(bar, scrollBehavior);
updateScrollHint();
});
});
}, 220);
return;
}
scrollActiveCategoryIntoView(bar, scrollBehavior);
updateScrollHint();
}
function initCategoryBarInteractions() {
const bar = document.getElementById("category-bar");
if (!bar || bar.dataset.clickBound === "true") return;
bar.dataset.clickBound = "true";
bar.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const pill = event.target.closest<HTMLAnchorElement>(".category-pill");
if (!pill) return;
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
if (pill.target && pill.target !== "_self") return;
const targetUrl = new URL(pill.href, window.location.href);
if (targetUrl.origin !== window.location.origin) return;
setActiveCategory(bar, targetUrl);
scrollActiveCategoryIntoView(bar, "auto");
});
}
function initScrollFeatures() {
const bar = document.getElementById("category-bar");
if (!bar) return;
const scroll = bar.querySelector<HTMLElement>(".category-scroll");
if (!scroll) return;
if (scroll.dataset.featuresBound === "true") {
updateScrollHint();
return;
}
scroll.dataset.featuresBound = "true";
scroll.addEventListener("wheel", (e) => {
if (
scroll.scrollWidth <= scroll.clientWidth ||
e.ctrlKey ||
Math.abs(e.deltaX) >= Math.abs(e.deltaY)
) return;
const delta = e.deltaMode === WheelEvent.DOM_DELTA_LINE
? e.deltaY * 16
: e.deltaMode === WheelEvent.DOM_DELTA_PAGE
? e.deltaY * scroll.clientWidth
: e.deltaY;
const maxScrollLeft = scroll.scrollWidth - scroll.clientWidth;
const atStart = scroll.scrollLeft <= 1;
const atEnd = scroll.scrollLeft >= maxScrollLeft - 1;
if ((delta < 0 && atStart) || (delta > 0 && atEnd)) return;
const nextScrollLeft = Math.min(
maxScrollLeft,
Math.max(0, scroll.scrollLeft + delta),
);
if (nextScrollLeft === scroll.scrollLeft) return;
e.preventDefault();
scroll.scrollLeft = nextScrollLeft;
}, { passive: false });
scroll.addEventListener("scroll", updateScrollHint);
window.addEventListener("resize", updateScrollHint);
updateScrollHint();
}
function updateScrollHint() {
const bar = document.getElementById("category-bar");
if (!bar) return;
const scroll = bar.querySelector<HTMLElement>(".category-scroll");
const fadeLeft = bar.querySelector<HTMLElement>(".scroll-fade-left");
const fadeRight = bar.querySelector<HTMLElement>(".scroll-fade-right");
if (!scroll || !fadeLeft || !fadeRight) return;
const hasOverflow = scroll.scrollWidth > scroll.clientWidth + 1;
const atStart = scroll.scrollLeft <= 1;
const atEnd = scroll.scrollLeft + scroll.clientWidth >= scroll.scrollWidth - 1;
if (hasOverflow && !atStart) {
fadeLeft.setAttribute("data-visible", "");
} else {
fadeLeft.removeAttribute("data-visible");
}
if (hasOverflow && !atEnd) {
fadeRight.setAttribute("data-visible", "");
} else {
fadeRight.removeAttribute("data-visible");
}
const moreDivider = bar.querySelector<HTMLElement>(".more-divider");
if (moreDivider) {
if (hasOverflow) {
moreDivider.setAttribute("data-visible", "");
} else {
moreDivider.removeAttribute("data-visible");
}
}
}
updateCategoryBar();
initCategoryBarInteractions();
initScrollFeatures();
if (!window.__categoryBarInit) {
window.__categoryBarInit = true;
document.addEventListener("astro:page-load", () => {
updateCategoryBar();
initScrollFeatures();
});
document.addEventListener("swup:contentReplaced", () => {
updateCategoryBar();
requestAnimationFrame(() => {
updateCategoryBar();
});
});
if (window.swup?.hooks) {
setupSwupEarlyUpdate();
} else {
document.addEventListener("swup:enable", setupSwupEarlyUpdate);
}
}
function setupSwupEarlyUpdate() {
if (!window.swup?.hooks) return;
window.swup.hooks.on("visit:start", (visit: any) => {
const bar = document.getElementById("category-bar");
if (!bar) return;
const targetUrl = new URL(visit.to.url, window.location.href);
setActiveCategory(bar, targetUrl);
});
}
</script>

总结#

  1. 精准控制:通过 allowedPaths 数组自由增减固定路由(如 /tags、/series),配合 pathname.startsWith("/posts/") 优雅覆盖所有文章详情页。
  2. 丝滑体验:结合 Swup 的生命周期钩子,实现无刷新跳转时的即时显隐切换与状态对齐,省去用户手动刷新的烦恼。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
给 Astro 博客的 CategoryBar 组件添加“特定页面显示”及 Swup 无刷新跳转适配指南
https://929255.xyz/posts/astro-category-bar-conditional-render/
作者
ZaoCat
发布于
2026-10-21
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
ZaoCat
A digital momad
最新动态
站点统计
文章
12
分类
2
标签
25
总字数
13,129
运行时长
0 天
最后活动
0 天前