Arcane-Blog 上线记:从设计稿到 Lighthouse 满分的 Astro 主题之旅

Arcane-Cloud avatar Arcane-Cloud · · 12 分钟

缘起:一份设计稿,一个执念

每一个写代码的人,大概都想过拥有一个真正属于自己的博客。

不是 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 报了三个问题:

  1. Render-blocking CSSfonts.googleapis.com 的 stylesheet 阻塞渲染
  2. Unused preconnect:preconnect 用不上反而扣分
  3. 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.xmlrobots.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 博客,可以参考这个项目的实现。任何问题,评论区见。

Arcane-Cloud avatar

Arcane-Cloud

全栈开发者,热爱开源

返回文章列表

评论