news 2026/10/24 6:14:41

Mapbox GL JS 多点拖拽实战:创建、事件绑定与坐标同步全攻略

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Mapbox GL JS 多点拖拽实战:创建、事件绑定与坐标同步全攻略

在地图类应用里,让用户在地图上拖拽标记点,基本上属于“看着简单、做起来全是细节”的活儿。尤其是“Mapbox 创建多个可拖动的标记点”这种需求,单点拖拽还好说,一旦变成多个点,坐标同步、数组管理、事件绑定、样式反馈、增删联动这些事就会一起找上门。Mapbox GL JS 的 Marker 本身支持 draggable,但“支持”和“好用”之间还有很大一段距离。这篇文章我把完整思路和可运行的代码拆开讲,从地图初始化、批量创建可拖拽 Marker,到拖拽结束后的坐标回写、点位新增删除、侧栏联动,再到我自己实测中踩过的坑和性能取舍,一次性说清楚。适合正在用 Mapbox GL JS 写地图交互,或者正准备给业务系统加一个“地图标注编辑”模块的开发者参考。

1. 项目需求拆解:多点拖拽到底在解决什么问题

1.1 最常见的业务场景长什么样

我遇到这类需求,通常来自几个方向:内部管理系统里,管理员需要在地图上微调网点坐标;选址工具里,用户把候选位置标出来,然后手动拽到更精确的地点;配送路径规划页面,起点、途经点、终点都是可拖拽的标记点,每次拖完还要重新计算路线;还有一种是把一批门店展示在地图上,但允许运营人员手动修正位置。

这些场景有一个共同特征:地图上的点不是只读的。系统不能直接信任第三方数据源给的坐标,必须允许用户“亲眼看到位置后手动修正”。这时候,能否顺滑地拖拽多个标记点,直接决定这个功能是能用还是好用。

1.2 需求拆解:从“显示点”升级为“编辑点”

一个完整的多点可拖拽功能,最少包含四件事:

  • 地图加载后,根据业务数据批量生成标记点;
  • 每个标记点都能被鼠标或手指拖动;
  • 拖拽结束后,能拿到每个点的新经纬度,并同步回数据模型;
  • 支持新增和删除标记点,界面上有明确反馈。

如果做成产品级,通常还要加:右侧列表与地图标记点双向联动、拖拽时的视觉反馈、拖拽完成后坐标数字实时更新,甚至多个标记点之间画连线并随拖拽刷新。下面我会按这个完整链条逐步实现。

1.3 为什么选择 Marker 而不是 GeoJSON 图层

可能有人会问,地图上展示一堆点,不是还可以用 addSource + addLayer 方式渲染 GeoJSON 吗?确实可以。如果你只是“展示”几十上百个点,用图层方案性能更好,还能做聚合簇。但要做到“拖拽”,情况就不一样了:GeoJSON 图层里的 circle 或 symbol 只是一个样式类,你需要自己去监听鼠标事件、计算像素坐标、做命中检测,然后更新数据源,工作量和坑都大得多。

Mapbox GL JS 的 Marker 本质是地图上的一个 DOM 元素,天然就能处理鼠标事件,官方 API 也给了 draggable 选项。所以我的选型结论很简单:数量不多但要编辑交互的点,用 Marker;数量很大且只读的点,用图层渲染。两者可以混用。这个项目里我们完全走 Marker 路线。

2. 环境准备与数据模型设计

2.1 引入 Mapbox GL JS 并初始化地图

无论是 npm 安装还是 CDN 引入,起步都很快。日常开发我习惯用 npm 管理版本,避免 CDN 缓存或版本不一致导致行为差异,但写 Demo 用 CDN 更省事。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Mapbox 多个可拖动标记点示例</title> <script src="https://api.mapbox.com/mapbox-gl-js/v3.4.0/mapbox-gl.js"></script> <link href="https://api.mapbox.com/mapbox-gl-js/v3.4.0/mapbox-gl.css" rel="stylesheet" /> <style> body { margin: 0; font-family: "Microsoft YaHei", Arial, sans-serif; } #map { width: 100%; height: 100vh; } </style> </head> <body> <div id="map"></div>

初始化地图时,关键是 center、zoom、style 这三个参数。style 我会选择底图干扰较小的 light 主题,因为标记点本身要频繁交互,底图太花会影响视觉判断。

mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN'; const map = new mapboxgl.Map({ container: 'map', style: 'mapbox://styles/mapbox/light-v11', center: [116.38, 39.90], // 演示坐标,实际请替换成你的业务坐标 zoom: 12 });

2.2 Access Token 的安全使用

