从某天开始,我脑子里总绕着一个想法:如果我能在项目里实现一个真正优雅的时间线,那该有多好。那种可以自然地展现一段历程、一串事件的时间流,不仅清晰易读,还要视觉上舒适、交互上顺滑。最初是因为产品设计提了个需求,希望我们为用户的行为记录模块增加时间线功能,最好能像某些社交应用那样,能展开收起、自动适配时间轴、支持图标、甚至带有一些轻动画。说实话,我当时还挺兴奋的,因为这正是我想挑战的一种 UI 模式。

我从最初的草图开始,先勾勒了一种竖直线型结构:左边是一条主轴,右边是每个事件节点的内容模块,中间则是一个居中的点,或者是带图标的小圆圈。我希望每个节点都能自适应内容高度,还要支持 hover 高亮、点击展开更多详情,而且得在保持一致性的同时适配各种终端。这个时候我心里已经清楚,这可不是靠几个 div 和 css border 就能搞定的活。

为了明确整个组件的架构,我用一张流程图把核心模块关系理了出来:

做一个 Timeline_ico

明确了结构,我开始动手搭建组件。我选择使用 React 和 TypeScript 作为技术栈,这是我目前项目的基础框架。先是构建 Timeline 和 TimelineItem 这两个核心组件,前者是容器,负责渲染子节点和主轴线;后者则是每一个时间节点的具象表现。我决定让 Timeline 支持垂直和水平两种方向切换,同时可以接受参数控制样式,比如是否居中显示、是否带动画等等。下面是最初构建的组件代码片段:

// Timeline.tsx
import React from 'react';
import clsx from 'clsx';

type TimelineProps = {
  children: React.ReactNode;
  mode?: 'left' | 'right' | 'alternate' | 'center';
  direction?: 'vertical' | 'horizontal';
  withLine?: boolean;
};

export const Timeline: React.FC<TimelineProps> = ({
  children,
  mode = 'left',
  direction = 'vertical',
  withLine = true,
}) => {
  return (
    <div
      className={clsx(
        'timeline',
        `timeline-${direction}`,
        `timeline-mode-${mode}`,
        { 'timeline-with-line': withLine }
      )}
    >
      {children}
    </div>
  );
};

而 TimelineItem 的实现更加细腻,我需要让每一个 item 能够自动计算间距、支持插入 icon、时间戳、富文本内容,还要为每一个节点设定不同的状态颜色(比如成功、失败、进行中等)。为了实现这些,我对每个节点设置了 status 属性,并通过 tailwind 的 class 合并动态渲染颜色与状态图标。

// TimelineItem.tsx
import React from 'react';
import clsx from 'clsx';

type TimelineItemProps = {
  title: string;
  time?: string;
  content?: React.ReactNode;
  icon?: React.ReactNode;
  status?: 'default' | 'success' | 'error' | 'processing';
};

export const TimelineItem: React.FC<TimelineItemProps> = ({
  title,
  time,
  content,
  icon,
  status = 'default',
}) => {
  return (
    <div className="timeline-item">
      <div className={clsx('timeline-icon', `status-${status}`)}>
        {icon}
      </div>
      <div className="timeline-content">
        <div className="timeline-header">
          <span className="timeline-title">{title}</span>
          {time && <span className="timeline-time">{time}</span>}
        </div>
        {content && <div className="timeline-body">{content}</div>}
      </div>
    </div>
  );
};

UI 层我用了 TailwindCSS 来做基础样式定制,比如 timeline 主体使用 border-l-2 构建竖线,item 节点用 relative + before:content-[''] before:absolute before:rounded-full 来实现 icon 圆圈效果。关键是节点要自动对齐到主线的位置,这个我用 flexabsolute 结合计算偏移来完成。每个节点之间的连线也得保持一致长度,为此我手动调节了每个节点的 margin-bottom 值,并在实际渲染后动态调整连线高度,保证在各种内容高度下的平衡美感。

中间我遇到一个大坑,就是 timeline-item 高度动态时,连线的高度无法自动适配。我本来想直接用 CSS 的百分比高度来解决,但发现并不可靠,尤其在某些节点内容很多时会打破布局。我于是转向使用 ResizeObserver 监听每个节点的实际高度,然后再动态调整上一个节点的连线高度。大致逻辑如下:

useEffect(() => {
  const observer = new ResizeObserver((entries) => {
    entries.forEach((entry) => {
      const height = entry.contentRect.height;
      adjustLineHeight(entry.target, height);
    });
  });

  const el = ref.current;
  if (el) observer.observe(el);
  return () => observer.disconnect();
}, []);

这个优化点看起来简单,但涉及到多个 timeline item 的相互位置调整,一旦一个节点变化,其前后节点连线都可能受影响。我后来改成了统一交给 Timeline 父组件管理所有子节点的位置状态,用 context 分发位置信息,这样每个子节点只负责自身内容,不需要频繁改动上下结构。

除了基本的线性展示,我还加入了横向时间线的支持,这一部分比较复杂,特别是在内容宽度自适应和滑动体验上的优化。我给横向模式加了自动滚动区域和 touch 支持,用 scroll-snap-type 让每个节点能在移动端自然对齐居中。



