最近对博客的项目展示页做了一次重构,把原来的 Bento Grid 布局改成了时间线布局。这篇文章记录一下整个过程和思考。
为什么要改?
说实话,最开始看到原来的作品集页面时,我感觉很普通。

就是一个很常见的卡片网格布局,虽然功能上没什么问题,但总觉得缺少点什么。看起来就像是一个「项目清单」,而不是一个「成长故事」。
我希望访客在浏览我的项目时,能够感受到:
- 时间的流动:知道这些项目是什么时候做的
- 成长的轨迹:看到我从第一个项目到现在的进步
- 里程碑时刻:哪些项目对我来说意义重大
于是我想到了时间线布局——这种布局天然就带有「故事感」,很适合展示个人的成长历程。
设计思路
在动手之前,我先在脑海中构思了一下想要的效果:
2024
│
┌─────────────────●
│ 🎯 项目 A │
│ ┌───────────┐ │
│ │ 项目图片 │ │
│ └───────────┘ │
│ Vue 3 · Java │
└─────────────────┤
│
●─────────────────┐
│ 🚀 项目 B │
│ ┌───────────┐ │
│ │ 项目图片 │ │
│ └───────────┘ │
├─────────────────┘
│
2023
│
起点
核心设计要点:
| 元素 | 设计 |
|---|---|
| 时间线 | 居中的垂直线,连接所有项目 |
| 项目卡片 | 左右交替排列,增加视觉层次 |
| 年份标记 | 醒目的年份徽章,划分时间段 |
| 里程碑 | 特殊标记重要项目,带脉冲动画 |
| 响应式 | 移动端改为单列布局 |
技术实现
组件结构
整个时间线由四个组件组成:
Timeline.tsx # 主容器,负责按年份分组
├── TimelineYearMarker.tsx # 年份标记
├── TimelineItem.tsx # 项目卡片
│ └── TimelineNode.tsx # 时间线节点
└── "起点" 标记
按年份分组
项目数据需要按年份分组,这样才能在每组项目前显示年份标记:
function groupProjectsByYear(projects: Project[]): ProjectsByYear[] {
// 按日期降序排序
const sorted = [...projects].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
// 按年份分组
const groups: Map<number, Project[]> = new Map();
for (const project of sorted) {
const year = new Date(project.date).getFullYear();
const existing = groups.get(year);
if (existing) {
existing.push(project);
} else {
groups.set(year, [project]);
}
}
// 转换为数组,年份降序
return Array.from(groups.entries())
.sort(([a], [b]) => b - a)
.map(([year, projects]) => ({ year, projects }));
}
左右交替布局
桌面端的卡片需要左右交替排列,通过计算全局索引来实现:
// 计算全局索引用于左右交替
const globalIndex = projectsByYear
.slice(0, yearIndex)
.reduce((acc, g) => acc + g.projects.length, 0) + projectIndex;
const isLeft = globalIndex % 2 === 0;
然后根据 isLeft 决定卡片放在左边还是右边:
<div className={cn(
'relative grid gap-4',
isMobile
? 'grid-cols-[24px_1fr] pl-2' // 移动端:单列
: 'grid-cols-[1fr_48px_1fr]' // 桌面端:三列
)}>
{/* 左侧区域 */}
{!isMobile && (
<div className={cn('flex', isLeft ? 'justify-end' : 'justify-start')}>
{isLeft && <TimelineCard project={project} />}
</div>
)}
{/* 中间时间线节点 */}
<TimelineNode milestone={project.milestone} />
{/* 右侧区域 */}
<div>
{(isMobile || !isLeft) && <TimelineCard project={project} />}
</div>
</div>
里程碑脉冲动画
对于标记为里程碑的项目,节点会有一个脉冲动画效果:
{milestone && (
<motion.div
className="absolute h-5 w-5 rounded-full bg-primary"
initial={{ scale: 1, opacity: 0.5 }}
animate={{ scale: 2.5, opacity: 0 }}
transition={{
duration: 2,
repeat: Infinity,
ease: 'easeOut',
}}
/>
)}
滚动渐入动画
使用 Motion 的 whileInView 实现滚动时的渐入效果:
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ ...microDampingPreset, duration: 0.5 }}
whileHover={{ y: -6, scale: 1.01 }}
>
{/* 卡片内容 */}
</motion.div>
数据结构调整
为了支持时间线布局,项目数据需要添加几个新字段:
- id: "pet-social"
title: "PetSocial 宠物社交"
description: "..."
link: "https://github.com/..."
image: "/img/cover/1.webp"
tags: ["Vue 3", "Java", "AI"]
# 新增字段
date: "2024-06-15" # 项目日期
milestone: true # 是否里程碑
milestoneLabel: "首个 AI 项目" # 里程碑标签
对于没有 date 字段的旧数据,代码会自动提供默认值,保证向后兼容。
最终效果
重构后的项目页看起来清爽多了:

改进点:
| 方面 | 改进 |
|---|---|
| 视觉层次 | 左右交替 + 年份分隔,层次分明 |
| 时间感 | 一眼就能看出项目的时间顺序 |
| 故事性 | 从「起点」到现在,展示成长历程 |
| 交互体验 | 滚动渐入 + 悬浮效果,更有活力 |
| 里程碑 | 重要项目有特殊标记,突出重点 |
写在最后
这次重构让我意识到,展示方式和内容本身一样重要。
同样的项目列表,用普通的卡片网格展示,就是一个冷冰冰的清单;用时间线展示,就变成了一个有温度的成长故事。
作为开发者,我们不仅要关注功能实现,也要思考如何更好地呈现内容。一个好的展示方式,能让访客更好地理解你、记住你。
如果你也想给自己的作品集加点「故事感」,不妨试试时间线布局。