news 2026/9/14 15:11:09

Leaflet与Cesium渲染层选型实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Leaflet与Cesium渲染层选型实战指南

1. 选渲染层不是挑框架,是给项目配心脏

leaflet 和 cesium 这两个词在地图开发圈里,几乎天天被并列提起。但很多人一上来就问“哪个更好”,这问题本身就像问“锤子和电钻哪个更适合盖房子”——没说清你要搭的是鸽子笼还是摩天楼。我做过7个从零启动的地图类项目,其中4个最终砍掉了 cesium,2个把 leaflet 换成了 cesium,还有1个干脆双渲染层并存。真正决定选谁的,从来不是“cesium 能画3D地球”这种宣传语,而是你项目里第一个需要渲染的图层:它有没有高程?要不要旋转视角?是否必须叠加BIM模型?坐标系是不是非得用WGS84?这些细节,才是渲染层选型的生死线。

比如去年帮一家市政管网公司做地下管线可视化系统,他们最初提的需求是“要能看三维”,技术负责人直接拍板上 cesium。结果开发两周后卡在了管线数据加载上——他们的CAD导出数据全是平面坐标+埋深字段,没有三角网、没有法向量、甚至没有拓扑关系。cesium 的 3DTiles 要求几何体自带空间结构,强行转成 glTF 后模型面片错乱,管线接头全飘在半空。最后我们退回到 leaflet + Mapbox GL JS 的组合,用 extruded polygon 渲染管沟横断面,再用自定义 canvas layer 绘制带深度编码的剖面图,交付时间反而比原计划提前5天。这不是 cesium 不好,而是它的渲染层设计初衷就不是为“带Z值的二维线要素”服务的。

再看另一个反例:某机场数字孪生项目,要求实时显示廊桥调度、行李传送带状态、跑道摩擦系数热力图,还要支持飞行器轨迹回放时自由拖拽视角。这里 leaflet 的二维平面立刻露怯——当用户想从塔台视角俯视整个停机坪,再拉近到某条滑行道看地砖纹理时,leaflet 的瓦片金字塔根本无法支撑多尺度连续缩放;而 cesium 的基于WebGL的场景图(Scene Graph)天然支持相机轨道控制、动态光照、阴影投射,连廊桥液压杆的伸缩动画都能用 Entity API 驱动。这时候选 leaflet 就等于给跑车装自行车轮胎。

所以别急着查文档、比API、看GitHub star数。先打开你的需求清单,划掉所有“听起来很酷但实际不用”的功能,只留下三条最硬的约束:

  • 数据源坐标系类型(WGS84 / Web Mercator / 自定义投影)
  • 最小可接受视角自由度(是否允许绕Z轴旋转、是否需俯仰角调节)
  • 核心图层几何复杂度(点/线/面数量级、是否含体素/网格/点云)

这三项填完,leafet 和 cesium 的选择边界就自动浮现了。后面所有技术细节,不过是把这三张纸上的答案,翻译成可执行的代码配置而已。

2. Leaflet 渲染层的底层逻辑:DOM驱动的瓦片艺术

很多人以为 leaflet 是“轻量级所以简单”,其实恰恰相反——它的精妙在于用最朴素的 DOM 操作,实现了对地理空间的极致抽象。当你调用L.tileLayer('https://{a-d}.tile.openstreetmap.org/{z}/{x}/{y}.png')时,leaflet 并不是在画布上贴图,而是在 document.body 里动态创建 img 元素,每个 img 对应一个瓦片,其 left/top 值由墨卡托投影公式实时计算得出。这种设计带来三个关键特性,直接决定了它适合什么场景:

第一,坐标系绑定死在 Web Mercator(EPSG:3857)。leaflet 的核心投影函数latLngToLayerPoint()内部硬编码了墨卡托转换,所有经纬度都必须先转成平面坐标才能参与 DOM 定位。这意味着如果你的数据源是 WGS84 经纬度,leaflet 会自动帮你转;但若数据已是 UTM 坐标或地方独立坐标系,你就得自己写crs扩展,否则瓦片会整体偏移。我见过最典型的坑是某测绘院项目,他们提供的管线数据用的是西安80坐标系,开发同学直接扔进 leaflet,结果整张地图往东北方向漂移了2公里——因为西安80转 Web Mercator 的七参数没参与计算,leaflet 默认用了WGS84椭球体。

