3061 words
15 minutes
0
0
Astro Blog Layout Refactoring and UX Optimization——Astro 博客布局重构与用户体验优化

在持续优化个人博客的过程中,我遇到了三个关键的用户体验问题:原生基于 fuwari 的 astro 模板的 TOC 目录在右侧时的阅读不便、页面切换时滚动位置的不合理表现,以及 Widget 展开时内容被截断的困扰。本文记录了针对这三个问题的完整解决方案,涵盖了布局重构、滚动行为优化和组件通信机制改进。

前言#

作为一名追求极致用户体验的开发者,我始终关注博客的每一个交互细节。在基于 Astro 构建的静态博客中,我发现了三个亟待解决的问题:

  1. TOC 布局问题:目录位于右侧时,与文章内容的阅读流不一致,特别是在移动端体验较差
  2. 页面切换动画问题:使用 Swup 进行页面切换时,无论是点击链接还是浏览器前进/后退,都会强制滚动到顶部,破坏了浏览器的原生滚动恢复机制
  3. 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 在 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. 解决方案:智能滚动标志#

核心思路是区分两种导航场景:

  1. 用户点击链接:应该滚动到顶部(新页面开始)
  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();
})

在内容替换后,只有当 shouldScrollToToptrue 时才滚动到顶部。

导航完成后重置标志#

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 包装,支持折叠/展开功能。当内容较长时,初始状态为折叠,显示部分内容和一个 ” 查看更多 ” 按钮。

原有问题

  1. 展开后,由于父容器 #sidebar-sticky 设置了 max-height: calc(100vh - 1rem - 1rem),展开的内容被截断
  2. 展开后,如果内容超出视口,没有自动滚动以确保内容可见

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);
});

实现要点

  1. 自定义事件:使用 CustomEvent 发送 widget:expand 事件,包含 widget 的 ID 和元素引用
  2. 自动滚动:展开动画完成后(WIDGET_EXPAND_ANIMATION_DELAY),检查是否需要滚动以确保内容可见
  3. 智能计算:计算 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. 组件通信模式#

本次重构建立了一个清晰的组件通信模式:

widgetlayout.svg

优势

  • 解耦:组件间无直接引用,降低耦合度
  • 可测试性:事件可以独立测试和模拟
  • 可扩展性:新组件可以轻松监听事件

4.2. 性能优化策略#

  1. 渲染优化:使用 will-change 和 CSS contain 优化过渡动画
  2. 滚动优化:在页面切换时使用 behavior: 'auto' 而非 'smooth',减少性能开销
  3. 延迟执行:使用 requestAnimationFrame 确保 DOM 更新完成后再执行操作

4.3. 代码组织原则#

  1. 常量集中管理:所有魔法数字和配置都提取到 constants.ts
  2. 函数单一职责:每个函数只做一件事,易于理解和测试
  3. 注释清晰:关键逻辑都有详细的 JSDoc 注释

5. 总结与展望#

5.1. 本次优化成果#

通过本次重构,我成功解决了三个关键的用户体验问题:

  1. TOC 布局优化:从右侧迁移至左侧,提升阅读体验
  2. 滚动行为优化:区分用户导航和浏览器导航,保留原生行为
  3. Widget 展开修复:完整显示内容,自动滚动确保可见

5.2. 架构改进#

  • 建立了基于 Custom Events 的组件通信机制
  • 优化了页面切换时的渲染性能
  • 提升了代码的可维护性和可扩展性

5.3. 未来优化方向#

  1. 虚拟滚动:如果 TOC 内容很长,可以考虑虚拟滚动优化性能
  2. 动画细化:进一步优化展开动画的流畅度
  3. 无障碍性:添加更多的 ARIA 标签和键盘导航支持

本次重构不仅解决了当前的问题,还为未来的扩展打下了良好的基础。通过事件驱动的架构和清晰的代码组织,我建立了一个更加健壮、可维护的博客系统。

Astro Blog Layout Refactoring and UX Optimization——Astro 博客布局重构与用户体验优化
https://xieyi.org/posts/astro-blog-layout-refactoring-and-ux-optimizationastro-博客布局重构与用户体验优化/
Author
謝懿Shine
Published at
2025-11-02
License
CC BY-NC-SA 4.0