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

✨AI 摘要:
🤖 正在生成摘要...
在基于 Astro(配合 Swup 客户端无刷新跳转)的博客主题中,我们经常会遇到需要对某些特定组件(如分类栏 CategoryBar)进行路由控制的需求。
在实际开发中,我们往往不希望分类栏在全站所有页面无条件显示(例如在关于页、友链页等独立页面中显得多余),而是希望它只在特定页面显示。例如:
- 首页(
/) - 归档页(
/archive/) - 分类汇总页(
/categories/) - 标签页与系列页(
/tags/、/series/) - 所有文章详情页(例如以
/posts/开头的所有路径:/posts/hello-world/、/posts/astro-tutorial/等)
本文将记录如何实现这一需求,并解决在无刷新跳转(Swup)环境下的状态同步与显隐控制问题。
核心痛点分析
在实现“特定页面显示”时,会遇到两个典型问题:
- 直接服务端
return null导致 Swup 状态失效:如果用传统的服务端判断,当处于非白名单页面时直接让组件不渲染,那么当用户通过 Swup 局部无刷新跳转到白名单页面时,由于 DOM 树中根本不存在该组件,导致分类栏无法显示,必须手动刷新网页。 - 动态路由匹配(如文章详情页
/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>总结
- 精准控制:通过
allowedPaths数组自由增减固定路由(如/tags、/series),配合pathname.startsWith("/posts/")优雅覆盖所有文章详情页。 - 丝滑体验:结合 Swup 的生命周期钩子,实现无刷新跳转时的即时显隐切换与状态对齐,省去用户手动刷新的烦恼。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!
给 Astro 博客的 CategoryBar 组件添加“特定页面显示”及 Swup 无刷新跳转适配指南
https://929255.xyz/posts/astro-category-bar-conditional-render/
早安喵栈





