阅读前提:本文适合使用 Astro、Hugo、Hexo 等静态站点生成器(SSG)开发博客的开发者。如果你也在为静态博客添加需要识别”当前文章”的功能,这篇文章可能对你有帮助。
核心要点:在静态网站生成(SSG)中,构建时无法获取运行时 URL。为实现”识别当前文章”的功能,必须采用父传子(Props 传递)模式,将文章标识符从构建时已知的父组件显式传递给需要它的子组件。
这篇文章能帮你解决什么问题
给博客加了点赞功能,需要知道用户在看哪篇文章。本来觉得很简单——“从浏览器地址栏读一下 URL 不就行了?”
结果一试,点赞按钮在文章页不显示,在首页反而显示了。
这篇文章就记录我踩的坑,以及最后怎么解决的。
先来认识两个角色
在说问题之前,先介绍两个重要的概念。
1. 浏览器能做的事 vs 不能做的事
| 浏览器能做的 | 浏览器不能做的 |
|---|---|
| 用户访问网页时显示正确内容 | 知道”当前”是哪篇文章(构建时) |
| 点击按钮执行代码 | 访问还没生成的页面 |
| 读取地址栏的 URL | 在网站还没”盖好”的时候就知道文章列表 |
第二列听起来怪怪的,但这恰恰是静态网站生成(SSG)的核心特点。
2. 什么是”静态网站生成”
普通网站:有人来访问 → 服务器实时查数据库 → 返回页面
静态网站:网站管理员”打包”网站时就把所有页面都生成好了 → 用户访问直接拿现成的
就像:
- 动态网站 = 你去餐厅点餐,厨师现场做
- 静态网站 = 方便盒饭,饭已经做好了,来直接拿
问题来了:饭做好的时候,厨师并不知道”现在是谁来拿”。
我踩的坑
第一步:我觉得很简单
点赞需要知道是哪篇文章,URL 里不是有吗?比如:
https://blog.example.com/posts/dns-guide/我写了这样的代码:
// 在浏览器里运行的代码const path = window.location.pathname; // 获取地址栏路径const slug = path.split('/').filter(Boolean).pop(); // 取出最后一截以为这样就能拿到 dns-guide,然后告诉点赞接口:“帮我给这篇文章 +1”。
第二步:电脑端怎么都不显示
代码写好了,但点赞按钮就是不出来。
我开始排查:
- 组件引用对不对?✓ 对
- CSS 是不是藏起来了?✓ 不是
- JS 报错了吗?✓ 没报错
后来发现,点赞按钮在文章页面不显示,在首页反而显示了。
第三步:意识到问题的根源
我用的是 Astro 静态博客,部署时所有页面一次性生成。
构建的过程是这样的:
- Astro 读取所有文章
- 为每篇文章生成一个页面文件(
dns-guide.html、markdown-tutorial.html…) - 上传到服务器
问题在于:生成页面的时候,代码根本不知道最终用户会访问哪个 URL。
为什么从 URL 读取行不通
关键概念:构建时 vs 运行时
在继续之前,必须弄清楚两个阶段:
| 阶段 | 说明 | 有什么 |
|---|---|---|
| 构建时(Build Time) | 网站代码被”打包”的阶段 | 没有浏览器,没有用户,没有 URL |
| 运行时(Runtime) | 用户真正访问页面的阶段 | 有浏览器,有 window.location |
window.location.pathname 是运行时的浏览器 API,只有用户访问页面时才存在。
核心问题:静态生成的 JavaScript 是”通用”的
你可能会想:既然构建时不知道 URL,运行时再读不就行了?
技术上可以,但不是最佳方案。
关键点在于:静态生成时,所有页面的 JavaScript 代码是完全一样的。
这导致两种截然不同的架构模式:
| 方案 | 数据与视图的关系 | 本质 |
|---|---|---|
| 运行时读 URL | 数据与视图分离 | 先加载空白视图,再异步获取数据 |
| Props 传递 | 数据与视图同步生成 | 视图加载时数据已就位 |
想象一下,如果采用”运行时读 URL”的方式:
// 这个代码在所有文章页面都是一样的const slug = window.location.pathname.split('/').filter(Boolean).pop();fetch(`/api/like?slug=${slug}`);这会导致:
- 页面 HTML 已经加载完成,但点赞数还是空的
- JavaScript 才开始执行,去读 URL,发请求
- 等到响应返回,才能显示点赞数
- 用户看到的是:先看到空白,再看到数字闪烁
而用 Props 传递的方式,实现了数据与视图同步生成——页面构建时,slug 就已经注入到组件里,用户访问时直接看到完整的点赞数。
类比说明
还是用盒饭来理解:
- 动态网站:厨师知道谁来,现场做(运行时渲染)
- 静态网站:厨师不知道谁来,但可以在每个盒饭上提前贴好标签
- 问题:如果厨师说”你来之前告诉我你叫什么名,我再写上去”,那你得等(运行时读取 URL)
- 更好的做法:直接印好”姓名:______“的标签,来的人填上就走(Props 传递数据)
正确的做法
核心思路:让”父亲”告诉”孩子”
既然页面不知道自己是谁,那就让知道的人传递给它。这在 React/Vue/Astro 等现代框架中叫做 Props 传递(父传子)。
组件层级关系:
[posts/[...slug].astro] ← 知道 entry.id 是 "dns-guide/index.md" ↓ (props: postSlug)[MainGridLayout.astro] ← 原样传递 ↓ (props: postSlug)[FloatingLikeButton.astro] ← 用 removeFileExtension() 提取 slug ↓ (props: slug)[LikeButton.svelte] ← 使用 slug,发请求获取点赞数第一步:文章页面传递身份
<MainGridLayout postSlug={entry.id} <!-- 把文章的"身份证号"传下去 -->>注意:这里传的是 entry.id,不是 URL slug。entry.id 是 Astro 通过 getCollection 等 API 获取文章元数据时返回的属性,在构建时就已经确定了。
entry.id 长这样:
dns-guide/index.mdmarkdown-tutorial.md
为什么用 entry.id 而不是 URL slug? 因为 entry.id 是 Astro 的内部标识符,在构建阶段就可以获取到,而 URL 是用户访问时才存在的。
第二步:每一层都原样传递
<FloatingLikeButton postSlug={postSlug} />const { postSlug } = Astro.props;const slug = removeFileExtension(postSlug); // 关键:去掉 .md 后缀第三步:核心实现
关键改进:组件自己从 URL 提取 slug,而不是依赖外部 props 传入。这样 Swup 切换页面时组件能自动更新。
1. 从 URL 提取 slug
// 从 URL 提取 slugfunction extractSlugFromUrl(pathname: string): string { const cleanPath = pathname.replace(/^\/|\/$/g, '').replace(/\.html$/, ''); if (cleanPath.match(/^posts\/.+/)) { const parts = cleanPath.split('/'); return parts[parts.length - 1] || parts[parts.length - 2]; } return cleanPath;}2. 处理 Swup 页面切换
博客使用 Swup 实现页面无刷新切换,需要监听 swup:page:view 事件来处理页面切换:
onMount(() => { const pathname = window.location.pathname;
// 只有文章页才显示按钮 if (!pathname.includes('/posts/')) { mounted = false; return; }
const currentSlug = extractSlugFromUrl(pathname); mounted = true; fetchLikes(currentSlug);
// 监听 Swup 页面切换事件 const handlePageView = () => { const newPathname = window.location.pathname;
// 切换到非文章页时隐藏按钮 if (!newPathname.includes('/posts/')) { mounted = false; return; }
const newSlug = extractSlugFromUrl(newPathname); mounted = true; fetchLikes(newSlug); };
document.addEventListener('swup:page:view', handlePageView); return () => { document.removeEventListener('swup:page:view', handlePageView); };});3. 关键点总结
| 问题 | 解决方案 |
|---|---|
| Props 传递的 slug 在 Swup 切换时不更新 | 组件自己从 URL 提取 slug |
| 非文章页也显示按钮 | 每次检查 URL,只有 /posts/ 开头才显示 |
| 页面切换后状态错乱 | 监听 swup:page:view,切换时重新获取数据 |
补充知识:几个容易搞混的概念
entry.id vs slug vs 文件路径
| 概念 | 例子 | 来源 | 作用 |
|---|---|---|---|
| 文件路径 | src/content/posts/dns-guide/index.md | 开发者组织文件 | 文件在电脑里的位置 |
| entry.id | dns-guide/index.md | Astro getCollection() API 返回 | 构建时标识符,连接文件系统与页面生成 |
| URL slug | dns-guide | 从 entry.id 派生 | URL 中使用的简短标识 |
关键理解:entry.id 是构建时的标识符,它是连接”文件系统中的文章”与”生成的页面”之间的桥梁。正因为它在构建时已知,才能可靠地传递给子组件。
扩展名要小心
Astro 的 entry.id 包含文件扩展名,不能直接当 slug 用:
// ❌ entry.id 带着扩展名"dns-guide/index.md"
// ✅ 用函数去掉扩展名removeFileExtension("dns-guide/index.md") // → "dns-guide"总结
踩坑总结
| 错误做法 | 问题 | 正确做法(Props 传递) |
|---|---|---|
| 从 URL 读路径 | 构建时不知道”当前”是谁 | 通过 props 显式传递 |
直接用 entry.id | 包含 .md 后缀 | 用 removeFileExtension() 处理 |
| 假设”当前页面”概念 | SSG 没有这个 | 每个页面显式传递自己的身份 |
一句话记住
静态网站构建时不知道”现在是谁”,数据必须从父到子显式传递。
关键代码
// 去掉文件扩展名function removeFileExtension(id: string): string { const result = id.replace(/\.(md|mdx|markdown)$/i, ""); return result || 'index';}<!-- 父传子的标准模式 --><ChildComponent dataFromParent={parentKnowsThis} />扩展思考
掌握了 Props 传递(父传子)的方法后,还可以进一步探索:
| 方向 | 核心概念 | 关键词 |
|---|---|---|
| 按用户隔离 | 需要知道是谁点的赞 | 用户认证(Auth)、状态管理 |
| SSR 方案 | 服务器端直接获取 URL | 服务端中间件、动态路由 |
| CSR 方案 | 页面加载后客户端获取 | 客户端状态管理、首屏加载优化 |
学习路径建议
- 初学者:优先掌握 Props 传递(父传子)模式,这是 SSG 的标准实践
- 进阶:需要用户个性化功能时,探索用户认证与状态管理
- 扩展:考虑技术栈迁移时,了解 SSR/CSR 方案作为备选
方案适用边界
本文介绍的 Props 传递(父传子)模式是 SSG 框架下的标准实践,适合纯静态博客。
对于支持混合渲染的框架(如 Next.js 同时支持 SSG/SSR/CSR),需要根据具体页面的渲染方式选择策略:
- SSG 页面:用 Props 传递(数据与视图同步)
- SSR 页面:可以用服务端获取 URL
- CSR 页面:运行时读取或客户端路由(数据与视图分离)
部分信息可能已经过时









