第17章 - 移动端响应式与无障碍
17.1 移动端不是“窄一点的桌面”
Leaflet 从设计之初就面向移动端,但 2.0 的变化让“移动端适配”这件事的落点变了:Pointer Events 统一了鼠标、触摸、触控笔;tapHold 在所有平台默认开启;触摸相关的 CSS 从设备判断改成了能力判断。照 1.x 的老经验写,会出现两类典型故障——悬停效果在手指上卡住、双击缩放在 iOS 上触发两次。
无障碍同理。2.0 把 aria-keyShortcuts 从默认开启改为默认关闭(因为键名只对美式键盘正确),给地图容器加了 role="region" 与 aria-label,默认 marker 图标换成内联 SVG 并带可翻译的 alt。这些默认值是本章的起点,不是可选项。
17.2 触摸交互的参数
与触摸直接相关的 Map 选项:
| 选项 | 默认值 | 说明 |
|---|---|---|
tapHold |
true |
长按触发 contextmenu(iOS Safari 原生不触发) |
tapTolerance |
15 |
手指移动不超过该像素数才算 tap |
pinchZoom |
true |
双指捏合缩放;旧名 touchZoom 仍生效但打印弃用警告 |
bounceAtZoomLimits |
true |
捏合超过缩放下限时回弹 |
dragging |
true |
拖动平移 |
inertia |
true |
惯性滑动 |
inertiaMaxSpeed |
1400 |
惯性最大速度(像素/秒),2.0 从 Infinity 收紧 |
tapTolerance 是最常被调错的参数。默认 15px 是“手指抖动”的合理阈值;触控笔或戴手套操作可以调到 20~25,但调太大之后,轻微拖动会被误判成点击,marker 的点击与地图平移会同时触发。
tapHold 在 2.0 对所有平台默认开启。带来的副作用是:任何长按都会触发 contextmenu,如果业务在 contextmenu 上绑了菜单,移动端用户长按地图就会弹出来。不想要这个行为就显式关闭:
import {LeafletMap} from 'leaflet';
const map = new LeafletMap('map', {
center: [39.9042, 116.4074],
zoom: 12,
tapHold: false, // 移动端长按不再触发 contextmenu
tapTolerance: 18,
zoomSnap: 0.5, // 允许半级缩放,捏合更细腻
zoomDelta: 0.5
});
17.3 让触摸手势不被浏览器抢走
移动端地图卡住不动,十有八九是 touch-action 没配对。Leaflet 通过两个类切换 CSS:
.leaflet-container.leaflet-touch-zoom { touch-action: pan-x pan-y; }
.leaflet-container.leaflet-touch-drag { touch-action: pinch-zoom; }
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { touch-action: none; }
三类对应关系:只开捏合缩放时允许浏览器接管单指滚动;只开拖动时禁止单指滚动但允许浏览器捏合;两者都开时 touch-action: none,全部手势交给 Leaflet。反过来说,如果页面里用自定义手势库接管了触摸,或者给地图容器设了 touch-action: pan-y,Leaflet 的拖动就会失效。
容器尺寸也属于移动端问题。官方示例的写法是:
html, body, #map {
height: 100%;
margin: 0;
padding: 0;
}
移动浏览器地址栏收放会改变 100vh 的实际高度,导致地图底部被截。稳妥做法是用 100dvh(动态视口高度),并对不支持的老浏览器回退:
#map {
height: 100vh;
height: 100dvh;
width: 100vw;
}
尺寸变化后必须通知 Leaflet 重算。trackResize 默认 true,2.0 用 ResizeObserver 监听容器尺寸(1.x 监听 window.resize),所以容器自身变化(侧栏展开、tab 切换)也能触发。但 display: none 隐藏再显示这类变化不会产生有效尺寸,需要手动调:
// 容器从 display:none 恢复后
map.invalidateSize({pan: false, debounceMoveend: true});
17.4 定位:map.locate 的参数
map.locate() 封装了 Geolocation API,参数与默认值直接来自源码:
| 选项 | 默认值 | 说明 |
|---|---|---|
watch |
false |
true 时持续监听位置变化(watchPosition) |
setView |
false |
自动把视图移到定位结果 |
maxZoom |
Infinity |
setView 时允许的最大缩放级别 |
timeout |
10000 |
等待定位结果的毫秒数,超时触发 locationerror |
maximumAge |
0 |
允许使用多旧的缓存定位(毫秒) |
enableHighAccuracy |
false |
高精度模式,耗电与耗时都更高 |
完整示例(移动端全屏地图 + 定位):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>移动端定位地图</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@2.0.0-beta.3/dist/leaflet.css">
<style>
html, body, #map { height: 100%; margin: 0; }
#map { height: 100dvh; }
</style>
</head>
<body>
<div id="map" aria-label="附近设施地图"></div>
<script type="importmap">
{
"imports": {
"leaflet": "https://cdn.jsdelivr.net/npm/leaflet@2.0.0-beta.3/dist/leaflet.js"
}
}
</script>
<script type="module">
import {LeafletMap, TileLayer, Marker, Circle, DomUtil} from 'leaflet';
const map = new LeafletMap('map').setView([39.9042, 116.4074], 12);
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
function onLocationFound(e) {
const accuracy = Math.round(e.accuracy);
new Marker(e.latlng, {alt: '我的位置'}).addTo(map)
.bindPopup(`当前位置,精度约 ${accuracy} 米`)
.openPopup();
new Circle(e.latlng, {radius: e.accuracy, weight: 1, color: '#3388ff'}).addTo(map);
}
function onLocationError(e) {
DomUtil.create('div', 'leaflet-bar').textContent = e.message;
console.warn('定位失败', e.code, e.message);
}
map.on('locationfound', onLocationFound);
map.on('locationerror', onLocationError);
// 关键:必须在 HTTPS 下调用
map.locate({setView: true, maxZoom: 16, timeout: 8000, enableHighAccuracy: true});
</script>
</body>
</html>
三个必须知道的边界:
- Geolocation 只在 HTTPS(或 localhost)下可用,源码注释明确写了这一点。HTTP 页面调用会直接进
locationerror。 - 失败时
e.code是 W3C 错误码:1用户拒绝、2位置不可用、3超时;Leaflet 额外加了一个code: 0表示浏览器不支持。文案要根据 code 区分,不要统一弹“定位失败”。 - 持续监听要用
watch: true并在不需要时调map.stopLocate(),否则 GPS 会一直开着耗电。
17.5 无障碍:Leaflet 已经做了什么
2.0 内建的无障碍能力,不需要额外代码:
| 能力 | 实现 |
|---|---|
| 地图语义 | 容器自动加 role="region" 与 aria-label(取 translate('Map')),已有属性则不覆盖 |
| 键盘可聚焦 | keyboard: true 时容器 tabIndex = 0,可 Tab 进入 |
| 地图导航 | 方向键平移、+/- 缩放、Shift 三倍步长、Escape 关闭弹窗 |
| marker 可聚焦 | keyboard: true(默认),Tab 可达;Enter/Space 触发 click |
| marker 名称 | alt 默认 'Marker',title 默认空 |
| 控件语义 | 缩放/全屏/图层按钮带 role="button" 与 aria-label;Space 可激活 |
| 焦点保持 | 点击控件后焦点留在控件上(1.x 的 _refocusOnMap 已移除) |
| 减少动画 | 尊重 prefers-reduced-motion |
| 弹窗 | 关闭按钮有 aria-label,Escape 可关,焦点从键盘打开的弹窗移入 |
官方示例给出的快捷键表:
| 按键 | 动作 |
|---|---|
Tab / Shift+Tab |
在地图、marker、控件之间移动 |
| 方向键 | 平移地图 |
+ 或 =,- 或 _ |
放大、缩小 |
平移或缩放键配合 Shift |
三倍步长 |
marker 上按 Enter / Space |
打开弹窗 |
Escape |
关闭弹窗 |
页面上没有任何视觉提示告诉用户这些键存在,所以要在图例或帮助里写出来;想让读屏软件播报快捷键,开启 ariaKeyShortcuts:
const map = new LeafletMap('map', {
ariaKeyShortcuts: true // 设为字符串可为非美式键盘自定义,如 'Left Right = -'
});
注意 aria-keyshortcuts 只接受 ARIA 键名,跟 KeyboardEvent.key 不是同一套;源码里 true 写死的是 'ArrowLeft ArrowRight ArrowUp ArrowDown = Plus -'。
17.6 marker 必须有无障碍名称
marker 是纯图形,读屏软件只能靠 alt 或 title 得到名称。默认 alt 是 'Marker',一百个 marker 会被念一百次 “Marker”。每个业务 marker 都应该给具体名称:
import {Marker, DivIcon} from 'leaflet';
// 正确:alt 说明了这是什么
new Marker([39.9042, 116.4074], {alt: '天安门广场'})
.addTo(map)
.bindPopup('天安门广场');
// DivIcon 也需要名称:alt 无效时用 title,或把文字放进 HTML
new Marker([39.90, 116.42], {
icon: new DivIcon({html: '<span class="pin">医院</span>', className: 'my-pin'}),
title: '人民医院'
}).addTo(map);
对 DivIcon,alt 不生效(它不是 <img>),要用 title 或者把可读文本放进 HTML。纯装饰性的图标应加 aria-hidden="true",避免读屏软件念出无意义的字符。
17.7 内置文案国际化
控件的 “Zoom in”、“Layers”、“Enter fullscreen” 都是英文。2.0 提供了统一的翻译入口 map.translate,在渲染时调用:
const zh = {
'Map': '地图',
'Marker': '标记',
'Zoom in': '放大',
'Zoom out': '缩小',
'Layers': '图层',
'Enter fullscreen': '进入全屏',
'Exit fullscreen': '退出全屏',
'Close popup': '关闭弹窗'
};
const map = new LeafletMap('map', {
translate: text => zh[text] // 返回 undefined 则保持英文
});
源码里 Map.translate() 的实现是 this.options.translate?.(text) ?? text,所以翻译表缺项不会报错,只是回落英文。翻译发生在渲染时刻,必须在添加控件与打开弹窗之前设置,运行期改 options.translate 对已渲染的控件无效。
已经用了 i18n 库的项目直接桥接:translate: text => i18next.t(text)。
17.8 纯装饰地图与焦点陷阱
不是所有地图都要交互。封面图、背景装饰类地图应该完全退出无障碍树:
<!-- inert:不可聚焦、读屏跳过 -->
<div id="decorative-map" inert></div>
inert 是原生属性,比 aria-hidden 更彻底——它同时禁止焦点进入。只用 aria-hidden="true" 而地图里还有 marker 按钮,会出现“读屏看不见但 Tab 能聚焦”的割裂状态,这是无障碍审计里的经典问题。
反过来,真正需要交互的地图应该给足够大的触控目标。内置控件按钮是 30px 见方(.leaflet-bar a 的固定尺寸),WCAG 2.2 的最低要求是 24×24 CSS px,30px 达标但不宽裕。自己加的一排图标按钮建议不小于 36×36。
17.9 响应式布局的两种做法
做法一:控件重排。窄屏时把比例尺从右下移到右上,避免与版权条重叠:
import {ScaleControl, AttributionControl} from 'leaflet';
const scale = new ScaleControl({position: 'bottomleft'}).addTo(map);
const attr = new AttributionControl({position: 'bottomright'}).addTo(map);
const mq = matchMedia('(max-width: 640px)');
function layout() {
const narrow = mq.matches;
scale.setPosition(narrow ? 'topright' : 'bottomleft');
attr.setPosition(narrow ? 'bottomleft' : 'bottomright');
}
layout();
mq.addEventListener('change', layout);
setPosition() 的实现是 remove() 再 add(),DOM 与事件监听都会重建,所以不要在 resize 里无条件调用。
做法二:页面级布局。地图放进 flex 容器,侧栏在窄屏折叠为抽屉。关键点始终是:容器尺寸变化后 Leaflet 必须知道,trackResize 只处理“容器自身尺寸变化”,父级布局用 transform 缩放容器时 Observer 不会触发,需要手动 invalidateSize()。
17.10 排错
手机上地图完全不能拖动。 检查容器或祖先元素的 touch-action。设为 pan-y、pan-x 或页面级 touch-action: manipulation 都会截走手势。Leaflet 需要 .leaflet-touch-drag 类的对应规则生效,自定义 CSS 里不要用 !important 覆盖。
双击缩放触发两次,或长按弹出系统菜单。 2.0 已修复 iOS 上 contextmenu 与 click 的顺序问题,但前提是走 Leaflet 的事件路径。直接给容器加原生 addEventListener('click') 会绕过内部判断。用 map.on('click')。
marker 悬停效果在触摸屏上不消失。 pointerover 由触摸也会触发,而 pointerout 的到达时机不可靠。悬停 UI 先判断 e.originalEvent.pointerType === 'mouse'。
地图高度为 0 或只显示一条。 容器没有显式高度。Leaflet 要求容器有确定尺寸,height: 100% 在父级没高度时等于 0。用 100dvh 或给父级固定高度。
display:none 的 tab 切回来后地图错位。 ResizeObserver 在隐藏期间收到 0 尺寸,恢复后需要 map.invalidateSize()。
读屏软件把地图念成“区域”但没有名称。 容器已有 aria-label 时 Leaflet 不会覆盖,检查页面是否自己设了空值。设 aria-label="..." 或通过 translate('Map') 返回中文。
Tab 键能进入 marker 却看不到焦点框。 2.0 用 :focus-visible 控制焦点轮廓,鼠标点击不显示、键盘导航显示。如果全局 CSS 里 outline: none 一刀切,键盘用户就完全失去位置感,至少给 .leaflet-interactive:focus-visible 保留轮廓。
定位一直失败但 HTTPS 正常。 检查权限状态(navigator.permissions.query({name: 'geolocation'}))与 timeout。enableHighAccuracy: true 在室内可能等到超时;把 timeout 提到 15000 或关掉高精度。
17.11 生产建议
- 移动端把
tapHold显式设成业务想要的值,不要依赖默认。 - 触控目标统一 36px 以上,比 WCAG 的 24px 下限更稳。
- 每个 marker 都写
alt或title,这是成本最低的无障碍改进,只需一行。 - 项目第一天就接
translate,不要等上线前再翻源码找英文字符串。 - 用真机测三件事:单指拖动、双指缩放、地址栏收放后的地图高度。桌面模拟器测不出 touch-action 与 100vh 的问题。
- 无障碍验收至少跑一遍纯键盘操作与一次读屏(Windows Narrator 或 NVDA),自动化工具只能查属性缺失,查不出焦点顺序是否合理。
17.12 本章小结
| 要点 | 结论 |
|---|---|
| 触摸参数 | tapTolerance 15、tapHold 2.0 起默认开、pinchZoom 取代 touchZoom |
| touch-action | 由 leaflet-touch-zoom/leaflet-touch-drag 两个类切换,自定义 CSS 别覆盖 |
| 视口 | 100dvh 处理地址栏收放;容器必须有确定高度 |
| 尺寸变化 | trackResize 用 ResizeObserver;隐藏后恢复要 invalidateSize() |
| 定位 | 仅 HTTPS;timeout 10000、maximumAge 0、watch false |
| 地图语义 | 自动 role="region" + aria-label;ariaKeyShortcuts 默认关,需显式开 |
| 键盘 | 方向键、+/-、Shift ×3、Escape 关弹窗 |
| marker | alt 默认 “Marker”,必须按业务改名;DivIcon 用 title |
| 国际化 | map.translate 在渲染时调用,添加控件前设置 |
| 装饰地图 | 用 inert 彻底退出无障碍树与焦点顺序 |