JSON 與 YAML、XML 的互轉邊界
結論:JSON 是 YAML 的子集,這句話只保證了單向成立;而 XML 根本沒有官方 JSON 對映,全靠約定。

結論:以 JSON 作為中間格式最穩妥:先把來源格式解析成資料模型,再序列化為目標格式;轉換前先確認註解、XML 屬性與命名空間、以及數字和布林型別是否需要保留。
一條轉換鏈路能不能用,不取決於它轉得多漂亮,而取決於它丟了多少東西你有沒有意識到。
一、JSON 與 YAML:單向包含
YAML 1.2 規範明確寫了:JSON 是 YAML 的一個子集。也就是說任何合法 JSON 都是合法 YAML:
{"name": "json", "tags": ["a", "b"], "count": 3}
這段既是 JSON,也是 YAML 的流式寫法。反過來不成立。
| 特性 | JSON | YAML 1.2 |
|---|---|---|
| 註釋 | ✗ | ✓ |
| 錨點與別名 | ✗ | ✓ |
多文件(---) |
✗ | ✓ |
| 非字串鍵 | ✗ | ✓(1: a、true: b) |
| 複雜鍵 | ✗ | ✓(? [a, b]) |
| 縮排敏感 | ✗ | ✓(不接受 Tab 縮排) |
| 型別推斷 | 顯式 | 隱式(0o17、0x1F、.inf) |
| 單行可讀性 | 好 | 差(長內容靠摺疊語法) |
1.1 型別推斷是最大的坑
YAML 會把一些看起來像字串的值解析成別的型別:
zip: 01234 # 字串還是八進位制整數?YAML 1.2 core schema 視為字串
version: 1.10 # 1.1(浮點),語義丟失
country: NO # YAML 1.2 是字串;YAML 1.1 是布林 false
date: 2026-10-30 # 部分實現會解析成日期物件
NO 這個例子害過很多人:使用 YAML 1.1 解析器(部分老庫)時,country: NO 變成 false。
對策:所有「長得像數字或布林」的識別符號一律加引號,version: "1.10"。
1.2 錨點、別名與多文件
defaults: &defaults
retries: 3
timeout: 30
serviceA:
<<: *defaults
host: a.example.com
---
serviceB:
<<: *defaults
host: b.example.com
展開錨點後轉成 JSON,通常會得到兩份重複的配置物件,或者一個陣列(多文件必須轉成 [{...}, {...}])。
無論哪種,反向轉換時錨點不會恢復——這是必然的有損。
1.3 保留註釋的轉換
多數庫轉換時直接丟註釋。需要保留時用 yaml 包的 Document API:
import { parseDocument } from 'yaml';
const document = parseDocument(`
# 服務配置
port: 8080
`);
document.set('port', 3000);
console.log(document.toString());
// # 服務配置
// port: 3000
判斷標準很簡單:如果這份檔案會被人反覆編輯,就別用「解析 → 物件 → 序列化」的路徑。
1.4 往返一致性怎麼驗
/**
* 校驗 JSON → YAML → JSON 的往返一致性。
* @param sourceValue 原始 JSON 值
* @param stringify YAML 序列化函式
* @param parse YAML 解析函式
* @returns 是否完全一致
*/
function isRoundTripStable(
sourceValue: unknown,
stringify: (input: unknown) => string,
parse: (input: string) => unknown,
): boolean {
const yamlText = stringify(sourceValue);
const restored = parse(yamlText);
return JSON.stringify(restored) === JSON.stringify(sourceValue);
}
用真實資料跑一遍,比讀十遍文件有用。重點構造這些樣例:null、空字串、空陣列、"00"、"1e5"、
負零、超過 2^53 的整數、含換行的長字串。
二、JSON 與 XML:沒有標準的對映
XML 比 JSON 早得多,結構能力也更強(屬性、名稱空間、混合內容、DTD)。兩者之間沒有官方對映規範, 所有工具都在做同一件事:選一套約定,然後儘量別丟。
2.1 常見約定
| 約定 | 表示 | 示例 |
|---|---|---|
| 元素 → 物件鍵 | 同名元素變欄位 | <a>1</a> → {"a": "1"} |
屬性 → @ 字首 |
@name |
<a id="1"/> → {"a": {"@id": "1"}} |
文字 → #text |
有屬性又有文字時 | <a id="1">hi</a> → {"a": {"@id": "1", "#text": "hi"}} |
| 重複元素 → 陣列 | 出現兩次以上則是陣列 | <i>1</i><i>2</i> → {"i": ["1","2"]} |
| 名稱空間 → 字首 | ns:tag |
<ns:tag/> → {"ns:tag": ...} |
<order id="A1001">
<item sku="X1" qty="2">筆記本</item>
<item sku="X2" qty="1">鋼筆</item>
</order>
{
"order": {
"@id": "A1001",
"item": [
{ "@sku": "X1", "@qty": "2", "#text": "筆記本" },
{ "@sku": "X2", "@qty": "1", "#text": "鋼筆" }
]
}
}
2.2 必然有損的五處
- 元素順序:JSON 物件無序,XML 是有序的元素序列;
- 屬性與子元素:在 XML 里語義不同,在 JSON 裡都變成欄位;
- 混合內容:
<p>你好 <b>世界</b> 再見</p>這類結構無法用普通物件表達; - 名稱空間宣告:
xmlns是作用域概念,轉成扁平欄位後作用域丟失; - CDATA 與實體:
<![CDATA[...]]>與&在 JSON 裡都只是文字。
2.3 「一個元素還是陣列」的兩難
<list><item>1</item></list>
只有一個 item 時,xml2js 預設給物件而不是陣列;出現兩個才給陣列。
這導致前端程式碼必須寫 Array.isArray(x) ? x : [x]。
對策:解析時統一開啟「始終陣列」選項,或依據 schema 宣告欄位為陣列型別。
import { parseStringPromise } from 'xml2js';
const parsed = await parseStringPromise(xmlText, {
explicitArray: true, // 單元素也返回陣列
trim: true,
explicitRoot: false,
});
2.4 安全提醒
解析不可信 XML 前必須禁用外部實體,否則可能觸發 XXE(讀取本地檔案、內網探測):
// Node 生態通常透過解析器選項關閉 DTD 與外部實體
const parserOptions = { doctype: false, externalEntities: false };
XML → JSON 只是轉換的第一層,轉換並不消除 XML 帶來的攻擊面。
三、三種格式的適用位置
| 場景 | 推薦格式 | 原因 |
|---|---|---|
| 對外 HTTP 介面 | JSON | 瀏覽器與所有語言原生支援 |
| 人工維護的配置 | YAML | 註釋、縮排、可讀性 |
| 需要強 schema 與名稱空間的文件(SOAP、Office Open XML、SVG) | XML | 結構能力與既有生態 |
| 與運維工具互動(K8s、CI) | YAML | 生態預設 |
| 資料落盤、快取、日誌 | JSON / JSONL | 通用、零依賴 |
四、轉換實現要點
import { stringify as toYaml } from 'yaml';
/**
* 把 JSON 值轉成 YAML 文字,保留中文不轉義。
* @param value 待轉換的 JSON 值
* @returns YAML 文字
*/
function jsonToYaml(value: unknown): string {
return toYaml(value, {
indent: 2,
lineWidth: 0, // 不自動折行,避免長字串被切
defaultStringType: 'QUOTE_DOUBLE',
});
}
幾個實用引數:
lineWidth: 0關閉自動折行,否則長 URL 會被切成多行,人眼難讀;- 統一雙引號,可避免
NO、1.10這類值被誤判; - 數字精度敏感的資料,先轉成字串再序列化。
XML 方向建議固定一份對映函式,把「屬性字首」「文字鍵名」「陣列策略」寫進函式簽名,而不是散落在各處:
type XmlMappingOptions = {
attributePrefix: string;
textKey: string;
alwaysArray: boolean;
};
const defaultMapping: XmlMappingOptions = {
attributePrefix: '@',
textKey: '#text',
alwaysArray: false,
};
五、檢查清單
- [ ] YAML 裡所有「像數字/布林」的識別符號加了引號;
- [ ] 明確 JSON → YAML → JSON 的驗證樣例集,並納入測試;
- [ ] 需要保留註釋時走 Document API,不做物件往返;
- [ ] XML 對映約定寫進文件,團隊共用一套引數;
- [ ] 解析不可信 XML 時禁用 DTD 與外部實體;
- [ ] 轉換前後的位元組數、欄位數做一次對照,異常即報警。
常見問題
JSON、YAML、XML 之間應該如何選擇轉換方向?
優先把 JSON 當作中間格式:YAML 與 XML 都先解析成資料模型,再從模型輸出目標格式,避免直接做文字替換。需要保留註解時走 YAML Document API;需要保留 XML 屬性與命名空間時,先固定一套對映約定。
為什麼 XML 轉 JSON 會遺失屬性和命名空間?
JSON 沒有屬性與命名空間的概念,XML 的屬性、字首和命名空間必須被壓進普通鍵值,常見做法是加 @ 字首或 #text 節點。對映規則一旦不統一,同一份 XML 就會得到不同結構的 JSON,往返後無法還原。
是否可以將三種格式長期雙向同步?
不建議。註解、錨點、XML 屬性與命名空間在轉換中不可逆,雙向同步會不斷累積差異。更穩妥的做法是選定一種來源格式,把其他格式當作一次性匯出或臨時查看結果。