譯全解:從SSR到Hydration的避坑指南)
只要一個(gè)項(xiàng)目里既有服務(wù)端渲染的頁面又有前端框架負(fù)責(zé)交互業(yè)務(wù)量上到一定程度就會(huì)碰到一個(gè)非常微妙的問題前后端 HTML 轉(zhuǎn)譯。所謂轉(zhuǎn)譯不是后端把 HTML 文件當(dāng)字符串扔給瀏覽器就完事而是有兩層含義一層是服務(wù)端模板渲染出來的標(biāo)記如何被前端安全地“接住”并轉(zhuǎn)換成組件狀態(tài)另一層是當(dāng)后續(xù)數(shù)據(jù)從接口回來時(shí)如何避免再生成一份和首屏不一致的 HTML。這兩個(gè)環(huán)節(jié)只要一個(gè)細(xì)節(jié)沒對(duì)齊輕則頁面上多個(gè)空格重則樣式錯(cuò)亂、白屏甚至被注入腳本。這篇文章我會(huì)把這類問題從現(xiàn)象到原理完整拆一遍重點(diǎn)講清楚轉(zhuǎn)義、模板語法、狀態(tài)序列化和首屏補(bǔ)水這幾塊并給出一套可以直接抄作業(yè)的實(shí)操鏈路。適合正在做 SSR、用模板引擎做服務(wù)端渲染、或者維護(hù)老系統(tǒng)增量改造的前端和服務(wù)端開發(fā)參考。1. 先弄清楚 HTML 前后端轉(zhuǎn)譯到底在轉(zhuǎn)什么1.1 轉(zhuǎn)譯的兩個(gè)方向很多人第一次聽到“前后端 HTML 轉(zhuǎn)譯”第一反應(yīng)是“HTML 還需要轉(zhuǎn)譯不就是一個(gè)字符串嗎”確實(shí)瀏覽器收到 HTML 后要經(jīng)歷字節(jié)流解析、字符編碼識(shí)別、Token 化、構(gòu)建 DOM 樹最終才變成我們能在控制臺(tái)里看到的頁面。這個(gè)過程本質(zhì)上就是瀏覽器對(duì) HTML 做的一次“轉(zhuǎn)譯”但前后端項(xiàng)目里討論的轉(zhuǎn)譯往往比這多了一層。常見的情況有兩種方向。第一種是服務(wù)端往客戶端方向后端用模板引擎輸出 HTML比如把用戶數(shù)據(jù)填進(jìn)一個(gè)article標(biāo)簽里返回的是一段完整的、帶結(jié)構(gòu)的標(biāo)記。前端拿到這個(gè)字符串后如果還用了組件化框架就需要把這段標(biāo)記轉(zhuǎn)成組件樹、綁定事件、掛載狀態(tài)。換句話說服務(wù)端輸出的是“骨架”前端要把“骨架”解讀成可交互的頁面這里就有轉(zhuǎn)譯成本。第二種是客戶端往服務(wù)端方向前端提交表單或者調(diào)用接口服務(wù)端返回的不是 JSON而是一段 HTML 片段。這種在舊系統(tǒng)里非常常見比如點(diǎn)擊某個(gè)按鈕后后端直接返回一個(gè)重新渲染過的select下拉選項(xiàng)前端把這段字符串塞進(jìn)容器里。這個(gè)過程中同樣存在標(biāo)記解析、事件丟失、樣式?jīng)_突等問題。所以“HTML 轉(zhuǎn)譯”并不是某一種具體技術(shù)而是一整條鏈路模板生成標(biāo)記 → 標(biāo)記傳輸 → 標(biāo)記解析 → 狀態(tài)恢復(fù)。它跨越 HTTP、HTML 解析器、DOM API 和前端運(yùn)行時(shí)四個(gè)層面。任何一個(gè)層面理解不一致頁面就會(huì)表現(xiàn)異常。1.2 兩端 HTML 的“方言”差異服務(wù)端模板生成的 HTML和前端框架運(yùn)行時(shí)生成的 DOM 結(jié)構(gòu)看起來長(zhǎng)得一樣實(shí)際規(guī)則卻不完全一致。最典型的就是實(shí)體編碼。在服務(wù)端模板里你寫amp;瀏覽器解析后顯示的是如果前端框架拿到這段文本后再往組件狀態(tài)里存的是textContent那么你得到的是而不是amp;。于是問題來了這些數(shù)據(jù)被再次渲染進(jìn)頁面時(shí)可能被框架自動(dòng)轉(zhuǎn)義一次也可能不會(huì)。一旦轉(zhuǎn)義策略不統(tǒng)一原來正常的“”會(huì)變成“”顯示出來或者反過來本應(yīng)作為文本展示的“”被當(dāng)成了標(biāo)簽解析。這就像兩個(gè)地方各說一種“方言”服務(wù)端認(rèn)為“我已經(jīng)轉(zhuǎn)義過交給瀏覽器就行”前端認(rèn)為“我拿到的是純文本我要再處理一次”。兩邊都覺得自己是對(duì)的頁面卻不給面子。另一個(gè)典型差異是空白的處理。HTML 解析器會(huì)把多個(gè)連續(xù)空格壓縮成一個(gè)換行符在某些場(chǎng)景會(huì)被當(dāng)作空格處理但pre和textarea內(nèi)部又會(huì)保留原始空白。后端模板為了可讀性經(jīng)常會(huì)把標(biāo)簽寫得換行、縮進(jìn)結(jié)果服務(wù)端輸出的空白字符和前端組件預(yù)期的文本內(nèi)容發(fā)生了偏差。后面我會(huì)專門講怎么處理這個(gè)坑。1.3 你的項(xiàng)目屬于哪一種我把實(shí)際項(xiàng)目大體分成三類先定位再處理問題會(huì)更高效。第一類是傳統(tǒng)服務(wù)端渲染后端模板直接生成完整頁面前端只用少量原生 JavaScript 做交互。這種場(chǎng)景下前后端 HTML 轉(zhuǎn)譯的問題主要集中在后端模板的轉(zhuǎn)義規(guī)范以及前端拿到 DOM 后的字符串處理。常見問題相對(duì)簡(jiǎn)單。第二類是混合架構(gòu)首屏由服務(wù)端模板輸出后續(xù)交互交給前端框架接管比如在模板里放一個(gè)div>script typeapplication/json idpage-state {userId: 7, role: editor, page: detail} /script這段代碼看著簡(jiǎn)單里面卻有兩個(gè)極其隱蔽的坑。第一個(gè)是閉合風(fēng)險(xiǎn)。JSON 數(shù)據(jù)里如果包含了/script字符串HTML 解析器會(huì)把后面的內(nèi)容誤判為腳本結(jié)束標(biāo)簽導(dǎo)致頁面結(jié)構(gòu)提前截?cái)唷U_做法是序列化后把所有轉(zhuǎn)成\u003cfunction safeJson(data) { return JSON.stringify(data).replace(//g, \\u003c); }這樣即使 JSON 里有危險(xiǎn)字符也不會(huì)破壞 HTML 結(jié)構(gòu)。注意這一步必須在服務(wù)端序列化時(shí)做不能等前端再處理。第二個(gè)坑是字符編碼。如果頁面上的meta聲明是utf-8HTTP 響應(yīng)頭也是utf-8但服務(wù)端序列化 JSON 時(shí)沒有指定編碼或者對(duì)特殊字符做了htmlspecialchars式的轉(zhuǎn)義客戶端JSON.parse就可能在中文或多字節(jié)字符上出錯(cuò)。我的習(xí)慣是狀態(tài) JSON 只允許包含合法的 JSON 字符特殊字符統(tǒng)一轉(zhuǎn)成\uXXXX不要把 HTML 實(shí)體混進(jìn) JSON。因?yàn)?HTML 的實(shí)體規(guī)則和 JSON 的字符串規(guī)則是兩套東西強(qiáng)行混用遲早出事。2.4 首屏補(bǔ)水時(shí)的一致性“補(bǔ)水”這個(gè)詞是從英文 hydration 來的通俗理解就是首屏 HTML 已經(jīng)是服務(wù)端渲染好的前端框架啟動(dòng)時(shí)不能把整棵 DOM 推倒重來而要在已有 DOM 上“接管”并補(bǔ)上事件和狀態(tài)。聽起來很美但實(shí)際執(zhí)行時(shí)前端框架會(huì)把服務(wù)端標(biāo)記和客戶端自己渲染的結(jié)果做對(duì)比一旦發(fā)現(xiàn)不一致輕則警告重則整塊重新渲染導(dǎo)致閃爍。最常見的“首屏不一致”來自三類原因。一是文本格式化不一致。服務(wù)端時(shí)區(qū)是 UTC渲染出來的日期是“2025-01-01 00:00”客戶端在本地時(shí)區(qū)渲染成“2025-01-01 08:00”兩者對(duì)不上導(dǎo)致補(bǔ)水崩潰。解決辦法是把原始時(shí)間戳放進(jìn)狀態(tài) JSON前端統(tǒng)一在客戶端完成格式化服務(wù)端只渲染一個(gè)占位符或同構(gòu)的格式化結(jié)果。二是布爾屬性和表單值不一致。服務(wù)端輸出input checked, 前端組件可能結(jié)合虛擬 DOM 輸出成checkedchecked或者反過來。不同框架對(duì)布爾屬性的處理策略不同比較理想的方式是服務(wù)端模板和前端組件共用同一套渲染約定或者明確在補(bǔ)水時(shí)忽略這一類屬性差異。三是空白和文本節(jié)點(diǎn)差異。之前說過HTML 解析器對(duì)換行縮進(jìn)有自己的處理規(guī)則。如果服務(wù)端模板用多行排版前端組件生成的虛擬 DOM 里沒有這些空白文本節(jié)點(diǎn)框架就會(huì)認(rèn)為結(jié)構(gòu)不同。排查的時(shí)候可以先把服務(wù)端輸出復(fù)制到瀏覽器控制臺(tái)用innerText看最終文本是不是符合預(yù)期再對(duì)照框架渲染出來的結(jié)構(gòu)。大多數(shù)補(bǔ)水不一致問題最后都能定位到“服務(wù)端多了一個(gè)看不見的換行”上。3. 實(shí)操?gòu)姆?wù)端模板到前端組件的一趟完整轉(zhuǎn)譯3.1 先定輸出協(xié)議我建議不要在項(xiàng)目里“自由發(fā)揮”先把服務(wù)端輸出的 HTML 定成一套協(xié)議。這套協(xié)議解決三件事容器有標(biāo)記、狀態(tài)有出口、事件有入口。舉個(gè)例子服務(wù)端渲染一個(gè)文章詳情頁輸出大致如下article idarticle-234 >const root document.querySelector([data-componentarticle-detail]); const stateElement document.getElementById(page-state); const pageData JSON.parse(stateElement.textContent); mountComponent(root, pageData);這里有一個(gè)容易寫錯(cuò)的地方如果你用stateElement.innerHTML去取 JSON瀏覽器會(huì)把lt;之類的實(shí)體自動(dòng)解碼而 JSON 本身可能含有或非常容易造成二次轉(zhuǎn)譯。正確做法是用textContent它返回的是純文本和JSON.parse的輸入要求對(duì)齊。拿到狀態(tài)之后不要用innerHTML手動(dòng)拼節(jié)點(diǎn)。即使你習(xí)慣了字符串拼 HTML也建議改用DOMAPI 或者一個(gè)渲染函數(shù)因?yàn)槭謩?dòng)拼接意味著你必須自己處理所有轉(zhuǎn)義。組件框架的一個(gè)核心價(jià)值恰恰是把轉(zhuǎn)義這件事集中管理。如果確實(shí)遇到后端返回 HTML 片段需要插入的場(chǎng)景比如接口返回一個(gè)已經(jīng)渲染好的列表處理要分兩步。第一步用DOMParser解析字符串new DOMParser().parseFromString(html, text/html)。第二步提取目標(biāo)節(jié)點(diǎn)后用appendChild或replaceWith插入文檔。這樣可以避免把字符串直接塞innerHTML因?yàn)閕nnerHTML會(huì)觸發(fā)整段重新解析還可能把script執(zhí)行一遍。兩步操作看起來多實(shí)際上比排查 XSS 和事件丟失省心得多。3.3 給轉(zhuǎn)譯鏈路加“安全欄”所謂安全欄說白了就是給不同類型的數(shù)據(jù)定好處理規(guī)則不能把所有值都往 HTML 里塞。我整理了一張常用的對(duì)照表數(shù)據(jù)去向需要處理內(nèi)容處理方式HTML 文本節(jié)點(diǎn)、、轉(zhuǎn)義成實(shí)體HTML 屬性值、、、控制字符屬性值加引號(hào)轉(zhuǎn)義特殊字符URL 地址javascript:、data:等危險(xiǎn)協(xié)議白名單校驗(yàn)協(xié)議內(nèi)聯(lián)腳本所有數(shù)據(jù)盡量禁用內(nèi)聯(lián)腳本JSON 狀態(tài)/script轉(zhuǎn)成\u003c安全欄的關(guān)鍵不是“能轉(zhuǎn)就轉(zhuǎn)”而是明確誰負(fù)責(zé)哪一道。我的做法是服務(wù)端輸出模板時(shí)對(duì)用戶內(nèi)容做一次 HTML 轉(zhuǎn)義前端組件從 DOM 里讀取數(shù)據(jù)時(shí)不做反轉(zhuǎn)義直接使用解析后的值狀態(tài)數(shù)據(jù)統(tǒng)一走 JSONJSON 內(nèi)部不做 HTML 實(shí)體替換。這樣每個(gè)環(huán)節(jié)的職責(zé)單一排查時(shí)不需要逆向猜測(cè)。還要提醒一點(diǎn)不要用“過濾危險(xiǎn)標(biāo)簽”的思路來寫安全邏輯。你不可能窮舉所有惡意寫法老老實(shí)實(shí)做轉(zhuǎn)義才是正道。如果實(shí)在需要富文本也應(yīng)該在后端做白名單過濾只允許預(yù)先定義的標(biāo)簽和屬性然后再交給前端渲染而不是信任任何一個(gè)直接來自接口的 HTML 片段。4. 問題排查與避坑實(shí)錄4.1 高頻問題速查表我會(huì)把這些年遇到的高頻問題匯總成一張速查表方便現(xiàn)場(chǎng)定位?,F(xiàn)象可能原因優(yōu)先排查項(xiàng)頁面顯示“”而不是“”二次轉(zhuǎn)義后端的實(shí)體是否被前端再次轉(zhuǎn)義中文變成亂碼編碼不一致HTTP 頭、meta 聲明、模板文件編碼首屏閃爍組件重新渲染補(bǔ)水不一致日期格式化、空白節(jié)點(diǎn)、布爾屬性JSON.parse 報(bào)錯(cuò)JSON 里混入 HTML 實(shí)體用 textContent 讀取序列化時(shí)轉(zhuǎn)義HTML 片段插入后腳本被執(zhí)行直接 innerHTML 插入使用 DOMParser 并提取節(jié)點(diǎn)模板語法被前端誤解析分隔符沖突分離 HTML 與數(shù)據(jù)或調(diào)整分隔符頁面結(jié)構(gòu)多了一個(gè)空格模板換行被解析使用!-- --拼接標(biāo)簽行這張表不是萬能的但覆蓋了大多數(shù)“看起來普通卻查很久”的問題。遇到新場(chǎng)景時(shí)我的習(xí)慣是先給問題貼標(biāo)簽是編碼問題、轉(zhuǎn)義問題、解析問題還是時(shí)序問題。貼錯(cuò)標(biāo)簽很容易跑偏比如把 JSON 里的實(shí)體問題當(dāng)成編碼問題去調(diào) HTTP 頭那就是浪費(fèi)時(shí)間。4.2 一個(gè)“看起來沒錯(cuò)卻亂掉”的定位過程分享一次真實(shí)的排查經(jīng)歷。當(dāng)時(shí)是一個(gè)混合架構(gòu)項(xiàng)目服務(wù)端輸出了一篇文章列表前端需要接管每一行的收藏按鈕。癥狀很奇怪頁面第一次加載時(shí)按鈕狀態(tài)是正確的點(diǎn)擊一次收藏后整個(gè)列表重新渲染狀態(tài)反而亂了。一開始我懷疑是前端組件狀態(tài)沒有同步逐行打印數(shù)據(jù)也沒發(fā)現(xiàn)問題。后來把服務(wù)端返回的 HTML 復(fù)制到瀏覽器里手動(dòng)看發(fā)現(xiàn)列表項(xiàng)里每個(gè)按鈕帶有>