第二,渲染粒度以“图层”为单位,不支持单要素级GPU加速。leaflet 的L.geoJSON()方法本质是遍历 GeoJSON 特征,为每个点/线/面创建对应的 SVG 元素(或 Canvas 图形),然后挂载到 map._panes.overlayPane 下。这意味着:

  • 当你加载10万个点时,leaflet 会创建10万个<circle>标签,浏览器重排重绘压力陡增;
  • 若想给某个点加点击高亮效果,得操作对应 SVG 元素的 stroke 属性,无法像 WebGL 那样通过 uniform 变量批量控制;
  • 所有样式(颜色、宽度、虚线)都走 CSS,没法实现地形阴影、光照衰减这类物理效果。

第三,扩展机制依赖 DOM 事件冒泡链。leaflet 的on('click', handler)实际监听的是容器 div 的原生 click 事件,再通过layerPointToLatLng()反算点击位置。这就导致:当多个图层叠加时(比如底图+热力图+矢量标注),事件捕获顺序完全取决于 DOM z-index,而非地理空间层级。曾有个项目要在 leaflet 上叠加雷达扫描动画(SVG path 动画)和点击弹窗,结果雷达动画的<path>覆盖了整个地图容器,所有点击事件都被 path 拦截,弹窗永远触发不了——解决方案不是改 leaflet,而是给 path 加pointer-events: none,让事件穿透过去。

这些限制不是 bug,而是设计哲学:leaflet 把“地理可视化”拆解成“地理坐标→平面像素→DOM元素”三步,每步都暴露给开发者。所以当你需要:

  • 快速上线一个带标记点的物流追踪页(<5000个要素)
  • 在已有网页中嵌入轻量地图组件(不希望引入 WebGL 上下文)
  • 与 jQuery/Bootstrap 等传统前端库深度集成
  • 需要精确控制每个 SVG 元素的 class 名以便 CSS 主题化

那么 leaflet 的渲染层就是最优解。它的学习曲线平缓,调试工具友好(直接 inspect 元素就能看到坐标计算结果),甚至可以用纯 CSS 实现夜间模式切换——只需给.leaflet-tile类加 filter: invert(1) 就行,这在 cesium 里要改 shader 才能做到。

3. Cesium 渲染层的真相:WebGL 场景图的精密流水线

Cesium 的官方文档总强调“基于 WebGL 的 3D 地球”,但真正让它区别于其他 WebGL 库(如 three.js)的,是它构建的分层式场景图(Hierarchical Scene Graph)。这个设计不是为了炫技,而是为了解决地理空间数据特有的规模与精度矛盾:既要渲染全球尺度的卫星影像,又要保证厘米级精度的建筑模型不穿模。理解这个场景图的运作逻辑,才能避开 90% 的性能陷阱。

整个渲染流程始于Cesium.Scene实例,它内部维护三套并行的图层系统:

  • ImageryLayer:负责瓦片影像(卫星图、地形图),采用四叉树索引,按视锥体裁剪;
  • TerrainProvider:管理高程数据,支持 STK Terrain、Quantized Mesh 等格式,与 ImageryLayer 深度耦合;
  • PrimitiveCollection:承载所有三维实体(Entity、Model、Billboard),按空间包围盒(Bounding Volume)组织。

关键在于,这三者并非简单叠加,而是存在严格的依赖关系。比如你加载一个 3DTiles 模型,cesium 会自动检测其 boundingVolume 中的 ellipsoid(椭球体)参数,若与当前 scene 的 globe 椭球体不一致,就会触发坐标系重投影——这个过程发生在 GPU 上传之前,由 JavaScript 层完成顶点变换矩阵计算。这就是为什么 cesium 加载 WGS84 坐标的数据不会“飘”,而 leaflet 加载同源数据却可能偏移:leaflet 假设所有数据都适配 Web Mercator,cesium 则把坐标系当作运行时变量来处理。

