Skip to content

实现 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-inputel-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 效果截图

Punycode / 中文域名转换 效果截图

工具访问地址:https://www.i91tools.com/tools/punycode