开发实践

国际化地址表单设计指南:别假设每个国家都有「省」

2025年2月10日
阅读时间:12分钟

问题的根源:把本国模型当成世界模型

绝大多数地址表单的字段是这样定下来的:照着本国的收货习惯列出六个框——收件人、省、市、区、详细地址、邮编—— 然后把它们做成数据库里六个 NOT NULL 的列。这套模型在单一市场里跑得很好,一旦要面向多个国家, 问题就会集中爆发:有的国家没有「省」,有的国家没有邮编,有的国家门牌号写在街道名后面, 有的国家整个地址是从大到小倒着写的。

地址不是一个「有标准答案」的数据类型,它是若干套彼此不兼容的本地约定的集合。 万国邮联(UPU)为此专门维护了 S42 标准,用一套通用的地址元素词汇加上各国模板, 来描述「同一个概念在不同国家如何拼装成一行地址」。这本身就说明了一件事: 不存在一个字段集合能同时适配所有国家,能做的只是让表单结构随国家变化。

一、行政层级到底差在哪里

下面这张表把几个典型市场的地址层级放在一起对比。注意它们的层数不同、必填项不同, 甚至同一个字段的语义也不同——中国的「市」是行政区划,英国的 post town 则是邮政投递单元, 两者不能简单对齐。

国家 / 地区典型层级设计要点
中国大陆省 / 直辖市 → 市 → 区县 → 街道地址四级结构完整,省级为必填,直辖市的「省」与「市」同名
美国State → City → 街道地址无「区」这一级;State 为两字母缩写,必填
英国(Post town)→ 街道地址 → PostcodeCounty(郡)在邮政投递中已非必需,Royal Mail 明确可省略
日本都道府县 → 市区町村 → 町域 → 丁目·番·号书写顺序由大到小,与欧美相反
新加坡街道地址 + 6 位邮编城邦国家,没有省/州这一级,城市字段本身冗余
阿联酋Emirate → Area → 建筑名 / 邮政信箱没有全国统一邮编体系,邮编字段应当隐藏或选填

从工程角度看,比较稳妥的抽象是把二级行政区统一命名为 administrativeArea, 把三级统一命名为 locality, 再在界面上按国家换成用户熟悉的标签:中国显示「省」,美国显示 State,日本显示「都道府県」, 英国干脆隐藏。数据库层保持稳定,展示层做本地化,这样后续新增国家不需要改表结构。

二、六个最常见的设计错误

把 State / Province 做成全局必填

香港、新加坡、摩纳哥、马耳他这类城邦或小型辖区没有二级行政区。强制必填的结果是用户随便填一个值,脏数据进库,后续的物流对接和统计全部失真。

把邮编做成全局必填且强正则

全球约有四十个国家和地区不使用邮政编码。要求这些用户填写会逼出 000000、N/A、00000 这类占位值,比留空更糟糕。

字段顺序写死为本国习惯

德语区习惯「街道名 门牌号」,英语区习惯「门牌号 街道名」;日本邮编写在最前,美国邮编写在最后。表单顺序应当跟随所选国家变化,否则用户会本能地填错格子。

只允许 ASCII 字符

德语的 ß 与变音符号、法语的连字符与撇号、日语汉字与假名、西班牙语的 ñ 都是合法地址字符。一刀切限制 [A-Za-z0-9 ] 会直接把这些用户挡在门外。

字段长度设得太短

把 address_line1 设成 varchar(50) 在测试环境从不报错,上线后遇到长街道名或含建筑名的地址就会静默截断。建议单行至少留 100 字符以上余量。

把姓名强行拆成 First / Last

许多文化中不存在「名在前姓在后」的结构,也存在只有单一名字的情况。收件人字段用一个「全名」输入框,比两个必填框稳健得多。

三、按国家驱动的表单结构

与其在组件里写一堆 if (country === "US"), 不如把每个国家的差异抽成配置数据。一个最小可用的配置大致是这样:

