18 KiB
前端面试题:养老场景电子围栏 — 绘制 + 越界检测 + 声光报警
题目描述
在 Vue 3 + 高德地图的微前端子应用中,实现一个养老院/社区电子围栏系统:
┌──────────────────────────────────────────────┐
│ sub-app-header │
├──────────────────────────────────────────────┤
│ 🚨 电子围栏报警!(越界时全屏覆盖) │
│ ✅ 老人在安全区域内 (状态标签) │
│ │
│ 🗺️ 高德地图 │
│ [绿色多边形围栏] + [👴 可拖拽老人] │
│ │
├──────────────────────────────────────────────┤
│ 🗺️ 电子围栏 [🔷绘制围栏][⭕圆形围栏][🗑️清除] │
│ 🎯 绘制提示条(顶点计数 + 闭合提示) │
├──────────────────────────────────────────────┤
│ 👴 老人控制 [➕添加][↑←→↓方向键][🗑️移除] │
├──────────────────────────────────────────────┤
│ 📋 报警记录(右侧浮层) │
└──────────────────────────────────────────────┘
核心要求:
- 支持多边形和圆形两种围栏绘制
- 老人标记可拖拽 + 方向键移动,每次移动即时检测越界
- 越界时触发声光报警:围栏变红、全屏浮层、蜂鸣音、报警记录
- 老人回到围栏内自动解除报警
- 组件卸载时完整清理所有地图覆盖物和事件监听
架构设计
文件分层
microapp-vue3/src/
├── composables/
│ ├── useAmap.ts ← 地图实例管理(单例加载 JSAPI、生命周期)
│ └── useGeofence.ts ← 电子围栏核心逻辑(围栏、标记、检测、报警)
├── views/
│ └── MapView.vue ← 视图层(布局、按钮、报警 UI)
├── config/amap.ts ← 密钥、版本、插件列表
└── vite-env.d.ts ← AMap 类型声明(Polygon/Circle/Marker/GeometryUtil...)
关键设计决策:逻辑与视图分离。 useGeofence composable 封装所有地图操作,MapView.vue 只负责 UI 渲染和事件绑定。好处:
- 围栏逻辑可在其他页面复用(如多围栏管理页)
- 单元测试只需测 composable,无需挂载 Vue 组件
- 视图层更换 UI 框架(如 Element Plus 面板)不影响核心逻辑
核心技术点拆解
1. 围栏绘制 — 交互设计与踩坑
多边形绘制流程
点击「🔷 绘制围栏」
→ map.setStatus({ doubleClickZoom: false }) ← 关键!禁用双击缩放
→ 注册 map.on('click', onClick)
→ 光标变 crosshair
每次单击:
├─ 地理距离 < 30m 且 已有点 ≥ 3? → 闭环完成 ✅(点击首顶点)
├─ 否则 → 添加顶点
│ ├─ new AMap.Marker(编号圆点)
│ ├─ new AMap.Polyline(虚线预览)
│ └─ 点数 ≥ 3?→ 销毁首顶点 → 重建为红色脉冲高亮标记
└─ 右键 → cancelDraw()
闭环(三种方式):
├─ 点击红色脉冲的首顶点(Haversine < 30m)
├─ 点击「✅ 闭合围栏(N点)」按钮
└─ 创建 AMap.Polygon → 清理绘制中间状态 → 恢复 doubleClickZoom
圆形绘制流程
点击「⭕ 圆形围栏」
→ 第 1 次点击:确定圆心(红色标记)
→ map.on('mousemove'):动态预览圆形(Haversine 实时计算半径)
→ 第 2 次点击:确定半径
→ 清理预览 Circle → 创建正式 Circle 围栏
→ 右键:取消
⚠️ 踩坑记录(面试中最有区分度)
| 踩坑 | 现象 | 根因 | 修复 |
|---|---|---|---|
| 双击闭合 | 双击后地图缩放 + 多了 2 个废顶点 | AMap 默认 doubleClickZoom: true 拦截双击;双击 = click+click+dblclick |
map.setStatus({ doubleClickZoom: false }) + 改用点击首顶点闭合 |
lngLatToContainer({lng,lat}) |
第 4 次点击报 Pixel(NaN, NaN) |
AMap v2 不接受普通对象,只接受 [lng,lat] 或 LngLat 实例 |
用 Haversine 地理距离替换像素距离判断,不再调用此方法 |
marker.setContent() |
更新标记内容后内部状态损坏 → NaN | AMap v2 的 setContent 触发重新布局,新旧内容尺寸不同时偏移计算异常 |
改为 destroy + new Marker 重建,确保内部状态干净 |
e.pixel 不可靠 |
偶发 NaN | 某些事件回调中 e.pixel 可能未被填充 |
一律使用 e.lnglat + Haversine,不依赖像素坐标 |
| scoped CSS 无效 | 自定义 Marker 样式不生效 | AMap 注入的 DOM 不带 Vue 的 data-v-xxx 属性 |
新增非 scoped 的 <style> 块,用全局 class 选择器 |
2. 老人标记管理
// 创建:HTML content + draggable + dragend 自动检测
const marker = new AMap.Marker({
position,
content: '<div class="geofence-person-marker">👴</div>', // ← CSS class,非 inline
offset: new AMap.Pixel(-17, -17), // 34×34 居中
draggable: true, // 允许拖拽
zIndex: 200, // 高于围栏(100)和顶点(120)
})
marker.on('dragend', () => {
// 每次拖拽结束 → 更新位置 → 立即检测越界
const pos = marker.getPosition()
personPosition.value = [pos.lng, pos.lat]
checkBoundary()
})
两种移动方式:
| 方式 | 实现 | 步长 | 适用场景 |
|---|---|---|---|
| 拖拽 | draggable: true + dragend 事件 |
任意 | 手动测试、演示 |
| 方向键 | movePerson(direction) ± 0.0005° |
~30-50m | 精确步进测试 |
为什么步长用 0.0005° 而非固定米数? 经纬度步长简单可靠,不依赖地图投影。对于养老场景的社区围栏(通常 100-500m 范围),像素级精度足够。
3. 越界检测 — 双算法兜底
function checkBoundary(): void {
const pp = personPosition.value
if (!pp || !fenceOverlay.value) return
let inside: boolean
if (fenceType.value === 'circle') {
// 圆形:AMap 内置 contains() 方法
inside = (fenceOverlay.value as AMap.Circle).contains(pp)
} else {
// 多边形:优先 AMap.GeometryUtil,兜底射线法
try {
inside = AMap.GeometryUtil.isPointInRing(
{ lng: pp[0], lat: pp[1] },
fenceVertices.value
)
} catch {
inside = isPointInPolygon(pp, fenceVertices.value) // 纯 JS 兜底
}
}
// 状态变迁检测:只在"进入→出去"时报警,"出去→进入"时解除
const wasInside = isInside.value
isInside.value = inside
if (!inside && wasInside) triggerAlarm(pp) // 刚刚越界
if (inside && !wasInside) resetAlarm() // 刚刚回归
}
为什么需要兜底? AMap.GeometryUtil 在某些加载顺序下可能未就绪(JSAPI 异步加载)。射线法作为纯数学算法始终可用:
射线法(Ray Casting):从目标点向右发射一条水平线
→ 统计与多边形各边的交点数
→ 奇数 = 内部,偶数 = 外部
状态变迁检测的设计意图: 不直接用 if (!inside) alert(),而是追踪 wasInside → isInside 的变化。这样:
- 持续在围栏外 → 不重复报警
- 出去再进来再出去 → 每次跨越边界都报警(累计次数 + 历史记录)
4. 报警系统 — 五通道并行的设计模式
越界时同时触发 5 个通道,每个通道独立可关闭:
triggerAlarm()
├─ 通道 1:视觉-围栏
│ updateFenceStyle(true)
│ 绿色实线 → 红色虚线(fillColor/strokeColor 切换)
│
├─ 通道 2:视觉-全屏浮层
│ alarmActive = true → v-if 渲染
│ 全屏红色脉冲背景 + 震动弹窗 + 脉冲图标
│ 点击任意处 → dismissAlarm()
│
├─ 通道 3:视觉-状态标签
│ fence-status--safe → fence-status--alarm
│ "✅ 安全" → "🚨 已越界!"
│
├─ 通道 4:听觉-蜂鸣
│ startAlarmSound()
│ Web Audio API → OscillatorNode 方波 880Hz
│ 每 800ms 蜂鸣一次(setInterval)
│ 0.3 秒 duration + exponentialRampToValueAtTime 淡出
│
└─ 通道 5:记录-历史
alarmHistory.push({ time, position })
右侧浮层实时展示
updateFenceStyle 的兼容处理:
// AMap 不同版本可能没有 setOptions,兜底用 Object.assign
poly.setOptions?.(style) || Object.assign(poly as any, style)
5. 响应式设计 — shallowRef 的正确用法
// ✅ 正确:shallowRef — 只在引用替换时触发更新
const fenceOverlay = shallowRef<AMap.Polygon | AMap.Circle | null>(null)
const personMarker = shallowRef<AMap.Marker | null>(null)
// ❌ 错误:ref — 会深度追踪 AMap 实例的数千个内部属性
// const fenceOverlay = ref<AMap.Polygon | null>(null)
// → setCenter/setZoom 等操作触发深度 diff → 性能灾难
| 存储方式 | 适用场景 | 代价 |
|---|---|---|
shallowRef |
地图实例、覆盖物(内部状态由 AMap 自行管理) | 无性能损耗 |
ref |
简单值(坐标数组、布尔、计数) | 需要响应式追踪 |
computed |
派生状态(hasFence、hasPerson) |
自动缓存 |
6. 生命周期清理 — 避免内存泄漏的完整链条
onUnmounted(() => {
destroyGeofence()
├─ stopAlarmSound() // 清理 setInterval + Oscillator
├─ clearFence() // map.remove(polygon/circle) + 重置状态
├─ removePerson() // map.remove(marker) + 停止报警
└─ cancelDraw() // 清理预览线/顶点/事件监听 + 恢复 doubleClickZoom
})
事件监听的清理机制:
// 绑定:将 handler 引用存储到 map 实例的自定义属性上
;(map as any)._geofenceClick = onClick
map.on('click', onClick)
// 清理:通过引用精确 off,避免误删其他监听器
const m = map as any
if (m._geofenceClick) {
map.off('click', m._geofenceClick)
delete m._geofenceClick
}
完整调用链路图
用户操作 → 电子围栏响应
═══════════════════════════════════════════
┌─ 绘制 ─────────────────────────────────────┐
│ [🔷绘制围栏] → startDrawPolygon() │
│ → map.setStatus({ doubleClickZoom:false }) │
│ → 逐一点击 → onClick(e) │
│ → 每点:new Marker + new Polyline │
│ → ≥3点:首顶点变红脉冲 │
│ → 点击首顶点 / [✅闭合] → finishDraw() │
│ → finishPolygonDraw() │
│ → new AMap.Polygon(path, FENCE_STYLE) │
│ → cleanupDrawing(map) │
│ → map.setStatus({ doubleClickZoom:true})│
└────────────────────────────────────────────┘
┌─ 老人 ─────────────────────────────────────┐
│ [➕添加老人] → addPerson() │
│ → new AMap.Marker({draggable:true}) │
│ → marker.on('dragend', checkBoundary) │
│ → checkBoundary() // 初始检测 │
│ │
│ [↑↓←→] / 拖拽 → movePerson() │
│ → marker.setPosition([lng, lat]) │
│ → checkBoundary() // 每次移动立即检测 │
└────────────────────────────────────────────┘
┌─ 检测 ─────────────────────────────────────┐
│ checkBoundary() │
│ ├─ 无围栏 → isInside = true (skip) │
│ ├─ 圆形 → Circle.contains(point) │
│ └─ 多边形 → GeometryUtil.isPointInRing() │
│ └─ 失败 → isPointInPolygon() │
│ └─ wasInside && !inside → triggerAlarm() │
│ └─ !wasInside && inside → resetAlarm() │
└────────────────────────────────────────────┘
┌─ 报警 ─────────────────────────────────────┐
│ triggerAlarm(position) │
│ ├─ alarmCount++ │
│ ├─ alarmHistory.push({time, position}) │
│ ├─ updateFenceStyle(true) // 围栏变红虚线 │
│ ├─ alarmActive = true // 浮层出现 │
│ └─ startAlarmSound() // 蜂鸣开始 │
│ │
│ dismissAlarm() / resetAlarm() │
│ ├─ alarmActive = false │
│ ├─ updateFenceStyle(false) // 恢复绿色 │
│ └─ stopAlarmSound() // 蜂鸣停止 │
└────────────────────────────────────────────┘
考察点汇总
| 层级 | 考察点 | 难度 |
|---|---|---|
| 架构设计 | Composable 模式:逻辑/视图分离、单一职责 | ⭐⭐⭐ |
| 响应式 | shallowRef vs ref vs computed 的选择理由 |
⭐⭐ |
| 几何算法 | 射线法(Ray Casting)原理 + Haversine 公式 | ⭐⭐⭐⭐ |
| 地图 SDK 踩坑 | doubleClickZoom、lngLatToContainer 参数格式、setContent 副作用 |
⭐⭐⭐⭐⭐ |
| 交互设计 | 三种闭环方式(点击首顶点/按钮/右键取消)的 UX 考量 | ⭐⭐⭐ |
| 事件管理 | 动态事件绑定/解绑、闭包捕获、内存泄漏防范 | ⭐⭐⭐ |
| Web Audio API | OscillatorNode + GainNode + exponentialRampToValueAtTime | ⭐⭐⭐ |
| 生命周期 | onUnmounted 中 4 步清理链(音效→围栏→标记→绘制态) |
⭐⭐ |
| CSS 隔离 | scoped vs 全局样式在 AMap 自定义标记中的应用 | ⭐⭐⭐ |
| 状态机 | wasInside/inside 变迁检测避免重复报警 | ⭐⭐ |
加分项
- 能解释为什么不用
AMap.MouseTool而手动处理 click 事件 — 更细粒度的交互控制(首顶点高亮、实时顶点计数、三通道闭环) - 能指出
setContent的替代方案 — destroy + new Marker 重建,或使用setOptions(如果 SDK 支持) - 能说明
lngLatToContainer的参数格式陷阱 — AMap v2 只接受[lng, lat]数组或LngLat实例,不接受{lng, lat}普通对象 - 能提出改进方向:
- 支持多个围栏 + 多个老人(一对多/多对多关系)
- 使用
watchPosition替代getCurrentPosition实现实时 GPS 追踪 - 报警信息通过 WebSocket 推送到护工端
- 围栏数据持久化(localStorage/后端)+ 编辑已有围栏的顶点
- 使用 AMap 的
PolygonEditor插件支持围栏二次编辑
- 能分析
movePerson的步长选择 — 0.0005° 约 30-50 米的经纬度换算
追问方向
追问 1:如果围栏有 100 个顶点,isPointInPolygon 的性能如何?
期望回答: 射线法时间复杂度 O(n),n 为顶点数。100 个顶点约 0.01ms。但养老场景围栏通常 4-20 个顶点,性能完全不是瓶颈。如果真有数千顶点,可以先用 bounding box 做粗筛。
追问 2:圆形围栏的 contains() 精度取决于什么?
期望回答: AMap.Circle.contains() 基于球面几何计算,精度受限于:
- 地球半径常量(AMap 使用 6378137m)
- 高纬度地区 Mercator 投影变形
- 极小的圆(<1m)可能受浮点精度影响 对于养老场景(通常 100-1000m 半径),精度完全够用。
追问 3:alarmActive 为什么用 ref 而不用 shallowRef?
期望回答: alarmActive 是布尔值,不是对象。ref(true) 和 shallowRef(true) 对原始值行为完全相同——Vue 对原始类型不会深度追踪。但用 ref 语义更清晰("这是一个需要响应式的值")。
追问 4:如果老人恰好在围栏边界线上,isPointInRing 返回什么?
期望回答: 取决于浮点精度和算法实现。射线法在边界线上行为不确定(可能 true 也可能 false)。生产环境应该在边界 ±2m 加一个模糊区间(hysteresis),避免在边界反复触发报警/解除。
追问 5:startAlarmSound 中为什么要 try-catch?
期望回答:
- 部分浏览器(如 iOS Safari)要求 AudioContext 必须在用户手势中创建
- 某些企业环境可能禁用 Web Audio API
- setInterval 可能被浏览器节流(后台标签页降至 1s)
- 静默失败比页面崩溃更优雅——视觉报警仍然工作
追问 6:为什么不在 drag 事件中检测,而是 dragend?
期望回答: drag 事件每像素触发一次,拖拽 100px 就触发 100 次 checkBoundary,其中 99 次是浪费。dragend 只触发一次,刚好够。"边界穿越"在拖拽中段发生还是在终点发生,对报警结果无影响——报警只需要知道"最终位置是否在围栏外"。