用 React + TypeScript 写一个世界杯淘汰赛对阵树组件

世界杯进入淘汰赛后,赛程展示就不太适合继续用普通列表了。

小组赛可以用表格。

淘汰赛更适合用对阵树。

因为淘汰赛要表达的不只是"谁对谁",还包括:

  • 胜者进入哪一轮
  • 哪几场比赛汇入同一条路径
  • 哪支球队可能在下一轮相遇
  • 当前比赛属于哪一轮
  • 比赛是否已结束
  • 晋级球队是谁

这类信息如果全靠文字,很容易写成一坨。

所以今天我们用 React + TypeScript 写一个世界杯淘汰赛对阵树组件。

示例数据使用今日 32 强赛:

  • 科特迪瓦 vs 挪威
  • 法国 vs 瑞典
  • 墨西哥 vs 厄瓜多尔

本文只做前端组件实现,不做胜负预测,不涉及博彩、赔率或下注。


一、组件目标

我们要实现一个基础版 KnockoutBracket 组件。

它需要支持:

  1. 渲染比赛卡片
  2. 展示轮次
  3. 展示球队名称
  4. 展示比赛状态
  5. 展示晋级占位卡片
  6. 用 SVG 连线表示晋级路径
  7. 保持类型清晰,方便后续扩展

最终结构大概是:

markdown 复制代码
Round of 32                 Round of 16

科特迪瓦 vs 挪威  ┐
                  ├── 胜者进入下一轮
法国 vs 瑞典      ┘

墨西哥 vs 厄瓜多尔 ─── 胜者进入下一轮

看起来简单,但这类组件很容易失控。

一开始只是三张卡片,后面就会有人说:能不能加比分、加点球、加晋级队、加动画、加悬浮详情、加小组来源、加移动端适配。

前端项目的痛苦,通常就从"顺手加一下"开始。人类对复杂度的敬畏总是来得太晚。


二、定义 TypeScript 类型

先定义比赛节点类型。

创建文件:

bash 复制代码
src/types/bracket.ts

代码如下:

typescript 复制代码
export type MatchStatus = 'scheduled' | 'live' | 'finished'

export interface Team {
  name: string
  shortName: string
}

export interface BracketMatch {
  id: string
  round: string
  home: Team
  away: Team
  homeScore?: number
  awayScore?: number
  status: MatchStatus
  note?: string
  nextMatchId?: string
}

export interface BracketLayoutNode {
  matchId: string
  x: number
  y: number
}

这里把数据拆成两层:

第一层是比赛数据:

复制代码
BracketMatch

第二层是布局数据:

复制代码
BracketLayoutNode

为什么要拆?

因为比赛本身和 UI 坐标不应该绑死。

比赛数据关心球队、比分、状态。

布局数据关心这张卡片放在哪里。

如果把它们混在一起,后面一改布局,业务数据也跟着动。那不是组件设计,那是给未来的自己埋定时炸弹。


三、准备比赛数据

创建文件:

bash 复制代码
src/mock/knockoutMatches.ts

写入今日比赛数据:

css 复制代码
import type { BracketMatch } from '@/types/bracket'

export const knockoutMatches: BracketMatch[] = [
  {
    id: 'r32-m1',
    round: 'Round of 32',
    home: {
      name: '科特迪瓦',
      shortName: 'CIV',
    },
    away: {
      name: '挪威',
      shortName: 'NOR',
    },
    status: 'scheduled',
    note: '锋线效率和身体对抗是关键变量',
    nextMatchId: 'r16-m1',
  },
  {
    id: 'r32-m2',
    round: 'Round of 32',
    home: {
      name: '法国',
      shortName: 'FRA',
    },
    away: {
      name: '瑞典',
      shortName: 'SWE',
    },
    status: 'scheduled',
    note: '法国需要尽快破解瑞典防线',
    nextMatchId: 'r16-m1',
  },
  {
    id: 'r32-m3',
    round: 'Round of 32',
    home: {
      name: '墨西哥',
      shortName: 'MEX',
    },
    away: {
      name: '厄瓜多尔',
      shortName: 'ECU',
    },
    status: 'scheduled',
    note: '主场压力和转换冲击都值得关注',
    nextMatchId: 'r16-m2',
  },
  {
    id: 'r16-m1',
    round: 'Round of 16',
    home: {
      name: '胜者待定',
      shortName: 'TBD',
    },
    away: {
      name: '胜者待定',
      shortName: 'TBD',
    },
    status: 'scheduled',
    note: '胜者进入下一轮',
  },
  {
    id: 'r16-m2',
    round: 'Round of 16',
    home: {
      name: '胜者待定',
      shortName: 'TBD',
    },
    away: {
      name: '胜者待定',
      shortName: 'TBD',
    },
    status: 'scheduled',
    note: '胜者进入下一轮',
  },
]