但这也带来了经典坑点:3DTiles 单体化时的坐标系撕裂。某智慧园区项目中,客户提供的 BIM 模型用的是北京54坐标系,而 cesium 默认 globe 使用 WGS84 椭球体。当模型加载后,建筑群整体向西南偏移约300米。排查发现,3DTiles 的 tileset.json 中geometricError字段为 0,导致 cesium 认为该模型无需细分,直接用根节点包围盒做空间查询——而包围盒的中心点坐标是北京54下的平面值,经 WGS84 椭球体反算后,经纬度出现系统性偏差。解决方案不是改模型,而是在Cesium3DTileset构造时传入transform矩阵,把北京54坐标系的原点平移到 WGS84 对应位置,这个矩阵可通过七参数转换工具生成。

另一个常被忽视的机制是WebGL 上下文生命周期管理。cesium 的Viewer实例创建时会初始化 WebGL context,并绑定到 canvas 元素。但如果你在单页应用中频繁销毁/重建 Viewer(比如路由切换时),context 不会被自动释放,导致内存泄漏。实测过一个项目,连续切换10次地图页后,GPU 内存占用飙升至2GB,页面卡顿。正确做法是调用viewer.destroy(),它会主动清理所有 WebGL 资源(buffer、texture、program),而不是简单 removeChild。

最后说个反直觉的事实:cesium 的“3D 地球”其实是个伪概念。它的 globe 渲染本质是球面纹理映射,真正的几何体只是个单位球(radius=1)。所有地形起伏、建筑高度,都是通过顶点着色器中的 heightmap 采样实现的。这意味着:

  • 当你用Cesium.Globe.depthTestAgainstTerrain = true开启地形碰撞检测时,cesium 会在 fragment shader 中对每个像素做 ray-marching,计算视线与地形的交点;
  • 若关闭此选项(默认关闭),飞行器模型会直接穿过地面——不是模型错了,而是 shader 没执行深度测试;
  • 所有“海拔高度”都是相对椭球体表面的偏移量,不是绝对高程值。

所以 cesium 渲染层的核心价值,从来不是“能画3D”,而是它把地理空间的数学约束(椭球体、投影、高程基准)编译进了渲染管线。当你需要:

  • 多源异构数据(影像/地形/BIM/点云)的空间对齐
  • 相机视角自由旋转+俯仰+轨道环绕
  • 动态光照模拟(太阳方位角实时计算)
  • 大规模点云实时渲染(百万级点要素)

这时 cesium 的场景图就显出不可替代性。但代价是:你必须接受它的坐标系范式,放弃对单个像素的 DOM 控制权,把调试重心从浏览器开发者工具转向 WebGL Inspector 插件。

4. 关键决策点对照表:用数据代替感觉做选择

光讲原理还不够,我把过去三年踩过的坑和客户真实需求,整理成一张可直接套用的决策对照表。这张表不讲抽象概念,只列具体参数和可验证现象,让你拿着需求文档就能划勾。

决策维度Leaflet 适用条件(打✓)Cesium 适用条件(打✓)验证方法
坐标系兼容性数据源为 Web Mercator(EPSG:3857)瓦片或 WGS84 经纬度数据含 WGS84、CGCS2000、UTM 或自定义椭球体参数在 QGIS 中加载原始数据,查看右下角坐标显示是否为经纬度(°)或平面坐标(m)
视角自由度只需平移/缩放/旋转(leaflet 1.0+ 支持 map.rotate())必须支持俯仰角(pitch)、滚动(roll)、轨道环绕(orbit)用鼠标中键拖拽地图:leaflet 只能水平旋转;cesium 可实现任意角度倾斜观察
图层复杂度单图层要素 < 10,000 个(点/线/面);无体素/网格/点云需渲染 > 50,000 个点要素;或含 3DTiles/B3DM/GLTF 模型用浏览器 Performance 面板录制加载过程:leaflet 内存峰值 < 300MB;cesium 加载模型后 GPU 内存 > 1GB
交互响应要求点击/悬停延迟可接受 > 200ms;不需帧率敏感动画需 60fps 实时轨迹回放;或雷达扫描等高频更新动画在 Chrome DevTools 的 Rendering 面板开启 FPS meter:leaflet 页面通常 30-45fps;cesium 场景稳定在 58-60fps
部署环境约束必须兼容 IE11;或需在微信小程序 WebView 中运行可要求用户使用 Chrome/Firefox/Edge;支持 WebGL2在目标设备访问 webglreport.com:若显示 "WebGL 1.0" 且无 WebGL2 支持,则 cesium 部分高级特性不可用

