最近我给博客增加了一个“足迹”板块。最初的想法很简单:把我拍过的照片放到地图上,让照片和地点对应起来。
以前照片只是文章里的附件,放在某篇文章下面。这样虽然能保存照片,但过一段时间之后,很难快速回忆“这张照片是在什么地方拍的”。所以我想做一个单独的页面,用地图记录地点,再用照片补充记忆。
先把一张照片放到地图上
第一条样例足迹使用的是一张 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。
申请流程大致是:
- 注册或登录腾讯位置服务控制台;
- 进入“应用管理”中的“我的应用”;
- 创建一个应用;
- 在应用中添加 Key;
- 启用 JavaScript API GL;
- 配置博客域名白名单;
- 按控制台要求分配调用额度;
- 将 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 会是一个不错的选择。