在持续优化个人博客的过程中,我遇到了三个关键的用户体验问题:原生基于 fuwari 的 astro 模板的 TOC 目录在右侧时的阅读不便、页面切换时滚动位置的不合理表现,以及 Widget 展开时内容被截断的困扰。本文记录了针对这三个问题的完整解决方案,涵盖了布局重构、滚动行为优化和组件通信机制改进。
前言
作为一名追求极致用户体验的开发者,我始终关注博客的每一个交互细节。在基于 Astro 构建的静态博客中,我发现了三个亟待解决的问题:
- TOC 布局问题:目录位于右侧时,与文章内容的阅读流不一致,特别是在移动端体验较差
- 页面切换动画问题:使用 Swup 进行页面切换时,无论是点击链接还是浏览器前进/后退,都会强制滚动到顶部,破坏了浏览器的原生滚动恢复机制
- Widget 展开不完整:侧边栏中的 Categories 和 Tags 组件在展开时,由于父容器的
max-height限制,导致部分内容被截断
经过系统性的分析和重构,我成功解决了这些问题,并建立了更加健壮的架构。本文将详细记录每个问题的分析过程、解决方案和最佳实践。
1. TOC 布局重构:从右侧到左侧的迁移
1.1. 问题分析
在原始的布局设计中,TOC 目录位于主内容区域的右侧。这种设计在桌面端可能看起来合理,但存在以下问题:
- 阅读流不一致:中文阅读习惯是从左到右,目录在右侧打断了自然的阅读流程
- 移动端体验差:在小屏幕设备上,TOC 占据宝贵的垂直空间
- 注意力分散:目录与内容分离,增加了视觉跳转的认知负担
1.2. 重构方案
网格布局调整
核心改动在 MainGridLayout.astro 中,将 CSS Grid 的列定义从 grid-cols-[auto_17.5rem] 改为 grid-cols-[17.5rem_auto]:
<div id="main-grid" class="transition duration-700 w-full left-0 right-0 grid grid-cols-[17.5rem_auto] grid-rows-[auto_1fr_auto] lg:grid-rows-[auto] mx-auto gap-4 px-0 md:px-4"这个改动意味着:
- 第一列(17.5rem):SideBar 组件,包含 TOC、Profile、Categories 和 Tags
- 第二列(auto):主内容区域,自动填充剩余空间
SideBar 组件定位
SideBar 在 Grid 中的定位也需要相应调整:
<SideBar class="mb-4 row-start-2 row-end-3 col-span-2 lg:row-start-1 lg:row-end-2 lg:col-span-1 lg:max-w-[17.5rem] onload-animation" headings={headings}></SideBar>- 移动端:
col-span-2占据全宽,位于主内容上方 - 桌面端:
col-span-1占据第一列,lg:row-start-1确保从第一行开始
响应式行为
布局完全响应式,在不同屏幕尺寸下自动适配:
/* 移动端:垂直堆叠 */grid-rows: [auto_1fr_auto] /* SideBar 在 row 2 */
/* 桌面端:水平排列 */lg:grid-rows: [auto] /* SideBar 和 Main 在同一行 */lg:grid-cols: [17.5rem_auto] /* 固定左侧宽度 */1.3. TOC 高度管理与可见性控制
布局调整后,还需要确保 TOC 在各种场景下都能正常工作。SideBar.astro 中实现了完整的高度管理和可见性控制:
动态高度计算
function updateTOCHeight() { const tocWrapper = document.getElementById('toc-wrapper'); const tocInnerWrapper = document.getElementById('toc-inner-wrapper'); if (!tocWrapper || !tocInnerWrapper) return;
// Only update when TOC is visible, hidden state uses CSS default if (tocWrapper.classList.contains('hidden')) { return; }
// Calculate TOC max height: 80% of available height // Matches CSS calc((100vh - 32px) * 0.8) const viewportHeight = window.innerHeight; const availableHeight = viewportHeight - SIDEBAR_SPACING_PX; const tocMaxHeight = availableHeight * TOC_MAX_HEIGHT_RATIO;
// Set maxHeight only, allowing content to shrink when small, scroll when large tocInnerWrapper.style.maxHeight = `${tocMaxHeight}px`;}智能可见性检测
function updateTOCVisibility() { const tocWrapper = document.getElementById('toc-wrapper'); if (!tocWrapper) return;
// Check if TOC has content (table-of-contents element or links) // Since Swup only replaces #toc content, we check the actual content const tocElement = document.getElementById('toc'); const hasTOCContent = tocElement && ( tocElement.querySelector('table-of-contents') !== null || tocElement.querySelectorAll('a[href^="#"]').length > 0 );
// Get siteConfig.toc.enable from data attribute (fallback to checking content) // If data attribute is not available (e.g., during Swup transitions), // we assume TOC is enabled if content exists const tocEnabledFromData = tocWrapper.dataset.tocEnabled; const tocEnabled = tocEnabledFromData !== undefined ? tocEnabledFromData === 'true' : hasTOCContent; // If no data attribute, infer from content
if (tocEnabled && hasTOCContent) { // CSS already sets initial max-height, just show the wrapper // No need to set height in JS, avoiding unnecessary DOM manipulation tocWrapper.classList.remove('hidden'); // Remove toc-not-ready class when TOC is ready and should be visible // This ensures TOC shows immediately when ready, not waiting for Layout.astro's timeout tocWrapper.classList.remove('toc-not-ready'); } else { tocWrapper.classList.add('hidden'); // Clear inline styles when hidden to let CSS take control const tocInnerWrapper = document.getElementById('toc-inner-wrapper'); if (tocInnerWrapper && tocInnerWrapper.style.maxHeight) { // Only clear inline styles that may exist, let CSS rules apply tocInnerWrapper.style.maxHeight = ''; } }}1.4. 重构效果
- ✅ 阅读体验提升:目录位于左侧,与阅读流一致,降低认知负担
- ✅ 移动端优化:小屏幕设备上目录位于内容上方,不占用侧边空间
- ✅ 响应式完善:所有断点下的布局都经过仔细测试和优化
2. 页面导航滚动行为优化
2.1. 问题分析
在使用 Swup 进行页面切换时,原有的实现会在所有导航场景下强制滚动到页面顶部:
// 原有实现(问题代码)window.swup.hooks.on('content:replace', () => { window.scrollTo({ top: 0, behavior: 'smooth' });});这导致的问题:
- 浏览器后退/前进失效:用户点击浏览器的后退按钮时,期望看到之前的滚动位置,但被强制滚动到顶部
- 用户体验断裂:破坏了浏览器原生的滚动恢复机制(Scroll Restoration API)
- 性能问题:不必要的滚动动画增加了性能开销
2.2. 解决方案:智能滚动标志
核心思路是区分两种导航场景:
- 用户点击链接:应该滚动到顶部(新页面开始)
- 浏览器前进/后退:保持浏览器原生行为(恢复之前的滚动位置)
实现智能滚动标志
/** * Track if we should scroll to top on next navigation * Only scroll to top when user clicks a link (forward navigation) * For browser back/forward navigation, let browser handle scroll restoration */let shouldScrollToTop = false;
/** * Scroll to top of the page after content is replaced * This avoids showing old content during the scroll animation */function scrollToTopAfterReplace() { if (shouldScrollToTop) { // Use requestAnimationFrame to ensure DOM update is complete requestAnimationFrame(() => { window.scrollTo({ top: 0, behavior: 'auto' }); }); }}链接点击时设置标志
window.swup.hooks.on('link:click', () => { // Mark that we should scroll to top on next navigation shouldScrollToTop = true;
// Remove the delay for the first time page load document.documentElement.style.setProperty('--content-delay', '0ms')
// Prevent elements from overlapping the navbar if (!bannerEnabled) { return } const threshold = window.innerHeight * (BANNER_HEIGHT / 100) - NAVBAR_HEIGHT - STANDARD_PADDING; const navbar = document.getElementById('navbar-wrapper'); if (!navbar || !document.body.classList.contains('lg:is-home')) { return } if (isScrolledPast(threshold)) { navbar.classList.add('navbar-hidden'); }})关键点:
- 只在
link:click事件时设置shouldScrollToTop = true - 浏览器前进/后退不会触发
link:click,因此标志保持为false
内容替换时条件滚动
window.swup.hooks.on('content:replace', () => { initCustomScrollbar(); scrollToTopAfterReplace();})在内容替换后,只有当 shouldScrollToTop 为 true 时才滚动到顶部。
导航完成后重置标志
window.swup.hooks.on('page:view', () => { // Restore normal rendering after new page is visible restoreRendering();
// Reset the scroll flag after navigation completes // This ensures we don't scroll on subsequent navigations unless a link is clicked shouldScrollToTop = false;
// Sync UI elements based on current scroll position // Use requestAnimationFrame to ensure DOM updates are complete requestAnimationFrame(() => { scrollFunction(); });});2.3. 性能优化:渲染优化
在页面切换过程中,还添加了渲染性能优化:
/** * Optimize rendering performance during page transitions * Uses CSS containment and will-change to improve rendering performance */function optimizeRendering() { const swupContainer = document.getElementById('swup-container'); if (swupContainer) { // Temporarily optimize for transitions swupContainer.style.willChange = 'transform, opacity'; swupContainer.style.contain = 'layout style paint'; // Disable smooth scrolling during transition to prevent performance issues document.documentElement.style.scrollBehavior = 'auto'; }}
/** * Restore normal rendering after transition */function restoreRendering() { const swupContainer = document.getElementById('swup-container'); if (swupContainer) { // Remove performance optimizations after transition swupContainer.style.willChange = ''; swupContainer.style.contain = ''; } // Restore smooth scrolling after transition document.documentElement.style.scrollBehavior = '';}优化要点:
- 使用
will-change提示浏览器进行优化 - 使用 CSS
contain属性限制重绘范围 - 在过渡期间禁用平滑滚动,使用
auto行为以提高性能
2.4. 优化效果
- ✅ 浏览器原生行为保留:前进/后退时正确恢复滚动位置
- ✅ 用户体验提升:符合用户期望的导航行为
- ✅ 性能改善:减少不必要的滚动动画,优化渲染性能
3. Widget 展开功能完整修复
3.1. 问题分析
侧边栏中的 Categories 和 Tags 组件使用 WidgetLayout 包装,支持折叠/展开功能。当内容较长时,初始状态为折叠,显示部分内容和一个 ” 查看更多 ” 按钮。
原有问题:
- 展开后,由于父容器
#sidebar-sticky设置了max-height: calc(100vh - 1rem - 1rem),展开的内容被截断 - 展开后,如果内容超出视口,没有自动滚动以确保内容可见
3.2. 解决方案:事件驱动的组件通信
采用 Custom Events 机制实现组件间的解耦通信:
WidgetLayout:展开时发送事件
btn.addEventListener('click', () => { // Remove collapsed styles wrapper.classList.remove('collapsed'); wrapper.classList.remove('overflow-hidden'); btn.classList.add('hidden');
/** * Dispatch custom event to notify other components (e.g., SideBar) that widget has expanded * Uses custom events for decoupled component communication, easier to port and maintain * Event detail contains widgetId and widgetElement for listeners to use */ const expandEvent = new CustomEvent('widget:expand', { detail: { widgetId: id, widgetElement: this }, bubbles: true }); this.dispatchEvent(expandEvent);
// Scroll after expansion to ensure content is visible setTimeout(() => { const widgetRect = this.getBoundingClientRect(); const viewportHeight = window.innerHeight; const widgetBottom = widgetRect.bottom;
// If widget bottom exceeds viewport, scroll to ensure full visibility if (widgetBottom > viewportHeight) { const scrollOffset = widgetBottom - viewportHeight + this.SCROLL_PADDING; window.scrollBy({ top: scrollOffset, behavior: 'smooth' }); } }, this.EXPAND_ANIMATION_DELAY);});实现要点:
- 自定义事件:使用
CustomEvent发送widget:expand事件,包含 widget 的 ID 和元素引用 - 自动滚动:展开动画完成后(
WIDGET_EXPAND_ANIMATION_DELAY),检查是否需要滚动以确保内容可见 - 智能计算:计算 widget 底部位置,如果超出视口,自动滚动相应距离
SideBar:监听事件并移除高度限制
/** * Handle widget expand event * Removes sidebar-sticky max-height restriction when widget expands * Uses CSS class instead of direct style manipulation for better maintainability */function handleWidgetExpand() { const sidebarSticky = document.getElementById('sidebar-sticky'); if (sidebarSticky) { sidebarSticky.classList.add('widget-expanded'); }}监听事件:
// Listen for widget expand events (decoupled component communication)document.addEventListener('widget:expand', handleWidgetExpand);CSS 样式:移除高度限制
<style> /** * Widget expanded state styles * Allows sidebar-sticky to exceed viewport height when widget expands * Uses !important to ensure it overrides inline styles */ #sidebar-sticky.widget-expanded { max-height: none !important; }</style>设计优势:
- ✅ 解耦通信:使用 Custom Events,组件间无直接依赖
- ✅ 易于维护:CSS 类控制样式,而非直接操作 DOM 样式
- ✅ 可扩展性:其他组件也可以监听
widget:expand事件进行响应
3.3. 常量配置
相关常量定义在 constants.ts 中:
// Widget constants// Delay after expand animation completes (ms)export const WIDGET_EXPAND_ANIMATION_DELAY = 100;// Extra padding for auto-scroll (px)export const WIDGET_SCROLL_PADDING = 20;3.4. 修复效果
- ✅ 内容完整显示:展开后所有内容都可见,不再被截断
- ✅ 自动滚动:展开后自动滚动确保内容在视口中
- ✅ 架构优化:建立了清晰的组件通信机制,便于后续扩展
4. 架构设计与最佳实践
4.1. 组件通信模式
本次重构建立了一个清晰的组件通信模式:
优势:
- 解耦:组件间无直接引用,降低耦合度
- 可测试性:事件可以独立测试和模拟
- 可扩展性:新组件可以轻松监听事件
4.2. 性能优化策略
- 渲染优化:使用
will-change和 CSScontain优化过渡动画 - 滚动优化:在页面切换时使用
behavior: 'auto'而非'smooth',减少性能开销 - 延迟执行:使用
requestAnimationFrame确保 DOM 更新完成后再执行操作
4.3. 代码组织原则
- 常量集中管理:所有魔法数字和配置都提取到
constants.ts - 函数单一职责:每个函数只做一件事,易于理解和测试
- 注释清晰:关键逻辑都有详细的 JSDoc 注释
5. 总结与展望
5.1. 本次优化成果
通过本次重构,我成功解决了三个关键的用户体验问题:
- ✅ TOC 布局优化:从右侧迁移至左侧,提升阅读体验
- ✅ 滚动行为优化:区分用户导航和浏览器导航,保留原生行为
- ✅ Widget 展开修复:完整显示内容,自动滚动确保可见
5.2. 架构改进
- 建立了基于 Custom Events 的组件通信机制
- 优化了页面切换时的渲染性能
- 提升了代码的可维护性和可扩展性
5.3. 未来优化方向
- 虚拟滚动:如果 TOC 内容很长,可以考虑虚拟滚动优化性能
- 动画细化:进一步优化展开动画的流畅度
- 无障碍性:添加更多的 ARIA 标签和键盘导航支持
本次重构不仅解决了当前的问题,还为未来的扩展打下了良好的基础。通过事件驱动的架构和清晰的代码组织,我建立了一个更加健壮、可维护的博客系统。