很多新手直接把 token 写在代码里然后提交到仓库,这会导致 token 被爬走盗刷。我的建议是:开发环境用环境变量,比如 Vite 的import.meta.env.VITE_MAPBOX_TOKEN;生产环境最好由后端做代理转发,前端拿不到真实 token。个人 Demo 或内网工具可以放宽,但心里要有这根弦。

2.3 数据模型:给每个标记点一个稳定的 id

这是最容易踩坑的一步。创建多个标记点时,如果你只用数组下标去标识“哪一个点”,一旦中间删除了一个点,后面所有点下标都变了,删除逻辑和列表联动马上错乱。所以从最开始,就要给每个标记点一个独立 id。

演示数据我这样设计:

const pointsData = [ { id: 'p1', label: '点位1', lnglat: [116.38, 39.90] }, { id: 'p2', label: '点位2', lnglat: [116.41, 39.88] }, { id: 'p3', label: '点位3', lnglat: [116.44, 39.86] } ];

对应的运行态数组:

const markers = []; // 存放 Marker 实例,用于批量操作

id、label、lnglat 是三个基本字段。label 用来显示文案,id 用来做联动和删除定位,lnglat 是坐标。实际项目中你还可以扩展 address、type、status 等字段,但核心结构不建议变。

3. 核心实现:批量创建可拖动的 Marker

3.1 单个 Marker 的拖拽配置与事件监听

Mapbox GL JS 创建可拖拽 Marker 的 API 非常简洁:

const marker = new mapboxgl.Marker({ draggable: true }) .setLngLat([116.38, 39.90]) .addTo(map);

这行代码之后,Marker 就能被鼠标拖动了。但如果你只写到这里,会发现拖完以后事件没有任何反馈,你不知道坐标变成多少了。所以要注册拖拽事件。Marker 的拖拽事件有三个:dragstart、drag、dragend。

  • dragstart:开始拖拽,适合给 Marker 添加放大、阴影等视觉反馈;
  • drag:拖拽过程中持续触发,适合实时更新坐标数字或 tooltip;
  • dragend:拖拽结束,适合做最终的数据同步和后端保存。
