做在线域名DNS查询工具核心JS实现看着不难真正跑通一遍才发现里面有不少讲究。这个工具解决的问题很实际不用开终端敲 dig 或 nslookup浏览器里输个域名就能把 A、AAAA、CNAME、MX、TXT、NS、SOA 这些记录一次查明白。尤其是排查域名解析异常、验证 CDN 切换是否生效、确认 MX 和 SPF 配置对不对网页版工具比命令行直观太多给非运维岗位的同学用也完全没有门槛。我这次做的是纯前端方案不搭后端服务整个查询逻辑全部用原生 JavaScript 实现。整体代码量不大三百多行但里面涉及 DNS 协议交互、HTTPS 请求构造、JSON 解析、输入校验、超时兜底这些关键环节每一块都有值得抠的细节。这篇文章把当时的设计思路、接口协议、完整代码和踩过的坑全部整理出来给正在做类似工具或者想了解纯前端怎么对接网络查询接口的同学一份可复现的参考。1. 项目背景与核心需求拆解1.1 网页版DNS查询解决了什么痛点日常工作中查域名解析记录多数人的第一反应是命令行工具。dig 确实强大输出信息也很全但有两个现实问题一是要求使用者具备一定命令行基础很多非技术岗位的同事看到终端就发怵二是不同操作系统的命令差异不小平时用 Linux 的人切到 Windows 上连 nslookup 的交互模式都能绕晕。所以做一个浏览器直接访问的查询工具本质上就是把解析查询这个高频操作从终端搬到 GUI 里。项目要解决的核心需求有三个输入一个域名就能查支持多种常见记录类型结果展示要直观不能把一片 JSON 糊在页面上。第三个需求往往被新手忽略但恰恰是工具能不能真正落地使用的关键。我当时定的展示方案是表格化输出每条记录单独一行类型、TTL、解析值分列展示一眼扫过就能拿到需要的信息。1.2 技术方案对比纯前端、本地代理、后端聚合动手之前我对比了三条技术路线。第一条是纯前端直连公共DNS服务的HTTPS查询接口优点是零部署、代码简单、任何人打开 HTML 文件就能用缺点是受跨域策略限制只能依赖允许跨域的公共接口而且公共服务都有速率限制不适合做高频批量查询。第二条方案是自己写一个轻量后端代理前端请求自己的服务由后端去查询并缓存结果。优势是彻底规避了跨域和限流问题还能自己控制查询上游代价是多维护一套服务部署和运维成本上去了对个人小工具来说偏重。第三条是接入现成的第三方查询 API轮子很现成但可控性差、免费额度有限而且这类服务经常调整规则依赖它们等于把关键链路交给别人。综合比较后我选择了第一条路线纯前端直连公共DoH接口把端点配置做成前端可切换的选项。查询频率不高的话完全够用又避开了后端维护的成本。这篇文章的核心也是这条路线下 JS 实现的关键细节。2. DNS查询交互协议与数据格式详解2.1 常见DNS记录类型速查做查询工具之前先把要支持的记录类型捋清楚。DNS 的记录类型有几十种日常排查用得最多的是下面这七种记录类型用途典型返回值A域名指向的 IPv4 地址93.184.216.34AAAA域名指向的 IPv6 地址2606:2800:220:1:...CNAME别名指向另一个域名target.example.comMX邮件接收服务器及优先级10 mail.example.comTXT任意文本信息常用于 SPF/DKIM 验证vspf1 include:...NS域名使用的权威解析服务器ns1.example.comSOA起始授权记录含区域主服务器、序列号等master.example.com ...接口里的记录类型最终要映射成数字编码比如 A 记录对应 1、AAAA 对应 28、MX 对应 15、TXT 对应 16。不过 JSON 接口会直接返回类型数字前端把数字转成可读的类型名称就行。2.2 DoH的JSON响应格式DNS over HTTPS简称 DoH本质就是把传统的 DNS 查询封装进 HTTPS 请求里。对前端开发者来说最友好的形态是 JSON 式接口构造一个 GET 请求带上域名和记录类型参数接口返回结构化的 JSON 数据省去了自己解析二进制 DNS 报文的麻烦。一个标准响应长这样{ Status: 0, TC: false, RD: true, RA: true, AD: true, CD: false, Question: [ { name: example.com, type: 1 } ], Answer: [ { name: example.com, type: 1, TTL: 3600, data: 93.184.216.34 } ] }这里几个字段的含意分别是Status表示 DNS 响应状态码0 是正常Question是查询的问题部分回显你查的域名和类型Answer是答案部分真正展示给用户的数据都在这TTL是缓存的存活时间单位是秒AD字段表示响应是否通过了 DNSSEC 校验这个在判断解析是否被篡改时有用。2.3 响应状态码与边界情况DNS 协议里有一组标准的响应码DoH 接口会原样放进Status字段里。做前端展示时必须对几个关键状态码做专门处理不然用户看到一堆数字根本不知道发生了什么Status 值含义前端提示0NOERROR查询正常正常展示结果2SERVFAIL解析服务器故障提示服务器端异常稍后重试3NXDOMAIN域名不存在明确提示域名不存在4NOTIMPL不支持查询类型提示换一种记录类型5REFUSED查询被拒绝提示更换解析服务或检查参数另外有两种返回情况特别容易让新手困惑。第一种是查询成功但Answer数组为空比如你查一个不存在的子域名下的 A 记录状态码可能是 0 但没有答案。第二种是查询 MX 或 TXT 时答案可能不在Answer数组里而在接口的Authority或额外字段中尤其当查询的域名本身没有配置该类型记录时。所以前端千万别只盯着Answer解析还要看Status和数组长度组合起来才能给出准确的用户提示。3. 核心JS实现逐步拆解3.1 域名输入校验与归一化处理输入校验是这类工具最容易翻车的地方。用户输入五花八门可能带空格、带http://前缀、带路径甚至直接输入中文域名。我第一版只做了简单的非空判断结果一个https://www.example.com/path输进去后端查询直接报错界面上一片红。后来补了一套完整的归一化流程。function normalizeDomain(rawInput) { let domain rawInput.trim().toLowerCase(); // 去掉常见的 URL 前缀 domain domain.replace(/^https?:\/\//i, ); // 去掉路径和端口部分 domain domain.split(/)[0].split(:)[0]; // 去掉结尾的句点 domain domain.replace(/\.$/, ); // 中文域名转换为 punycode 编码 if (/[\u4e00-\u9fa5]/.test(domain)) { try { domain new URL(http:// domain).hostname; } catch (e) { return null; } } // 基本的域名格式校验 const DOMAIN_RE /^(?.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9-]{2,})$/; return DOMAIN_RE.test(domain) ? domain : null; }这段代码里几个细节值得说。用new URL(http:// domain).hostname来转中文域名是浏览器原生能力能自动把例子.cn转成xn--fsqu00a.cn少引一个 JS 依赖库。正则里的xn--分支是为了兼容已经转成 punycode 形式的后缀。校验不通过时返回null调用方统一提示域名格式不正确避免出现一套校验逻辑多处散落的局面。3.2 查询请求的构造与发送请求构造要关注三件事端点和路径、查询参数、请求头。DoH 的 JSON 接口通常支持/dns-query和/resolve两种路径风格但参数格式大同小异核心是name域名和type记录类型。请求头必须带上Accept: application/dns-json有些服务不带头会返回二进制 DNS 报文而不是 JSON。const DOH_ENDPOINTS [ { label: 公共解析服务一, base: https://1.1.1.1/dns-query }, { label: 公共解析服务二, base: https://8.8.8.8/resolve } ]; async function queryDNS(domain, type, baseUrl) { const url new URL(baseUrl); url.searchParams.set(name, domain); url.searchParams.set(type, type); const controller new AbortController(); const timeoutTimer setTimeout(() controller.abort(), 8000); try { const resp await fetch(url.toString(), { method: GET, headers: { Accept: application/dns-json }, signal: controller.signal }); if (!resp.ok) { throw new Error(HTTP resp.status); } const data await resp.json(); return data; } finally { clearTimeout(timeoutTimer); } }用URLSearchParams来拼参数而不是手动拼字符串是为了处理特殊字符的转义比如_dmarc.example.com这类带下划线的域名。超时控制用AbortController实现8 秒没响应就主动中断请求避免用户在慢网环境下对着转圈等半天。这是纯前端网络请求里最容易被忽略但非常重要的一层防护。3.3 响应数据的解析与表格渲染拿到 JSON 之后的处理逻辑分三步判状态、抽答案、渲染表格。function parseDNSResponse(data) { if (data.Status 3) { return { ok: false, message: 域名不存在NXDOMAIN }; } if (data.Status ! 0) { return { ok: false, message: 查询异常状态码 data.Status }; } const records (data.Answer || []).map(item ({ name: item.name, type: recordTypeToString(item.type), ttl: formatTTL(item.TTL), data: item.data })); if (records.length 0) { return { ok: true, records: [], empty: true }; } return { ok: true, records }; }渲染部分我用原生 DOM 操作生成表格没有引框架。由于查询结果字段固定用createElement逐行构建反而比操作字符串模板更直观、更好控制样式。这里有个经验type字段要把数字映射成名称比如1转成A、15转成MX展示清楚还方便用户理解。TTL我做了人性化格式化比如 3600 秒显示成 1小时比干巴巴的数字友好得多。function formatTTL(seconds) { if (!seconds) return -; if (seconds 60) return seconds 秒; if (seconds 3600) return Math.floor(seconds / 60) 分钟; if (seconds 86400) { const h Math.floor(seconds / 3600); const m Math.floor((seconds % 3600) / 60); return h 小时 (m ? m 分 : ); } return Math.floor(seconds / 86400) 天; }3.4 UI状态管理与交互反馈UI 状态我提炼成四种空闲、加载中、成功、失败。页面上的按钮在加载时禁用并显示查询中…结果区域在失败时显示红色错误条成功时显示表格。这块逻辑不复杂但状态切换如果没理顺容易出现用户连续点击后拿到重复结果或错误提示残留的问题。我的做法是点击查询后先清空上一次的结果区再置为加载状态等请求返回后根据结果决定渲染表格还是提示错误。同时给查询按钮和回车键绑定同一个处理函数这样键盘习惯的用户可以全程用回车操作。移动端适配也简单输入框和按钮都按全宽排列结果表格在小屏上撑满容器宽度即可。4. 完整实操代码与界面设计4.1 页面结构设计页面结构保持极简就一个输入区加一个结果区。HTML 骨架如下div classcontainer h1在线域名DNS查询/h1 div classquery-bar input typetext iddomainInput placeholder请输入域名如 example.com / select idtypeSelect option valueAA/option option valueAAAAAAAA/option option valueCNAMECNAME/option option valueMXMX/option option valueTXTTXT/option option valueNSNS/option option valueSOASOA/option /select select idendpointSelect/select button idqueryBtn查询/button /div div idstatusBar/div div idresultArea/div /div下拉框里的解析服务选项在页面加载时从DOH_ENDPOINTS数组动态生成这样以后新增服务商只改数组一处地方。4.2 完整JS代码走读把前面拆解的片段整合在一起就是完整的核心逻辑const DOH_ENDPOINTS [ { label: 公共解析服务一, base: https://1.1.1.1/dns-query }, { label: 公共解析服务二, base: https://8.8.8.8/resolve } ]; const TYPE_NAMES { 1: A, 28: AAAA, 5: CNAME, 15: MX, 16: TXT, 2: NS, 6: SOA }; function recordTypeToString(type) { return TYPE_NAMES[type] || TYPE- type; } function initEndpoints() { const select document.getElementById(endpointSelect); DOH_ENDPOINTS.forEach((ep, i) { const option document.createElement(option); option.value i; option.textContent ep.label; select.appendChild(option); }); } async function handleQuery() { const input document.getElementById(domainInput).value; const domain normalizeDomain(input); if (!domain) { showStatus(域名格式不正确请检查后重试, error); return; } const type document.getElementById(typeSelect).value; const endpointIndex document.getElementById(endpointSelect).value; const baseUrl DOH_ENDPOINTS[endpointIndex].base; const btn document.getElementById(queryBtn); btn.disabled true; btn.textContent 查询中…; document.getElementById(resultArea).innerHTML ; showStatus(, ); try { const data await queryDNS(domain, type, baseUrl); const result parseDNSResponse(data); if (!result.ok) { showStatus(result.message, error); } else if (result.empty) { showStatus(该域名没有 type 记录, warning); } else { renderTable(domain, type, result.records); showStatus(查询成功共 result.records.length 条记录, success); } } catch (err) { if (err.name AbortError) { showStatus(请求超时请检查网络或更换解析服务, error); } else { showStatus(查询失败 err.message, error); } } finally { btn.disabled false; btn.textContent 查询; } } function renderTable(domain, type, records) { const area document.getElementById(resultArea); const table document.createElement(table); table.className result-table; const thead document.createElement(thead); thead.innerHTML trth名称/thth类型/ththTTL/thth解析值/th/tr; table.appendChild(thead); const tbody document.createElement(tbody); records.forEach(rec { const tr document.createElement(tr); const cells [rec.name, rec.type, rec.ttl, rec.data]; cells.forEach(text { const td document.createElement(td); td.textContent text; tr.appendChild(td); }); tbody.appendChild(tr); }); table.appendChild(tbody); area.appendChild(table); } document.getElementById(queryBtn).addEventListener(click, handleQuery); document.getElementById(domainInput).addEventListener(keydown, e { if (e.key Enter) handleQuery(); }); initEndpoints();这里要专门提示一点渲染解析值data字段时我用的是textContent而不是innerHTML因为 TXT 记录里可能出现\n、引号等特殊字符用innerHTML直接插入既有 XSS 风险又可能破坏排版。用textContent是安全且省心的习惯。4.3 查询效果实测验证代码写完我做了多组验证。查example.com的 A 记录返回正常查一个不存在的nonexist.example.com状态码 3页面正确提示域名不存在查gmail.com的 MX 记录返回了带优先级的邮件服务器地址输入https://www.example.com/path?x1这种脏数据归一化函数也能正确提取出www.example.com并完成查询。其中一个比较有意思的测试是查一个做了 CDN 加速的域名A 记录一次性返回了多个 IP表格整齐排列出来非常直观。换到命令行去 dig 看输出反而没有页面排版清晰。这也侧面验证了网页版工具在展示层确实有独特优势。5. 常见问题排查与避坑记录5.1 接口跨域与可用性问题纯前端直连公共 DoH 接口最大的坑就在跨域。幸好在浏览器里测试多种公共解析服务的 JSON 接口时发现普遍允许跨域请求。但如果接入了一个不支持跨域的私有服务或自建接口浏览器会直接拦截响应报 CORS 错误。遇到这种情况我的排查路径是先看浏览器控制台具体错误信息确认是 CORS 拦截还是网络不通再用 curl 试一下相同请求排除服务本身的问题最后考虑换一个端点或自建代理转发。另外公共解析服务通常有速率限制频繁查询可能返回 HTTP 429。工具里对这种情况要单独提示请求过于频繁请稍后再试而不是笼统地报网络错误。对于个人使用频率这个限制基本碰不到。5.2 解析结果显示的边界怪相实际使用中还有几类容易误会的边界情况。第一类是查询多级子域名比如a.b.example.com如果中间某级没有配置接口可能返回空 Answer 但状态码是 0这时要判断是没有该记录而不是查询失败。第二类是某些域名开启了 CNAME 拉平A 记录查询会直接返回 CNAME 目标的 IP数据展示上看起来和普通 A 记录一样不必惊讶。第三类是 DNSSEC 校验字段AD它只表示递归解析器校验过签名不代表域名一定可信页面里如果展示这个字段措辞要严谨别让用户误解成安全评级。5.3 性能优化与功能扩展方向基础版跑通之后还有几个值得继续打磨的方向。一是加查询历史用localStorage存最近查询的域名点击即可重复查询省去重复输入。二是支持批量查询比如粘贴一组域名逐个查询但要注意控制请求并发数避免触发速率限制。三是增加结果导出把表格内容序列化成 CSV 下载这个对运维批量核对配置很有用。四是接入自定义 DoH 端点在页面设置项里允许用户填自己的服务地址方便内网场景使用。性能方面公共解析接口和浏览器本身都有 HTTP 缓存同一域名的重复查询第二次会快很多。代码层面不需要做额外缓存保持简单即可。结尾的几点体会这套工具从设计到跑通让我重新理解了简单功能背后不简单这句话。最深的体会是网络查询类工具的核心不在 UI 多花哨而在协议理解、边界处理和数据准确性这三件事上。把 DNS 的响应码含义搞清楚、把输入归一化做好、把特殊字符渲染问题兜住工具就能稳定地工作用户才会真正信任它。最后再分享一个小技巧如果日后想给这个工具加国际化域名支持千万别自己写 punycode 转换算法直接用内置的URL对象去解析域名浏览器会帮你处理所有编码细节。另外页面里留一个更换解析服务的下拉框是我认为性价比最高的功能它让工具在某个上游接口出问题时还能继续用下去。当前这个版本我已经放进自己的工具箱排查域名问题的时候随手就打开下一步计划把查询历史加上做成越用越顺手的小工具。