真实项目里,这些数据可以来自接口。

但文章演示时,mock 数据足够。

别一开始就接接口、缓存、鉴权、错误重试、状态管理。一个组件如果刚出生就背上微服务包袱,它会恨你的。


四、定义布局坐标

创建文件:

bash 复制代码
src/mock/bracketLayout.ts

代码如下:

yaml 复制代码
import type { BracketLayoutNode } from '@/types/bracket'

export const bracketLayout: BracketLayoutNode[] = [
  {
    matchId: 'r32-m1',
    x: 40,
    y: 40,
  },
  {
    matchId: 'r32-m2',
    x: 40,
    y: 220,
  },
  {
    matchId: 'r32-m3',
    x: 40,
    y: 400,
  },
  {
    matchId: 'r16-m1',
    x: 520,
    y: 130,
  },
  {
    matchId: 'r16-m2',
    x: 520,
    y: 400,
  },
]

这里用绝对坐标。

它的优点是简单直观。

缺点也明显:完整 32 强对阵树需要更复杂的布局算法。

但这篇文章是组件入门,不是让大家在第一步就写一个体育赛事排版引擎。前端已经够累了,不需要额外自虐。


五、实现 MatchCard 组件

创建文件:

bash 复制代码
src/components/MatchCard.tsx

代码如下:

typescript 复制代码
import type { BracketMatch } from '@/types/bracket'
import './MatchCard.css'

interface MatchCardProps {
  match: BracketMatch
  x: number
  y: number
}

function formatScore(match: BracketMatch) {
  if (
    typeof match.homeScore === 'number' &&
    typeof match.awayScore === 'number'
  ) {
    return `${match.homeScore} - ${match.awayScore}`
  }

  return 'VS'
}

function getStatusText(status: BracketMatch['status']) {
  if (status === 'finished') return '已结束'
  if (status === 'live') return '进行中'
  return '未开赛'
}

export function MatchCard({ match, x, y }: MatchCardProps) {
  return (
    <article
      className="match-card"
      style={{
        transform: `translate(${x}px, ${y}px)`,
      }}
    >
      <header className="match-card__header">
        <span className="match-card__round">{match.round}</span>
        <span className={`match-card__status match-card__status--${match.status}`}>
          {getStatusText(match.status)}
        </span>
      </header>

      <section className="match-card__teams">
        <div className="match-card__team">
          <strong>{match.home.name}</strong>
          <span>{match.home.shortName}</span>
        </div>

        <div className="match-card__score">{formatScore(match)}</div>

        <div className="match-card__team match-card__team--right">
          <strong>{match.away.name}</strong>
          <span>{match.away.shortName}</span>
        </div>
      </section>

      {match.note && <p className="match-card__note">{match.note}</p>}
    </article>
  )
}

这个组件只做一件事:

根据 match 渲染比赛卡片。

注意这里没有把布局逻辑塞进组件里。

xy 是外部传入的。

这样 MatchCard 只负责展示,KnockoutBracket 负责布局。

组件职责清晰,后面才不容易炸。


六、写 MatchCard 样式

创建:

bash 复制代码
src/components/MatchCard.css

代码:

css 复制代码
.match-card {
  position: absolute;
  width: 340px;
  min-height: 132px;
  padding: 16px;
  border: 1px solid #e5e7eb;
  border-radius: 16px;
  background: #ffffff;
  box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
  box-sizing: border-box;
}

.match-card__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 14px;
}

.match-card__round {
  font-size: 13px;
  color: #64748b;
}

.match-card__status {
  border-radius: 999px;
  padding: 4px 10px;
  font-size: 12px;
  font-weight: 700;
}

.match-card__status--scheduled {
  background: #f1f5f9;
  color: #475569;
}

.match-card__status--live {
  background: #fce7f3;
  color: #be185d;
}

.match-card__status--finished {
  background: #dcfce7;
  color: #166534;
}

.match-card__teams {
  display: grid;
  grid-template-columns: 1fr 72px 1fr;
  align-items: center;
  gap: 12px;
}

