Appearance
实现 Punycode / 中文域名转换 工具
分类:developer | 标签:Punycode、域名、IDN 中文域名与 Punycode(xn--) 互转
2.1 功能说明
中文域名与 Punycode(xn--) 互转
核心能力
- toAscii
- toUnicode
- 原始
- 转换后
- 是否变化
2.1.1 使用指南
功能说明
域名系统(DNS)在传输层面只接受 ASCII 字符,而「中文.中国」这类国际化域名(IDN)包含非 ASCII 字符,必须先按 RFC 3492 定义的 Punycode 算法编码成以 xn-- 开头的 ASCII 形式才能被解析。本工具实现了完整的 Punycode 编解码,支持单个域名与整条 URL 的双向转换,全部运算在浏览器本地完成。
算法实现说明
本工具内置了纯 JavaScript 编写的 RFC 3492 编解码实现,没有依赖任何第三方库,也没有使用浏览器的全局 punycode 对象——该对象早已从主流浏览器与 Node.js 标准库中移除,直接调用会报错。编码遵循标准的 Bootstring 参数:基数 36、tmin 为 1、tmax 为 26、skew 为 38、damp 为 700、初始偏置 72、初始 n 为 128,并按码点遍历字符串,能正确处理增补平面字符。
转换规则
域名按点号切分成若干标签(label)逐个处理。编码方向:只有包含非 ASCII 字符的标签才会被转换并加上 xn-- 前缀,纯 ASCII 标签原样保留,这与 IDNA 规范一致。解码方向:只有以 xn-- 开头的标签才会被还原,其余标签原样保留。为方便中文输入,全角句号「。」、「.」、「。」会被自动识别为域名分隔点。
整条 URL 模式
开启「转换整条 URL」后,工具会拆出协议、用户信息、主机名、端口与路径查询片段,只对主机名做转换,其余部分原样保留。这一点很重要:路径与查询参数中的中文应当使用 percent-encoding(URL 编码)而不是 Punycode,两者不能混用。若你需要处理路径中的中文,请改用 URL 编码工具。
常见注意事项
编码结果统一为小写,因为 DNS 标签大小写不敏感。单个标签编码后长度不能超过 63 字节,整个域名不超过 253 字节,超长会被 DNS 拒绝。此外浏览器出于防钓鱼考虑,对混用不同文种(如西里尔字母冒充拉丁字母)的域名会直接显示 xn-- 原形而不显示中文,这属于正常的安全策略。
使用场景
中文域名注册与备案核对、Nginx 与证书配置中填写 ASCII 域名、邮件与日志中出现的 xn-- 串还原排查、爬虫与网络工具的域名规范化。
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。
2.2.2 核心逻辑概览
<script setup> 中定义的主要函数/方法:
adaptBias()digitToBasic()basicToDigit()toCodePoints()punycodeEncode()punycodeDecode()hasNonAscii()domainToAscii()domainToUnicode()convertUrl()showHint()convert()swap()loadSample()clearAll()copyOut()downloadOut()
2.2.3 关键实现代码
以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。
vue
import { computed, ref } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'
/* ------------------------------------------------------------------
* RFC 3492 Punycode 纯 JS 实现(Bootstring 参数按规范固定)
* 不依赖浏览器全局 punycode 对象,该对象已在主流环境中被移除
* ------------------------------------------------------------------ */
const BASE = 36
const T_MIN = 1
const T_MAX = 26
const SKEW = 38
const DAMP = 700
const INITIAL_BIAS = 72
const INITIAL_N = 128
const DELIMITER = '-'
const MAX_INT = 2147483647
const BASE_MINUS_T_MIN = BASE - T_MIN
function adaptBias(delta, numPoints, firstTime) {
let value = firstTime ? Math.floor(delta / DAMP) : delta >> 1
value += Math.floor(value / numPoints)
let k = 0
while (value > ((BASE_MINUS_T_MIN * T_MAX) >> 1)) {
value = Math.floor(value / BASE_MINUS_T_MIN)
k += BASE
}
return Math.floor(k + ((BASE_MINUS_T_MIN + 1) * value) / (value + SKEW))
}
// 数字值转 basic code point:0..25 映射 a..z,26..35 映射 0..9
function digitToBasic(digit) {
return digit + 22 + (digit < 26 ? 75 : 0)
}
// basic code point 转数字值,非法字符返回 BASE
function basicToDigit(codePoint) {
if (codePoint >= 0x30 && codePoint < 0x3a) return 26 + (codePoint - 0x30)
if (codePoint >= 0x41 && codePoint < 0x5b) return codePoint - 0x41
if (codePoint >= 0x61 && codePoint < 0x7b) return codePoint - 0x61
return BASE
}
// 按码点拆分,正确处理增补平面(不使用 split(''))
function toCodePoints(str) {
return Array.from(str).map(ch => ch.codePointAt(0))
}
function punycodeEncode(label) {
const codePoints = toCodePoints(label)
const inputLength = codePoints.length
const output = []
let n = INITIAL_N
let delta = 0
let bias = INITIAL_BIAS
for (const cp of codePoints) {
if (cp < 0x80) output.push(String.fromCharCode(cp))
}
const basicLength = output.length
let handledCount = basicLength
if (basicLength) output.push(DELIMITER)
while (handledCount < inputLength) {
// 找出不小于 n 的最小码点
let m = MAX_INT
for (const cp of codePoints) {
if (cp >= n && cp < m) m = cp
}
const handledPlusOne = handledCount + 1
if (m - n > Math.floor((MAX_INT - delta) / handledPlusOne)) {
throw new RangeError('Punycode 编码溢出')
}
delta += (m - n) * handledPlusOne
n = m
for (const cp of codePoints) {
if (cp < n && ++delta > MAX_INT) throw new RangeError('Punycode 编码溢出')
if (cp !== n) continue
let q = delta
for (let k = BASE; ; k += BASE) {
const t = k <= bias ? T_MIN : (k >= bias + T_MAX ? T_MAX : k - bias)
if (q < t) break
const qMinusT = q - t
const baseMinusT = BASE - t
output.push(String.fromCharCode(digitToBasic(t + (qMinusT % baseMinusT))))
q = Math.floor(qMinusT / baseMinusT)
}
output.push(String.fromCharCode(digitToBasic(q)))
bias = adaptBias(delta, handledPlusOne, handledCount === basicLength)
delta = 0
handledCount++
}
delta++
n++
}
return output.join('')
}
function punycodeDecode(encoded) {
const output = []
const inputLength = encoded.length
let n = INITIAL_N
let i = 0
let bias = INITIAL_BIAS
let basicEnd = encoded.lastIndexOf(DELIMITER)
if (basicEnd < 0) basicEnd = 0
for (let j = 0; j < basicEnd; j++) {
const code = encoded.charCodeAt(j)
if (code >= 0x80) throw new RangeError('Punycode 中出现非 ASCII 字符')
output.push(code)
}
for (let index = basicEnd > 0 ? basicEnd + 1 : 0; index < inputLength;) {
const oldI = i
for (let w = 1, k = BASE; ; k += BASE) {
if (index >= inputLength) throw new RangeError('Punycode 数据不完整')
const digit = basicToDigit(encoded.charCodeAt(index++))
if (digit >= BASE) throw new RangeError('Punycode 中出现非法字符')
if (digit > Math.floor((MAX_INT - i) / w)) throw new RangeError('Punycode 解码溢出')
i += digit * w
const t = k <= bias ? T_MIN : (k >= bias + T_MAX ? T_MAX : k - bias)
if (digit < t) break
const baseMinusT = BASE - t
if (w > Math.floor(MAX_INT / baseMinusT)) throw new RangeError('Punycode 解码溢出')
w *= baseMinusT
}
const outLength = output.length + 1
bias = adaptBias(i - oldI, outLength, oldI === 0)
if (Math.floor(i / outLength) > MAX_INT - n) throw new RangeError('Punycode 解码溢出')
n += Math.floor(i / outLength)
i %= outLength
output.splice(i++, 0, n)
}
return output.map(cp => String.fromCodePoint(cp)).join('')
}
/* ------------------------- 域名与 URL 层封装 ------------------------- */
const DOT_PATTERN = /[.\u3002\uFF0E\uFF61]/
function hasNonAscii(str) {
return Array.from(str).some(ch => ch.codePointAt(0) >= 0x80)
}
function domainToAscii(domain) {
return domain.split(DOT_PATTERN)
.map(label => (hasNonAscii(label) ? 'xn--' + punycodeEncode(label.toLowerCase()) : label))
.join('.')
}
function domainToUnicode(domain) {
return domain.split(DOT_PATTERN)
.map(label => (/^xn--/i.test(label) ? punycodeDecode(label.slice(4).toLowerCase()) : label))
.join('.')
}
// 拆出 URL 的主机名,只替换该部分
function convertUrl(raw, converter) {
const match = raw.match(/^([a-zA-Z][a-zA-Z0-9+.\-]*:\/\/)?([^/?#]*)([\s\S]*)$/)
if (!match) return converter(raw)
const scheme = match[1] || ''
const authority = match[2] || ''
const rest = match[3] || ''
if (!authority) return raw
const atIndex = authority.lastIndexOf('@')
const userInfo = atIndex >= 0 ? authority.slice(0, atIndex + 1) : ''
let hostPort = atIndex >= 0 ? authority.slice(atIndex + 1) : authority
// IPv6 字面量不做转换
if (hostPort.startsWith('[')) return raw
let port = ''
const colonIndex = hostPort.lastIndexOf(':')
if (colonIndex > 0 && /^\d+$/.test(hostPort.slice(colonIndex + 1))) {
port = hostPort.slice(colonIndex)
hostPort = hostPort.slice(0, colonIndex)
}
return scheme + userInfo + converter(hostPort) + port + rest
}
/* ------------------------------ 组件状态 ------------------------------ */
const mode = ref('toAscii')
const wholeUrl = ref(false)
const input = ref('')
const output = ref('')
const error = ref('')
const hint = ref('')
const details = ref([])
const placeholder = computed(() => {
if (mode.value === 'toAscii') {
return wholeUrl.value
? '输入完整地址,例如 https://中文域名.中国:8080/路径?a=1\n支持多行,每行一条'
: '输入中文域名,例如 中文域名.中国\n支持多行,每行一条'
}
return wholeUrl.value
? '输入完整地址,例如 https://xn--fiq06l2rdsvs.xn--fiqs8s/path\n支持多行,每行一条'
: '输入 Punycode 域名,例如 xn--fiq06l2rdsvs.xn--fiqs8s\n支持多行,每行一条'
})
function showHint(text) {
hint.value = text
window.setTimeout(() => { hint.value = '' }, 2000)
}
function convert() {
error.value = ''
details.value = []
const raw = input.value
if (!raw.trim()) { output.value = ''; return }
const converter = mode.value === 'toAscii' ? domainToAscii : domainToUnicode
const lines = raw.split(/\r?\n/)
const results = []
const rows = []
const errors = []
lines.forEach((line, idx) => {
const trimmed = line.trim()
if (!trimmed) { results.push(''); return }
try {
const converted = wholeUrl.value ? convertUrl(trimmed, converter) : converter(trimmed)
results.push(converted)
rows.push({
index: idx + 1,
source: trimmed,
result: converted,
changed: converted === trimmed ? '未变化' : '已转换'
})
} catch (e) {
results.push(trimmed)
errors.push('第 ' + (idx + 1) + ' 行:' + e.message)
rows.push({ index: idx + 1, source: trimmed, result: '转换失败', changed: '失败' })
}
})
output.value = results.join('\n')
details.value = rows
if (errors.length) error.value = errors.join(';')
}
function swap() {
const previous = output.value
mode.value = mode.value === 'toAscii' ? 'toUnicode' : 'toAscii'
input.value = previous
convert()
}
function loadSample() {
if (mode.value === 'toAscii') {
input.value = wholeUrl.value
? 'https://中文域名.中国:8080/文档?q=1'
: '中文域名.中国\n测试.公司\nexample.com'
} else {
input.value = wholeUrl.value
? 'https://xn--fiq06l2rdsvs.xn--fiqs8s:8080/文档?q=1'
: 'xn--fiq06l2rdsvs.xn--fiqs8s\nxn--0zwm56d.com'
}
convert()
}
function clearAll() {
input.value = ''
output.value = ''
error.value = ''
details.value = []
}
async function copyOut() {
try {
await navigator.clipboard.writeText(output.value)
showHint('已复制到剪贴板')
} catch {
showHint('复制失败,请手动选择文本复制')
}
}
function downloadOut() {
const blob = new Blob([output.value], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = mode.value === 'toAscii' ? 'punycode-ascii.txt' : 'punycode-unicode.txt'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
showHint('已开始下载')
}2.3 效果截图
