腾讯地图接入实录:我如何给博客增加足迹功能

记录我如何用 Hugo、TypeScript 和腾讯地图,把照片放到地图上。

最近我给博客增加了一个“足迹”板块。最初的想法很简单:把我拍过的照片放到地图上,让照片和地点对应起来。

以前照片只是文章里的附件,放在某篇文章下面。这样虽然能保存照片,但过一段时间之后,很难快速回忆“这张照片是在什么地方拍的”。所以我想做一个单独的页面,用地图记录地点,再用照片补充记忆。

先把一张照片放到地图上

第一条样例足迹使用的是一张 2017 年在香港大学梅堂拍摄的照片。

这张照片原本带有 EXIF 信息,里面包括拍摄日期、照片方向和 GPS 坐标。我先从原图中读取这些信息,再把它们整理成足迹数据:

locations:
  - id: hku-may-hall
    name: 香港大学 · 梅堂
    date: "2017-01-16"
    lat: 22.282117
    lng: 114.139808
    country: 中国
    province: 香港
    city: 香港
    category: architecture
    category_name: 建筑
    description: 在香港大学梅堂前留下的一张照片。
    cover: /images/footprints/hong-kong-2017-thumb.webp
    photos:
      - src: /images/footprints/hong-kong-2017.webp
        thumb: /images/footprints/hong-kong-2017-thumb.webp
        alt: 2017 年在香港大学梅堂前的留影
        caption: 香港大学 · 梅堂

目前地点数据放在 Hugo 的 data/footprints.yaml 中。这样做的好处是结构清晰,新增地点时只需要增加一段数据,不需要修改页面模板。

页面使用 Hugo 和原生 TypeScript

足迹页面没有引入数据库,也没有使用 React 或 Vue,而是继续沿用博客现有的 Hugo 结构。

主要文件包括:

  • data/footprints.yaml:地点和照片数据;
  • layouts/page/footprints.html:生成页面结构;
  • assets/ts/footprints.ts:处理地图和交互;
  • assets/scss/footprints.scss:处理样式和响应式布局;
  • config.toml:配置腾讯地图 Web Key。

Hugo 构建页面时,会把 YAML 数据写进 HTML:

<script type="application/json" data-footprints-data>
  ...
</script>

浏览器加载页面后,TypeScript 再读取这段 JSON,完成搜索、筛选、地图标记和照片浏览。

整体运行逻辑是:

footprints.yaml
        ↓
Hugo 生成静态 HTML
        ↓
页面内嵌地点 JSON
        ↓
TypeScript 读取数据
        ↓
腾讯地图显示坐标
        ↓
用户进行筛选和照片浏览

腾讯位置服务怎么获取

地图部分使用腾讯地图 JavaScript API GL。

申请流程大致是:

  1. 注册或登录腾讯位置服务控制台;
  2. 进入“应用管理”中的“我的应用”;
  3. 创建一个应用;
  4. 在应用中添加 Key;
  5. 启用 JavaScript API GL;
  6. 配置博客域名白名单;
  7. 按控制台要求分配调用额度;
  8. 将 Key 配置到博客中。

腾讯官方文档说明,Key 用于识别开发者身份,不同产品能力可以单独启用。WebService API 文档

我在 Hugo 页面中这样加载地图 SDK:

{{ with .Site.Params.footprints.tencentMapKey }}
    <script src="https://map.qq.com/api/gljs?v=1.exp&key={{ . | urlquery }}"></script>
{{ end }}

然后在 TypeScript 中初始化地图:

map = new TMap.Map(root.querySelector('[data-map]'), {
    center: new TMap.LatLng(first.lat, first.lng),
    zoom: 12,
    minZoom: 3,
    maxZoom: 18
});

这里需要注意,前端地图使用的 Web Key 会出现在脚本地址里,因此重点不是完全隐藏它,而是配置正确的域名白名单和调用权限。真正的服务端密钥、签名密钥不能写入公开页面。

之前参考的地图示例中还包含本地代理配置,例如:

window._TMapSecurityConfig = {
    serviceHost: 'http://127.0.0.1:...'
};

这种地址只适合本地环境。正式部署后,127.0.0.1 指向的是访问者自己的设备,不能继续使用。线上必须使用真实域名和对应的 Key。

地图上的标记使用照片缩略图

我没有使用普通图钉,而是让每个地点的封面缩略图直接作为地图标记。

locations.forEach((location) => {
    styles[location.id] = new TMap.MarkerStyle({
        width: 52,
        height: 52,
        anchor: { x: 26, y: 52 },
        src: location.cover
    });
});

然后通过 TMap.MultiMarker 批量添加:

markerLayer = new TMap.MultiMarker({
    map,
    styles,
    geometries: []
});

这样用户看到地图时,可以直接通过图片判断这个地点对应的内容。

