🌏 Google Maps API 最佳实践:如何优雅地在 Vue 项目中封装地图服务

5 阅读6分钟

前言

之前在分享路线规划功能时,由于篇幅和展示限制,很多核心逻辑没有完整呈现。所以打算开一个专题:在 JavaScript 中究竟该如何优雅、高效且节省成本地调用 Google Maps API?

本文将作为“Google Maps 开发专题”的核心篇章,以技术分享的视角,深度剖析项目中的 src/utils/googleMaps.js 文件。我们将从初始化策略按需加载地址补全与会话管理数据清洗,一步步讲解如何将其封装为一个健壮的公共工具库。

📚 官方文档参考
本文涉及的 API 均基于 Google Maps Platform 官方文档:
Place Class Data Fields


🚀 为什么要封装?

在直接查看代码之前,我们先思考一个问题:为什么不直接在 Vue 组件里写 new google.maps.Map()

如果在每个需要地图的 .vue 文件中都单独引入 API,你会面临以下灾难:

  1. 重复加载与性能损耗
    每个组件都试图向 <head> 注入 <script> 标签,不仅浪费网络资源(Google Maps SDK 并不小),还可能引发 google is not definedAPI loaded multiple times 错误。
  2. 昂贵的计费陷阱
    如果不封装 Places Autocomplete 的 Session Token 逻辑,用户每一次键盘敲击(如输入 "New York" 的 "N", "Ne", "New"...)都会算作一次 API 调用。未优化的调用方式可能导致费用高出数十倍
  3. 维护噩梦
    无论是更换 API Key、调整语言(zh-CN vs en)、还是修改版本号,分散在几十个文件中的硬编码将让维护变得极其痛苦。
  4. 异步地狱
    Google Maps API 是异步加载的。你需要一个统一的机制来确保:在 API 真正就绪前,任何地图操作都应处于“等待”状态,而不是直接报错。

因此,我们需要一个 src/utils/googleMaps.js,作为一个单例(Singleton) 适配器,统一管理所有与 Google 地图的交互。


🛠️ 核心代码深度解析

让我们逐块分析 src/utils/googleMaps.js 的实现思路。所有代码片段均取自该文件。

1. 单例初始化与动态加载 (The Loader)

为了防止地图脚本被重复加载,我们使用了一个闭包变量 initPromise 来缓存加载状态。这也是避免很多报错的关键 包括以上我们提到的 google is not defined、API loaded multiple times、Google map 的重复加载等。

/* src/utils/googleMaps.js */
import { setOptions, importLibrary } from "@googlemaps/js-api-loader"

let isInitialized = false
let initPromise = null

/**
 * 核心初始化函数
 * 确保 Google Maps SDK 只会被加载和配置一次
 */
export function initGoogleMaps(apiKey) {
  // 1. 如果已经加载完成,直接返回 resolved promise
  if (isInitialized) return Promise.resolve()

  // 2. 如果正在加载中(Promise pending),返回同一个 Promise
  // 这防止了多个组件同时挂载时触发多次网络请求
  if (initPromise) return initPromise

  // 3. 开始初始化
  initPromise = (async () => {
    try {
      if (!isInitialized) {
        // 设置全局配置,如 API Key 和版本
        setOptions({
          key: apiKey,
          v: "weekly", // 建议使用 weekly 或 quarterly 以保持稳定
        })
        isInitialized = true
      }
    } catch (error) {
      console.error("Failed to initialize Google Maps:", error)
      initPromise = null // 出错重置,允许后续重试
      throw error
    }
  })()

  return initPromise
}

技术亮点:动态库加载 (Dynamic Library Import)
传统的做法是一次性加载所有功能。而现在的最佳实践是使用 importLibrary
我们封装了 loadGoogleMapsLibrary,支持按需加载 mapsplacesroutes 等库。

  • 首屏优化:如果用户只是在看静态页面,不需要加载庞大的 routes 算法库。
  • 代码分割:Google 在内部也做了大量的代码分割,按需加载能显著减少主线程阻塞。
export async function loadGoogleMapsLibrary(libraryName) {
  return await importLibrary(libraryName)
}

2. 智能的地址补全(Autocomplete & Session Tokens)

这是最需要注意钱包的地方!💸