之后,我开始思考一个更贴近实际场景的问题:如果我要让这个组件能够动态绑定数据,并且在一些复杂的使用场景中,比如用户操作日志、项目进度追踪或者产品更新历史中,都能灵活胜任,我需要构建怎样的 API?单纯靠静态的 TimelineItem 显然是远远不够的。我于是开始引入数据驱动的模式,打算让 Timeline 接收一个数据列表,并自动渲染出对应的时间节点。

我设计了一个数据结构,类似于下面的样子:

type TimelineDataItem = {
  id: string;
  title: string;
  time?: string;
  content?: string | React.ReactNode;
  status?: 'default' | 'success' | 'error' | 'processing';
  icon?: React.ReactNode;
  children?: TimelineDataItem[]; // 支持嵌套事件
};

接下来,我让 Timeline 支持一个新的 data 属性,并通过 map 函数去生成一组 TimelineItem 元素。更复杂的是,若每一个数据项还有 children,那么我希望它能形成“树状时间线”,也就是在某个节点下继续展示分支节点,这种模式在多任务调度或多阶段流程中特别常见。我引入了一个递归渲染函数 renderTimelineItems

const renderTimelineItems = (items: TimelineDataItem[]) => {
  return items.map((item) => (
    <React.Fragment key={item.id}>
      <TimelineItem
        title={item.title}
        time={item.time}
        content={item.content}
        icon={item.icon}
        status={item.status}
      />
      {item.children && renderTimelineItems(item.children)}
    </React.Fragment>
  ));
};

这样一来,无论数据有多复杂,都能渲染成一条逻辑连贯、清晰可读的时间线。我还为此画了一张结构流程图,以可视化展示数据驱动渲染逻辑:

做一个 Timeline_Time_02

这一阶段我最头痛的问题其实是“嵌套子节点”的视觉表现:如果子节点缩进过多,整体会显得层级混乱,太浅又看不出区分。我一开始尝试通过 padding-left 层层递增的方式表现嵌套层级,但在实际效果中很快就暴露了问题:一旦嵌套超过三层,整个时间线就像一棵斜着长的藤蔓一样歪歪扭扭,非常影响阅读。

于是我重构了视觉表现方案,放弃了传统的“树形缩进”,转而采用“平行分叉 + 阶梯式线条”结合的方式。每一层嵌套不再往右缩进,而是保持主轴统一,并通过在主轴旁绘制一条偏移连接线,连接子节点。这种方式更接近 BPMN 图或是一些专业日志工具的展现风格。我还配合 SVG 实现了动态线段连接,逻辑上类似这样:

<svg className="timeline-branch-line" width="100%" height="60">
  <line x1="20" y1="0" x2="40" y2="30" stroke="#999" strokeWidth="2" />
  <line x1="40" y1="30" x2="20" y2="60" stroke="#999" strokeWidth="2" />
</svg>

这种视觉增强的方案解决了我之前担心的“节点混乱”问题,同时也让时间线整体层次更有辨识度。UI 上我还加入了过渡动画,用来强调子节点的展开与隐藏过程,让用户不会一下子“看花眼”。

当 Timeline 的基础展示和数据驱动都走通之后,我开始考虑“实时数据”的支持——也就是时间线不仅要加载一次性数据,还要支持动态插入节点。这个需求是后端同事提的:他们希望能在后台任务运行过程中,每一个阶段都能实时往 Timeline 中追加事件,比如“任务开始 -> 正在拉取文件 -> 解压中 -> 成功 -> 提交入库”等。

为此我构建了一个 useTimeline hook,管理所有 Timeline 数据状态,并暴露一个 addItem 函数。这个函数允许外部在任意时刻插入新节点:

const useTimeline = () => {
  const [items, setItems] = useState<TimelineDataItem[]>([]);

  const addItem = (item: TimelineDataItem, parentId?: string) => {
    if (!parentId) {
      setItems([...items, item]);
    } else {
      const insertRecursively = (list: TimelineDataItem[]): TimelineDataItem[] =>
        list.map((node) => {
          if (node.id === parentId) {
            return {
              ...node,
              children: [...(node.children || []), item],
            };
          } else if (node.children) {
            return { ...node, children: insertRecursively(node.children) };
          }
          return node;
        });

      setItems(insertRecursively(items));
    }
  };

  return { items, addItem };
};

这个小小的 hook 让 Timeline 真正变成了一个“实时可控组件”,而不仅仅是 UI 展示组件。开发中我通过 WebSocket 或任务轮询接口拉取后台进度更新,并实时用 addItem 塞入时间线中,体验非常丝滑。这种方式对前端状态管理能力提出了更高要求,但也给了用户更强的“即时感”反馈。

我还加入了一些细节功能,比如自动滚动到底部(常用于任务进度更新)、时间节点的 collapse 折叠功能(方便处理非常长的时间线),以及夜间模式适配。视觉主题我设计了亮色和深色两套,支持自动切换,内部使用 CSS 变量定义颜色方案:

:root {
  --timeline-line-color: #e0e0e0;
  --timeline-item-bg: #fff;
  --timeline-text-color: #333;
}

[data-theme='dark'] {
  --timeline-line-color: #444;
  --timeline-item-bg: #222;
  --timeline-text-color: #ddd;
}