• 记vue+leaflet的一次canvas渲染爆栈


    背景:

    在地图上绘制大量的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>
    View Code

    绘制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的属性
  • 相关阅读:
    Markdown学习笔记
    Go 学习笔记(一)
    case中定义变量
    <转>MySql 与Oracle区别
    Java 时间转换问题总结
    线程之间共享
    并发编程快速入门
    redis主从复制
    jedis操作redis
    redis持久化方案
  • 原文地址:https://www.cnblogs.com/cqq626/p/9434040.html
Copyright © 2020-2023  润新知