Arcane-Blog 上线记:从设计稿到 Lighthouse 满分的 Astro 主题之旅
缘起:一份设计稿,一个执念
每一个写代码的人,大概都想过拥有一个真正属于自己的博客。
不是 Medium,不是知乎,不是公众号那种被平台绑架的内容容器,而是一个从 HTML 的第一个字节到 CSS 的最后一行声明都由自己掌控的数字花园。
这份执念在 2026 年的夏天终于落地——Arcane-Blog 上线了。
它源自一份 Figma 设计稿,5 个页面:首页、文章列表、文章详情、归档、关于我。视觉风格致敬 Vercel——黑白灰极简、克制留白、JetBrains 等宽字体的标签细节。然后被一个执念驱动到了偏执的程度:
Lighthouse 四项满分。
不是 95,不是 98,是 Performance 100、Accessibility 100、Best Practices 100、SEO 100。
这篇文章就是这个旅程的完整复盘。
技术选型:为什么是 Astro
在 2026 年选一个静态站点生成器,选项比 2020 年多太多了。Next.js、SvelteKit、Remix、Nuxt、Eleventy、Hugo……每一个都能写博客。
但我选了 Astro 7,原因很简单:
---
// 一个 .astro 文件,默认零 JS
import PostCard from '../components/PostCard.astro';
const posts = await getCollection('blog');
---
<BaseLayout>
{posts.map(post => <PostCard post={post} />)}
</BaseLayout>
默认零 JavaScript。 不像 Next.js 那样默认注入 React 运行时,Astro 编译出来的就是纯 HTML + CSS。只有当你显式写 <script> 或导入框架组件时才有 JS。
对一个以阅读为核心、追求极致性能的博客来说,这是最干净的选择。
其他加分项:
- Content Collections:用 Zod schema 校验 frontmatter,IDE 里
post.data.title有完整类型提示 <Image>组件:自动生成 webp + 响应式 srcset + 按需缩放- View Transitions API:
<ClientRouter />一行代码开启页面切换动画 - Shiki 双主题高亮:亮/暗模式代码块自动切换,零运行时 JS
- Tailwind CSS 4:通过
@tailwindcss/vite插件原生接入,告别 PostCSS 配置地狱
设计系统还原:CSS 变量驱动的 Vercel 美学
设计稿用的是 Vercel 的设计 token,我把它原封不动搬进了 global.css:
:root {
--background: #ffffff;
--foreground: #0a0a0a;
--muted-foreground: #6a6a6a; /* 这一行的故事后面会讲 */
--border: #e8e8e8;
--primary: #121212;
--radius-lg: 0.75rem;
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
/* ... */
}
.dark {
--background: #0a0a0a;
--foreground: #fafafa;
--muted-foreground: #9b9b9b;
/* ... */
}
关键决策有两个。
决策一:放弃 Google Fonts
最初我用了 Inter + Noto Sans SC + JetBrains Mono,通过 <link rel="preconnect"> + display=swap 加载。但 Lighthouse 报了三个问题:
- Render-blocking CSS:
fonts.googleapis.com的 stylesheet 阻塞渲染 - Unused preconnect:preconnect 用不上反而扣分
- CLS:字体异步加载导致文字回流
改用系统字体栈后,零网络请求、零阻塞、零 CLS。视觉上 macOS 用 San Francisco、Windows 用 Segoe UI、中文用 PingFang SC / Microsoft YaHei,和 Inter 几乎无差。
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Helvetica Neue', sans-serif;
决策二:CSS 变量驱动深色模式
不是 Tailwind 的 dark: 前缀,而是用 .dark 类切换 CSS 变量。这样组件代码里只有 color: var(--foreground) 一行,亮暗模式自动切换:
<button style="color: var(--foreground); background: var(--background);">
按钮
</button>
配合一段内联脚本防止 FOUC(闪烁):
<script is:inline>
(function() {
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
这段脚本在 <head> 中同步执行,浏览器渲染 body 前就已确定主题,零闪烁。
LCP 优化:把首屏图片压到 7 KB
Lighthouse 给我的第一课是:public/ 目录的图片不会被 Astro 优化。
最初我把 5 张封面图放在 public/images/,frontmatter 里写 heroImage: '/images/image_0.jpg'。Lighthouse 报:
Improve image delivery — Est savings of 289 KiB
原图 1368×768,但卡片只显示 379×213。浏览器下载了完整尺寸然后缩放,纯浪费。
修复:移到 src/assets/ + 用 <Image> 组件
---
import { Image } from 'astro:assets';
import { resolveImage } from '../utils/images';
const hero = resolveImage(post.data.heroImage);
---
<Image
src={hero}
alt={post.data.title}
width={640}
height={360}
widths={[400, 640]}
sizes="(max-width: 640px) 100vw, 33vw"
loading={eager ? 'eager' : 'lazy'}
fetchpriority={eager ? 'high' : 'auto'}
format="webp"
quality={70}
/>
构建时 Sharp 自动生成 400w 和 640w 两档 webp,浏览器按 sizes 提示选最合适的:
| 显示场景 | 优化前 | 优化后 |
|---|---|---|
| 桌面首图(LCP) | 144 KB | 14 KB |
| 手机首图 | 144 KB | 7 KB |
| 其他卡片图 | 75-92 KB | 3-8 KB |
关键细节:LCP 元素必须 eager
首页第一张卡片是 LCP 元素,必须 loading="eager" + fetchpriority="high",否则浏览器会等 CSS 解析完才发现它,延迟 2400ms。
更进一步,用 <link rel="preload"> 在 head 提前发现:
---
const lcpImage = await getImage({
src: firstHero, width: 640, format: 'webp', quality: 70,
});
---
<BaseLayout preloadImage={lcpImage.src}>
<head>
<link rel="preload" as="image" href="/_astro/image_0.xxx.webp" fetchpriority="high" />
</head>
浏览器解析到 <head> 第一行就并行下载首图,不再等 CSS。LCP 直接砍掉 2.4 秒。
评论区改造:把 185 KB JS 推到视口外
接入 Waline 评论系统是最折腾的一段。
最初版本很简单:
<script>
import { init } from '@waline/client';
import '@waline/client/style';
init({ el: '#waline', serverURL: '...' });
</script>
但 Lighthouse 报:
Network dependency tree — Maximum critical path latency: 2537 ms
构建产物里 Comments.astro 同步打包了 185 KB JS + 21 KB CSS,每个文章页都阻塞加载。
改造方案:动态 import + IntersectionObserver
<script>
let walineInstance = null;
let loadPromise = null;
function loadWaline() {
if (loadPromise) return loadPromise;
loadPromise = import('@waline/client').then(mod => mod.init);
return loadPromise;
}
function setupObserver() {
const container = document.getElementById('waline');
if (!container) return;
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadWalineCss(cssUrl);
initWaline();
observer.disconnect();
}
}, { rootMargin: '200px' });
observer.observe(container);
}
setupObserver();
</script>
效果:
| 资源 | 优化前 | 优化后 |
|---|---|---|
| 文章页初始 JS | 185 KB(Waline 同步加载) | 0 KB |
| 文章页初始 CSS | 21 KB(Waline CSS 同步 link) | 0 KB |
| 触发时机 | 页面加载即加载 | 滚动到评论区前 200px |
Waline 代码被拆成独立 chunk,仅在用户真的滚动到评论区时才动态下载。LCP 完全不受影响。
CSS 也得延迟
import '@waline/client/style' 会被 Astro 编译成静态 <link>,仍然阻塞渲染。改用 ?url 后缀拿到 URL,运行时再动态创建 <link>:
---
import walineCssUrl from '@waline/client/style?url';
---
<div id="waline" data-css={walineCssUrl}></div>
function loadWalineCss(cssUrl) {
if (document.getElementById('waline-css')) return;
const link = document.createElement('link');
link.id = 'waline-css';
link.rel = 'stylesheet';
link.href = cssUrl;
document.head.appendChild(link);
}
对比度修复:从 4.04 到 5.92
Lighthouse 的 Accessibility 报警里最隐蔽的一类是颜色对比度。
Vercel 原版的 --muted-foreground: #858585 对白底只有 4.04:1,不达 WCAG AA 标准(4.5:1)。看起来差不多,但色弱用户、强光环境下真的看不清。
改成 #6a6a6a 后对比度 5.92:1,提升 46%。
:root { --muted-foreground: #6a6a6a; } /* 4.04:1 → 5.92:1 */
.dark { --muted-foreground: #9b9b9b; } /* 暗色模式微调 */
Shiki 代码块的对比度
更难的是 Shiki 代码高亮。github-light 主题里很多 token 颜色对白底不达标:
| Token | 原色 | 对比度 | 修复后 | 新对比度 |
|---|---|---|---|---|
| 注释 | #6e7781 |
4.0:1 | #4d5666 |
6.4:1 |
| 浅蓝 | #6cb6ff |
2.0:1 | #0a4a8c |
7.1:1 |
| 浅紫 | #b392f0 |
3.0:1 | #5b32b3 |
7.5:1 |
| 橙色 | #ffa657 |
2.0:1 | #8a3c00 |
7.3:1 |
Shiki 用内联 style 属性输出颜色,CSS 必须 !important 才能覆盖:
.astro-code span[style*='color: #6e7781'] {
color: #4d5666 !important;
}
精准匹配属性选择器,不误伤其他颜色。
SEO:让 Google 知道你在写什么
SEO 满分靠的是结构化数据 + 元数据 + 站点地图三件套。
1. JSON-LD 结构化数据
每个页面注入对应的 JSON-LD:
<!-- 首页:WebSite + SearchAction -->
<script type="application/ld+json" set:html={JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Arcane-Blog',
url: 'https://blog.arcane-cloud.cn',
potentialAction: {
'@type': 'SearchAction',
target: { '@type': 'EntryPoint', urlTemplate: '...?q={search_term_string}' },
'query-input': 'required name=search_term_string',
},
})} />
<!-- 文章页:BlogPosting + BreadcrumbList -->
<script type="application/ld+json" set:html={JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.data.title,
datePublished: post.data.pubDate,
author: { '@type': 'Person', name: post.data.author },
publisher: { '@type': 'Organization', name: 'Arcane-Blog' },
})} />
Google 抓取后能在搜索结果里展示文章标题、作者、发布日期,甚至面包屑。
2. Open Graph + Twitter Card
<meta property="og:title" content="..." />
<meta property="og:description" content="..." />
<meta property="og:image" content="/og-image.jpg" />
<meta property="og:url" content="..." />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
分享到 Twitter、微信、Telegram 都会显示卡片预览。
3. RSS + Sitemap + robots.txt
// rss.xml.ts
import rss from '@astrojs/rss';
export async function GET(context) {
const posts = await getCollection('blog');
return rss({
title: 'Arcane-Blog',
description: '...',
site: context.site,
items: posts.filter(p => !p.data.draft).map(p => ({
title: p.data.title,
pubDate: p.data.pubDate,
link: `/blog/${p.id}/`,
})),
});
}
@astrojs/sitemap 自动生成 sitemap-index.xml,robots.txt 引用它:
User-agent: *
Allow: /
Sitemap: https://blog.arcane-cloud.cn/sitemap-index.xml
4. Google Search Console 验证
最后在 <head> 加一行:
<meta name="google-site-verification" content="your-token" />
去 Google Search Console 提交 sitemap,1-3 天后站点就会出现在 Google 搜索结果里。
多端适配:从 32px 到 44px 的细节
移动端最容易忽略的是触控目标大小。
最初我的 Header 图标按钮是 32×32px,看起来精致,但 Lighthouse 报:
Tap targets are not sized appropriately
WCAG 要求触控目标至少 48×48px(推荐)或 24×24px(最低)。改成 44×44px(图标本身仍 18-20px,只是点击区域变大):
<button
aria-label="切换主题"
style="width: 44px; height: 44px;"
>
<Sun width={20} height={20} />
</button>
视觉上没区别,但拇指点起来舒服多了。
最终成果
部署到 Vercel 后跑 Lighthouse:
| 维度 | 分数 |
|---|---|
| Performance | 100 |
| Accessibility | 100 |
| Best Practices | 100 |
| SEO | 100 |
构建产物:
- 50 个静态页面(首页 + 文章列表 4 页 + 24 篇文章 + 19 个标签页 + 归档 + 关于 + RSS + 404)
- 首屏 JS:16 KB(ClientRouter,可移除)
- 首屏 CSS:17 KB(BaseLayout 共享样式)
- 首屏图片:7 KB(手机)/ 14 KB(桌面)
- 总首屏传输:< 40 KB
写在最后
这个项目让我学到的最重要的一课是:
Lighthouse 100 不是炫技,是用户视角的还原。
每一个扣分项背后都是真实的用户体验问题——首图太大用户要多等 2 秒、对比度不够色弱用户看不清、字体阻塞渲染让首屏空白、评论 JS 阻塞文章渲染。
当你把每一个 0.1 秒、每一个对比度小数点都当成必须解决的问题时,100 分只是副产品。
真正的成果是:一个打开即见、即读即走的博客。
博客已开源,欢迎 star:github.com/Arcane-Cloud1/arcane-blog
如果你也想搭一个 Lighthouse 满分的 Astro 博客,可以参考这个项目的实现。任何问题,评论区见。
评论