拆开 Leaflet 才明白,最强地图库连渲染引擎都没写
- Leaflet 仅 1.4 万行代码,不写渲染引擎:瓦片用 img 标签、矢量走 SVG DOM 节点、拖动靠 CSS transform,将渲染外包给浏览器
- 核心是坐标换算链:以 85.0511 纬度常数和 pixelOrigin 锚点为基础,地图状态压缩为 zoom、center、pixelOrigin 三个量,锚点不变即不重算
- 瓦片调度是重点:整数缩放级别加 CSS 拉伸实现缩放动画,按距视口中心距离排序加载,金字塔式父子瓦片保留策略避免放大缩小时出现空白
- DOM 渲染路线带来结构性代价:分数缩放瓦片缝隙 issue 开了十年未关闭,跨 180 度经线断裂等长期问题难以根治,且维护高度依赖单一维护者
- 选型建议:轻量嵌入交互地图选 Leaflet,需要矢量瓦片、旋转和 3D 选 MapLibre GL,完整 GIS 能力选 OpenLayers
你在找房网站上拖过地图,在新闻专题页里缩放过事件分布图,在骑行 App 的分享页里追踪过自己的轨迹。这些页面底部很可能印着同一行小字,Leaflet。
npm 上它上周被下载了 8366429 次,GitHub 上 45673 个 star。可你把这仓库克隆下来数一数,src 目录 82 个 JS 文件,总共 14012 行代码。
一个 React 组件库动辄十几万行,一个地图库只有 1.4 万行。
更反常识的还在后面。它连渲染引擎都没写。没有 canvas 绘图循环,没有 WebGL 管线,瓦片就是 img 标签,矢量默认是 SVG 的 DOM 节点,拖动地图改的是 CSS transform。这个库 2010 年 9 月由住在基辅的 Volodymyr Agafonkin 创建,16 年后依然是 web 地图的事实标准之一。
所以 Leaflet 这个仓库值得拆一次,不为怀旧,为搞清楚一个问题,不写渲染引擎,地图到底是怎么跑起来的。
先铺一张全景,1.4 万行分成五层,自上而下是 API 装配、地图状态、图层渲染、坐标数学和 DOM 运行时。
后面就按这个顺序拆,从最底下的坐标数学讲起。
先把数学做对
Leaflet 的第一资产不是代码,是一套换算链。
src/geo/projection/Projection.SphericalMercator.js 开头躺着两个常量
const earthRadius = 6378137;
MAX_LATITUDE: 85.0511287798
85.0511287798 这个数不是拍的,它是 atan(sinh(π)) 换算成度数的值。墨卡托投影往两极走变形会趋于无穷,而这个纬度恰好让全世界投影成一个完美的正方形,刚好能切成 2 的 n 次方乘 2 的 n 次方的瓦片金字塔。整条 web 地图产业的地基,就压在这一个常数上,Google Maps、高德、OpenStreetMap 全都用它。
project() 的实现只有几行,把经纬度换算成以米为单位的平面坐标。接着 CRS 类把米换算成像素,系数是 2 的 z 次方,z 就是缩放级别。z 每加 1,世界面积翻四倍,像素边长翻两倍。
到这里,地球上任何一点在任何缩放级别下都有一个确定的像素坐标。剩下的问题只有一个,屏幕左上角对准的是哪个像素。
Map.js 里的 _getNewPixelOrigin() 回答了它
_getNewPixelOrigin(center, zoom) {
const viewHalf = this.getSize()._divideBy(2);
return this.project(center, zoom)._subtract(viewHalf)
._add(this._getMapPanePos())._round();
}
中心点投影成像素,减去半个视口,得到左上角那个像素,再取整。这个值叫 pixelOrigin,它是整个库的锚点。
整条换算链串起来长这样,一个经纬度四步变成屏幕上的一个位置。
看懂这条链,Leaflet 的一半就看懂了。
我一直觉得这是 Leaflet 最值得偷师的设计。地图的全部状态被压缩成 zoom、center、pixelOrigin 三个量,视图一变就重算锚点,所有图层的定位都是相对锚点的减法。没有脏矩形管理,没有场景图,没有重绘调度。连拖动地图都不重算锚点,只给 mapPane 一个 CSS transform,松手后才更新。
状态少了,bug 就少了。
六层 div 的层叠宇宙
打开 Map.js 的 _initPanes(),能看到 Leaflet 对「谁盖谁」这个问题给出的全部答案
this._mapPane = this.createPane('mapPane', this._container);
this.createPane('tilePane'); // zIndex 200,放瓦片
this.createPane('overlayPane'); // 400,放矢量
this.createPane('shadowPane'); // 500,放标记阴影
this.createPane('markerPane'); // 600,放图标
this.createPane('tooltipPane'); // 650
this.createPane('popupPane'); // 700
六个子 pane,靠 zIndex 排好队,就这么多。
你想想看,传统图形库要解决层叠关系,得写画家算法或者维护场景树。Leaflet 的答案是根本不解决,把层叠直接外包给浏览器,CSS 的 z-index 天生就是干这个的,还顺手白送硬件加速。
这也是矢量层默认走 SVG 渲染器的原因。一条 Polyline 就是一串 path 节点,插进 overlayPane,浏览器负责画。点多了扛不住?preferCanvas: true 一开,同一个 Path 接口底下换成 Canvas 渲染器批量绘制。两套渲染器共同继承 Renderer 基类,业务代码一行不用改。
选 DOM 不只省代码。无障碍焦点、文本选中、CSS 滤镜、打印,这些浏览器白送的能力 canvas 一概没有。Leaflet 的 tooltip 天生可以选中复制,换 canvas 引擎这些轮子都得自己造一遍。
瓦片是调度出来的,不是画出来的
src/layer/tile/GridLayer.js 898 行,是全库最大的文件,也是密度最高的。先说一个容易忽略的事实,瓦片永远放在整数缩放级别上。_setView() 里第一行就是
let tileZoom = Math.round(zoom);
你缩放到 12.6 倍,瓦片取 z 等于 13 那一层,整个瓦片容器被 _setZoomTransform() 用 getZoomScale() 算出的比例做 CSS transform 缩放。也就是说缩放动画全程是「旧图拉伸,新图淡入」,没有任何实时绘制。
真正的密度在加载调度上。
_update() 每次把视口内缺的瓦片坐标算进队列,队列按到视口中心的距离排序,中心先加载,边缘后加载。然后一次性塞进 DocumentFragment 批量插入,避免逐个 append 引发多次回流。keepBuffer 默认为 2,视口外多保留两行两列,你轻轻拖一下不至于看到白板。
其实吧,最有意思的是 _pruneTiles() 里的金字塔保留策略。一个当前可见的瓦片,Leaflet 会向上递归找它的父瓦片,最多 5 级,_retainParent() 的参数就是 z - 5,父瓦片已经加载过就保留。向上找不到,就向下保留子瓦片,最多 2 级。翻译成人话,放大时旧的大图留在底下垫着,新的高清层加载完淡入盖上去,缩小时反过来。你放大地图的瞬间从不看见空白,靠的就是这套金字塔。
说真的,细节控还可以再看两个默认值。updateWhenIdle 默认 false,拖动过程中边拖边加载,但检测到用户开了 reduced motion 就反过来,等拖完再加载,节流间隔也从 200 ms 拉到 5000 ms,这套检测写在 75 行的 Browser.js 里。瓦片加载完成后的 200 ms 淡入,在 _updateOpacity() 里用手写的 requestAnimationFrame 循环实现,不依赖 CSS transition。
对一个 2010 年就要兼顾 iPhone 3GS 的库来说,这些默认值全是实打实的移动端踩坑经验。
重写自己这件事,它干了两年半
v1.9.4 停在 2023 年 5 月 18 日。两年后的 2025 年 5 月,v2.0.0-alpha 发布,CHANGELOG 里自己说这是 two and a half years of hard work。
2.0 做的事可以概括为还债。砍掉 IE 和全部 polyfill,鼠标和触摸事件统一成 Pointer Events,源码改成纯 ESM 支持 tree shaking,全局变量 L 从核心包退役,想要旧体验有单独的 leaflet-global.js 兜底。矢量渲染器的基类也重构出新抽象 BlanketOverlay,统一管理覆盖整个视口的层。
最能看出年代感的是 Class 系统。1.x 的 src/core/Class.js 是 John Resig 式原型继承,Class.extend(props) 返回一个构造函数,靠 options 原型链合并、_initHooks 钩子数组、includes 混入,撑起了整个插件生态。文件开头还留着一行注释,Thanks to John Resig and Dean Edwards for inspiration。2.0 的 main 分支上,这个文件已经换成原生 class 语法,static include、mergeOptions、addInitHook,ES6 之后这些确实不需要手搓了。
不过注意,2.0 至今停在 alpha.1,2025 年 8 月 16 日之后没再出过新的预发布版。
一个开了十一年的 issue
现在说回那个 Math.round(zoom)。
整数放置加 CSS 拉伸的方案有代价。非整数缩放级别上,相邻两个 img 各自被拉伸,边缘落在半像素位置,不同浏览器对半像素的处理不一致,一条条白发丝似的缝隙就漏出来了。issue #3575,标题就叫 Space between tiles on fractional zoom levels,2015 年 6 月 30 日开出,201 条评论,到今天还开着。v1.9.4 的 CHANGELOG 里还躺着一条 Fix tile gaps in Chromium-based browsers,2023 年了还在给这个系列问题打补丁。
这不是 Leaflet 水平不行,是「DOM 当渲染引擎」这条路线的结构性代价。渲染权交出去,渲染的账也得认。
同类的还有 #1293,矢量跨 180 度经线画不连续,2013 年 1 月开出,69 条评论,同样还开着。地图开发者盼了十一年的 map idle 事件,#3178,2015 年开出,42 条评论,也一直没合入。
坦白讲,维护节奏也要诚实说。1.x 稳定版断档三年多,main 分支最近的提交十条里有七条是 dependabot 机器人升级依赖。贡献榜上 mourner 一个人 3708 次提交,第二名 443 次,差着将近十倍,bus factor 摆在那里。当然你也可以反过来想,一个 BSD 协议、1.4 万行、跑了 16 年的库,稳定到没人需要动它,这也是一种工程状态。README 开头那段乌克兰战争的呼吁还在,作者的家在基辅,README 里原话是,the future of Ukrainian citizens is the future of Leaflet。
什么时候还该选它
和后辈摆在一起看。
| 维度 | Leaflet | OpenLayers | MapLibre GL |
|---|---|---|---|
| 渲染方式 | DOM,矢量可选 SVG/Canvas | Canvas 2D | WebGL |
| 瓦片类型 | 栅格 | 栅格和矢量 | 矢量和栅格 |
| 体积 | 约 40kB gzip | 数倍于它 | 数倍于它 |
| 旋转和 3D | 无 | 无 | 有 |
| 定位 | 轻量嵌入 | 全功能 GIS | 现代体验 |
矢量瓦片是路线之争的核心。MapLibre GL 这类把样式化、地图旋转、3D 建筑全做进 WebGL,代价是体积和复杂度。Leaflet 的栅格路线决定了它做不了那些效果,但绝大多数地图需求,定位、标点、画圈、弹窗,根本用不上那些效果。
选型结论不纠结。往页面里嵌一个交互地图,展示几十上百个点,Leaflet 依然是性价比最高的选择,CDN 引两行代码就能跑,40kB 的体积用户无感。要矢量瓦片、地图旋转、3D 城市,直接上 MapLibre。要完整 GIS 能力和海量数据格式,选 OpenLayers。
带走一个判断
拆完这 1.4 万行,我带走的不是地图知识,是一个可以复用的架构判断,暂且叫它锚点不变量。
把系统的全部状态压缩成极少数锚点,Leaflet 只有一个 pixelOrigin,配上 zoom 和 center。锚点变了才重算,锚点没变,一切都只是缓存。渲染不自己造,交给平台最擅长的机制,DOM 层叠和 CSS transform。省下来的复杂度预算,全部花在调度和体验细节上,中心优先加载、金字塔保留、reduced-motion 降级、200 ms 淡入。
后来我翻别的几个跑了十几年的老库,多少都闻得到这个味道,状态压到最小,平台能力用满。Leaflet 没写渲染引擎不是偷懒,是想明白了什么不该写。

评论互动