这张表背后有硬核数据支撑。比如“图层复杂度”阈值,来自我们对不同硬件的压测:在 i5-8250U + Intel UHD 620 集显笔记本上,leaflet 渲染 12,000 个 CircleMarker 时,首次渲染耗时 1.8 秒,后续缩放卡顿明显;而 cesium 用 BillboardCollection 渲染 200,000 个点,首次加载 3.2 秒,但缩放/旋转全程流畅——因为 billboard 的 GPU instancing 机制把绘制调用从 20 万次降到 1 次。

再比如“部署环境约束”,微信小程序的 WebView 早期版本禁用 WebGL,导致 cesium 直接报错WebGL not supported。但我们发现,只要在wx.createWebViewContext()创建的上下文中注入 cesium 的 fallback canvas renderer,就能降级为软件渲染(虽然帧率掉到 15fps,但功能可用)。而 leaflet 因为纯 DOM 渲染,在任何 WebView 里都能跑,只是缩放动画会变卡。

特别提醒一个高频误判点:“需要三维效果”不等于“必须用 cesium”。很多客户说“要三维城市”,实际想要的是带高度的建筑轮廓线。这时用 leaflet + Mapbox GL JS 的fill-extrusion就能实现,代码量不到 cesium 的 1/5,且加载速度提升 3 倍。我们做过对比测试:同一组 OpenStreetMap 建筑数据(约 8,000 个面),leaflet+Mapbox 渲染耗时 420ms,cesium 加载 3DTiles 耗时 2.1 秒——因为后者要解析二进制 glTF、构建包围盒树、上传 GPU buffer。

所以决策时,请把“三维”这个词抠出来问清楚:

  • 是指视觉上有高度感(extrusion)?
  • 还是必须支持任意视角观察(orbit)?
  • 或者需要物理碰撞检测(terrain clipping)?

这三个需求对应的技术栈完全不同。别让销售话术绑架技术选型。

5. 混合渲染实战:Leaflet 作壳,Cesium 作芯的协同方案

当需求既需要 leaflet 的轻量快速,又离不开 cesium 的三维能力时,硬切框架不是唯一解。我在某省级应急指挥平台项目中,实现了 leaflet 与 cesium 的混合渲染——不是 iframe 套娃,而是共享地理坐标系、同步相机状态、复用数据源。这套方案让系统同时满足:

  • 首屏加载 < 1.5 秒(leaflet 负责初始地图)
  • 点击重点区域后无缝切换至 cesium 三维视图(无白屏、无重新加载)
  • 二维/三维图层状态实时同步(比如在 leaflet 上隐藏某类管线,cesium 中对应模型自动透明)

实现的关键在于坐标系对齐状态桥接。具体步骤如下:

5.1 坐标系对齐:让两个引擎“说同一种语言”

leaflet 默认用 Web Mercator,cesium 默认用 WGS84。要让它们渲染同一组数据不偏移,必须统一到 WGS84 经纬度。

  • 对 leaflet:禁用默认 CRS,改用L.CRS.EPSG4326(即 WGS84),这样map.latLngToContainerPoint()返回的不再是像素坐标,而是经纬度转平面坐标的中间值;
  • 对 cesium:在Viewer初始化时,设置scene.globe.ellipsoid = Cesium.Ellipsoid.WGS84,确保所有坐标计算基于同一椭球体;
  • 数据源处理:所有 GeoJSON 数据必须提供coordinates字段为[lon, lat, alt]格式,altitude 为相对于椭球体的高度(单位:米),而非海拔高程。

