:從渲染原理到多端差異完全指南)
做小程序開發(fā)這幾年幾乎每隔一段時間就會遇到有人問同一個問題微信小程序里的換行和空格怎么就不聽話后端返回了一段帶\n的公告頁面上愣是擠成一行想用nbsp;做縮進結(jié)果屏幕上直接打出了nbsp;這串字符在開發(fā)者工具里預(yù)覽好好的換到真機上行尾空格又神秘消失。這期就把我在微信小程序里處理換行、空格的完整經(jīng)驗拆開講清楚涉及渲染原理、組件差異、接口數(shù)據(jù)處理、蘋果安卓真機差異以及 echarts tooltip、markdown 等場景的延伸解法。剛從小程序起步的前端同學(xué)、從 Web 轉(zhuǎn)過來的老手都可以拿這篇當(dāng)一份排查手冊用。1. 先搞清楚小程序里“換行”為什么時靈時不靈1.1 雙線程架構(gòu)下\n要跨過兩道關(guān)卡才能顯示成換行很多從 Web 轉(zhuǎn)過來的朋友第一次在小程序里碰到換行失效第一反應(yīng)是“小程序是不是不支持換行符”。不是不支持而是小程序的數(shù)據(jù)傳遞鏈路比瀏覽器多了一層“翻譯”。小程序是雙線程架構(gòu)邏輯層跑著我們的 JS 業(yè)務(wù)代碼渲染層負責(zé)把 WXML 轉(zhuǎn)成界面。你在 JS 里寫第一行\(zhòng)n第二行這個字符串里的\n是一個真實的換行符通過setData傳給渲染層時數(shù)據(jù)本身沒有丟。真正丟換行的地方是渲染層在把文本節(jié)點繪制出來的時候遵循了一套類似 CSS 的空白處理規(guī)則。這個規(guī)則就是white-space。默認情況下頁面里文本節(jié)點的white-space是normal而在normal下連續(xù)空格、換行符都會被折疊成一個空格。你想想瀏覽器里 HTML 源碼里的換行是不是都被折疊了小程序渲染層對 WXML 文本節(jié)點的處理邏輯也差不多。我打個比方你在一張紙條上寫了“第一行”然后換行寫了“第二行”把紙條遞給同事幫忙抄到公告欄結(jié)果公告欄管理員有個規(guī)矩——抄寫時所有軟換行一律壓成空格。你寫的時候換了行但公告欄展示出來就是一行。這就是默認狀態(tài)下的真實表現(xiàn)。所以要穩(wěn)住換行效果最直接的做法是給文本所在組件顯式設(shè)置white-space: pre-wrap或pre-line。我在基礎(chǔ)庫 2.x、3.x 的 iOS 和 Android 真機上都驗證過pre-wrap是目前兼容性最好、最不會出幺蛾子的取值。1.2 最常見的換行失效根因\n變成了字符串\\n排除了渲染層樣式問題之后第二個高頻坑來自數(shù)據(jù)本身。很多人排查半天最后發(fā)現(xiàn)后端接口里返回的根本不是換行符而是反斜杠加字母n這兩個字符。這個場景在后端用 Java 的團隊里特別常見。JSON 序列化時字符串里的換行符標(biāo)準(zhǔn)寫法確實是\n兩個字符但 JSON 解析之后會還原成真正的換行符。問題在于有些后端同學(xué)在返回前又做了一次字符串轉(zhuǎn)義比如調(diào)用StringEscapeUtils.escapeJava原本的換行符被轉(zhuǎn)義成了\\n前端用JSON.parse解析之后得到的是字面上的“反斜杠n”而不是 ASCII 碼 10 的換行。怎么定位別去看頁面顯示先看數(shù)據(jù)源頭。在開發(fā)者工具的 Network 面板里找到對應(yīng)接口查看原始 Response 文本看\n前面是不是還有一個反斜杠。更直接的辦法是在回調(diào)里console.log這個字段把字符串里每個字符的charCodeAt打出來。如果是[92, 110]那第二個字符就是\n 是字面字符如果是[10]這才是真正的換行。如果確認是雙重轉(zhuǎn)義前端可以救一下// 把字面的 \n 還原成真正的換行符 function restoreNewline(text) { return String(text).replace(/\\n/g, \n); }注意正則里\\n表示匹配“反斜杠n”這個字面序列。處理完以后再配合white-space: pre-wrap頁面上的換行就能正常顯示了。順帶提醒一句不要在 WXML 里直接寫text第一行\(zhòng)n第二行/textWXML 不是 JS 環(huán)境這里的\n會被當(dāng)成普通字符展示成“反斜杠n”。這個跟 HTML 里寫\n是同樣道理不解析就是不解析別在這個地方白白浪費時間。1.3 text 組件和 view 組件默認行為其實不完全一樣微信小程序提供了text組件很多同學(xué)認為它是專門顯示文本的換行空格應(yīng)該天然支持。實測下來text組件在部分基礎(chǔ)庫版本上確實會對\n更友好但它依然沒有擺脫默認white-space的約束換行符照樣可能被折疊。view組件更是如此默認狀態(tài)下不保留換行。這里我整理了一份自己實測常用的對照表方便你直接查組件默認的空白處理連續(xù)空格\n換行推薦做法text折疊折疊不穩(wěn)定顯式加white-space: pre-wrapview折疊折疊折疊長文本加white-space: pre-wrapword-breaktextdecode折疊可解析實體空格無效適合單處空格不解決換行textspace保留指定空格可顯示連續(xù)空格無效適合對齊類空格換行仍需樣式一句話總結(jié)不要依賴組件默認行為只要文本可能包含換行就直接在樣式里聲明white-space: pre-wrap把不確定性降到零。這比我下面要說的一切技巧都更基礎(chǔ)也是所有換行問題里最值得先做的一步。2. 空格不是空一格小程序空格的正確打開方式2.1nbsp;在小程序里不是 HTMLWeb 前端習(xí)慣了nbsp;這個 HTML 實體一進小程序很容易踩坑。原因不復(fù)雜WXML 不是 HTML它不會主動解析實體字符。你在 WXML 里寫textnbsp;/text不火大才怪頁面直接給你顯示一個字符串nbsp;。小程序里只有text組件提供了decode屬性設(shè)置為true之后才會解析nbsp;、lt;、gt;、apos;、quot;這幾個實體。而且請注意decode只在text組件上有效放到view里是沒有任何效果的。還有一個容易忽略的細節(jié)就算decode把nbsp;解析出來了得到的字符是 U00A0不換行空格它的寬度、換行行為都和普通半角空格不完全一樣。比如在 flex 布局里它依然是一個有寬度的文本字符不會因為white-space被折疊但也別指望它能在所有場景下精確對齊。2.2 三種空格字符實測\u0020、\u2003、\u3000比nbsp;更好用的辦法是在 JS 數(shù)據(jù)里直接構(gòu)造空格字符。我平時主要用這三個字符含義寬度受 white-space 折疊影響適用場景\u0020普通半角空格半個漢字左右會被折疊需要pre-wrap才穩(wěn)定\u2003em space一個 em約等于漢字寬度一般不會折疊簡單分隔、英文排版\u3000全角空格一個漢字寬度基本不折疊中文對齊、縮進、字段補位這里最推薦的是\u3000全角空格。它的最大優(yōu)勢是不依賴white-space設(shè)置在某些不穩(wěn)定的文本容器里也能堅持顯示為空格。做中文排版時需要表頭對齊、編號對齊、關(guān)鍵詞間距補位直接拼\u3000比寫一長串nbsp;省心得多。我通常在常量文件里統(tǒng)一管理const SPACE { HALF: \u0020, // 半角空格 EM: \u2003, // em 空格 FULL: \u3000, // 全角空格 NBSP: \u00A0 // 不換行空格 };2.3 flex 布局和文本溢出里的“空格刺客”有時候你明明只加了幾個普通空格頁面布局卻突然亂了。這種問題在 flex 容器里尤其典型文本節(jié)點里如果有換行或空格渲染層會把這些空白當(dāng)作獨立的文本節(jié)點參與 flex 布局導(dǎo)致子元素之間出現(xiàn)意外間距甚至觸發(fā)換行。另一個隱藏坑是text-overflow: ellipsis。普通半角空格在normal狀態(tài)下被折疊后省略號可能正常出現(xiàn)但如果是\u2003或\u3000這種不可折疊空格連續(xù)出現(xiàn)文本的實際寬度會被撐大省略號反而被擠沒甚至整段文字不省略。遇到這種情況優(yōu)先檢查是不是文本里混入了不可見空格。再說一個跟中文排版相關(guān)的小知識中文和英文、數(shù)字之間加空格確實能明顯提升閱讀體驗這也是很多人提到的論文排版規(guī)范。但在實際項目里我強烈建議不要依賴“在字符串里拼空格”來實現(xiàn)這個效果因為空格寬度受字體、渲染環(huán)境、折疊規(guī)則影響不可控。更穩(wěn)的做法是用 flex 布局給相鄰組件之間加margin或者gap讓排版這件事交給布局去管而不是交給字符串。3. 按場景選方案text、view、rich-text 怎么讓換行空格各司其職3.1 text 組件decode 和 space兩個屬性管不同的事前面提到text組件的decode屬性它解決的問題是“實體字符串能不能被解析”。而text組件還有一個容易被遺忘的屬性space它解決的是“連續(xù)空格能不能顯示、按什么寬度顯示”。space屬性有三個值nbsp、ensp、emsp。我直接說實際用法text spaceemsp項目一 說明文字/text不加space時這里面的連續(xù)普通空格會被折疊成一個加上spaceemsp之后空格會保留并且每個空格的寬度接近一個中文字符寬度。做簡單的對齊展示、訂單號分段、標(biāo)簽與內(nèi)容間距這個方案比解析nbsp;更直接。decode和space各管一攤可以同時設(shè)置。比如text decode spaceemspnbsp; 標(biāo)題內(nèi)容/text這里decode把nbsp;解析成一個不換行空格space則負責(zé)后續(xù)普通空格的顯示寬度。兩者不沖突但別天真地以為decode能解析\n它不負責(zé)換行換行還是要靠white-space樣式。3.2 view 標(biāo)簽white-space 三選一長文本才穩(wěn)view作為最常用的容器組件處理長文本時靠的是white-space樣式。三個取值各有脾氣pre-wrap保留空格和換行符同時允許文本到達容器邊界時自動換行。這個是處理長文本最推薦的取值公告、詳情、評論展示都用它。pre-line合并連續(xù)空格但保留換行符。適合那種“我只想換行不想管空格”的場景。pre完全按文本原始格式顯示空格、換行全部保留但不會自動換行一個很長的英文單詞會直接把頁面撐出橫向滾動。我最常用的組合是這樣.text-content { white-space: pre-wrap; word-break: break-word; word-wrap: break-word; }word-break: break-word是為了兜底長 URL 和長英文。很多同學(xué)只加了white-space: pre-wrap結(jié)果遇到接口返回一段帶鏈接的英文文本還是被一個超長單詞撐破布局。這兩個屬性配合使用才算把長文本的底兜住。3.3 rich-text 富文本把換行權(quán)交給標(biāo)簽如果頁面里的內(nèi)容是后端富文本編輯器生成的那問題又不一樣。富文本內(nèi)容通常是 HTML 字符串里面可能有p、div、br/等標(biāo)簽也可能有用戶隨手敲的換行。rich-text組件有自己的 HTML 解析器它雖然能解析一部分標(biāo)簽但默認情況下字符串里的\n不會被自動轉(zhuǎn)成br/。如果你的后端存的是純文本\n傳到rich-text里極有可能被當(dāng)空白吞掉。我的建議是富文本內(nèi)容在進入rich-text之前統(tǒng)一做一次轉(zhuǎn)換把換行符轉(zhuǎn)成br/function plainTextToRichText(text) { return String(text) .replace(/\r\n/g, \n) .replace(/\r/g, \n) .replace(/\n/g, br/); }另外rich-text對標(biāo)簽的支持是有限制的像table、thead、tr這類復(fù)雜表格標(biāo)簽會被過濾掉。如果業(yè)務(wù)里確實要展示表格別指望rich-text一把梭。我現(xiàn)在的建議是直接用社區(qū)維護的mp-html這類增強組件它對表格、代碼塊、數(shù)學(xué)公式的支持都比官方rich-text更完整。說句實在話官方rich-text的定位就是輕量富文本復(fù)雜排版還是交給專業(yè)組件。3.4 表格、代碼、長段數(shù)字別用空格做對齊最后一個場景我提出來單說是因為我在實際項目里見過太多次“手敲空格做表格”的慘劇。小程序基礎(chǔ)組件里沒有table新手最容易想到的替代方案就是“用全角空格把字段對齊”。結(jié)果不同機型、不同字體下全角空格和文字的寬度比例不完全一致表格在 Android 上對齊了到 iOS 上又歪了。正確的做法是用 flex 布局做網(wǎng)格view classtable-row view classtable-col stylewidth: 30%;姓名/view view classtable-col stylewidth: 40%;職位/view view classtable-col stylewidth: 30%;城市/view /view view classtable-row wx:for{{list}} wx:keyid view classtable-col stylewidth: 30%;{{item.name}}/view view classtable-col stylewidth: 40%;{{item.job}}/view view classtable-col stylewidth: 30%;{{item.city}}/view /view配合flex加固定寬度比例寬度問題天然解決。如果需要橫向滾動在外面包一層scroll-view scroll-x給每列設(shè)置最小寬度即可。代碼塊、銀行卡號分段這些有固定格式的內(nèi)容同樣建議用一組定寬的view來排列而不是在字符串里拼空格。這里是網(wǎng)易嚴選那種下拉刷新、長按拖拽滾動都適用的一個原則排版對齊是布局問題不是文本問題。4. 接口數(shù)據(jù)全鏈路從后端返回到頁面渲染的統(tǒng)一處理4.1 先定位是接口轉(zhuǎn)義問題還是渲染問題處理換行空格問題最忌諱的是在渲染層亂試 CSS。正確順序是先切分問題是數(shù)據(jù)到了頁面時就已經(jīng)不對還是數(shù)據(jù)正確但顯示不對。我會在接口回調(diào)里加一行日志requestSuccess(res) { const text res.data.content; console.log(content chars:, [...text].map(c c.charCodeAt(0))); }通過字符編碼數(shù)組一眼看出有沒有\(zhòng)n10、\u300012288、字面\92、字母n110。這一步能砍掉至少一半的排查時間。如果字符編碼里確實有 10但頁面依然不換行那是渲染層樣式問題去加white-space: pre-wrap。如果編號里根本沒有 10而是 92、110那是數(shù)據(jù)轉(zhuǎn)義問題去清洗數(shù)據(jù)。4.2 我常用的文本清洗函數(shù)為了避免每個頁面都重復(fù)處理我會在公共工具庫里放一個統(tǒng)一的文本規(guī)范化函數(shù)function normalizeText(text) { if (typeof text ! string) return text; return text .replace(/\r\n/g, \n) // Windows 換行統(tǒng)一 .replace(/\r/g, \n) // 老 Mac 換行統(tǒng)一 .replace(/\\n/g, \n) // 雙重轉(zhuǎn)義反還原 .replace(/\\r\\n/g, \n) // 雙重轉(zhuǎn)義 CRLF 反還原 .replace(/\u00A0/g, \u3000); // 不換行空格統(tǒng)一成全角空格方便對齊 }這個函數(shù)在數(shù)據(jù)進入setData之前調(diào)用一次而不是在渲染時調(diào)用。為什么強調(diào)這點因為小程序里每一幀渲染都很寶貴你放在 WXML 的綁定表達式里反復(fù)處理字符串既浪費性能又難維護。順帶提一個 WXS 的坑WXS 語法雖然有近似 JS 的寫法但不支持正則表達式所以別想在 WXS 里寫.replace(/\\n/g, \n)這是做不到的。跨端項目如果用了 uni-app 或 Taro換行空格的處理邏輯建議放在 JS 公共模塊里不要在模板層處理。4.3 echarts tooltip 自動換行和 text 組件不是一回事有一個高頻熱搜詞是“echarts tooltip 自動換行”。很多人把小程序文本換行方案直接套到 echarts 圖表上結(jié)果發(fā)現(xiàn) tooltip 里返回帶\n的字符串換行就是不生效或者把 canvas 撐得很怪。echarts 圖表在小程序里使用 ec-canvas 承載tooltip 是一個獨立的浮層容器它默認有一套自己的樣式。想讓 tooltip 內(nèi)容按指定格式換行正確做法是用 formatter 返回數(shù)組echarts 會自動把數(shù)組每一項渲染成一行tooltip: { trigger: axis, formatter: function(params) { return [ params[0].axisValue, 銷量 params[0].data ]; } }如果不方便返回數(shù)組也可以用extraCssText控制 tooltip 容器的換行規(guī)則tooltip: { trigger: axis, extraCssText: white-space:pre-wrap;max-width:600rpx; }注意這里設(shè)了max-width否則長文本還是有可能把 tooltip 撐出屏幕。這個思路和文本組件一致只是入口從 WXML 樣式換到了 echarts 配置。4.4 markdown、web-view 等擴展場景的換行約定還有不少項目會在小程序里渲染 markdown 內(nèi)容比如社區(qū)文章、幫助文檔。markdown 的換行規(guī)則和純文本完全兩碼事markdown 里單個換行通常不被識別為換行必須在行尾加兩個空格或者用一個空行分隔段落。很多同學(xué)把 markdown 原文直接塞進text組件加white-space: pre-wrap之后換行倒是顯示了但 markdown 語法也被暴露了。這種情況下應(yīng)該先用 towxml、mp-html 這類 markdown 渲染組件讓解析器按 markdown 規(guī)范處理而不是自己去替換換行符。如果只是簡單展示一段帶換行的用戶輸入就用white-space: pre-wrap如果是正經(jīng) markdown 文檔就用解析器。兩條路線不要混用。web-view加載的 H5 頁面則完全不受小程序白色空間規(guī)則約束里面該用white-space用white-space該用nbsp;用nbsp;那是瀏覽器的地盤小程序管不著。這一點在跨端調(diào)試時容易造成誤解遇到 web-view 里的排版問題記得先把邊界切開。5. iOS 和 Android 的差異讓我差點懷疑人生5.1 一行結(jié)尾的空格在兩個系統(tǒng)上表現(xiàn)完全不一樣我在接一個訂單備注展示需求時碰到過這個問題Android 真機上顯示的備注末尾有一個空格用來和后面的金額分隔效果正常同一套代碼放到 iOS 上末尾的空格就像被人吃掉了一樣文本直接緊挨在一起。原因出在 iOS 小程序渲染層基于 WKWebView而 Android 側(cè)用的是 XWeb 或其他系統(tǒng)內(nèi)核兩者對行尾空白字符的處理策略不同。行尾普通空格在 iOS 上容易被優(yōu)化裁剪掉Android 則不會。解法也不難行尾需要保留空格的場景把普通半角空格換成全角空格\u3000或者不換行空格\u00A0這兩個字符在 iOS 上被裁剪的概率低很多。如果是數(shù)據(jù)展示而非文本拼接更推薦直接用margin-right來制造視覺間距徹底繞開“行尾空格”這個坑。5.2 textarea 回顯時換行丟失textarea是另一個重災(zāi)區(qū)。用戶在小程序里輸入多行內(nèi)容存到后端下次進入頁面回顯到textarea里偶爾會出現(xiàn)換行全部消失或者變成空格的情況。這里通常有兩個原因。一個是后端存儲時把換行符替換掉了這種情況要去數(shù)據(jù)接口排查另一個則是前端在賦值前對內(nèi)容做了處理比如習(xí)慣性調(diào)用.trim()或者用replace(/\s/g, )把空格壓縮結(jié)果把換行也當(dāng)成空白一并處理了。我的建議是textarea的value直接綁定原始數(shù)據(jù)回顯時不要做任何字符串清洗。真正需要處理的時候比如在其他地方展示這段文本再單獨做一個處理函數(shù)并且只處理換行轉(zhuǎn)換不要順手把空格全清了。如果回顯時發(fā)現(xiàn)換行丟失第一時間用charCodeAt打印一下原始 value確認數(shù)據(jù)層有沒有問題再去看組件層。5.3 長英文和 URL 溢出word-break 要組合使用長英文單詞和長 URL 自動換行這在 Web 開發(fā)里是老生常談在小程序里同樣存在而且 iOS 和 Android 的表現(xiàn)還有細微差異。只給容器加white-space: pre-wrap遇到一個連續(xù) 50 個字符的鏈接在部分 Android 機型上依然會溢出屏幕因為pre-wrap允許自動換行但默認的斷詞規(guī)則不會在一個單詞中間斷開。這時要補上word-break: break-word或overflow-wrap: break-word。我的穩(wěn)定組合是.long-text { white-space: pre-wrap; word-break: break-word; overflow-wrap: break-word; }不過要提醒一句word-break: break-all會強制在任何字符間斷行雖然能百分百防止溢出但中文文本看起來容易斷得很難看。能用break-word優(yōu)先用break-word只有遇到長數(shù)字串、長 URL 這種特殊內(nèi)容時再考慮針對性的break-all。5.4 引入 scroll-view 解決溢出的新麻煩有些同學(xué)為了應(yīng)對長內(nèi)容溢出會把文本包進scroll-view橫向滾動結(jié)果在 iOS 上又偶發(fā)“內(nèi)容不能滑動滾動”的問題。這個鍋不全在scroll-view多半是外層容器沒有明確高度或者內(nèi)容高度計算時機不對。蘋果系統(tǒng)上scroll-view對高度計算更敏感給scroll-view加一個明確的高度或者把父容器的height: 100%鏈補齊問題通常會消失。如果你是因為換行空格問題被迫引入橫向滾動我勸你先回頭看看 3.4 節(jié)的原則用結(jié)構(gòu)性布局解決對齊大多數(shù)情況下根本不需要滾動容器也就不會踩到滾動失效的坑。最后再分享一個排錯技巧處理換行空格這類小問題最怕的就是靠肉眼猜。調(diào)樣式、調(diào)數(shù)據(jù)來回試浪費時間還容易把問題搞混。我自己的習(xí)慣是先打印字符串每個字符的charCodeAt確認數(shù)據(jù)層再看元素的white-space和word-break確認樣式層最后才動手改代碼。真機上遇到和開發(fā)者工具表現(xiàn)不一致優(yōu)先懷疑空格字符的類型和 iOS 的內(nèi)核對空白符的裁剪邏輯。如果你能把“文本對齊用布局、換行靠樣式、數(shù)據(jù)入口統(tǒng)一清洗”這三條原則落實到位絕大多數(shù)換行空格的問題在開發(fā)階段就能被消滅而不是等測試報 bug 再回頭查。我個人在實際項目里靠這套方法已經(jīng)很久沒有被換行空格折騰過了希望這篇經(jīng)驗對你也有用。