type AddressFieldConfig = {
  // 字段渲染顺序,未出现的字段不渲染
  order: FieldKey[]
  // 各字段的本地化标签
  labels: Partial<Record<FieldKey, string>>
  // 必填字段集合
  required: FieldKey[]
  // 二级行政区是否为固定枚举(如美国州列表)
  administrativeAreaOptions?: { code: string; name: string }[]
  // 邮编的宽松格式提示(仅用于提示,不做硬拦截)
  postalCodeHint?: string
}

const US: AddressFieldConfig = {
  order: ["name", "line1", "line2", "locality",
          "administrativeArea", "postalCode"],
  labels: { administrativeArea: "State", locality: "City",
            postalCode: "ZIP Code" },
  required: ["name", "line1", "locality",
             "administrativeArea", "postalCode"],
  administrativeAreaOptions: US_STATES,
  postalCodeHint: "12345 或 12345-6789",
}

const SG: AddressFieldConfig = {
  order: ["name", "line1", "line2", "postalCode"],
  labels: { postalCode: "Postal Code" },
  required: ["name", "line1", "postalCode"],
  postalCodeHint: "6 位数字",
}

这个模式的好处是:新增一个国家等于新增一条配置,不需要碰渲染逻辑; 测试时也可以直接遍历所有配置,验证每个国家的表单都能正常渲染与提交。 Google 开源的 libaddressinput 数据集、以及各类地址元数据库都采用了类似的思路, 如果不想自己维护,可以直接引用现成的元数据。

四、别忘了 autocomplete:一行属性省下十次输入

HTML 规范定义了一组地址相关的 autocomplete 词元,浏览器和密码管理器依赖它们来判断 「这个框该填什么」。把它们标对,用户点一下就能自动填完整个地址;标错或不标, 自动填充就会把电话填进邮编框。这是投入产出比最高的一项优化。

收件人全名
name
街道地址第一行
address-line1
街道地址第二行(门牌补充)
address-line2
省 / 州 / 都道府县
address-level1
市 / 区
address-level2
邮政编码
postal-code
国家(代码)
country
国家(显示名)
country-name
电话
tel

补充两点:一是这些词元需要写在 input 元素上, 且表单最好包在 form 里,浏览器才会把它们当成一组地址; 二是 address-level1address-level4 是按行政层级从粗到细编号的, 不是按「省市区」硬绑定,具体映射由国家决定。

五、校验:宽进严出,而不是宽出严进

建议这样做

  • 失焦后再校验,而不是每敲一个键就飘红
  • 格式不符时给「提示」而非「拦截」,允许用户坚持提交
  • 提交前做一次归一化:去首尾空格、合并连续空格、统一大小写
  • 错误信息写清楚「哪一个框、哪里不对、期望是什么」
  • 切换国家时保留用户已填的自由文本,只重置枚举字段

避免这样做

  • 用一条正则校验全世界的邮编
  • 禁止空格、连字符、撇号等地址中的合法字符
  • 切换国家时清空整个表单,逼用户重填
  • 只在提交后一次性抛出全部错误,且不定位到出错字段
  • 把第二行地址设为必填(大量地址确实没有第二行)

六、上线前的自检清单

  1. 用不带二级行政区的国家(如新加坡)跑一遍全流程,确认没有必填拦截。
  2. 用不使用邮编的国家(如阿联酋、香港)跑一遍,确认邮编字段被隐藏或标记为选填。
  3. 输入含变音符号、假名、汉字的地址,确认从表单到数据库到打印模板全链路无乱码、无截断。
  4. 把每一行地址填到字段长度上限,观察是否静默截断。
  5. 在移动端浏览器上测试自动填充,确认各字段落位正确。
  6. 切换国家两三次后再提交,确认残留的旧枚举值不会被写库。
  7. 用屏幕阅读器过一遍,确认每个 label 都与输入框正确关联。

小结

好的地址表单不是「字段更多」,而是「在正确的国家显示正确的字段、用正确的标签、 做正确强度的校验」。把国家差异抽成数据、把校验从拦截改成提示、把 autocomplete 标对, 这三件事就能解决绝大多数地址填写失败。真正要覆盖多国场景时, 手工造测试数据往往是瓶颈,可以用本站的地址生成工具批量产出各国格式的样例数据来做回归测试。