Google Places API 的计费机制中有一个概念叫 Session

  • 如果不使用 Session:用户输入 "Airport",敲击 7 次键盘,触发 7 次请求,计费 7 次。
  • 使用 Session:从用户开始输入,到最终选中某个地点的整个过程(无论敲击多少次),只算作一次请求。

我们在封装中强制引入了 SessionToken 的概念:

/**
 * 创建一个独立的会话 Token
 * 在用户聚焦输入框时调用此方法
 */
export async function createAutocompleteSessionToken(apiKey) {
  await initGoogleMaps(apiKey)
  const { AutocompleteSessionToken } = await loadGoogleMapsLibrary("places")
  return new AutocompleteSessionToken()
}

/**
 * 获取建议列表
 * 自动处理了库加载、Token 传递和空值校验
 */
export async function fetchAutocompleteSuggestions(options) {
  const { input, sessionToken, ...rest } = options

  if (!input || !input.trim()) return []

  // 1. 加载 places 库
  const { AutocompleteSuggestion } = await loadGoogleMapsLibrary("places")

  // 2. 构建请求,务必带上 sessionToken
  const request = {
    input: input.trim(),
    sessionToken: sessionToken, // ✅ 关键:传入 Token 节省费用
    language: "zh-CN",
    ...rest,
  }

  try {
    const { suggestions } =
      await AutocompleteSuggestion.fetchAutocompleteSuggestions(request)
    return suggestions || []
  } catch (error) {
    console.error("Autocomplete error:", error)
    return []
  }
}

3. 数据清洗 (Data Normalization)

Google Maps 返回的 Place 对象结构非常深且复杂(包含大量的 Getter 方法、类实例等)。如果直接把这个对象传给 Vue 的 templatestate,可能会导致序列化问题,且使用起来非常繁琐(例如必须写 place.location.lat() 这种函数调用,而且有什么你还根本拿不到一些信息,比如你在智能的地址补全中,你要获取地址的简称,如果你不进行出来而是在vue的Script 中就直接使用返回的数据去获取,你会得到undefined。具体的原因有可能是vue的Proxy 陷阱,后面在单独一个文章讲解这个吧)。

为了让业务组件更专注,我们封装了 placeToLocationData 函数,作为适配器模式的体现:

/**
 * 将复杂的 Google Place 对象转换为扁平的纯 JSON 对象
 */
export function placeToLocationData(place) {
  return {
    place_id: place.placeId || "",
    name: place.displayName || "",
    formatted_address: place.formattedAddress || "",
    // 将经纬度方法转换为纯数字对象,方便存储和传递
    geometry: {
      location: place.location
        ? {
            lat: place.location.lat(), // 转换 lat() 方法为属性
            lng: place.location.lng(), // 转换 lng() 方法为属性
          }
        : null,
    },
  }
}

收益:

  • 解耦:组件层不需要关心 Google Maps 的内部数据结构。即使 Google 升级了 API,我们也只需要修改这一个转换函数。
  • 响应式友好:纯 JSON 对象在 Vue/React 的状态管理中性能更好。

4. 路线规划 (Routing)

路线规划通常涉及复杂的参数配置。即使代码看起来很简单,封装的好处在于我们可以预设一些默认行为,并统一错误处理。

export async function computeRoutes(apiKey, request) {
  await initGoogleMaps(apiKey)
  // 动态加载路由库,这是体积最大的库之一,只有在真正需要计算路线时才加载
  const { Route } = await loadGoogleMapsLibrary("routes")

  return await Route.computeRoutes(request)
}

💡 总结与最佳实践

回顾我们的 googleMaps.js,它并不仅仅是一个工具函数集合,它是防御性编程工程化思维的体现:

  1. Loader 模式:通过 Promise 单例处理异步加载,彻底消灭 google is not defined
  2. 按需加载:利用 importLibrary 拆分体积,提升页面性能。
  3. 成本控制:通过 SessionToken 的强制封装,在架构层面帮公司/个人省钱。
  4. 适配器模式:通过数据清洗,隔离第三方 SDK 的复杂性,保持业务逻辑纯净。

下一步建议

在实际项目中,还可以进一步扩展此文件,例如:

  • 封装 Map 实例的创建,统一设置地图样式(Dark Mode)但这往往需要你在控制台 开通对应的地图id。
  • 添加 Markers 的批量管理和聚合(Clustering)。

希望这篇文章能帮助大家构建更健壮的前端地图应用!