Travel 相册与地图功能实现说明
这篇文档不是给“只想加一篇新游记”的人看的操作手册,而是给以后继续维护这套功能的人看的实现说明。
目标只有一个:
- 把当前 Travel 板块为什么这样设计、现在是怎么跑起来的、以后应该沿着哪条线继续维护,讲清楚。
如果你只想新增内容,先看:
如果你遇到高德点位整体侧漂,先看:
这套功能最终解决了什么问题
当前 Travel 板块不是单纯的“图片排版”,而是一条完整链路,解决了下面几件事:
- 把一篇旅行内容稳定落成
docs/Travel/*.mdx页面。 - 把图片引用统一规范成可维护的相对路径,而不是散落的完整 OSS URL。
- 自动从 JPEG 提取 EXIF、拍摄时间、设备、镜头、GPS。
- 对有 GPS 的图片补高德逆地理编码,得到更像“人话”的地点名。
- 在文章页展示“这篇文章的拍摄点地图”。
- 在 Travel 分类页展示“我去过的地方总览地图”。
- 让以后新增文章时,不需要重新手写一遍地图和元数据逻辑。
设计原则
这套实现背后有五条固定原则:
1. 文章文件负责表达内容结构
docs/Travel/*.mdx 决定:
- 文章标题
- 段落顺序
- 图片分组
- 每组图片出现的顺序
也就是说,文章文件是“叙事结构”的唯一真源。
2. 元数据和地图数据都走生成产物
前端不应该每次运行时重新去读 EXIF、重新请求逆地理编码,也不应该自己临时推断地图结构。
所以现在有两份生成产物:
src/data/travelPhotoMetadata.generated.jsonsrc/data/travelMap.generated.json
前者解决“单张图片的信息”,后者解决“文章级与地点级地图信息”。
3. 原始 GPS 保留 在数据层
照片 EXIF 里的坐标保留原始 GPS,不在生成阶段强行改成高德可直接渲染的坐标。
这样做的原因是:
- 原始数据可追溯
- 逆地理编码和别的地图 SDK 仍然可以复用原始 GPS
- 坐标系转换集中在前端地图渲染层,职责更清晰
4. 地图默认不抢正文注意力
文章页地图默认折叠,用户点击后再展开;分类页地图同样折叠。
原因很简单:
- 地图是辅助信息,不是正文主角
- 高德 JSAPI 和 WebGL 资源不该在首屏无脑加载
- 折叠加载更利于性能和页面稳定性
5. 生成流程优先可维护,不优先“一次性最快”
所以现在不是直接从“用户口头描述”一步到最终 MDX,而是更偏两阶段:
- 先整理成源稿
- 再由脚本生成最终文章
这比一次性直接手写最终 MDX 更稳,因为以后改文案、增删图片、重排分组时,可以复用同一条链路。
总体分层
当前实现可以拆成四层。
| 层级 | 主要职责 | 关键文件 |
|---|---|---|
| 内容层 | 保存最终 Travel 文章 | docs/Travel/*.mdx |
| 生成层 | 生成文章、照片元数据、地图数据 | scripts/create-travel-article.mjs、scripts/generate-travel-photo-metadata.mjs、scripts/generate-travel-map-data.mjs |
| 数据层 | 给前端提供稳定 JSON | src/data/travelPhotoMetadata.generated.json、src/data/travelMap.generated.json |
| 展示层 | 渲染相册、灯箱、文章地图、总览地图 | src/components/TravelGallery/*、src/components/TravelMap/* |
端到端数据流
完整流程是这样跑的:
- 用户给出地点、OSS 图片链接、少量描述,或者直接给一份 Markdown 原稿。
- AI 先整理出源稿,或者你手动准备好源稿。
scripts/create-travel-article.mjs把源稿转成docs/Travel/<slug>.mdx。scripts/generate-travel-photo-metadata.mjs扫描所有 Travel 文章里的图片引用,下载 JPEG,提取 EXIF 和 GPS,并在需要时调用高德逆地理编码。scripts/generate-travel-map-data.mjs再根据文章里的TravelGallery顺序和照片元数据,生成文章页地图与分类页地图需要的结构。TravelGallery从照片元数据 JSON 里补齐每张图的展示信息。TravelStoryMap和TravelOverviewMap从地图 JSON 里读点位,再在前端渲染高德地图。
内容层:为什么最终文章仍然是 MDX
最终落地在 docs/Travel/*.mdx,而不是只保留数据库或 JSON,原因有三个:
- Docusaurus 原生就以文档文件为核心。
- 文章正文和图片组的关系,本质上就是文档结构。
- MDX 可以直接复用
TravelGallery和TravelStoryMap组件,不需要额外解释层。
当前一篇 Travel 文章至少包含:
- front matter
TravelGallery引入TravelStoryMap引入- 若干
createTravelPhotos([...]) - 正文与图片块交替出现
文章生成层:为什么单独做 create-travel-article
关键脚本:
scripts/create-travel-article.mjs
它存在的目的不是“偷懒”,而是把格式规范固化下来。
这个脚本现在负责:
- 解析 front matter。
- 容忍 UTF-8 BOM。
- 识别 Markdown 图片行。
- 只接受
picture.nevergpdzy.cn下的 JPEG。 - 把完整 OSS URL 转成仓库内统一使用的相对路径。
- 保留
##章节结构。 - 自动生成
createTravelPhotos([...])。 - 自动插入
<TravelStoryMap />。 - 在需要时继续联动
generate:travel或build。
这一层的核心思路是:
- 把“格式规范”写进脚本,而不是写进人脑。
这样以后无论是你自己手工补文章,还是 AI 帮你补文章,最终落到仓库里的结构都会尽量一致。
照片元数据生成层:为什么不在前端读 EXIF
关键脚本:
scripts/generate-travel-photo-metadata.mjs
前端不适合直接处理 EXIF,有几个原因:
- 图片体积太大。
- 浏览器端解析不稳定且浪费性能。
- 逆地理编码需要请求节流和缓存。
- 构建后静态站点更适合消费现成 JSON。
所以现在的做法是:
- 扫描
docs/Travel/*.mdx里出现的 JPEG。 - 统一把引用归一化为 canonical image key。
- 下载图片。
- 从 JPEG 中解析
DateTimeOriginal、Model、LensModel、GPS。 - 如果有 GPS,再调用高德 Web 服务逆地理编码。
- 生成
travelPhotoMetadata.generated.json。
这一层还做了几件维护性很重要的小事:
- 复用已有缓存,不重复请求
- 对高德请求做节流,避免无限打 API
- 对 iPhone 镜头名称做归一化
- 当 GPS 不可用时,允许回退到文章标题继承的地点名
地图数据生成层:为什么还要多一层 travelMap.generated.json
关键脚本:
scripts/generate-travel-map-data.mjs
看上去前端已经有照片元数据了,似乎可以直接拿来画地图,但当前项目没有这么做。原因是前端还缺几类“文章级信息”:
- 这篇文章对应哪个 permalink
- 各个
TravelGallery的顺序是什么 - 哪张图属于哪个章节
- 哪个点适合作为文章概览点
- Travel 分类页应该用哪个点来代表“去过这个地方”
所以这层脚本单独把“适合地图渲染”的结构整理好,再交给前端消费。
这层的几个关键设计决定是:
1. 地图只展示点,不展示路线
当前 Travel 地图不再画路线,只展示:
- 文章页中的拍摄点
- 分类页中的地点代表点
原因是实际效果上,照片 GPS 更适合表达“拍到了哪里”,不适合表达“完整怎么走的路线”。
2. 分类页用代表点,而不是所有点硬铺开
分类页如果把一篇文章的全部点都展开,信息量会太大,也很难点击。
所以脚本会为每篇文章求一个较合适的代表点,再汇总成总览地图。
3. 章节顺序继续从文章里继承
地图数据不是独立定义章节,而是回读文章里的 TravelGallery 与标题顺序。
这能保证地图和正文不会各说各话。
展示层:为什么拆成 TravelGallery 和 TravelMap
TravelGallery
关键文件:
src/components/TravelGallery/index.js
这一层负责:
- 归一化图片引用
- 拼接远程图片地址
- 从
travelPhotoMetadata.generated.json中补齐拍摄时间、设备、镜头、地点 - 渲染网格相册和灯箱
它不负责:
- 决定文章结构
- 请求高德地图
- 解析 EXIF
TravelMap
关键文件:
src/components/TravelMap/shared.jssrc/components/TravelMap/TravelStoryMap.jssrc/components/TravelMap/TravelOverviewMap.jssrc/components/TravelMap/TravelMapDisclosure.js
这里又分成三部分:
shared.js负责高德配置读取、loader 单例、地图创建、控件接入、坐标转换、fit view、销毁与 resize。TravelStoryMap.js负责文章页折叠地图、照片点聚合、marker、点击信息窗。TravelOverviewMap.js负责分类页地点概览图和跳转文章。
为什么 shared.js 要做成公共层
因为地图真正稳定运行,依赖的是一套共用规则:
- 统一读取
key / serviceHost / securityJsCode - 统一设置
window._AMapSecurityConfig - 统一懒加载
@amap/amap-jsapi-loader - 统一加
AMap.ToolBar和AMap.Scale - 统一做
gps -> 高德坐标转换 - 统一
fitTravelMapToOverlays - 统一在组件卸载时
map.destroy()
只要这些逻辑散落到各个地图组件里,后面就一定会出现行为漂移。
高德接入层:为什么现在同时支持 serviceHost 和 securityJsCode
关键文件:
docusaurus.config.jssrc/components/TravelMap/shared.js
这部分的核心矛盾是:
- 前端加载高德 JSAPI 需要
AMAP_JSAPI_KEY - 但
AMAP_SECURITY_JS_CODE不应该在生产环境里长期裸露给客户端
所以现在的策略是:
- 本地或无代理环境下,可以用
securityJsCode兜底。 - 生产环境优先走
AMAP_SERVICE_HOST。 - 一旦配置了
AMAP_SERVICE_HOST,构建时就不再把AMAP_SECURITY_JS_CODE注入前端customFields.amap。
这样做的好处是:
- 本地调试仍然简单
- 正式上线时可以把真正的安全密钥留在服务器代理层
坐标处理:为什么选择“渲染前转换”,而不是“生成时覆盖”
这件事单独重要到值得再写一遍。
当前数据里保存的是原始 EXIF GPS,这意味着:
travelPhotoMetadata.generated.json里的坐标仍然是原始 GPStravelMap.generated.json里的点位也继续沿用这组原始 GPS
真正变成高德可直接渲染坐标,是在前端调用:
AMap.convertFrom(..., 'gps')
这个设计是故意的,不是疏忽。原因是:
- 原始 GPS 更通用。
- 生成脚本不应该偷偷写入“只适合某个地图厂商”的坐标。
- 地图厂商切换时,只需要换渲染层,不需要重洗历史数据。
相关排障记录见:
地图交互:为什么默认折叠、为什么只点开时加载
文章页地图和分类页地图都通过 TravelMapDisclosure 折叠。
原因有三条:
- 地图不应该压过正文。
- 高德 JSAPI 首屏加载成本不低。
- 折叠后才初始化地图,可以减少无意义的 WebGL 实例。
当前行为是:
- 默认不展开
- 用户点击后才真正创建地图
- 地图创建后请求 resize,避免折叠动画导致画布尺寸不对
- 页面销毁时销毁 map 实例
这套实现为什么没有继续保留“路线”
这是一个明确的产品取舍,不是功能没做完。
之前路线的主要问题是:
- 视觉上像轨迹图,不像相册
- 多数旅行图组的 GPS 点并不适合线性讲述
- 有些照片没有 GPS,路线天然不完整
- 地图越像“导航产品”,正文越容易被抢焦点
所以这次改成:
- 文章页只标拍摄点
- 分类页只标去过的地方
- 点位说明仍然来源于照片元数据和文章结构
这条线更贴合这个站点的 Travel 板块定位。
为什么这套实现适合你现在的工作方式
你现在最常见的输入,不是直接提交一篇完整 MDX,而是:
- 一个地点
- 一组 OSS 图片链接
- 一点点你自己的感觉和风格要求
所以当前系统刻意兼容这种输入方式:
- 人可以先只提供少量描述
- AI 先补源稿
- 再自动生成文章
- 再自动生成元数据和地图
这比要求你先把每篇旅行都手写成完全规范的 MDX 更符合实际。
以后继续扩展时,优先遵守的规则
1. 不要手改生成 JSON
下面这两份文件都视为生成产物:
src/data/travelPhotoMetadata.generated.jsonsrc/data/travelMap.generated.json
要改逻辑,就改脚本,不要直接修 JSON。
2. 新增地图组件时先复用 shared.js
不要在新组件里重新写:
- 高德 loader 初始化
- 安全配置
- 坐标转换
fit view- 销毁逻辑