世界杯进入淘汰赛后,赛程展示就不太适合继续用普通列表了。
小组赛可以用表格。
淘汰赛更适合用对阵树。
因为淘汰赛要表达的不只是"谁对谁",还包括:
- 胜者进入哪一轮
- 哪几场比赛汇入同一条路径
- 哪支球队可能在下一轮相遇
- 当前比赛属于哪一轮
- 比赛是否已结束
- 晋级球队是谁
这类信息如果全靠文字,很容易写成一坨。
所以今天我们用 React + TypeScript 写一个世界杯淘汰赛对阵树组件。
示例数据使用今日 32 强赛:
- 科特迪瓦 vs 挪威
- 法国 vs 瑞典
- 墨西哥 vs 厄瓜多尔
本文只做前端组件实现,不做胜负预测,不涉及博彩、赔率或下注。
一、组件目标
我们要实现一个基础版 KnockoutBracket 组件。
它需要支持:
- 渲染比赛卡片
- 展示轮次
- 展示球队名称
- 展示比赛状态
- 展示晋级占位卡片
- 用 SVG 连线表示晋级路径
- 保持类型清晰,方便后续扩展
最终结构大概是:

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 渲染比赛卡片。
注意这里没有把布局逻辑塞进组件里。
x 和 y 是外部传入的。
这样 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>
)
}
这里的核心逻辑是:
- 用
layoutMap快速根据 matchId 找坐标 - 根据
nextMatchId生成连线 - 比赛卡片由
MatchCard渲染 - 晋级路径由
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 工具订阅充值流程问题,不是替代工具本身。使用前建议看清套餐说明、到账说明和售后规则。