背景:
在地圖上繪制大量的circleMarker,leaflet能選擇使用canvas來渲染,比起默認的svg渲染來說在大量繪制的情況下會更加流暢。但當觸發其中某一個circleMarker的tooltip或popup時,瀏覽器報錯“Uncaught RangeError: Maximum call stack size exceeded”:
解決過程:
1. 寫了個測試代碼來復現問題:

1 <!DOCTYPE html> 2 <html> 3 <head> 4 <meta charset='utf-8' /> 5 <title>Add a raster tile source</title> 6 <meta name='viewport' content='initial-scale=1,maximum-scale=1,user-scalable=no' /> 7 <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script> 8 <script src="https://unpkg.com/leaflet@1.3.1/dist/leaflet-src.js"></script> 9 <!--<script src="./vue.js"></script>--> 10 <!--<script src="./leaflet.js"></script>--> 11 <link rel="stylesheet" href="https://unpkg.com/leaflet@1.3.1/dist/leaflet.css" 12 integrity="sha512-Rksm5RenBEKSKFjgI3a41vrjkw4EVPlJ3+OiI65vTjIdo9brlAacEuKOiQ5OFh7cOI1bkDwLqdLw3Zg0cRJAAQ==" 13 crossorigin=""/> 14 <style> 15 * { margin:0; padding:0; } 16 html,body,#vue-wrap,#map { height: 100%; } 17 </style> 18 </head> 19 <body> 20 <div id="vue-wrap"> 21 <div id="map">test</div> 22 </div> 23 <script> 24 new Vue({ 25 el: '#vue-wrap', 26 data: function () { 27 return { 28 map: '', 29 canvas: L.canvas() 30 }; 31 }, 32 mounted: function () { 33 this.init(); 34 }, 35 methods: { 36 init () { 37 this.map = new L.Map('map', { 38 center: [39.928953, 116.389129], 39 zoom: 11, 40 maxZoom: 18, 41 attributionControl: false, 42 zoomControl: true 43 }); 44 45 this.paintMarkers(); 46 }, 47 paintMarkers () { 48 console.log('start paint'); 49 console.time('paint'); 50 for (let i = 0; i < 50000; i++) { 51 let marker = L.circleMarker(this.generateLatlng(), { 52 color: '#000', 53 weight: 1, 54 opacity: 1, 55 fillOpacity: 0.8, 56 radius: 6, 57 fillColor: 'orange', 58 59 renderer: this.canvas 60 }); 61 marker.bindTooltip(i + ''); 62 marker.bindPopup(`i: ${i}`); 63 this.map.addLayer(marker); 64 } 65 console.timeEnd('paint'); 66 }, 67 generateLatlng () { 68 let lat_min = 39.70111, 69 lat_max = 40.14660, 70 lng_min = 116.05843, 71 lng_max = 116.63521; 72 73 let lat = this.getRandomNum(lat_min, lat_max), 74 lng = this.getRandomNum(lng_min, lng_max); 75 76 return [lat, lng]; 77 }, 78 getRandomNum (min, max) { 79 max = Math.max(min, max); 80 min = Math.min(min, max); 81 return Math.random() * (max - min) + min; 82 } 83 } 84 }); 85 </script> 86 87 </body> 88 </html>
繪制50000個circleMarker,當鼠標移動到其中某個marker上時,瀏覽器報錯。
注釋第59行的代碼,或者把map從vue實例的data里提取出來放在全局都不會爆棧,因此現在有兩個問題:
- 為什么放在全局不會爆棧
- 為什么svg渲染不會爆棧
2. 問題1肯定和vue.js的observe函數相關,通過查看vue.js的代碼發現
vue.js初始化實例時會調用: _init->initState->initData->observe(data),在observe函數里會新建個Observe,標注__ob__屬性,如果該值為對象,還會調用walk函數來為對象的所有屬性添加Observe。
3. 感覺爆棧可能和這個walk有關,但是要怎么證明呢?修改了一下vue.js源碼,每次walk的時候都輸出此時的遍歷鏈條,如:{a:{b: c}}遍歷到c屬性時輸出 a->b->c。
關鍵修改點:
1 Observer.prototype.walk = function walk (obj, prefix = '') { // 添加prefix存儲遍歷節點 2 var keys = Object.keys(obj); 3 for (var i = 0; i < keys.length; i++) { 4 defineReactive(obj, keys[i], undefined, undefined, undefined, `${prefix}->${keys[i]}`); 5 console.log(`${prefix}->${keys[i]}`); 6 } 7 };
同時,observe相關的函數都添加prefix來保存遍歷的節點信息
4. 先只繪制5個點來測試一下輸出結果
沒問題,當把鼠標放在Marker上觸發tooltip時,有意思的事情出現了:
注意到_order->prev這個鏈條很長,observe遞歸很深。當修改marker繪制數目為50000后,確實是->prev->不斷的遞歸並爆棧
5、查看leaflet代碼發現canvas繪制時會為畫布上的元素添加_order鏈表屬性來存儲畫布上所有元素的繪制先后順序,方便bringToFront、bringToBack之類的方法實現;當模擬事件觸發時也是通過這個鏈表來尋找對應的元素。因此當繪制元素過多時,鏈表太長,vue的observe不斷的遞歸,造成了爆棧現象
6、那么為什么只有觸發tooltip/popup的時候才爆棧呢?
因為map.addLayer(marker)時,能夠觸發observe的操作在搭建鏈表關系之前(添加子圖層this._layers[id]=layer不能觸發observe)。紅框部分的push觸發了observe
7. 所以把map從vue實例的data中拿出來放在外面,map的屬性沒有被observe就不存在爆棧的問題了。而svg渲染時不存在這樣的鏈表結構,所以也不會爆棧。
8. 為什么svg不需要這種鏈表結構?
因為svg可以利用DOM API來實現bringToFront/bringToBack之類的操作,而且事件能直接綁定在dom元素上,也不需要遍歷所有元素來判斷哪個元素是事件的觸發對象。而canvas需要使用事件委托來捕獲事件,並遍歷所有元素來判斷具體哪個元素是事件的觸發對象。
解決方案:
- 把視圖無關的屬性從data里拿出來,但是這樣不太方便mixin,只能考慮做成getter、setter形式。這樣也能減少不必要的observe
- 利用vue的相關api來unwatch相關屬性,但目前沒找到如何unwatch data的屬性