但这也带来一个性能问题:如果标记直接使用原图,地图初始化时就会加载很多大文件。

所以图片会处理成两种尺寸:

  • 展示图:最长边 2560 像素;
  • 缩略图:最长边 640 像素。

地图标记和地点列表使用缩略图,用户点击照片后,灯箱才加载展示图。

图片处理还解决了方向和隐私问题

手机照片不一定真的按照最终方向存储,有些照片只是依赖 EXIF 信息告诉浏览器如何旋转。

如果直接转换成 WebP,可能出现图片横向或倒置的问题。因此处理图片时,先根据 EXIF 修正方向,再进行压缩。

公开展示版本还会删除 EXIF 和 GPS 信息,避免把不必要的拍摄位置暴露出去。

目前图片处理流程是:

上传原图
  ↓
检查扩展名、MIME 和真实文件签名
  ↓
读取并修正 EXIF 方向
  ↓
生成展示图和缩略图
  ↓
删除公开版本中的 EXIF/GPS
  ↓
写入静态图片目录

文件名也不会继续使用用户原始文件名,而是由服务端生成随机 ID,避免重名和覆盖已有文件。

搜索、筛选和地图联动

页面目前支持:

  • 地点名称搜索;
  • 城市筛选;
  • 年份筛选;
  • 场景类型筛选;
  • 地图和照片视图切换;
  • 点击地点卡片定位地图;
  • 点击照片打开大图;
  • 在灯箱中切换上一张和下一张照片。

筛选时,TypeScript 会对本地地点数组进行过滤:

const filtered = locations.filter((location) =>
    matchesFilters(location) && inMapBounds(location)
);

过滤结果会同时更新地点列表和地图标记。

点击地点卡片时,地图会移动到对应坐标:

map.easeTo({
    center: new TMap.LatLng(location.lat, location.lng),
    zoom: 16,
    duration: 600
});

照片浏览部分没有引入第三方轮播库,因为需求并不复杂。记录当前照片数组和索引,再根据索引更新灯箱中的图片即可。

过程中遇到的问题

第一个问题是图片不能直接使用原图。原图太大,不适合作为地图标记,也会影响首屏加载,所以必须生成缩略图。

第二个问题是本地地图配置不能直接部署到线上。本地代理地址和正式域名的加载方式不同,必须重新申请 Key,并配置域名白名单。

第三个问题是地点和照片之间的关系需要提前设计。如果只保存图片地址,后面还要补地点、日期、分类和说明。因此我最后把地点作为一级数据,照片作为地点下面的数组。

第四个问题是部署后的资源缓存。足迹页面同时包含 Hugo 模板、SCSS、TypeScript、地图 SDK 和图片。不能只看本地构建成功,还要检查线上页面是否引用了最新的 CSS、JS 哈希,以及图片地址是否能够正常访问。

目前照片先通过服务器后台上传一张 Demo 图片,生成展示图和缩略图,再把返回的 YAML 手动写入足迹数据。后面我准备做一个管理后台,让我可以直接用手机上传照片,具体实现放到下一篇。

SQLite 会不会更好

这取决于足迹模块之后会不会变成一个需要频繁维护的内容系统。

以目前的规模来看,YAML 反而更合适:

  • 地点数量少;
  • 数据结构简单;
  • 内容主要由自己维护;
  • Hugo 可以直接生成静态页面;
  • 不需要运行数据库;
  • Git 可以直接记录每次数据变化。

如果现在直接引入 SQLite,就需要额外处理数据库备份、权限、接口、部署和数据同步,复杂度会增加。

但如果下一步要做手机管理后台,SQLite 就会变得很有价值。它适合这种单用户、小规模、低并发的管理系统。

比较合理的结构是:

手机管理后台
        ↓
Python API
        ↓
SQLite 保存地点和照片元数据
        ↓
图片文件保存到静态目录
        ↓
公开足迹页读取数据

SQLite 中只保存元数据,不建议把图片本身塞进数据库:

locations
- id
- name
- date
- lat
- lng
- city
- category
- description

photos
- id
- location_id
- src
- thumb
- caption
- sort_order

图片依然保存为 WebP 文件,数据库只保存图片路径、所属地点和排序信息。

我的建议是:当前阶段继续使用 YAML,把公开页先做稳定;管理后台第一版也可以继续生成 YAML。等以后需要手机新增地点、修改坐标、调整照片顺序、删除照片,再把 SQLite 作为后台的数据源。

SQLite 不是现在一定更好,而是当管理需求增加后会更合适。对于目前这个只有少量地点的个人足迹页,YAML 更简单;对于下一阶段的手机管理后台,SQLite 会是一个不错的选择。

使用 Hugo 构建
主题 Stack 由 Jimmy 设计
参考 iOS 26 液态玻璃风格改造