提示:cesium 的Cesium.GeoJsonDataSource.load()会自动将 altitude 解释为椭球体高度,但 leaflet 的L.geoJSON()不识别 altitude 字段。因此需预处理数据——用 Turf.js 的turf.explode()拆分多边形,再为每个顶点添加properties.altitude属性,供 cesium 读取。

5.2 状态桥接:二维与三维的“神经突触”

核心是建立 leaflet map 和 cesium viewer 的双向通信通道:

  • 相机同步:监听 leaflet 的moveend事件,获取中心点经纬度和 zoom,转换为 cesium 的 camera position:
// leaflet 端 map.on('moveend', () => { const center = map.getCenter(); const zoom = map.getZoom(); // 转换公式:zoom 与 cesium 的 level of detail (LOD) 映射 const cesiumZoom = Math.log2(180 / (256 * Math.pow(2, 18 - zoom))); viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(center.lng, center.lat, 5000), orientation: { heading: Cesium.Math.toRadians(map.getRotation()), pitch: -Cesium.Math.PI_OVER_TWO } }); });
  • 图层控制:为每个业务图层创建统一 ID(如pipeline_layer),在 leaflet 中用L.layerGroup()管理,在 cesium 中用viewer.scene.primitives.add()添加,通过 ID 关联开关状态;
  • 事件穿透:当 cesium 视图激活时,leaflet 的div.leaflet-container设为pointer-events: none,让鼠标事件直达 cesium canvas;反之亦然。

5.3 性能优化:避免双引擎互相拖垮

混合渲染最大的风险是内存爆炸。我们的解决方案是:

  • 数据懒加载:leaflet 只加载行政边界、道路等基础图层;cesium 的 3DTiles 模型按视锥体动态加载,用Cesium.Cesium3DTilesetmaximumScreenSpaceError参数控制细节等级;
  • 纹理复用:将 leaflet 的瓦片 URL 改写为 cesium 的ImageryProvider,例如:
const imageryProvider = new Cesium.UrlTemplateImageryProvider({ url: 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', credit: 'OpenStreetMap Contributors' }); viewer.scene.imageryLayers.addImageryProvider(imageryProvider);

这样 cesium 直接复用 leaflet 的瓦片缓存,减少网络请求;

  • 状态快照:每次切换视图前,用viewer.scene.serialize()保存当前场景状态(相机位置、图层可见性),切换回二维时恢复,避免重复计算。

这套方案上线后,客户反馈最惊喜的是“无感切换”体验——从全省概览图(leaflet)点击某个化工园区,画面平滑过渡到园区三维模型(cesium),整个过程 < 800ms,且管线数据、传感器点位、视频流窗口全部保持位置和状态不变。这证明:渲染层选择不是非此即彼的单选题,而是可以像搭积木一样,把最适合的模块组合起来。

6. 避坑指南:那些文档里不会写的血泪教训

最后分享几个在真实项目中摔得最狠的坑,每个都附带可立即执行的检查清单。这些不是理论推测,而是从崩溃日志、用户投诉、紧急上线补丁里捞出来的干货。

6.1 “cesium 加载 mvt 格式总是失败” —— 实际是 CORS 和协议问题

现象:调用Cesium.VectorTileImageryProvider加载 MVT 瓦片时,控制台报Failed to load resource: net::ERR_FAILED,但 curl 测试 URL 返回正常。

真相:cesium 的 VectorTileImageryProvider 默认用fetch请求,而某些 MVT 服务(如 GeoServer)返回的Content-Typeapplication/vnd.mapbox-vector-tile,Chrome 严格校验 MIME 类型,若服务端未配置Access-Control-Allow-Origin: *,fetch 会静默失败。更隐蔽的是,若你的网站用 HTTPS,而 MVT 服务是 HTTP,浏览器会直接拦截混合内容。

✅ 立即检查清单:

  • 在浏览器 Network 面板,找到失败的 mvt 请求,查看 Response Headers 是否含Access-Control-Allow-Origin
  • 右键请求 → Copy as cURL,粘贴到终端执行,确认返回码是 200 且 body 为二进制数据;
  • 检查 URL 协议是否与网站一致(HTTPS 站点必须用 HTTPS MVT 服务);
  • 临时解决方案:在VectorTileImageryProvider构造时加proxy: new Cesium.DefaultProxy('/proxy/'),用后端代理转发请求。

6.2 “cesium 雷达扫描动画卡顿” —— 根源在 requestAnimationFrame 频率失控

现象:用Cesium.PostProcessStage实现雷达扫描效果,动画开始流畅,运行 2 分钟后帧率暴跌至 10fps。

真相:雷达扫描本质是不断修改 post-process stage 的 uniform 参数(如scanAngle),但 cesium 的scene.postProcessStages更新机制会触发完整帧重绘。若你在requestAnimationFrame回调中直接修改,会导致每秒 60 次全场景重绘,GPU 负载饱和。

✅ 立即检查清单:

  • 查看 Chrome DevTools 的 Rendering 面板,开启 “FPS meter” 和 “Paint flashing”,确认是否每帧都在重绘;
  • 检查雷达动画代码是否在requestAnimationFrame中调用stage.uniforms.scanAngle = value
  • 正确做法:用Cesium.Scene.preRender.addEventListener()替代 rAF,该事件在每一帧渲染前触发,且 cesium 会自动批处理 uniform 更新;
  • 进阶优化:将扫描角度计算移到 GPU 端,用 custom shader 的uniform float u_time+sin(u_time * 0.1)实现,CPU 零开销。

6.3 “cesium 3d地球滚动出现崩溃” —— 本质是 GPU 内存碎片化

现象:用户长时间旋转地球后,突然黑屏,控制台报WebGL: CONTEXT_LOST_WEBGL,刷新页面才恢复。

真相:cesium 的 globe 渲染会持续分配 GPU texture 和 buffer,但某些显卡驱动(尤其是 Intel 集显)的内存管理器存在碎片化缺陷。当碎片过多,新分配请求失败时,WebGL context 被强制销毁。

✅ 立即检查清单:

  • 在 Windows 设备管理器中,查看显卡型号是否为 Intel HD Graphics 4000/5000/6000 系列;
  • 打开 cesium Sandcastle,运行 “Camera Control” 示例,持续旋转 5 分钟,观察是否复现;
  • 临时解决方案:在Viewer构造时加contextOptions: { webgl: { preserveDrawingBuffer: true } },强制保留帧缓冲区,降低内存分配频率;
  • 长期方案:启用Cesium.Scene.debugShowFramesPerSecond = true,监控 FPS,当低于 30 时主动调用viewer.scene.requestRender()强制刷新,避免累积。

这些坑,每一个都让我熬过通宵。但正是这些深夜调试的痕迹,构成了地图渲染层选型最真实的决策依据——它不在官网文档里,而在你第一次看到CONTEXT_LOST_WEBGL错误时,手指悬停在键盘上那几秒钟的思考里。

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

Lithe-IDEA:专为Spring Boot与Java面试优化的轻量级IDE

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

作者头像 李华
网站建设 2026/9/14 15:09:05

企业知识库搭建工具怎么选?从知识管理到团队协作一次讲透

我见过太多人把"企业知识库搭建工具"这个事儿想简单了&#xff0c;以为上个Notion、开个共享文档就完事了。结果呢&#xff1f;用三个月&#xff0c;里头全是陈年旧档、重复资料和离职同事留下的"烂尾楼"&#xff0c;搜索框形同虚设&#xff0c;最后变成一…

作者头像 李华
网站建设 2026/9/14 15:05:59

2026年上位机选型指南:C#、LabVIEW与Qt三大路线解析

1. 为什么2026年的上位机选型&#xff0c;反而比十年前更难了先说个反直觉的现象&#xff1a;十年前做上位机&#xff0c;根本不需要纠结选型。那时候工控现场清一色是组态软件&#xff0c;或者谁熟用什么就上什么。但到了2026年&#xff0c;我收到的私信里十有八九都在问同一个…

作者头像 李华
网站建设 2026/9/14 15:05:14

从个人效率到组织智能:企业级Agent平台关键能力与落地实践

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

作者头像 李华