第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: '&copy; 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>

三个必须知道的边界:

  1. Geolocation 只在 HTTPS(或 localhost)下可用,源码注释明确写了这一点。HTTP 页面调用会直接进 locationerror。
  2. 失败时 e.code 是 W3C 错误码:1 用户拒绝、2 位置不可用、3 超时;Leaflet 额外加了一个 code: 0 表示浏览器不支持。文案要根据 code 区分,不要统一弹“定位失败”。
  3. 持续监听要用 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 彻底退出无障碍树与焦点顺序