.match-card__team {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.match-card__team strong {
  font-size: 18px;
  color: #0f172a;
}

.match-card__team span {
  font-size: 12px;
  color: #94a3b8;
}

.match-card__team--right {
  text-align: right;
}

.match-card__score {
  text-align: center;
  font-size: 24px;
  font-weight: 800;
  color: #111827;
}

.match-card__note {
  margin: 14px 0 0;
  font-size: 14px;
  line-height: 1.7;
  color: #475569;
}

这里用 position: absolute 是为了方便对阵树布局。

普通比赛列表可以用 grid 或 flex。

对阵树更适合坐标布局。

当然,这不是唯一方案。

也可以用 CSS Grid 做列布局,再用 SVG 连接。

但对初版来说,绝对定位最容易讲清楚。


七、实现 SVG 连线组件

创建文件:

bash 复制代码
src/components/BracketConnector.tsx

代码如下:

typescript 复制代码
interface Point {
  x: number
  y: number
}

interface BracketConnectorProps {
  from: Point
  to: Point
}

export function BracketConnector({ from, to }: BracketConnectorProps) {
  const midX = (from.x + to.x) / 2

  const path = [
    `M ${from.x} ${from.y}`,
    `L ${midX} ${from.y}`,
    `L ${midX} ${to.y}`,
    `L ${to.x} ${to.y}`,
  ].join(' ')

  return (
    <path
      d={path}
      fill="none"
      stroke="#94a3b8"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
    />
  )
}

这里不用直线,而是用折线:

复制代码
水平线 → 垂直线 → 水平线

这更像传统淘汰赛 bracket。

SVG 的好处是可以精细控制路径。

坏处是你调坐标时会想和显示器谈人生。前端并不总是写业务,有时也负责测量宇宙。


八、实现 KnockoutBracket 组件

创建:

bash 复制代码
src/components/KnockoutBracket.tsx

完整代码如下:

javascript 复制代码
import type { BracketLayoutNode, BracketMatch } from '@/types/bracket'
import { BracketConnector } from './BracketConnector'
import { MatchCard } from './MatchCard'
import './KnockoutBracket.css'

interface KnockoutBracketProps {
  matches: BracketMatch[]
  layout: BracketLayoutNode[]
}

const CARD_WIDTH = 340
const CARD_HEIGHT = 132

function buildLayoutMap(layout: BracketLayoutNode[]) {
  return new Map(layout.map((item) => [item.matchId, item]))
}

function getMatchCenterRight(node: BracketLayoutNode) {
  return {
    x: node.x + CARD_WIDTH,
    y: node.y + CARD_HEIGHT / 2,
  }
}

function getMatchCenterLeft(node: BracketLayoutNode) {
  return {
    x: node.x,
    y: node.y + CARD_HEIGHT / 2,
  }
}

export function KnockoutBracket({ matches, layout }: KnockoutBracketProps) {
  const layoutMap = buildLayoutMap(layout)

  const connectors = matches
    .filter((match) => match.nextMatchId)
    .map((match) => {
      const fromNode = layoutMap.get(match.id)
      const toNode = layoutMap.get(match.nextMatchId!)

      if (!fromNode || !toNode) {
        return null
      }

      return {
        id: `${match.id}-${match.nextMatchId}`,
        from: getMatchCenterRight(fromNode),
        to: getMatchCenterLeft(toNode),
      }
    })
    .filter(Boolean)

  return (
    <section className="knockout-bracket">
      <header className="knockout-bracket__header">
        <p className="knockout-bracket__eyebrow">World Cup 2026</p>
        <h1>世界杯 32 强淘汰赛对阵树</h1>
        <p>用 React + TypeScript 渲染比赛卡片,并用 SVG 绘制晋级路径。</p>
      </header>

      <div className="knockout-bracket__canvas">
        <svg className="knockout-bracket__svg">
          {connectors.map((connector) => {
            if (!connector) return null

            return (
              <BracketConnector
                key={connector.id}
                from={connector.from}
                to={connector.to}
              />
            )
          })}
        </svg>

        {matches.map((match) => {
          const node = layoutMap.get(match.id)

          if (!node) {
            return null
          }

          return (
            <MatchCard
              key={match.id}
              match={match}
              x={node.x}
              y={node.y}
            />
          )
        })}
      </div>
    </section>
  )
}

这里的核心逻辑是:

  1. layoutMap 快速根据 matchId 找坐标
  2. 根据 nextMatchId 生成连线
  3. 比赛卡片由 MatchCard 渲染
  4. 晋级路径由 BracketConnector 渲染

这就是组件拆分的价值。

MatchCard 不关心自己连到哪里。

BracketConnector 不关心比赛是谁。

KnockoutBracket 负责把数据和布局拼起来。

分工清楚,代码就不容易变成前端毛线团。


九、写 KnockoutBracket 样式

创建:

bash 复制代码
src/components/KnockoutBracket.css

代码如下:

css 复制代码
.knockout-bracket {
  max-width: 1080px;
  margin: 0 auto;
  padding: 40px 20px;
}

.knockout-bracket__header {
  margin-bottom: 28px;
}

.knockout-bracket__eyebrow {
  margin: 0 0 8px;
  color: #2563eb;
  font-size: 13px;
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.knockout-bracket__header h1 {
  margin: 0;
  font-size: 32px;
  color: #0f172a;
}

.knockout-bracket__header p {
  margin-top: 10px;
  color: #64748b;
  line-height: 1.7;
}

.knockout-bracket__canvas {
  position: relative;
  width: 100%;
  height: 620px;
  overflow: auto;
  border: 1px solid #e5e7eb;
  border-radius: 24px;
  background:
    linear-gradient(90deg, rgba(148, 163, 184, 0.1) 1px, transparent 1px),
    linear-gradient(180deg, rgba(148, 163, 184, 0.1) 1px, transparent 1px),
    #f8fafc;
  background-size: 40px 40px;
}

.knockout-bracket__svg {
  position: absolute;
  inset: 0;
  width: 920px;
  height: 620px;
  pointer-events: none;
}

这里做了一个网格背景。

看起来更像工程化画布。

这类小细节很适合掘金文章截图,至少比纯白背景更像"我真的写了个组件",而不是拿 div 临时糊了一张纸。


十、页面中使用组件

创建页面:

bash 复制代码
src/pages/WorldCupBracketPage.tsx

代码:

javascript 复制代码
import { KnockoutBracket } from '@/components/KnockoutBracket'
import { bracketLayout } from '@/mock/bracketLayout'
import { knockoutMatches } from '@/mock/knockoutMatches'

export function WorldCupBracketPage() {
  return (
    <KnockoutBracket
      matches={knockoutMatches}
      layout={bracketLayout}
    />
  )
}

如果用 Vite + React,可以在 App.tsx 里直接引入:

javascript 复制代码
import { WorldCupBracketPage } from './pages/WorldCupBracketPage'

function App() {
  return <WorldCupBracketPage />
}

export default App

十一、如何支持赛后晋级?

等比赛结束后,数据可以变成这样:

css 复制代码
{
  id: 'r32-m2',
  round: 'Round of 32',
  home: {
    name: '法国',
    shortName: 'FRA',
  },
  away: {
    name: '瑞典',
    shortName: 'SWE',
  },
  homeScore: 2,
  awayScore: 1,
  status: 'finished',
  note: '法国晋级下一轮',
  nextMatchId: 'r16-m1',
}

然后可以增加一个 winner 字段:

makefile 复制代码
winner?: Team

类型改成:

typescript 复制代码
export interface BracketMatch {
  id: string
  round: string
  home: Team
  away: Team
  homeScore?: number
  awayScore?: number
  status: MatchStatus
  note?: string
  nextMatchId?: string
  winner?: Team
}

卡片里展示晋级队:

ini 复制代码
{match.winner && (
  <p className="match-card__winner">
    晋级:{match.winner.name}
  </p>
)}

样式:

css 复制代码
.match-card__winner {
  margin: 10px 0 0;
  font-size: 13px;
  font-weight: 700;
  color: #16a34a;
}

这样组件就能从赛前对阵树升级为赛后晋级路径图。


十二、如何避免布局写死?

现在的坐标是手动写的。

如果数据规模变大,可以按轮次自动计算。

比如先按 round 分组:

lua 复制代码
function groupByRound(matches: BracketMatch[]) {
  return matches.reduce<Record<string, BracketMatch[]>>((acc, match) => {
    if (!acc[match.round]) {
      acc[match.round] = []
    }

    acc[match.round].push(match)

    return acc
  }, {})
}

然后根据轮次生成坐标:

javascript 复制代码
function createAutoLayout(matches: BracketMatch[]) {
  const grouped = groupByRound(matches)
  const rounds = Object.keys(grouped)

  return rounds.flatMap((round, roundIndex) => {
    return grouped[round].map((match, matchIndex) => {
      return {
        matchId: match.id,
        x: roundIndex * 480 + 40,
        y: matchIndex * 180 + 40,
      }
    })
  })
}

这只是一个基础算法。

完整淘汰赛 bracket 还需要考虑:

  • 上下半区
  • 同轮节点间距
  • 下一轮节点居中
  • 移动端缩放
  • 冠军路径
  • 加时和点球显示

这类问题做深了就会变成正经项目。

前端组件可怕的地方就在这里:一开始只是卡片,最后变成体育赛事可视化系统。需求膨胀像加时赛,没人想踢,但总会发生。


十三、可以怎么继续扩展?

1. 支持点球大战

类型增加:

makefile 复制代码
homePenaltyScore?: number
awayPenaltyScore?: number

比分显示:

复制代码
法国 1 - 1 瑞典
点球 4 - 3

2. 支持比赛时间

增加字段:

vbnet 复制代码
kickoffTime: string
venue: string

卡片展示:

css 复制代码
<p>{match.kickoffTime}</p>
<p>{match.venue}</p>

3. 支持移动端缩放

可以给画布加:

css 复制代码
transform: scale(0.8);
transform-origin: left top;

或者改成横向滚动。

4. 支持点击查看详情

给卡片加点击事件:

javascript 复制代码
onMatchClick?: (match: BracketMatch) => void

组件 props:

typescript 复制代码
interface MatchCardProps {
  match: BracketMatch
  x: number
  y: number
  onClick?: (match: BracketMatch) => void
}

5. 接入接口数据

把 mock 换成接口:

csharp 复制代码
const response = await fetch('/api/worldcup/knockout')
const data = await response.json()

然后用接口返回的比赛数据渲染整个 bracket。


十四、总结

本文用 React + TypeScript 实现了一个世界杯淘汰赛对阵树组件。

它完成了几件事:

  • 定义淘汰赛比赛类型
  • 使用 mock 数据维护今日 32 强赛
  • 拆分 MatchCard、BracketConnector、KnockoutBracket 三个组件
  • 用 SVG 绘制晋级连线
  • 用绝对定位实现对阵树布局
  • 支持后续扩展比分、晋级队、点球和自动布局

以今日比赛为例:

  • 科特迪瓦 vs 挪威
  • 法国 vs 瑞典
  • 墨西哥 vs 厄瓜多尔

这些对阵如果只做成文字列表,信息不够直观。

做成淘汰赛对阵树后,路径关系会清楚很多。

世界杯热点不一定只能写球评。

前端开发者也可以把它拆成组件、类型、布局、连线和状态。

别人看淘汰赛,我们写 bracket。

这才是掘金比较合理的打开方式。


轻量补充

如果你长期使用 ChatGPT、Claude、Gemini、Cursor 或 Kiro 这类 AI 工具做组件生成、代码重构、文章改写和数据整理,也可以把 gpt68.com 作为第三方 AI 会员充值平台入口之一了解。它解决的是 AI 工具订阅充值流程问题,不是替代工具本身。使用前建议看清套餐说明、到账说明和售后规则。

相关推荐
AlbertZein2 小时前
别只盯着最强模型了,Agent 场景更该看这类 Flash 档模型
aigc·openai·ai编程
武子康9 小时前
调查研究-203 SpaceX IPO 总览:先别急着讲故事,先把发行事实和信息边界立住
人工智能·openai·agent
怕浪猫9 小时前
第7章 检索增强生成:打造知识库驱动型Agent
aigc·openai·ai编程
uccs20 小时前
流式响应的三次进化:EventSource → ReadableStream → TransformStream
openai·ai编程·claude
宅小年1 天前
Codex Skills 怎么选?我常用的几个推荐给你
openai
机器之心1 天前
近80年后,埃尔德什经典「拉姆齐数下界」,被三位中国学者首次指数级改进
人工智能·openai
机器之心1 天前
Nvidia都在点赞的LoopWM世界模型,竟然来自一家中国初创FaceMind?
人工智能·openai
武子康1 天前
调查研究-202 SGLang 深度解析:为什么大模型推理框架不只是“把模型跑起来“
人工智能·openai·agent
怕浪猫1 天前
第6章 多智能体协作:从单兵作战到群体智能
aigc·openai·ai编程