1. 从零搭建一个 DOTA2 信息站:我的完整技术选型与落地实录
打 DOTA2 十几年,从 6.48 时代一路玩到现在的 7.3x,中间断断续续也做过几个小工具站。最开始只是想给自己和朋友做一个能快速查英雄胜率、看版本改动、追踪比赛结果的小页面,后来用着用着发现身边不少人也需要,就干脆把它开源了。这篇文章不讲虚的,纯粹从一个业余开发者的角度,把整个项目的技术选型、架构设计、踩坑记录和部署流程完整地分享出来。
这个信息站的核心定位很明确:轻量、快速、免费部署、数据自动更新。它不需要用户注册登录,不需要复杂的后台管理,打开就能看到当前版本的热门英雄、胜率排行、最近的职业比赛结果,以及一些基础的英雄克制关系。适合的读者包括:想自己动手做一个游戏数据站的开发者、对 Astro 和 Cloudflare Workers 感兴趣的前端工程师、以及任何想找一个完整开源项目练手的朋友。
整个项目的前端用Astro构建,部署在Cloudflare Pages上;后端数据接口和定时任务跑在Cloudflare Workers上;实时推送部分用到了WebSocket;数据源主要来自公开的 DOTA2 官方 API 和社区维护的开放数据接口。代码全部开源,没有任何私有依赖,你 clone 下来改改配置就能跑自己的版本。
下面我按模块拆开讲,每个部分都会说清楚为什么这么选、怎么做的、以及实际跑下来遇到了什么问题。
2. 整体架构设计与技术选型背后的思考
2.1 为什么是 Astro 而不是 Next.js 或纯静态 HTML
最开始我用的是一套纯静态 HTML + jQuery 的方案,页面能跑,但维护起来很痛苦。每加一个英雄页面就要手动复制一份模板,改一个样式要全局替换,数据更新全靠手动跑脚本然后重新生成 HTML。后来想加个搜索功能,发现纯静态根本做不了,就开始找框架。
Next.js 当然是最顺手的选择,但我这个站的核心诉求是内容为主、交互为辅。90% 的页面是静态展示,只有搜索和实时比分需要动态能力。Next.js 的 SSR 和 API Routes 对我来说太重了,而且部署到 Cloudflare 上还要处理各种兼容问题。
Astro 的Islands 架构正好打中这个需求。默认情况下 Astro 把整个页面编译成纯静态 HTML,零 JavaScript 运行时。只有需要交互的组件才单独加载 JS,而且可以选择用哪个框架来写这个组件。我的英雄列表页、英雄详情页、版本改动页全部是静态生成的,只有搜索框和实时比分模块是动态的。实测下来,首页的 Lighthouse 性能分常年 98 以上,首屏加载时间在 1 秒以内。
另一个关键因素是Astro 的内容集合(Content Collections)。我可以把英雄数据、物品数据、版本改动记录全部用 Markdown 或 JSON 管理,Astro 在构建时自动做类型校验和路由生成。比如我在src/content/heroes/下面放一个axe.json,Astro 就会自动生成/heroes/axe这个页面,完全不用手写路由。
2.2 Cloudflare Workers 承担了什么角色
静态站点最大的问题是数据更新。DOTA2 的版本更新很频繁,英雄胜率每天都在变,如果每次都要重新构建部署,那太麻烦了。所以我把所有需要动态获取的数据都交给了 Cloudflare Workers。
具体来说,Workers 承担了三个职责:
- 数据聚合接口:从多个公开数据源拉取英雄胜率、出场率、禁用率,做简单的清洗和合并,然后以统一的 JSON 格式返回给前端。前端在构建时调用这个接口生成静态页面,同时在客户端也会定时轮询获取最新数据。
- 定时任务:用 Cloudflare 的 Cron Triggers 每小时触发一次数据更新,把最新数据写入 Workers KV 存储。这样即使前端不请求,数据也是最新的。
- 实时推送:比赛进行中时,通过 WebSocket 把比分变化推送给正在观看的用户。
选 Cloudflare Workers 而不是传统的 VPS 或者 Serverless 函数,核心原因是免费额度足够大、冷启动几乎为零、全球边缘节点。我的站日活不高,Workers 的免费额度每天 10 万次请求完全够用,KV 的读写额度也绰绰有余。而且 Workers 跑在 Cloudflare 的边缘节点上,用户请求数据接口的延迟基本在 50ms 以内。
2.3 WebSocket 在游戏信息站里的实际用途
很多人觉得一个信息站不需要 WebSocket,轮询就够了。我一开始也是这么想的,直到有用户反馈说看比赛的时候比分更新太慢。轮询的间隔设短了浪费请求,设长了体验差。WebSocket 正好解决这个问题。
我的实现方案是:当用户打开比赛详情页时,前端建立一个 WebSocket 连接,订阅这场比赛的数据更新。Workers 端维护一个简单的订阅表,当定时任务检测到比分变化时,主动推送给所有订阅了这场比赛的客户端。连接断开后自动重连,重连时带上上次收到的时间戳,服务端只推送这个时间戳之后的变化,避免重复数据。
这里有个细节:Cloudflare Workers 本身不直接支持长连接的 WebSocket 服务端,需要用Durable Objects来维护有状态的连接。Durable Objects 是 Cloudflare 提供的有状态 Serverless 组件,每个对象实例可以维护自己的内存状态和 WebSocket 连接。我的做法是每场比赛对应一个 Durable Object 实例,所有订阅这场比赛的客户端都连接到同一个实例上,由这个实例负责广播。
2.4 数据源的选择与处理策略
DOTA2 的数据源主要有几个:官方的 WebAPI、OpenDota、Stratz、Dotabuff 等。官方 API 最权威但调用限制严格,OpenDota 免费且数据全但偶尔不稳定,Stratz 数据质量高但免费额度有限。
我的策略是多源聚合 + 本地缓存。Workers 的定时任务同时从 OpenDota 和官方 API 拉数据,做交叉验证。如果两个源的数据差异超过阈值(比如胜率差 2% 以上),就标记为可疑数据,暂时保留上一次的有效值。所有数据写入 KV 时带上时间戳,前端展示时注明数据更新时间。
注意:使用任何第三方 API 前一定要仔细阅读其服务条款,确认允许的调用频率和数据使用范围。我的项目里所有数据源都是明确允许非商业用途的公开接口。
3. 核心模块拆解与关键实现细节
3.1 英雄数据模块:从原始 JSON 到可视化页面
英雄数据是整个站的基础。我的数据管道是这样的:Workers 定时任务从 OpenDota 拉取/heroStats接口,拿到每个英雄的胜率、出场率、禁用率等原始数据,然后和本地的英雄基础信息(名字、属性、技能描述)做合并,最终生成一个结构化的 JSON 对象。
本地英雄基础信息我放在src/data/heroes.json里,每个英雄包含:
{ "id": 2, "name": "Axe", "localizedName": "斧王", "primaryAttr": "strength", "attackType": "melee", "roles": ["initiator", "durable", "disabler"], "baseStats": { "str": 25, "agi": 20, "int": 18 } }这个文件是手动维护的,因为英雄的基础属性变化不频繁,没必要每次构建都去拉。胜率、出场率这些动态数据则在构建时通过 Workers 接口获取,注入到页面里。
Astro 的getStaticPaths函数在这里非常关键。我在src/pages/heroes/[slug].astro里这样写:
export async function getStaticPaths() { const heroes = await fetch('https://api.example.com/heroes').then(r => r.json()); return heroes.map(hero => ({ params: { slug: hero.name.toLowerCase() }, props: { hero } })); }构建时 Astro 会为每个英雄生成一个独立页面,页面里包含该英雄的详细数据、技能说明、克制关系、以及最近比赛的出场记录。所有页面都是纯静态 HTML,加载速度极快。
3.2 实时比分模块:WebSocket 连接的建立与维护
实时比分模块是整个项目里技术复杂度最高的部分。前端在比赛详情页加载时,会检查当前是否有进行中的比赛。如果有,就建立一个 WebSocket 连接。
前端代码大致是这样的:
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket(`${protocol}//api.example.com/match/${matchId}`); ws.onopen = () => { console.log('WebSocket connected'); ws.send(JSON.stringify({ type: 'subscribe', matchId, lastTimestamp: lastUpdate })); }; ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'score_update') { updateScoreDisplay(data.payload); lastUpdate = data.timestamp; } }; ws.onclose = () => { setTimeout(connectWebSocket, 3000); };服务端用 Durable Object 实现,每个比赛 ID 对应一个 DO 实例。DO 内部维护一个Set存储所有活跃的 WebSocket 连接,当收到比分更新时遍历这个 Set 推送消息。DO 的webSocketMessage方法处理订阅请求,webSocketClose方法清理断开的连接。
这里有个坑:Cloudflare Workers 的 WebSocket 有1000 个连接的上限(每个 DO 实例)。对于热门比赛,同时观看的用户可能超过这个数。我的解决方案是分片:如果某个比赛的订阅数接近上限,就自动创建新的 DO 实例,新连接分配到新实例上。前端不需要知道分片的存在,因为每个分片都会收到相同的比分更新。
3.3 版本改动追踪:如何自动抓取和展示更新日志
DOTA2 的版本更新日志发布在官方博客上,格式是 HTML。我写了一个 Workers 脚本,定时抓取博客页面,用正则和 DOM 解析提取出版本号、更新日期、以及具体的改动条目。
解析后的数据存成这样的结构:
{ "version": "7.35c", "date": "2024-01-15", "changes": [ { "category": "英雄", "target": "Axe", "detail": "反击螺旋的触发几率从 20% 调整为 25%" }, { "category": "物品", "target": "闪烁匕首", "detail": "冷却时间从 12 秒增加到 14 秒" } ] }前端用 Astro 的 Content Collections 来管理这些数据,每个版本一个 Markdown 文件,构建时自动生成版本列表页和详情页。用户可以在版本详情页里按英雄或物品筛选改动,也可以搜索关键词。
实操心得:解析 HTML 时不要用过于严格的正则,官方博客的格式偶尔会微调。我的做法是用 cheerio 做 DOM 解析,然后按语义选择器提取内容,这样即使外层结构变了,只要核心标签还在就能正常工作。
3.4 搜索功能:静态站点如何实现快速全文检索
静态站点做搜索是个经典难题。我的方案是构建时生成索引 + 客户端模糊搜索。
构建阶段,Astro 会遍历所有英雄、物品、版本改动数据,生成一个扁平的搜索索引文件search-index.json,结构如下:
[ { "type": "hero", "id": "axe", "title": "斧王", "keywords": ["axe", "斧王", "力量", "先手"] }, { "type": "item", "id": "blink-dagger", "title": "闪烁匕首", "keywords": ["blink", "跳刀", "闪烁"] } ]这个文件在构建时生成,部署到 CDN 上。客户端用 Fuse.js 做模糊匹配,用户输入关键词后,Fuse.js 在索引里搜索并返回排序后的结果。整个搜索过程在浏览器本地完成,不需要请求服务器,响应速度在 10ms 以内。
索引文件的大小控制在 200KB 以内(gzip 后约 50KB),对首屏加载几乎没有影响。如果数据量继续增长,可以考虑按类型拆分索引,或者用 Web Worker 在后台线程做搜索。
4. 完整部署流程与实操步骤
4.1 本地开发环境的搭建
先把项目 clone 下来:
git clone https://github.com/yourname/dota2-info-site.git cd dota2-info-site npm install项目依赖的主要包包括:astro、@astrojs/cloudflare、fuse.js、cheerio、wrangler。Node 版本要求 18 以上,推荐用 20 LTS。
本地开发时,前端用npm run dev启动 Astro 的开发服务器,默认在localhost:4321。Workers 部分用wrangler dev启动本地模拟环境,默认在localhost:8787。两个服务同时跑,前端通过环境变量PUBLIC_API_BASE指向 Workers 的地址。
# 终端 1:启动前端 npm run dev # 终端 2:启动 Workers cd workers npx wrangler dev本地开发时 WebSocket 也能正常工作,wrangler 的本地模拟环境支持 Durable Objects 和 WebSocket。
4.2 Cloudflare 资源配置与绑定
部署到生产环境之前,需要在 Cloudflare 控制台创建几个资源:
- KV Namespace:用于存储聚合后的英雄数据和版本改动数据。创建两个命名空间,一个用于生产,一个用于预览。
- Durable Object Namespace:用于 WebSocket 连接管理。在
wrangler.toml里声明绑定。 - R2 Bucket(可选):如果搜索索引文件较大,可以放到 R2 上,通过 Workers 提供访问。
wrangler.toml的关键配置如下:
name = "dota2-info-api" main = "src/index.js" compatibility_date = "2024-01-01" [[kv_namespaces]] binding = "HERO_DATA" id = "your-kv-namespace-id" [[durable_objects.bindings]] name = "MATCH_ROOM" class_name = "MatchRoom" [[migrations]] tag = "v1" new_classes = ["MatchRoom"] [triggers] crons = ["0 * * * *"]Cron 表达式0 * * * *表示每小时整点触发一次数据更新任务。
4.3 前端构建与 Pages 部署
前端部署到 Cloudflare Pages,有两种方式:Git 集成自动部署,或者用 wrangler 手动部署。我推荐 Git 集成,每次 push 到 main 分支自动触发构建。
在 Cloudflare Pages 的控制台里,连接你的 GitHub 仓库,构建设置如下:
- 构建命令:
npm run build - 输出目录:
dist - 环境变量:
PUBLIC_API_BASE=https://api.yourdomain.com
构建时 Astro 会调用 Workers 接口获取最新数据,生成静态页面。如果 Workers 接口暂时不可用,构建会使用上一次缓存的数据,不会失败。
注意:Pages 的构建环境有 20 分钟的超时限制。如果你的数据源响应很慢,建议在 Workers 端做好缓存,确保接口在 1 秒内返回。
4.4 域名绑定与 HTTPS 配置
Cloudflare Pages 默认提供*.pages.dev的域名,但建议绑定自己的域名。在 Pages 项目的 Custom Domains 里添加你的域名,然后按照提示在 DNS 里添加 CNAME 记录。Cloudflare 会自动签发 SSL 证书,整个过程不需要手动操作。
Workers 的自定义域名在 Workers 的 Triggers 设置里添加,同样支持自动 HTTPS。WebSocket 连接会自动升级到wss://,不需要额外配置。
4.5 数据更新任务的验证与监控
部署完成后,需要验证定时任务是否正常工作。在 Cloudflare 控制台的 Workers 页面,可以看到 Cron Triggers 的执行记录。每次执行都会生成一条日志,包含执行时间、耗时、以及成功或失败的状态。
我还在 Workers 里加了一个简单的健康检查接口/health,返回最近一次数据更新的时间戳和状态。前端在页脚展示这个信息,用户可以看到数据的新鲜度。
async function handleHealth() { const lastUpdate = await KV.get('last_update_timestamp'); const status = await KV.get('last_update_status'); return new Response(JSON.stringify({ lastUpdate, status, ok: status === 'success' }), { headers: { 'Content-Type': 'application/json' } }); }5. 常见问题排查与实战避坑指南
5.1 WebSocket 连接频繁断开怎么办
这是我最开始遇到的最头疼的问题。用户反馈说看比赛的时候比分偶尔会卡住不更新,刷新页面又好了。排查后发现几个原因:
原因一:Cloudflare 的 WebSocket 空闲超时。Cloudflare 对 WebSocket 连接有 100 秒的空闲超时限制,如果 100 秒内没有数据传输,连接会被强制关闭。解决方案是加心跳机制,客户端每 30 秒发送一个 ping 消息,服务端回复 pong。
setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } }, 30000);原因二:Durable Object 被回收。DO 实例在一段时间没有活动后会被 Cloudflare 回收,所有连接都会断开。解决方案是在 DO 的alarm方法里定期唤醒自己,保持活跃状态。
原因三:客户端网络切换。移动端用户在 WiFi 和蜂窝数据之间切换时,WebSocket 连接会断开。解决方案是监听online和offline事件,在网络恢复时主动重连。
5.2 数据源接口限流与降级策略
OpenDota 的免费接口有每分钟 60 次的调用限制。我的定时任务每小时执行一次,每次需要调用多个接口,正常情况下不会超限。但有一次因为代码 bug 导致循环调用,触发了限流,接口返回 429 状态码。
从那以后我加了令牌桶限流器和降级策略。限流器确保每分钟的调用次数不超过阈值,降级策略是在接口不可用时使用上一次缓存的数据,并在日志里记录警告。
class RateLimiter { constructor(maxPerMinute) { this.maxPerMinute = maxPerMinute; this.tokens = maxPerMinute; this.lastRefill = Date.now(); } async acquire() { const now = Date.now(); const elapsed = now - this.lastRefill; this.tokens = Math.min( this.maxPerMinute, this.tokens + (elapsed / 60000) * this.maxPerMinute ); this.lastRefill = now; if (this.tokens < 1) { await new Promise(r => setTimeout(r, 1000)); return this.acquire(); } this.tokens -= 1; } }5.3 构建时数据获取失败的容错处理
Astro 在构建时会调用 Workers 接口获取数据。如果接口挂了,整个构建就会失败。我加了本地缓存兜底:每次成功获取数据后,把数据写入src/data/cache/目录。构建时如果接口请求失败,就读取本地缓存。
async function fetchWithFallback(url, cachePath) { try { const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); await fs.writeFile(cachePath, JSON.stringify(data)); return data; } catch (error) { console.warn(`Fetch failed, using cache: ${error.message}`); return JSON.parse(await fs.readFile(cachePath, 'utf-8')); } }这样即使 Workers 临时不可用,Pages 的构建也不会中断,只是数据可能稍微旧一点。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 页面数据不更新 | Cron 任务未执行 | 查看 Workers 日志 | 检查 wrangler.toml 的 crons 配置 |
| WebSocket 连不上 | DO 绑定配置错误 | 检查 wrangler.toml 的 migrations | 确保 new_classes 包含正确的类名 |
| 构建失败 | 数据接口超时 | 查看 Pages 构建日志 | 增加本地缓存兜底逻辑 |
| 搜索无结果 | 索引文件未生成 | 检查 dist 目录 | 确认构建脚本包含索引生成步骤 |
| 样式错乱 | CDN 缓存旧版本 | 查看响应头 | 在 Pages 设置里清除缓存或调整缓存策略 |
| 接口返回 429 | 调用频率超限 | 查看 Workers 日志 | 加限流器,降低调用频率 |
5.5 几个容易被忽略的细节
时区问题:DOTA2 的版本更新和比赛时间都是 UTC,但用户分布在全球各地。我在前端用Intl.DateTimeFormat自动转换成本地时间,同时在页面上标注时区。
移动端适配:信息站的用户有相当一部分是手机访问。Astro 的静态页面在移动端表现很好,但 WebSocket 在移动网络下不稳定。我的做法是在移动端降低推送频率,从实时推送改为每 30 秒轮询一次,省电也省流量。
SEO 优化:每个英雄页面都有独立的 title、description 和 Open Graph 标签。Astro 的SEO组件统一管理这些元信息,构建时自动注入。实测下来,英雄详情页在搜索引擎里的收录率很高。
开源许可证:项目用的是 MIT 许可证,允许任何人自由使用、修改、分发。但数据源的使用需要遵守各自的服务条款,我在 README 里明确说明了这一点。
6. 后续可以继续折腾的方向
这个项目从最初的一个单页工具,慢慢长成了一个还算完整的信息站。代码开源之后,有几个朋友 fork 过去改成了其他游戏的数据站,把英雄数据换成角色数据,把比赛数据换成对局数据,核心架构完全不用动。这也算是意外收获。
如果你也想做一个类似的项目,我的建议是先从最小的可用版本开始。不要一上来就搞 WebSocket、搞实时推送、搞复杂的缓存策略。先做一个能展示英雄列表和胜率的静态页面,跑通了再逐步加功能。我最初的那个版本只有 200 行代码,连框架都没用,就是纯 HTML + 一个 Python 脚本生成数据。后来需求越来越多,才慢慢演进成现在这个样子。
另外,Cloudflare 的免费额度对个人项目来说真的非常充裕。Workers 每天 10 万次请求、KV 每天 10 万次读、Pages 每月 500 次构建,这些限制对日活几千的站点完全够用。唯一需要注意的是 Durable Objects 的计费方式,它按请求数和持续时间计费,免费额度相对小一些。如果 WebSocket 连接数不多,问题不大;如果连接数很多,可能需要考虑优化或者升级到付费计划。
代码仓库的地址在 README 里有,欢迎提 issue 和 PR。如果你用它搭了自己的站,也欢迎在讨论区分享出来,互相交流一下实现思路。