marker.on('dragstart', () => { console.log('开始拖拽'); // 这里可以给 Marker 元素加一个高亮 class }); marker.on('drag', () => { const lngLat = marker.getLngLat(); console.log('拖拽中:', lngLat.lng, lngLat.lat); }); marker.on('dragend', () => { const lngLat = marker.getLngLat(); console.log('拖拽结束:', lngLat.lng, lngLat.lat); });

事件回调中直接调用marker.getLngLat()就能拿到当前坐标,返回对象里有lng和lat字段,注意顺序,Mapbox 内部统一是[lng, lat],不是[lat, lng],这是最容易搞反的地方。

3.2 批量生成多个标记点

创建多个可拖动的标记点,本质就是遍历数据数组,把 3.1 的创建逻辑重复执行,然后把每个 Marker 实例存进 markers 数组。完整代码:

map.on('load', () => { pointsData.forEach((item) => { const marker = new mapboxgl.Marker({ draggable: true, element: createCustomMarker(item.label) }) .setLngLat(item.lnglat) .addTo(map); marker.on('dragend', () => { const lngLat = marker.getLngLat(); const newLngLat = [lngLat.lng, lngLat.lat]; item.lnglat = newLngLat; console.log(`点位 ${item.id} 新坐标:`, newLngLat); // 这里可以触发 UI 刷新 }); markers.push(marker); }); });

这里有一个细节:我建议在地图的load事件之后再创建 Marker。虽然理论上地图初始化后就能 add 元素,但 Mapbox 的样式资源是异步加载的,等load事件结束再操作,能避免底图样式与 Marker 位置错位的问题。

3.3 自定义 Marker 样式:数字圆点与锚点控制

默认 Marker 的蓝色水滴样式虽然能用,但多个点放在一起,用户分不清哪个是哪个。所以我一般会自定义 Marker 元素,上面显示序号或业务名称。

function createCustomMarker(label) { const el = document.createElement('div'); el.className = 'custom-marker'; el.textContent = label; el.style.width = '28px'; el.style.height = '28px'; el.style.lineHeight = '28px'; el.style.textAlign = 'center'; el.style.background = '#2563eb'; el.style.color = '#fff'; el.style.fontSize = '13px'; el.style.fontWeight = 'bold'; el.style.borderRadius = '50%'; el.style.border = '2px solid #ffffff'; el.style.boxShadow = '0 2px 6px rgba(0, 0, 0, 0.3)'; el.style.cursor = 'grab'; return el; }

这个函数生成一个带文字的圆形元素。但有个关键点必须提:自定义元素后,锚点(anchor)默认是 center。圆形的视觉中心就是坐标点位置,所以用默认的 center 没问题。如果你用的是水滴图标或类似底部有个尖角的元素,视觉上要让尖角对准坐标,就要明确设置anchor: 'bottom':

new mapboxgl.Marker({ draggable: true, element: el, anchor: 'bottom' })

锚点选错,最直接的表现就是:拖拽时坐标点和图标视觉中心不重合,拖完以后点位“跑偏”了。这个坑我在前几天还帮同事调过一次。

3.4 拖拽事件中的数据同步与视觉反馈

拖拽结束后的数据回写,核心是把 Marker 的新坐标同步到 pointsData 里对应的数据项。上面代码用item.lnglat = newLngLat就已经完成了同步。

为了更好的体验,我会在dragstart和dragend时做两件事:拖拽中让 Marker 的外观放大或变色,让用户知道当前正在拖哪个;拖拽结束再恢复。实现起来很简单,就是操作marker.getElement()的 class 或 style:

marker.on('dragstart', () => { marker.getElement().style.boxShadow = '0 4px 14px rgba(0, 0, 0, 0.5)'; marker.getElement().style.transform += ' scale(1.15)'; // 小心:transform 会被 Marker 内部接管 }); marker.on('dragend', () => { marker.getElement().style.boxShadow = '0 2px 6px rgba(0, 0, 0, 0.3)'; });

这里要特别提醒:不要直接改 marker 元素自身的 transform 属性。Marker 在页面上的定位就依赖 transform,你改了它,拖拽计算会直接混乱。想放大缩小时,正确的做法是给元素内部再包一层子元素,在子元素上做 transform,或者用filter: drop-shadow、outline这些不影响定位的样式。上面代码里的transform += ' scale(1.15)'只适合极少数场景,实际项目中我不推荐这么写,用el.style.scale = '1.15'这种现代 CSS 属性更安全。

4. 功能扩展:点击添加、删除、列表联动与坐标展示

4.1 点击地图添加新标记点

很多需求场景里,标记点并不是预先写死的,而是用户在地图上自己点的。监听地图 click 事件,获取点击位置的经纬度,然后创建新的可拖拽 Marker 并加入数组即可。

map.on('click', (e) => { const { lng, lat } = e.lngLat; const id = 'p_' + Date.now(); const newItem = { id, label: `新点 ${id}`, lnglat: [lng, lat] }; const marker = new mapboxgl.Marker({ draggable: true, element: createCustomMarker(newItem.label) }) .setLngLat(newItem.lnglat) .addTo(map); marker.on('dragend', () => { const lngLat = marker.getLngLat(); newItem.lnglat = [lngLat.lng, lngLat.lat]; refreshCoordinatePanel(); }); markers.push(marker); pointsData.push(newItem); renderList(); });

注意id的生成,我习惯用Date.now()拼接随机后缀,保证不会和已有 id 冲突。如果你的业务有后端,id 应该由后端下发,前端只负责接收。

4.2 删除指定标记点

删除逻辑特别容易踩下标坑,所以一定要用 id 来定位,而不是数组下标。先根据 id 找到 pointsData 里的索引,再同步删除 Marker 和数据即可。

function removeMarkerById(id) { const dataIndex = pointsData.findIndex((item) => item.id === id); if (dataIndex === -1) return; const marker = markers[dataIndex]; marker.remove(); markers.splice(dataIndex, 1); pointsData.splice(dataIndex, 1); renderList(); }

这里我直接把 markers 和 pointsData 按下标对齐维护,保持两者顺序一致,删除时同时操作。更稳妥的做法是给 Marker 实例挂一个自定义属性,比如marker._pointId = item.id,删除时用这个 id 去关联,这样即使数组顺序在某次批量刷新后变了,也能准确找到对应的 Marker。后面 5.2 会再讲到下标错乱的其他坑。

4.3 侧栏列表与地图标记点联动

当标记点数量较多时,光靠在地图上数“哪个点是哪个”很痛苦。比较常见的设计是在地图旁边配一个点位列表面板,点击列表项时:

  1. 地图平移、缩放到该标记点附近;
  2. 该标记点视觉高亮一下,让用户能快速在图上定位。
function highlightMarkerById(id) { const dataIndex = pointsData.findIndex((item) => item.id === id); if (dataIndex === -1) return; const marker = markers[dataIndex]; const [lng, lat] = pointsData[dataIndex].lnglat; map.easeTo({ center: [lng, lat], zoom: Math.max(map.getZoom(), 14) }); const el = marker.getElement(); el.style.zIndex = '1000'; el.style.background = '#ef4444'; setTimeout(() => { el.style.background = '#2563eb'; el.style.zIndex = ''; }, 800); }

地图动画这里用easeTo而不是flyTo,因为flyTo在大跨度移动时会带旋转和飞行感,对这种点定位场景来说容易让人眩晕。easeTo更克制。

4.4 拖拽过程中的坐标实时展示

我做过的一些管理后台里,产品经理会要求拖拽时旁边能看到实时经纬度。实现也不复杂,把drag事件中拿到的坐标写到一个面板或 input 里就行。

marker.on('drag', () => { const lngLat = marker.getLngLat(); document.getElementById('coordInfo').textContent = `${lngLat.lng.toFixed(6)}, ${lngLat.lat.toFixed(6)}`; });

把经纬度 toFixed(6),大概精确到 0.1 米级别,对大多数手工微调场景足够。如果业务上不需要这么高精度,保留 5 位也行。

5. 实操中踩过的坑与性能优化

5.1 常见问题速查表

我把反复遇到的几个问题整理成表格,方便你排查时直接对照。

症状原因解决办法
鼠标拖不动 Marker自定义元素上触发了浏览器原生拖拽行为给元素加上draggable="false",并在 mousedown 里阻止默认行为
拖完以后点跑到图标侧面自定义元素的锚点选错根据图标形状设置anchor: 'bottom'或center
点击 Marker 却触发了地图 clickMarker 上的点击事件冒泡到了 map用marker.getElement().addEventListener('click', fn),在内部手动绑定
删掉一个点后,列表和地图对不上使用数组下标定位 Marker用唯一 id 关联 Marker 和数据项
拖拽过程中其他 Marker 跟着闪重置 z-index 或样式时影响到其他元素操作时先锁定目标 Marker 的 zIndex,结束后再恢复
高 DPI 屏幕下图标偏移自定义元素尺寸为非整数像素,或 transform 缩放导致模糊固定整数宽高,避免在 marker 元素上做 scale

5.2 闭包陷阱与事件重复绑定

循环里创建 Marker 并绑定事件时,最容易出的问题就是闭包引用错误。比如:

// 不推荐写法 for (var i = 0; i < pointsData.length; i++) { marker.on('dragend', () => { console.log(pointsData[i]); // 这里的 i 到最后会指向越界 }); }

因为var是函数级作用域,循环结束后i已经变成了数组长度,最终所有事件回调引用的都是同一个错误的索引。解决办法就是使用let或forEach,让每次循环的变量独立绑定。文章前面的代码使用forEach,已经天然规避了这个问题。

还有“重复绑定”的问题。如果你把 Marker 的创建逻辑封装成函数,然后在地图 load 事件里调了两次,就会给同一个 Marker 绑两个 dragend 回调,数据会被覆盖写两遍。建议每次创建 Marker 前,统一用一个数组去管理,创建前先清理旧的。

5.3 大量标记点时的性能策略

Marker 是 DOM 元素,浏览器对 DOM 节点的渲染和事件监听是有限度的。我实测下来:

  • 50 个以内的可拖拽 Marker,完全流畅;
  • 100 到 300 个之间,拖拽交互基本没问题,但初始化时会有轻微卡顿感;
  • 超过 500 个,页面滚动、地图拖动和拖拽手感都会显著下降;
  • 超过 1000 个,就不太适合继续“全量用 Marker”了。

如果你的业务确实需要同时展示大量点,但又要求其中某些点可拖拽,我建议折中:大量点用 GeoJSON 图层渲染并开启聚合,用户选中某个点后再动态创建一个可拖拽 Marker 覆盖在那个点上。这样既保留了地图整体性能,又满足了“重点点可编辑”的需求。

5.4 地图拖动与 Marker 拖拽的冲突处理

Mapbox GL JS 官方在 Marker 上做了较好的事件隔离:从 Marker 上按下鼠标开始拖动时,拖动的是 Marker,不是地图;从地图空白处按下拖动,拖动的才是地图。但在自定义元素上,这个隔离偶尔会被浏览器原生行为干扰。

解决方法是给自定义元素设置draggable="false",并且监听 mousedown 时调用e.preventDefault()。同时给 Marker 元素设置 cursor:

.custom-marker { cursor: grab; } .custom-marker:active { cursor: grabbing; }

这样用户看到手型光标就知道“这个可以拖”,体验也更自然。

6. 完整示例:带侧栏管理的多点拖拽编辑器

6.1 组合成一个可运行 Demo

把前面所有片段串起来,一个完整的最小实现大概是这样的逻辑结构:

<style> body { margin: 0; display: flex; font-family: "Microsoft YaHei", sans-serif; } #map { flex: 1; height: 100vh; } #sidebar { width: 260px; padding: 16px; background: #f8fafc; border-left: 1px solid #e2e8f0; box-sizing: border-box; overflow-y: auto; } .list-item { padding: 10px 12px; margin-bottom: 8px; background: #fff; border-radius: 8px; border: 1px solid #e2e8f0; cursor: pointer; font-size: 14px; } .list-item:hover { border-color: #2563eb; } </style>

HTML 布局是用左地图右侧栏的方式,侧栏负责展示标记点列表和当前坐标信息。创建 Marker、绑事件、数据同步的逻辑,统一封装成同名函数,这样点击添加、初始化、动态新增都走同一条路径。

function createMarkerFromData(item) { const marker = new mapboxgl.Marker({ draggable: true, element: createCustomMarker(item.label) }) .setLngLat(item.lnglat) .addTo(map); marker.on('dragend', () => { const lngLat = marker.getLngLat(); item.lnglat = [lngLat.lng, lngLat.lat]; updateSidebar(); }); markers.push(marker); return marker; }

把核心逻辑收敛到一个函数里,后面无论是初始化、点击添加还是列表刷新,都只调用这一个函数。这样做的好处是改动一处生效全局,如果后续要换成真实数据源,也只需要改数据层,不碰交互层。

6.2 数据导出与后端交互

这可能是很多人忽略的部分。做多点拖拽功能,最终目的是把修正后的坐标保存到后端。我建议在拖拽结束后,直接把整个 pointsData 序列化成 JSON,写入一个隐藏 input,或者通过接口提交。

function exportPointsJson() { const data = pointsData.map((item) => ({ id: item.id, label: item.label, lng: item.lnglat[0], lat: item.lnglat[1] })); console.log(JSON.stringify(data, null, 2)); }

格式上我习惯把坐标拆成 lng 和 lat 两个字段,而不是数组,因为很多后端表单模型不接受数组结构。前端展示时再用[lng, lat]组装回去。

6.3 后续值得扩展的方向

  • 拖拽时同步更新多个 Marker 之间的连线:可以把所有点连成一条折线,在 drag 事件中更新 GeoJSON 数据源;
  • 拖拽吸附:当拖到某个已有坐标附近时,自动吸附过去,适合网格点位或配送点;
  • 逆地理编码:拖拽结束后把经纬度反查成街道地址,显示在侧栏;
  • 多个地图实例共享点位:比如左屏展示总览、右屏放大编辑,拖拽时双屏联动。

如果项目需要我长期维护,我会强烈建议把 Marker 的创建、事件绑定、坐标回写封装成一个DraggableMapPoint类,每个点一个实例,内部自管理。这样数据量大了以后,增删逻辑会更可控,而不是靠一堆游离的数组函数支撑。

写在最后

做多点拖拽这个功能,技术难度本身不大,真正烧时间的永远是细节:坐标顺序、锚点位置、闭包引用、数组和 Marker 的同步、事件解绑。这些小问题单独拎出来都很容易解决,但组合在一起,就足够让一个刚上手 Mapbox 的开发者耗掉一整天。我个人实操中最受用的原则,是把“创建 Marker”和“更新数据”当成两件事来对待,每次拖拽结束,第一反应不是刷新界面,而是先把坐标写回数据模型,再触发所有依赖这份数据的 UI 更新。顺序对了,整个系统就稳了。

最后再分享一个小技巧:如果你之后要从这个 Demo 扩展到正式项目,建议在开发阶段把createCustomMarker里的文本内容做成可配置,最好支持传入任意 HTML,而不是只接受字符串。因为做到后面你大概率会发现,标记点上可能要显示状态图标、价格标签,甚至一个小型操作按钮。一开始就做成模板函数,比后面再重构省事得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 6:14:39

TensorFlow GPU环境搭建:Anaconda、CUDA到Keras实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 6:14:38

Win32 C++消息循环、窗口与GDI线程实战源码解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 6:13:47

Chrome XP最终版:面向工业嵌入式设备的极简浏览器重构指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 6:12:27

GPU二分查找实战:pylibcudf.search加速亿级数据区间匹配

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 6:12:21

PCA9422+STM32F413RH电源管理实战:硬件协同设计与可靠性优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 6:10:32

开源HIS系统实战:从选型到部署,快速搭建医院信息系统

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华