从零搭建DOTA2信息站:Astro+Cloudflare Workers技术选型与落地实录

发布时间:2026/9/23 14:19:15
从零搭建DOTA2信息站:Astro+Cloudflare Workers技术选型与落地实录
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.jsonAstro 就会自动生成/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_BASEhttps://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。如果你用它搭了自己的站也欢迎在讨论区分享出来互相交流一下实现思路。