化實錄:壓測暴露的坐標(biāo)精度、內(nèi)存泄漏與渲染瓶頸)
我至今還記得第一次把一份兩萬多節(jié)點的真實數(shù)據(jù)拖進畫布的那個瞬間畫面像慢放了十倍拖動時能清清楚楚看到每一步卡頓幀率連掩耳盜鈴的余地都不給我。什么無限畫布在真實數(shù)據(jù)面前就是一頁普通的 HTML。最初我只是想復(fù)刻一個類似 libtv 的無限畫布demo 做得風(fēng)生水起平移、縮放、框選樣樣齊全發(fā)到群里還有人夸了一句“可以啊”。然后朋友丟來這份數(shù)據(jù)我沉默了。接下來的三天我給自己布置了一項任務(wù)搭一套能重復(fù)跑、能采指標(biāo)、能暴露問題的壓力測試環(huán)境然后把這個無限畫布從里到外錘一遍。整個流程走完花了差不多 72 小時。這篇文章就是那 72 小時的完整記錄——我設(shè)計了哪些測試、測出了哪些問題、每一處問題是怎么一步步定位到根因的、最后落地了哪些優(yōu)化。如果你也在做無限畫布、白板、地圖編輯這類“視口無限但資源有限”的應(yīng)用這篇應(yīng)該能幫你少踩幾個坑。1. 事出有因一個“能用”的 demo 為什么逼我搭了壓測架子1.1 無限畫布的壓力模型和普通頁面完全不是一回事普通網(wǎng)頁的性能瓶頸翻來覆去就那幾樣DOM 節(jié)點太多、重排重繪太頻繁、圖片懶加載沒做好。你在一個普通頁面里塞兩萬個元素瀏覽器會罵你但還不至于直接癱掉。無限畫布不一樣。它的核心場景是“世界坐標(biāo)”無限大而“視口坐標(biāo)”永遠(yuǎn)只占屏幕那一小塊。聽起來很美好所有渲染只要處理可視區(qū)里的幾十個幾百個元素就行。但要讓這個模型成立你必須在每次視口變化時回答三個問題哪些元素現(xiàn)在可見——可見性剔除它們在世界坐標(biāo)下的位置換算到屏幕坐標(biāo)是多少——坐標(biāo)變換用戶的手勢、縮放、平移怎么和這套坐標(biāo)系統(tǒng)一——事件處理這三個問題環(huán)環(huán)相扣任何一個環(huán)節(jié)寫得不嚴(yán)謹(jǐn)元素量一上來就現(xiàn)原形。普通頁面不會遇到“世界坐標(biāo)里的浮點數(shù)精度不夠用”這種奇葩問題但無限畫布一定會遇到后面我會詳細(xì)講。我最開始的 demo 之所以“能用”是因為我只用幾十個元素自測數(shù)據(jù)少到所有問題都被掩埋了。等到真實數(shù)據(jù)灌進來復(fù)雜度直接從 O(可視區(qū)元素) 退化成了 O(全部元素)不卡才怪。1.2 為什么不能靠“手滑亂點”來壓測很多人覺得壓測就是打開頁面鼠標(biāo)瘋狂拖一拖卡了就截圖吐槽兩句。這種玩法只能給你一個模糊的感覺“好像有點卡。”它回答不了三個關(guān)鍵問題卡在哪里為什么卡改完代碼之后是變好了還是變壞了我當(dāng)時給了自己兩個明確目標(biāo)第一壓測過程必須可復(fù)現(xiàn)第二每次壓測必須有客觀指標(biāo)可以對比。手動操作這條直接廢掉我需要腳本化。我用 Puppeteer 驅(qū)動一個真實的 Chrome 實例通過 CDPChrome DevTools Protocol往里灌數(shù)據(jù)、模擬鼠標(biāo)指針軌跡、模擬滾輪縮放同時周期性采集幀率、內(nèi)存、長任務(wù)耗時。簡單說一下骨架const puppeteer require(puppeteer); const browser await puppeteer.launch({ headless: false, args: [--disable-gpu-sandbox, --window-size1440,900] }); const page await browser.newPage(); await page.goto(http://localhost:5173); // 在頁面里注入幀率采樣器 await page.evaluate(() { window.__fpsSamples []; let frames 0; const probe () { frames; window.__fpsSamples.push(performance.now()); }; setInterval(() { window.__fps frames; frames 0; }, 1000); requestAnimationFrame(probe); }); // 用 CDP 模擬鼠標(biāo)拖拽 const cdp await page.target().createCDPSession(); await cdp.send(Input.dispatchMouseEvent, { type: mousePressed, x: 700, y: 450, button: left, buttons: 1 }); for (let x 700; x 300; x - 10) { await cdp.send(Input.dispatchMouseEvent, { type: mouseMoved, x, y: 450, button: left, buttons: 1 }); await new Promise(r setTimeout(r, 16)); } await cdp.send(Input.dispatchMouseEvent, { type: mouseReleased, x: 300, y: 450, button: left, buttons: 0 });這套腳本本身不復(fù)雜但它把“我隨便拖一拖”變成了“每一次拖拽的路徑、速度、時間間隔都是固定的”。后續(xù)我再跑第二次、第三次拿到的是同一個條件下可以橫向?qū)Ρ鹊臄?shù)字。1.3 72 小時這個數(shù)字是怎么花掉的先說清楚不是三天三夜盯著屏幕不睡覺。壓力測試這個東西等結(jié)果的時間比干活的時間多得多。我大概的時間分配是第一天前 8 小時搭測試環(huán)境、寫采集腳本、調(diào)通自動化鏈路第一天到第二天鋪數(shù)據(jù)和跑基準(zhǔn)測試把 5k、20k、80k 三檔數(shù)據(jù)都生成出來每檔跑好幾輪操作序列存下 trace第二天大部分時間長時間穩(wěn)定性測試一輪 4 小時期間反復(fù)看內(nèi)存曲線和性能指標(biāo)第三天針對暴露的問題定位、修復(fù)、再跑回歸真正分析問題和改代碼的時間可能只占三分之一其余時間全在等 Chrome 跑完、等內(nèi)存曲線畫出來、等 heap snapshot 生成。但恰恰是這些等待時間暴露了平時手動操作根本發(fā)現(xiàn)不了的問題——比如內(nèi)存悄悄上漲這種事你手滑兩分鐘是絕對看不出來的。2. 測試矩陣怎么搭才有參考價值規(guī)模、操作、指標(biāo)三件套2.1 規(guī)模設(shè)計數(shù)據(jù)量只是第一層一開始我天真地以為壓力測試就是把元素數(shù)量翻倍20k 不夠就 80k。跑完一輪之后發(fā)現(xiàn)元素的“種類”和“分布方式”很大程度上決定了瓶頸會在哪里。我把測試數(shù)據(jù)設(shè)計成了三個維度元素類型混合比。矩形、路徑、文本、位圖這四類元素的渲染路徑完全不同。矩形和路徑在 Canvas 2D 里走的是幾何填充文本涉及字體測量和字形緩存位圖涉及貼圖上傳和 GPU 內(nèi)存。如果只測矩形你根本測不出文字緩存泄漏的問題如果只測位圖又會誤以為所有元素都那么吃顯存。我最終的混合比大概是矩形 40%、路徑 30%、文本 20%、位圖 10%算比較貼近真實白板類應(yīng)用。幾何復(fù)雜度。同樣是路徑一條直線和一條由幾千個貝塞爾曲線拼成的鋼筆路徑處理成本天差地別。我在壓測數(shù)據(jù)里摻了一部分高復(fù)雜度路徑——每條路徑幾百個點——來模擬真實用戶畫的那些“靈魂草圖”。分布方式。元素在無限畫布里的空間分布也很關(guān)鍵。我生成了三類分布均勻散布模擬平鋪的筆記、中心聚簇模擬用戶瘋狂放大一個區(qū)域畫圖、帶狀分布模擬橫向流程圖。這三類分布對可見性剔除的壓力完全不同聚簇分布很容易暴露空間索引退化的場景。最終我定了三檔測試規(guī)模5k 元素作為基準(zhǔn)檔20k 作為常規(guī)壓力檔80k 作為極限檔。每檔都有固定的元素類型混合比和分布方式保證不同檔位之間只有“量”的差異沒有“質(zhì)”的變量。2.2 操作設(shè)計縮放才是無限畫布的隱藏殺手畫布交互里最耗性能的操作不是平移而是縮放尤其是連續(xù)、深度的縮放。原因很簡單平移只改變視口原點而縮放改變的是整個坐標(biāo)變換的尺度所有可見元素的世界坐標(biāo)到屏幕坐標(biāo)的換算全部要重算浮點誤差也跟著放大。我的操作序列分成了四組慢速閱讀以大約 200px/s 的速度勻速平移模擬用戶在查看內(nèi)容中速拖拽以 800px/s 拖動中間穿插幾次停頓快速甩動模擬鼠標(biāo)快速甩過畫布直接觸發(fā)慣性滾動連續(xù)深度縮放從 1x 連續(xù)放大到 100000x再縮小回來反復(fù)三輪縮放中心固定在視口左上角三分之一處而不是視口中心重點說下最后這個。很多人測縮放是“一次性 setTransform 跳到某個倍率”這完全測不出精度問題。真實用戶縮放是一格一格滾動滾輪每次縮放都會在前一次的基礎(chǔ)上再乘一個系數(shù)浮點誤差會在這個過程中不斷累積。連續(xù)縮放三輪坐標(biāo)系統(tǒng)的漂移就會顯露出來。另外縮放中心不能總在視口中心。真實用戶通常把鼠標(biāo)指向自己關(guān)注的內(nèi)容縮放中心在鼠標(biāo)位置。這個操作路徑對坐標(biāo)系的要求更高也很容易把隱藏的 bug 暴露出來后面講第二跪的時候會細(xì)說。2.3 指標(biāo)設(shè)計FPS 之外還要盯什么幀率只是最表面的指標(biāo)它只能告訴你“卡了”但說不清卡在主線程還是合成器、是 CPU 不夠還是 GPU 內(nèi)存爆了。我給自己定了五個指標(biāo)指標(biāo)采集方法我設(shè)的紅線FPS頁面內(nèi) rAF 計數(shù)器每秒記錄一次常規(guī)操作不低于 55fps快速甩動不低于 30fps長任務(wù)PerformanceObserver 觀察 longtask10 分鐘內(nèi)超過 50ms 的長任務(wù)不超過 5 個JS 堆內(nèi)存performance.memory.usedJSHeapSize長跑 4 小時后曲線應(yīng)回落或保持平穩(wěn)不許線性爬升事件響應(yīng)延遲事件派發(fā)到下一幀渲染完成的時間不超過 100msGPU 進程內(nèi)存外部監(jiān)控 chrome GPU 進程 RSS不允許持續(xù)增長排除紋理泄漏FPS 和長任務(wù)用 Performance API 就能拿到JS 堆內(nèi)存靠 performance.memoryGPU 進程內(nèi)存我是用系統(tǒng)命令每隔 30 秒拉一次 chrome 子進程的物理內(nèi)存雖然不精確但紋理泄漏這類問題它會先報警。這五個指標(biāo)要綜合看。比如 FPS 掉到 30但長任務(wù)很少那問題可能不在 JS 計算而在合成器的光柵化壓力如果 JS 堆內(nèi)存曲線平穩(wěn)但 GPU 進程內(nèi)存一直漲那八成是離屏 canvas 或紋理對象沒釋放。2.4 固定測試用例集保證回歸有同一個基準(zhǔn)我把整輪操作序列寫成了一個用例文件里面包含了操作類型、坐標(biāo)、時長、間隔。所有代碼改完之后我都是重新跑這份完全相同的用例文件做回歸。這比“手動拖一拖差不多不卡了”靠譜得多因為它能給出前后對比的數(shù)字變化。最近我還把這套用例文件做成了簡單的 CI gate只跑基準(zhǔn)檔和常規(guī)壓力檔兩個檔位都過了閾值才允許合并代碼。極限 80k 檔跑得太慢留給每周手動跑一次。3. 七十二小時里最沖擊人的三連跪問題復(fù)現(xiàn)與排查鏈路3.1 第一跪節(jié)點一多Pan 就掉到 30fps問題根本不在 draw現(xiàn)象很干脆20k 元素中速拖拽FPS 穩(wěn)定在 30 上下視覺上已經(jīng)明顯掉幀。當(dāng)時的直覺是“繪制太慢”——畢竟元素多Canvas 2D 指令多重繪開銷大。所以我第一反應(yīng)是去看繪制函數(shù)的耗時火焰圖拉出來卻傻眼了整個 draw 階段只占每幀總耗時的 20%真正的大頭是一個叫updateViewport的函數(shù)每幀都要跑 25ms 以上。這個函數(shù)做了什么它遍歷了畫布里的所有元素把每個元素的世界坐標(biāo)轉(zhuǎn)換到屏幕坐標(biāo)寫回元素對象再做一次臟矩形檢測判斷哪些區(qū)域需要重繪。問題是遍歷的是“全部元素”而不是“可見元素”。20k 個元素哪怕只有 300 個在可視區(qū)內(nèi)循環(huán)依然要跑滿 20k 次。更蠢的是每次拖拽的每一幀都要跑一遍等于我拖 10 秒這個循環(huán)就跑了 600 次每次都是 20k 級別的遍歷。這是非常典型的錯誤認(rèn)知以為 Canvas 2D 的繪制很貴需要用軟件層面的坐標(biāo)變換去優(yōu)化結(jié)果把優(yōu)化寫成了新的瓶頸。Canvas 2D 的ctx.setTransform本身可以在 GPU 側(cè)完成視口變換完全不需要你在 JS 里手工改元素坐標(biāo)。根因定位之后其實修復(fù)很簡單元素坐標(biāo)永遠(yuǎn)保持世界坐標(biāo)不動視口變換全部交給 ctx 的變換矩陣updateViewport 只要根據(jù)視口范圍算出需要遍歷哪幾個格子然后調(diào) ctx.setTransform 就行。這個改動做完20k 元素平移直接回到 58fps 左右火焰圖里那個大頭消失了。3.2 第二跪深度縮放后圖形開始“發(fā)抖”定位到 double 精度問題這個問題的排查過程比第一個曲折得多。表現(xiàn)是當(dāng)畫布縮放到 300000% 左右再平移時圖形邊緣出現(xiàn)肉眼可見的抖動像坐標(biāo)被“取整”了一樣一格一格地跳。縮放再深一點圖形干脆“亂飛”。我一開始懷疑是抗鋸齒或者像素對齊的問題畢竟 Canvas 2D 在非整數(shù)坐標(biāo)下繪制會觸發(fā)次像素渲染。我把繪制坐標(biāo)全部改成Math.round取整確實有所緩解但只是把抖動從“一格一格”變成了“偶爾跳一下”治標(biāo)不治本。接下來我懷疑是 Path2D 構(gòu)造的問題——會不會是路徑字符串在構(gòu)造時丟精度我把所有路徑緩存換成離屏 canvas抖動依舊。排除了繪制層剩下的只有坐標(biāo)變換層。我寫了一個小實驗固定兩個相鄰元素它們的屏幕坐標(biāo)差應(yīng)該是 0.5px然后持續(xù)縮放到 1e6 級別每幀打印這兩個元素的實際屏幕坐標(biāo)。結(jié)果發(fā)現(xiàn)真正的問題是浮點數(shù)在兩次運算順序下的舍入差異。我當(dāng)時的代碼做的是screenX element.worldX * viewport.scale - camera.worldX * viewport.scale;先乘后減。當(dāng) viewport.scale 很大時element.worldX 乘出來是一個天文數(shù)字camera.worldX 乘出來也是一個天文數(shù)字兩個天文數(shù)字相減要得到一個小數(shù)字此時 double 精度根本不夠用誤差被放大到肉眼可見。正確做法是先減后乘讓大數(shù)在減完之后變小再乘screenX (element.worldX - camera.worldX) * viewport.scale;但光這樣還不夠。當(dāng)縮放足夠深時element.worldX 和 camera.worldX 本身已經(jīng)是 1e12 級別的數(shù)字兩個這么大的數(shù)相減結(jié)果的小數(shù)精度照樣損失。最終的解法是把世界坐標(biāo)拆成“瓦片索引 瓦片內(nèi)偏移”——大數(shù)部分用整數(shù)索引表達小數(shù)部分用浮點偏移表達先減偏移再乘 scale。這套思路地圖引擎里很常見我后面第四節(jié)會貼具體方案。3.3 第三跪明明什么都沒動內(nèi)存 4 小時漲了 400MB這個是我跑長時間穩(wěn)定性測試時才發(fā)現(xiàn)的。第一輪長跑前 30 分鐘一切正常JS 堆內(nèi)存有漲有落屬于正常的 GC 波動。但 1 小時之后我意識到曲線不對勁它漲一點回一點但每次回落都比上次的谷底高一點總體在爬坡。4 小時跑完漲了差不多 400MB。這種問題最討厭因為它不會讓你的頁面一夜之間崩掉但在用戶的真實使用場景里掛一整天之后畫布卡成幻燈片是一定的。排查思路是連續(xù)抓三份 heap snapshot。我先跑到 2 小時節(jié)點抓了一份再跑 30 分鐘抓一份又過 30 分鐘抓一份然后對比三份快照里的對象增長。diff 結(jié)果顯示了兩個異常第一個是 Path2D 緩存對象數(shù)量暴漲。我做了個 Path2D 緩存key 是路徑字符串想著能復(fù)用就復(fù)用。但 key 設(shè)計太粗糙文字元素的緩存 key 只有 text 和 fontSize不同顏色的、不用旋轉(zhuǎn)角度的文字全部命中同一個 key導(dǎo)致緩存不斷把舊值頂?shù)粼俅嫘轮得新实偷每蓱z純純的負(fù)優(yōu)化。第二個是離屏 canvas 對象出現(xiàn)大量 Detached說明有 canvas 從緩存里移除時沒有正確釋放 GPU 資源。這個其實只改一行代碼就能解決把 canvas 的 width 和 height 置為 0強制讓它釋放底層紋理。根因清楚了修復(fù)也很直接。Path2D 緩存換成 LRU限制最大條目數(shù)key 里加上填充色、描邊色、透明度、旋轉(zhuǎn)角度這些影響路徑實例的屬性。離屏 canvas 在移除時統(tǒng)一執(zhí)行canvas.width 0; canvas.height 0;。改完后再跑一輪 4 小時長測內(nèi)存曲線基本平了。3.4 一個被順帶打出來的 bug雙擊后手勢識別失靈這不是壓測主線上的問題但操作序列里包含雙擊縮放幾次循環(huán)之后我注意到一個詭異的現(xiàn)象雙擊縮放后下一次單擊會被識別成拖拽工具從 select 切到了 pan體驗直接崩壞。查下來根因讓人哭笑不得事件處理器里的 lastX/lastY 沒有跟隨縮放更新。第一次雙擊縮放后相機位置變了但 lastX/lastY 還是老位置。下一次按下鼠標(biāo)時計算位移距離用的是新的屏幕坐標(biāo)減去舊的 lastX/lastY距離直接超出手勢庫的閾值被判定為拖拽。這類問題很像“低概率偶發(fā) bug”——實際不是偶發(fā)而是我的事件層和渲染層各自維護了一套坐標(biāo)基準(zhǔn)沒有統(tǒng)一。修復(fù)方式是把事件處理器改成每次事件都用同一套 worldToScreen 函數(shù)重新計算當(dāng)前坐標(biāo)不再用累加的 lastX/lastY。4. 針對根因我最終落地的優(yōu)化方案與實測數(shù)據(jù)4.1 渲染分層把“每幀重算”改成“分層緩存 可見區(qū)瓦片”這一輪壓測讓我徹底放棄了“每次視口變化就重繪整個內(nèi)容層”的思路改成三層 canvas 疊加背景網(wǎng)格層離屏 canvas 緩存只有縮放層級變化時才重畫內(nèi)容層主畫布承載所有業(yè)務(wù)元素交互指示層框選、懸停高亮等高頻變化但內(nèi)容很少的東西單獨一層對于內(nèi)容層我并沒有做全量離屏緩存——因為元素太多時全量離屏一次重繪的成本也不低。我選擇了瓦片緩存把可視區(qū)按 512x512 切塊只緩存可視區(qū)周邊一圈的瓦片超出范圍的瓦片走 LRU 淘汰。某個瓦片內(nèi)的元素發(fā)生變化時只重繪那個瓦片不影響其他區(qū)域。這套方案相當(dāng)于把無限畫布拆成了“有限個小畫布”既避免了每幀全量重繪又不會因為緩存無上限而吃光內(nèi)存。4.2 變換重構(gòu)世界坐標(biāo)拆成“瓦片索引 瓦片內(nèi)偏移”針對 double 精度問題我借鑒了地圖引擎的坐標(biāo)方案。每個元素的世界坐標(biāo)不再直接是一個浮點數(shù)而是拆成worldX tileIndexX * TILE_WORLD_SIZE tileOffsetX; worldY tileIndexY * TILE_WORLD_SIZE tileOffsetY;tileIndexX 是整數(shù)tileOffsetX 是浮點偏移。渲染時視口變換走的是const screenX Math.round((offsetXFromCamera) * viewport.scale); const screenY Math.round((offsetYFromCamera) * viewport.scale); ctx.setTransform(viewport.scale, 0, 0, viewport.scale, screenX, screenY);這里的關(guān)鍵是把帶大數(shù)的部分從浮點運算里剝離出去只對小數(shù)偏移做浮點乘加最大程度保留精度。屏幕端保留 Math.round避免次像素抖動。4.3 事件側(cè)合并rAF 節(jié)流 坐標(biāo)基準(zhǔn)統(tǒng)一pointermove 事件的觸發(fā)頻率比 60fps 高得多如果每個事件都去更新視口、命中檢測、重繪必然造成大量重復(fù)工作。我在事件層加了個節(jié)流器事件回調(diào)只負(fù)責(zé)把最新的坐標(biāo)存下來真正的處理邏輯統(tǒng)一放到 requestAnimationFrame 里執(zhí)行。另外事件處理器和渲染層共用同一套坐標(biāo)系換算函數(shù)。這個看似不起眼的改動直接消除了上一節(jié)提到的 lastX/lastY 漂移問題——所有坐標(biāo)每次都是從 worldToScreen 重新計算不存在累積誤差。let pendingMove null; canvas.addEventListener(pointermove, (e) { pendingMove { x: e.clientX, y: e.clientY }; }); function onFrame() { if (pendingMove) { const { x, y } pendingMove; // 統(tǒng)一走 worldToScreen 的結(jié)果做手勢判定和渲染 pendingMove null; } requestAnimationFrame(onFrame); } requestAnimationFrame(onFrame);4.4 優(yōu)化后復(fù)測的數(shù)據(jù)對比改完上面幾處之后我用同一個用例集重新跑了一輪。挑三組有代表性的數(shù)據(jù)測試場景優(yōu)化前優(yōu)化后20k 元素中速平移 FPS31fps58fps連續(xù)深度縮放后平移 FPS22fps有亂跳55fps坐標(biāo)穩(wěn)定4 小時長跑 JS 堆內(nèi)存增量410MB45MB隨 GC 波動第三組數(shù)據(jù)特別說明45MB 的增量在 GC 正常波動范圍內(nèi)曲線不再持續(xù)爬坡。GPU 進程內(nèi)存也不再增長紋理泄漏問題解除。4.5 這些方案為什么算“夠用”而不是“最優(yōu)”必須說清楚這套方案是針對我自己的壓測數(shù)據(jù)設(shè)計的。空間索引我用的均勻網(wǎng)格因為測試數(shù)據(jù)分布比較均勻均勻網(wǎng)格的 O(1) 定位在可視區(qū)查詢時很劃算。但如果真實場景里元素高度聚簇——比如用戶瘋狂往一個區(qū)域里塞內(nèi)容均勻網(wǎng)格會退化到 O(n)這時候四叉樹或 R 樹是更合適的選擇。瓦片緩存也一樣我有意設(shè)置了緩存上限避免極端縮放時緩存爆炸。更極端的場景需要引入 LOD ——縮放層級很深時不再渲染細(xì)節(jié)元素只顯示聚合后的摘要圖形——這是第二階段的優(yōu)化任務(wù)了。5. 跑完這輪壓測我對無限畫布性能優(yōu)化的一些重新認(rèn)識5.1 靜態(tài)渲染流暢不等于交互流暢以前我覺得“打開畫布能立刻看到全部內(nèi)容”就算流暢現(xiàn)在我知道這只是及格線。真正考驗一個無限畫布的是交互過程中每一幀的穩(wěn)定性拖拽時能不能保持 60fps縮放時坐標(biāo)會不會漂移連續(xù)操作十幾分鐘之后內(nèi)存穩(wěn)不穩(wěn)。靜態(tài)渲染再快交互時掉幀用戶照樣覺得卡。5.2 壓測的投入產(chǎn)出比高得出人意料搭這套東西前三天我一度覺得“有這個時間我功能都多寫倆了”。跑完才發(fā)現(xiàn)一個性能問題在用戶手里被發(fā)現(xiàn)通常是不可復(fù)現(xiàn)、不好定位、消息滯后的但一個性能問題在自動化壓測里被發(fā)現(xiàn)它能穩(wěn)定復(fù)現(xiàn)、能采集上下文、能在修復(fù)后回歸驗證。這套測試環(huán)境從搭好到現(xiàn)在已經(jīng)幫我抓出好幾個我手測根本發(fā)現(xiàn)不了的問題回本了。5.3 一些可以直接帶走的具體建議總結(jié)幾條我在這次壓測里真正用上、也覺得最值得分享的實踐壓測前先定義三個必須達標(biāo)的場景比如“20k 元素平移不掉幀”“深度縮放不漂移”“長跑 4 小時內(nèi)存平穩(wěn)”后續(xù)所有優(yōu)化都以這個為準(zhǔn)繩。內(nèi)存測試一定要單獨跑并且至少跑 2 小時以上。短時間手動操作無法暴露緩慢的內(nèi)存泄漏。坐標(biāo)計算不要寫成“先乘后減”的形式大數(shù)乘完再減等于把精度問題放大到不可收拾永遠(yuǎn)先減后乘。任何緩存都要想清楚 key 和淘汰策略。沒有 LRU、沒有容量上限、key 設(shè)計粗糙的緩存在低數(shù)據(jù)量下是“優(yōu)化”在高數(shù)據(jù)量下就是泄漏源。長跑比猛跑更容易發(fā)現(xiàn)問題。單次高負(fù)載能測出計算瓶頸但只有長時間運行才能測出緩存和數(shù)據(jù)結(jié)構(gòu)的累積問題。5.4 最后再分享一個小技巧這套測試用例我后來做成了 JSON 文件里面就是一組操作指令和期望閾值每次改完代碼跑一遍用腳本自動判定通過與否。不需要真的寫復(fù)雜的測試框架一個幾十行的 Node 腳本足夠。這個習(xí)慣一旦養(yǎng)成后續(xù)每次重構(gòu)都有同一把尺子量著心里踏實很多。72 小時換來的不是一次“通過的測試”而是一份我對這個項目性能底線的清晰認(rèn)知。無限畫布這類應(yīng)用看起來難的是“無限”實際上難的是在無限的空間里始終保持有限的計算成本。每一次優(yōu)化本質(zhì)上都是在跟這個原則對齊。