html+canvas地图画布实现快速拖动出现瓦片空白问题优化
快速拖动出现问题,慢慢拖动不会有问题”的现象,问题的根本原因可能是鼠标移动事件触发得太频繁,导致每次鼠标移动时都重新渲染瓦片,造成性能问题或者视觉上的不流畅。特别是在快速拖动时,浏览器需要处理大量的瓦片加载和渲染,这就容易出现卡顿。
先上问题代码,快速拖动出现问题,慢慢拖动不会有问题
快速拖动出现问题,慢慢拖动不会有问题 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>瓦片拖动功能</title> <style> /* 地图容器*/ .map { position: fixed; width: 100%; height: 100%; left: 0; top: 0; } /*十字架*/ .line { position: absolute; background-color: #000; } /*十字架横轴*/ .lineX { left: 50%; top: 50%; transform: translate(-50%, -50%); width: 50px;/*改成50px*/ height: 2px; } /*十字架纵轴*/ .lineY { left: 50%; top: 50%; transform: translate(-50%, -50%); height: 50px;/*改成50px*/ width: 2px; } .clearButton{ position: absolute;left: 10px;top: 10px;cursor: pointer; } .refreshButton{ position: absolute;left: 90px;top: 10px;cursor: pointer; } </style> </head> <body> <div class="map" id="map"> <!-- 画布--> <canvas id="canvas" onmousedown="onMousedown(event)"></canvas> <!-- 十字架横轴--> <div class="line lineX"></div> <!-- 十字架纵轴--> <div class="line lineY"></div> <button class="clearButton" onclick="renderTiles()">重绘画布</button> <button class="refreshButton" onclick="onClear()">清除画布</button> </div> </body> <script> // 角度转弧度 function angleToRad(angle){ return angle * (Math.PI / 180) } // 弧度转角度 function radToAngle(rad){ return rad * (180 / Math.PI) } // 地球半径 单位:米 let EARTH_RAD = 6378137 // 4326转3857 function lngLatToMercator(lng, lat){ // 经度先转弧度,然后因为 弧度 = 弧长 / 半径 ,得到弧长为 弧长 = 弧度 * 半径 let x = angleToRad(lng) * EARTH_RAD // 纬度先转弧度 let rad = angleToRad(lat) // 下面我就看不懂了 let sin = Math.sin(rad) let y = (EARTH_RAD / 2) * Math.log((1 + sin) / (1 - sin)) return [x, y] } // 3857转4326 function mercatorToLngLat(x, y){ let lng = radToAngle(x) / EARTH_RAD let lat = radToAngle(2 * Math.atan(Math.exp(y / EARTH_RAD)) - Math.PI / 2) return [lng, lat] } // 地球周长 let EARTH_PERIMETER = 2 * Math.PI * EARTH_RAD // 瓦片像素 let TILE_SIZE = 256 // 获取某一层级下的分辨率 n 从0层开始 function getResolution(n){ let tileNums = Math.pow(2, n) //一行的瓦片数量 console.log("n,tileNums",n,tileNums) let tileTotalPx = tileNums * TILE_SIZE //一行的瓦片像素点 return EARTH_PERIMETER / tileTotalPx // 每像素点代表多少米 } // 拼接瓦片地址 function getTileUrl(x, y, z){ let domainIndexList = [1, 2, 3, 4] let domainIndex = domainIndexList[Math.floor(Math.random() * domainIndexList.length)] return `https://webrd0${domainIndex}.is.autonavi.com/appmaptile?x=${x}&y=${y}&z=${z}&lang=zh_cn&size=1&scale=1&style=8` } // 根据3857坐标及缩放层级计算瓦片行列号 function getTileRowAndCol1 (x, y, z){ let resolution = getResolution(z) let row = Math.floor(x / resolution / TILE_SIZE) let col = Math.floor(y / resolution / TILE_SIZE) return [row, col] } function getTileRowAndCol2 (x, y, z){ x += EARTH_PERIMETER / 2 // ++ y = EARTH_PERIMETER / 2 - y // ++ let resolution = getResolution(z) let row = Math.floor(x / resolution / TILE_SIZE) let col = Math.floor(y / resolution / TILE_SIZE) return [row, col] } // 分辨率列表 let resolutions = [] for (let i = 0; i <= 18; i++) { resolutions.push(getResolution(i)) } // 计算4326经纬度对应的像素坐标 function getPxFromLngLat (lng, lat, z) { let [_x, _y] = lngLatToMercator(lng, lat)// 4326转3857 // 转成世界平面图的坐标 _x += EARTH_PERIMETER / 2 _y = EARTH_PERIMETER / 2 - _y let resolution = resolutions[z]// 该层级的分辨率 // 米/分辨率得到像素 let x = Math.floor(_x / resolution) let y = Math.floor(_y / resolution) return [x, y] } </script> <script> let tileCache={}//缓存瓦片 let currentTileCache={} //记录当前地图上的瓦片 let center=[120.148732,30.231006] //已知变量,中心坐标 雷峰塔坐标 let zoom=17 //已知变量, 缩放层级 // 容器大小 let map=document.getElementById('map') let canvas=document.getElementById('canvas') let { width, height } = map.getBoundingClientRect() let isMousedown= false//是否按下鼠标 // 设置画布大小 canvas.width = width canvas.height = height // 获取绘图上下文 let ctx = canvas.getContext('2d') // 移动画布原点到画布中间,本来画布原点是左上角的 ctx.translate(width / 2, height / 2) renderTiles() // 绘制 function renderTiles(){ console.log("center=============",center) // 中心点对应的瓦片 let centerTile = getTileRowAndCol2( ...lngLatToMercator(...center),// 4326转3857 zoom // 缩放层级 ) // 中心瓦片左上角对应的像素坐标 let centerTilePos = [ centerTile[0] * TILE_SIZE, centerTile[1] * TILE_SIZE, ]; // 中心点对应的像素坐标 let centerPos = getPxFromLngLat(...center, zoom) // 中心像素坐标距中心瓦片左上角的差值 let offset = [ centerPos[0] - centerTilePos[0], centerPos[1] - centerTilePos[1] ] // 计算瓦片数量 Math.ceil()向上取整 let rowMinNum = Math.ceil((width / 2 - offset[0]) / TILE_SIZE)// 左 let colMinNum = Math.ceil((height / 2 - offset[1]) / TILE_SIZE)// 上 let rowMaxNum = Math.ceil((width / 2 - (TILE_SIZE - offset[0])) / TILE_SIZE)// 右 let colMaxNum = Math.ceil((height / 2 - (TILE_SIZE - offset[1])) / TILE_SIZE)// 下 // 渲染画布内所有瓦片 currentTileCache = {}; // 清空缓存对象 // 从上到下,从左到右,加载瓦片 for (let i = -rowMinNum; i <= rowMaxNum; i++) { for (let j = -colMinNum; j <= colMaxNum; j++) { // 当前瓦片的行列号 let row = centerTile[0] + i let col = centerTile[1] + j // 当前瓦片的显示位置 let x = i * TILE_SIZE - offset[0] let y = j * TILE_SIZE - offset[1] // 缓存key let cacheKey = row + '_' + col + '_' + zoom // 记录画布当前需要的瓦片 currentTileCache[cacheKey] = true if (tileCache[cacheKey]) { // 更新到当前位置 if(currentTileCache[cacheKey]){ ctx.drawImage( tileCache[cacheKey], x, y ) } }else { // 加载瓦片图片 let img = new Image() img.src = getTileUrl( row,// 行号 col,// 列号 zoom ) tileCache[cacheKey]=img img.onload = () => { // 只有在当前画布上的瓦片才需要渲染 if( currentTileCache[cacheKey]){ // 渲染到canvas ctx.drawImage( img, x, y ) } } } } } } function render(ctx,img,x,y,cacheKey){ if (!currentTileCache[cacheKey]) { return } ctx.drawImage(img, x, y) } // 鼠标按下 function onMousedown(e){ if (e.which === 1) { isMousedown = true; } } window.addEventListener("mousemove", onMousemove); window.addEventListener("mouseup", onMouseup); // 鼠标移动 function onMousemove(e){ if (!isMousedown) { return; } // 计算本次拖动的距离对应的经纬度数据 let mx = e.movementX * resolutions[zoom];//表示这一次鼠标事件距离上一次鼠标事件横向上的偏移量 ,基本就是 1 像素 let my = e.movementY * resolutions[zoom]; // 把当前中心点经纬度转成3857坐标 let [x, y] = lngLatToMercator(...center); // 更新拖动后的中心点经纬度 center = mercatorToLngLat(x - mx, my + y); onClear() renderTiles() } // 鼠标松开 function onMouseup(){ isMousedown = false; } // 清除画布 function onClear(){ ctx.clearRect( -width / 2, //参数1 和2 是起始位置 -height / 2, width, height ); } </script> </html>
建议可以通过以下几个方法来优化:
1. 防抖(Debounce)鼠标移动事件
防抖的目的是限制事件的触发频率,避免每次快速拖动都调用渲染函数,导致过多的绘制操作。可以使用 setTimeout
来做一个简单的防抖。
例如,你可以这样修改 onMousemove
函数:
let debounceTimer;
function onMousemove(e) {
if (!isMousedown) {
return;
}
// 清除上一次的定时器
clearTimeout(debounceTimer);
// 设置一个新的定时器,延迟50ms执行渲染操作
debounceTimer = setTimeout(() => {
let mx = e.movementX * resolutions[zoom];
let my = e.movementY * resolutions[zoom];
// 转换当前经纬度
let [x, y] = lngLatToMercator(...center);
center = mercatorToLngLat(x - mx, my + y);
onClear();
renderTiles();
}, 50); // 可以调整50ms的延迟
}
2. 避免重复渲染
你可以检查一下是否已经绘制了某个瓦片,而不需要每次都重新绘制。例如,如果某个瓦片已经加载并显示在画布上,且位置没有变化,就不需要重新绘制它。
你可以在 renderTiles
函数中优化一下瓦片的绘制逻辑,减少不必要的绘制。
3. 限制瓦片加载并发
当快速拖动时,浏览器可能会同时请求大量的瓦片,导致性能瓶颈。你可以通过限制并发请求的数量来减少浏览器的负担。例如,可以在加载瓦片时加入一个阈值,防止一次性加载过多瓦片。
比如,你可以在 renderTiles
中加入判断,只有当瓦片超出缓存时才进行加载,避免重复加载已显示的瓦片。
4. 使用 requestAnimationFrame
requestAnimationFrame
是一种优化动画渲染的方式,它确保浏览器在下一帧绘制时才进行渲染,从而减少了不必要的重绘,提升了性能。你可以使用 requestAnimationFrame
来包裹渲染函数,这样就能确保只会在浏览器刷新时执行一次渲染操作。
比如:
let isRendering = false;
function onMousemove(e) {
if (!isMousedown) {
return;
}
if (!isRendering) {
isRendering = true;
requestAnimationFrame(() => {
let mx = e.movementX * resolutions[zoom];
let my = e.movementY * resolutions[zoom];
let [x, y] = lngLatToMercator(...center);
center = mercatorToLngLat(x - mx, my + y);
onClear();
renderTiles();
isRendering = false;
});
}
}
这样,每次 mousemove
事件都会排队等待浏览器下一帧来执行,这样就不会造成频繁的渲染。
通过使用防抖技术(Debounce)、优化瓦片加载与渲染、限制并发瓦片请求以及使用 requestAnimationFrame
来优化渲染流程,可以显著提高快速拖动时的性能,避免出现拖动时的卡顿或不流畅问题。
研究发现,通过防抖并不能实际解决瓦片空白问题,又采用了如下方式:
先上可以快速拖动的代码:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>瓦片类拖动</title> <script src="MapUtil.js"></script> <style> /* 地图容器*/ .map { position: fixed; width: 100%; height: 100%; left: 0; top: 0; } /*十字架*/ .line { position: absolute; background-color: #000; } /*十字架横轴*/ .lineX { left: 50%; top: 50%; transform: translate(-50%, -50%); width: 50px;/*改成50px*/ height: 2px; } /*十字架纵轴*/ .lineY { left: 50%; top: 50%; transform: translate(-50%, -50%); height: 50px;/*改成50px*/ width: 2px; } .clearButton{ position: absolute;left: 10px;top: 10px;cursor: pointer; } .refreshButton{ position: absolute;left: 90px;top: 10px;cursor: pointer; } </style> </head> <body> <div class="map" id="map"> <!-- 画布--> <canvas id="canvas" onmousedown="onMousedown(event)"></canvas> <!-- 十字架横轴--> <div class="line lineX"></div> <!-- 十字架纵轴--> <div class="line lineY"></div> <button class="clearButton" onclick="renderTiles()">重绘画布</button> <button class="refreshButton" onclick="onClear()">清除画布</button> </div> </body> <script> // 角度转弧度 function angleToRad(angle){ return angle * (Math.PI / 180) } // 弧度转角度 function radToAngle(rad){ return rad * (180 / Math.PI) } // 地球半径 单位:米 let EARTH_RAD = 6378137 // 地球周长 let EARTH_PERIMETER = 2 * Math.PI * EARTH_RAD // 瓦片像素 let TILE_SIZE = 256 // 根据3857坐标及缩放层级计算瓦片行列号 function getTileRowAndCol1 (x, y, z){ let resolution = getResolution(z) let row = Math.floor(x / resolution / TILE_SIZE) let col = Math.floor(y / resolution / TILE_SIZE) return [row, col] } function getTileRowAndCol2 (x, y, z){ x += EARTH_PERIMETER / 2 // ++ y = EARTH_PERIMETER / 2 - y // ++ let resolution = getResolution(z) let row = Math.floor(x / resolution / TILE_SIZE) let col = Math.floor(y / resolution / TILE_SIZE) return [row, col] } // 4326转3857 function lngLatToMercator(lng, lat){ // 经度先转弧度,然后因为 弧度 = 弧长 / 半径 ,得到弧长为 弧长 = 弧度 * 半径 let x = angleToRad(lng) * EARTH_RAD // 纬度先转弧度 let rad = angleToRad(lat) // 下面我就看不懂了 let sin = Math.sin(rad) let y = (EARTH_RAD / 2) * Math.log((1 + sin) / (1 - sin)) return [x, y] } // 获取某一层级下的分辨率 n 从0层开始 function getResolution(n){ let tileNums = Math.pow(2, n) //一行的瓦片数量 console.log("n,tileNums",n,tileNums) let tileTotalPx = tileNums * TILE_SIZE //一行的瓦片像素点 return EARTH_PERIMETER / tileTotalPx // 每像素点代表多少米 } // 分辨率列表 let resolutions = [] for (let i = 0; i <= 18; i++) { resolutions.push(getResolution(i)) } // 计算4326经纬度对应的像素坐标 function getPxFromLngLat (lng, lat, z) { let [_x, _y] = lngLatToMercator(lng, lat)// 4326转3857 // 转成世界平面图的坐标 _x += EARTH_PERIMETER / 2 _y = EARTH_PERIMETER / 2 - _y let resolution = resolutions[z]// 该层级的分辨率 // 米/分辨率得到像素 let x = Math.floor(_x / resolution) let y = Math.floor(_y / resolution) return [x, y] } // 拼接瓦片地址 function getTileUrl(x, y, z){ let domainIndexList = [1, 2, 3, 4] let domainIndex = domainIndexList[Math.floor(Math.random() * domainIndexList.length)] return `https://webrd0${domainIndex}.is.autonavi.com/appmaptile?x=${x}&y=${y}&z=${z}&lang=zh_cn&size=1&scale=1&style=8` } // 3857转4326 function mercatorToLngLat(x, y){ let lng = radToAngle(x) / EARTH_RAD let lat = radToAngle(2 * Math.atan(Math.exp(y / EARTH_RAD)) - Math.PI / 2) return [lng, lat] } </script> <script> // 单张瓦片类 class Tile { constructor({ctx, row, col, zoom, x, y, shouldRender}) { this.ctx = ctx // 画布上下文 this.row = row // 瓦片行列号 this.col = col this.zoom = zoom // 瓦片层级 this.x = x // 显示位置 this.y = y this.shouldRender = shouldRender // 一个函数,判断某块瓦片是否应该渲染 this.url = getTileUrl(this.row, this.col, this.zoom) // 瓦片url this.cacheKey = this.row + '_' + this.col + '_' + this.zoom // 缓存key this.img = null // 图片 this.load() } // 加载图片 load() { this.img = new Image() this.img.src = this.url this.img.onload = () => { this.render() } } // 将图片渲染到canvas上 render() { if (!this.shouldRender(this.cacheKey)) { return } this.ctx.drawImage(this.img, this.x, this.y) } // 更新位置 updatePos(x, y) { this.x = x this.y = y return this } } // 初始化画布 let map=document.getElementById('map') let canvas=document.getElementById('canvas') let { width, height } = map.getBoundingClientRect() // 设置画布大小 canvas.width = width canvas.height = height // 获取绘图上下文 let ctx = canvas.getContext('2d') // 移动画布原点到画布中间,本来画布原点是左上角的 ctx.translate(width / 2, height / 2) let tileCache={}//缓存瓦片 let currentTileCache={} //记录当前地图上的瓦片 let center=[120.148732,30.231006] //已知变量,中心坐标 雷峰塔坐标 let zoom=17 //已知变量, 缩放层级 // 容器大小 let isMousedown= false//是否按下鼠标 renderTiles() // 绘制 function renderTiles(){ console.log("center=============",center) // 中心点对应的瓦片 let centerTile = getTileRowAndCol2( ...lngLatToMercator(...center),// 4326转3857 zoom // 缩放层级 ) // 中心瓦片左上角对应的像素坐标 let centerTilePos = [ centerTile[0] * TILE_SIZE, centerTile[1] * TILE_SIZE, ]; // 中心点对应的像素坐标 let centerPos = getPxFromLngLat(...center, zoom) // 中心像素坐标距中心瓦片左上角的差值 let offset = [ centerPos[0] - centerTilePos[0], centerPos[1] - centerTilePos[1] ] // 计算瓦片数量 Math.ceil()向上取整 let rowMinNum = Math.ceil((width / 2 - offset[0]) / TILE_SIZE)// 左 let colMinNum = Math.ceil((height / 2 - offset[1]) / TILE_SIZE)// 上 let rowMaxNum = Math.ceil((width / 2 - (TILE_SIZE - offset[0])) / TILE_SIZE)// 右 let colMaxNum = Math.ceil((height / 2 - (TILE_SIZE - offset[1])) / TILE_SIZE)// 下 // 渲染画布内所有瓦片 currentTileCache = {}; // 清空缓存对象 // 从上到下,从左到右,加载瓦片 for (let i = -rowMinNum; i <= rowMaxNum; i++) { for (let j = -colMinNum; j <= colMaxNum; j++) { // 当前瓦片的行列号 let row = centerTile[0] + i let col = centerTile[1] + j // 当前瓦片的显示位置 let x = i * TILE_SIZE - offset[0] let y = j * TILE_SIZE - offset[1] // 缓存key let cacheKey = row + '_' + col + '_' + zoom // 记录画布当前需要的瓦片 currentTileCache[cacheKey] = true if (tileCache[cacheKey]) { // 更新到当前位置 tileCache[cacheKey].updatePos(x, y).render(); }else { // 加载瓦片图片 tileCache[cacheKey] = new Tile({ ctx: ctx, row, col, zoom: zoom, x, y, // 判断瓦片是否在当前画布缓存对象上,是的话则代表需要渲染 shouldRender: (key) => { return currentTileCache[key]; }, }); } } } } // 鼠标按下 function onMousedown(e){ if (e.which === 1) { isMousedown = true; } } window.addEventListener("mousemove", onMousemove); window.addEventListener("mouseup", onMouseup); // 鼠标移动 function onMousemove(e){ if (!isMousedown) { return; } // 计算本次拖动的距离对应的经纬度数据 let mx = e.movementX * resolutions[zoom];//表示这一次鼠标事件距离上一次鼠标事件横向上的偏移量 ,基本就是 1 像素 let my = e.movementY * resolutions[zoom]; // 把当前中心点经纬度转成3857坐标 let [x, y] = lngLatToMercator(...center); // 更新拖动后的中心点经纬度 center = mercatorToLngLat(x - mx, my + y); onClear() renderTiles() } // 鼠标松开 function onMouseup(){ isMousedown = false; } // 清除画布 function onClear(){ ctx.clearRect( -width / 2, //参数1 和2 是起始位置 -height / 2, width, height ); } </script> </html>