我最近在一个维护了很久的 React 博客项目里做了一件很多人看来很“激进”的事把页面里绝大多数 React 组件删掉换成了 Astro 来做静态站点生成。不是 React 不行而是我意识到在“几乎没有用户交互”的页面里让浏览器下载一整套运行时属于纯粹的浪费。这篇文章就用我的实际操作讲清楚静态页面为什么会被 React 拖慢、Astro 的“岛架构”到底解决了什么、以及从 React 静态站迁到 Astro 有哪些可以照抄的步骤和值得记住的坑。如果你正在写博客、文档站、营销页或者公司官网本质上只是一堆静态内容如果你已经受够了为了一点交互效果就要加载几百 KB JavaScript如果你在 React 面试题里背过各种渲染优化却突然意识到自己从来没质疑过“这个页面真的需要 React 吗”这篇文章就是给你准备的。1. 先看清问题的本质静态页面是怎么被 JavaScript 框架玩坏的1.1 我做过的那个“会白屏的博客首页”先说一段真实经历。我三年前搭了一个博客技术栈是 React React Router Vite。功能很简单展示文章列表点进去看正文。没有登录、没有评论、没有实时数据。用户要做的核心操作就是“滚动阅读”和“点链接”。但上线后的表现相当难看。手机端首屏经常白屏接近两秒DevTools 里 Network 面板显示最大的 JS chunk 有 380KB 多。react-dom、路由、polyfill 全在里面。当时的挫败感在于页面 HTML 完全可以由服务端直接吐出来为什么我非要等 JS 跑完再往 #root 里塞内容后来我才想明白一个简单的道理SPA 的渲染管线是先下载 HTML然后下载 JS最后 JS 再“画出”页面。如果页面内容本来就是静态的那这一步就完全多余。热搜里那些“react 面经”“首屏白屏怎么解决”问的基本都是怎么从代码层面压缩、拆包、懒加载很少有人往前一步想换一种生成方式根本不需要这个包。1.2 “水合”到底在给谁花钱如果你用过 Next.js一定会听到“hydration”这个词中文叫水合。它的含义是服务端或者构建期已经生成了 HTML但为了让 React 事件能响应用户操作浏览器仍然要再执行一次 React 代码在客户端把组件树重新“接管”起来。这对有复杂交互的应用来说是有价值的。但问题是静态博客页面根本没有任何需要客户端管理的事件。页面只是展示一段文字和几张图却也逃不过水合这一步。相当于你雇了一个装修队到家里把墙刷好然后明明没有人住进去每个月还要付一笔“驻场维护费”。你可以做一个简单的实验。用 Next.js 写一个只有静态文本的页面构建之后打开浏览器Network 面板里依然能看到 react-dom 相关的 JS 在执行。这部分资源下载、解析、执行的耗时对于一个“0 交互”的页面就是纯负担。Astro 要解决的正是这个问题它默认生产静态 HTML不主动给任何页面注入 JS。1.3 搜索引擎和真实用户都在为“多余 JS”付钱有些朋友会说搜索引擎爬虫现在都能执行 JavaScript不用太担心 SEO。话是没错但执行 JS 需要爬虫额外消耗资源而且 Google 自己也说明过“如果内容在原始 HTML 里索引效率最高”。对内容型网站来说把正文直接放在 HTML 里永远是最稳妥的选择。从用户角度讲核心 Web 指标里的 LCP最大内容绘制和 INP交互到下次绘制延迟都会因为 JS bundle 过大而变差。LCP 会被下载和执行 JS 阻塞INP 会因为长任务抢占主线程而产生额外延迟。尤其是移动端CPU 本身就弱每多执行 1KB JavaScript 都是真金白银的成本。我把之前那个 React 项目和后来迁移到 Astro 的版本做了个简单对照数据大概是这样指标React SPA 版Astro 版首屏传输 JS386 KB18 KB首页 TTFB310 ms80 msLCPSlow 4G 模拟2.6 s0.8 sLighthouse Performance6896这里面的数据会因为机器和网络环境有浮动但方向非常明确把静态页面从 React 手里拿回来性能提升不是靠抠代码抠出来的而是靠改掉渲染模型直接赚到的。2. “岛架构”不是玄学Astro 怎样让 React 只干自己该干的活2.1 全站默认零 JS只有被点名的组件才进入客户端Astro 的核心概念叫“岛架构”。你可以把页面想象成一片海大部分区域是只包含 HTML 和 CSS 的静态内容也就是“陆地”只有少数需要交互功能的组件是一块块“岛屿”这些岛屿可以单独加载 JavaScript。默认情况下Astro 页面里的所有组件在构建时都会渲染成静态 HTML不会自动发送任何客户端 JS。如果你在页面里引入了一个 React 计数器它默认输出的是一个静态的按钮文本。只有当你给组件加上client:前缀指令Astro 才会把对应的 JavaScript 打包并在合适的时机加载到浏览器里。实际写起来是这样的一个 Astro 页面--- import Counter from ../components/Counter.jsx; --- section Counter client:visible / p这段文字是纯 HTML页面打开时不需要任何 JS。/p /sectionCounter 组件本身是普通的 React 函数组件import { useState } from react; export default function Counter({ initial 0 }) { const [count, setCount] useState(initial); return ( button onClick{() setCount((c) c 1)} 点我{count} /button ); }加上client:visible之后Astro 会等这个组件滚动进入用户视口时才去加载 React 运行时并水合这个组件。这个语义非常自然用户真正看到它时才开始初始化首屏资源被压到最低。指令选择也有讲究client:load页面加载后立即加载。用于首屏必须立刻可交互的组件。client:idle浏览器空闲后再加载。用于非紧急组件。client:visible组件进入视口才加载。用于页面靠下的部分。client:media(min-width: 768px)满足媒体查询条件才加载适合只在桌面端展示的交互组件。2.2 .astro 组件语法比 JSX 更适合内容型页面Astro 组件后缀是.astro长得更像“HTML 模板”而不是“组件函数”。文件上半部分用---包起来的是 frontmatter它只会在构建阶段执行可以写 JavaScript比如读取数据、拼接字符串、循环生成列表。下半部分写 HTML 结构和模板表达式。一个简单的卡片组件--- const { title, excerpt } Astro.props; --- div classcard h2 classtitle{title}/h2 p classexcerpt{excerpt}/p /div style .card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 1rem; } .title { font-size: 1.25rem; } /style注意这里style是默认作用域隔离的Astro 会给类名自动加哈希不会污染全局样式。这在 React 生态里你通常得靠 CSS Modules 或者 Tailwind 来保证Astro 从框架层面直接帮你做了。和 JSX 对比一下JSX 的组件是一等公民必须有根节点、必须 export组件树嵌套越深分裂出的抽象层级就越多。而.astro组件本质上是一段模板变量从Astro.props取样式写在文件尾部数据和 UI 结构清晰分离。对博客、文档站这类页面这种写法理解成本更低。2.3 内容管理Markdown Content Collections 是天生搭档Astro 对内容型站点最大的友好是内置了 Markdown/MDX 支持和 Content Collections。在src/content/posts/目录下放一个 Markdown 文件文件头部写 frontmatter--- title: Astro 实践笔记 date: 2025-01-15 tags: [astro, jamstack] --- 这里是正文内容Astro 会自动把它解析成页面。可以用 Content Collections 来做数据校验给每个文章的 frontmatter 定义一个 schema写错缺失字段时构建直接报错import { defineCollection, z } from astro:content; export const collections { posts: defineCollection({ schema: z.object({ title: z.string(), date: z.date(), tags: z.array(z.string()), }), }), };在生成文章页时使用getStaticPaths来创建动态路由--- import { getCollection } from astro:content; export async function getStaticPaths() { const posts await getCollection(posts); return posts.map((post) ({ params: { slug: post.slug }, props: { post }, })); } const { post } Astro.props; --- article h1{post.data.title}/h1 slot / /article这套流程全部在构建期完成不发送任何客户端 JS。这个体验是 React SPA 时代很难做到的内容即文件、路由即目录、校验即类型。Astro 把静态站生成这件事拉回到了它本该有的简单程度。3. 手里有个 React 静态站迁移的完整实操流程3.1 项目初始化和 React 集成迁移不是从零开始重写而是先搭一个 Astro 项目再把 React 组件逐步搬进来。新建项目npm create astrolatest # 根据提示选择 Blog 模板或个人项目模板 cd my-astro-site如果将来要保留 React 组件需要把 React 集成装好npx astro add react npm run dev这行命令会自动修改astro.config.mjs并调整 tsconfig 的 JSX 配置。我手动改过还是建议直接用它少踩“JSX 不被识别”的坑。安装后的astro.config.mjs大致长这样import { defineConfig } from astro/config; import react from astrojs/react; export default defineConfig({ integrations: [react()], });目录结构里src/pages是路由目录src/components放组件src/layouts放布局src/content放 Markdown 内容。迁移时可以按页面维度来一个页面一个页面从 React 换成 Astro不用一口气重写整个站。3.2 判断哪些组件该保留 React哪些该改写成 Astro迁移时最容易犯的错是“把所有组件都改写成.astro”。我的建议是先对组件分类看它到底是“展示型”还是“交互型”。判断标准很简单打开组件的源码问自己三个问题组件有没有useState、useReducer、事件监听组件有没有使用浏览器 API比如window、document、localStorage组件有没有依赖 React Context、react-router 或者第三方 React 图表库如果三个都答“没有”它可以直接改写为.astro。如果有任何一个“有”保留 React 组件并在引入时加上client:指令。比如原来的 React 卡片组件export const Card ({ title, excerpt }) ( div classNamecard h2{title}/h2 p{excerpt}/p /div );它没有状态、没有事件改成 Astro 就是纯模板--- const { title, excerpt } Astro.props; --- div classcard h2{title}/h2 p{excerpt}/p /div而一个评论区、一个搜索框、一个画图组件保留 React 并通过client:visible或client:idle加载。如果这个页面原本用了fetch(/api/posts)获取文章列表迁移时可以改成在 frontmatter 里直接用getCollection(posts)读取本地内容这样网络请求在构建期就已经完成用户打开页面时拿到的是完整 HTML。3.3 迁移完怎么验证性能真的变好了迁移不是“感觉变快了”就完事。我建议用三个工具验证第一打开 Chrome DevTools 的 Network 面板刷新页面过滤 JS 文件看传输大小。这一步能直观看到总 JS 体积的变化。第二打开 Performance 面板做一次 Record 后刷新看主线程的长任务数量。一个静态博客页面迁移后主线程长任务应该非常少甚至没有。第三跑一遍 Lighthouse Mobile 模式重点关注 Performance 分数、LCP 和 TTFB。不要只在本地快网络下看要在 Slow 4G throttling 下测才能模拟出真实移动用户的体验。我把自己的页面优化前后的核心指标整理成了表格方便你对照自己的项目判断指标迁移前迁移后页面传递 JS386 KB18 KB客户端水合组件数全部页面2 个TTFB310 ms80 msLCPSlow 4G2.6 s0.8 sLighthouse 性能分6896优化不是玄学每次改动完成后都跑一遍这套流程用数据说话。3.4 迁移路上常见的坑我替你踩过了坑一在.astro的 frontmatter 里访问window。frontmatter 是构建期执行的跑在 Node.js 环境里没有window和document。渲染组件时想判断浏览器设备类型就会直接报错。解决方案是把这个逻辑放进客户端 React 组件或者用typeof window ! undefined做环境判断再选择是否执行浏览器相关代码。坑二图片处理方式不同。直接用img src/xxx.png在 Astro 里会丧失内置的图片优化能力。Astro 提供了astro:assets的Image组件可以自动生成响应式尺寸、WebP 格式。迁移时顺手把站点图片切到Image组件LCP 会进一步改善。坑三路由从 JS 路由变成了传统链接跳转。React 项目里一般都用Link或useNavigate迁移到 Astro 后直接用a href跳转。刚开始会觉得是“退步”但内容站本来就不需要前端路由。用户跳转的就是一个新 HTML 页面浏览器原生预加载和缓存都能生效。坑四样式作用域差异。Astro 的style是 scoped 的写在某个组件里的样式默认不会作用到子组件。如果你在 React 组件内部用了 CSS Modules迁移到 Astro 后要注意把公共样式提取到全局文件或者继续让 React 组件自带样式文件。避免出现“我想当然地以为 Astro 也会继承父组件样式结果页面样式丢了”这种问题。坑五React 组件不能直接接收函数作为 props。Astro 生成的页面是静态 HTML客户端水合时React 组件的 props 只能传递可序列化的数据。如果你原来的组件是传onClick{() ...}这样的回调从 Astro 这边传不进去。正确做法是让交互事件逻辑待在那个 React 组件内部外部只传数据。4. 重新算一笔账为什么一个 3KB 的组件最后让浏览器啃了 90KB4.1 运行时成本才是大头很多人看到“一个计数器组件就几行代码”就误以为体积很小。账不是这么算的。只要页面里有一个 React 组件就必须把 React 运行时react、react-dom、scheduler等打包到产物里。压缩并 gzip 之后这部分体积大约 45KB 左右有的项目加上 polyfill 会更多。我在迁移项目上试过单独把一个只能在页面底部出现的“回到顶部”按钮用 React 实现它自己的代码写起来不到 3KB但浏览器为了这部分功能必须解析执行整份 React runtime。如果这个页面上还有另一块独立交互比如导航菜单和搜索框只要它们各自是独立 islands水合时仍会各自带着 runtime。这就像你买了一个很省电的台灯但为了点亮它供电局必须专门为你拉一条高压线。灯泡本身很便宜配套设施却一点不便宜。4.2 岛屿之间通信别被“全局状态”绑架Astro 的多岛屿模型有一个天然约束每个 island 是独立水合的不像 React SPA 里所有组件共享一棵树可以通过 Context 随意取全局状态。当两个 island 之间需要通信时我看到过几种方案通过CustomEvent在window上发事件另一个 island 监听。适合轻量联动。把状态同步到 URL 查询参数适合可分享状态。引入 Zustand/Pinia 这类外部 store但 store 本身会被打包进客户端而且多个 island 都依赖同一个 store 时仍然需要事件机制同步状态。我的建议是如果页面需要大量跨岛屿的共享状态要么把相关区域合并成一个更大的 island要么干脆重新考虑是否需要 Astro。岛屿模型的价值恰恰在于“大多数区域不需要通信”。一旦到处都需要通信架构的复杂度会抵消没有 JS 带来的性能收益。4.3 水合指令选得好体验能再进一步Astro 默认不水合但你一旦决定让某个交互组件活着选对加载时机同样重要。我自己的经验是首屏上方的搜索框、导航按钮用client:idle浏览器有空闲了再初始化不影响首屏渲染。首屏下方的评论区、分享按钮用client:visible用户滚动到附近才开始加载。只有在首屏打开就要求立刻响应的组件才用client:load比如一个必须马上可点的轮播控件。此外还有一个容易被忽略的client:media。比如你的桌面端导航有一个复杂的下拉交互移动端没有那么可以写成DesktopMenu client:media(min-width: 768px) /这样移动端就永远不会下载桌面端交互组件的 JS。这种粒度的控制在传统的 React SPA 里几乎做不到在 Astro 里只是一个属性的事。5. 别把 Astro 当万金油哪些场景我不建议硬套5.1 交互密集型应用Astro 帮不上大忙Astro 不是用来替代 React 应用的。如果你的项目是一个后台管理系统、一个实时协作编辑器、一个数据大屏页面里有成百上千个动态状态需要共享那 Astro 的“零 JS 默认”反而会成为负担。你会在页面上铺满水合岛屿到最后发现每个岛屿都像一个小 SPA还要额外处理岛屿之间的通信。判断的依据不是“页面是不是静态的”而是“用户在这个页面上主要做什么”。如果核心价值是“看内容”Astro 是绝佳选择。如果核心价值是“操作内容”比如编辑、拖拽、排序、填写复杂表单那 React/Vue SPA 或者 Next.js 这类 React 全栈框架更合适。5.2 和 Next.js 对比我的选型建议有不少人问Next.js 也能做 SSG为什么不用 Next.js这个问题问得到位。同样是预渲染Next.js 的静态页面在构建期也会生成 HTML但它毕竟是 React 全栈框架页面里的 React 组件在客户端水合时依然会引入运行时。哪怕做的是纯静态营销页Next.js 也不会自动变成“零 JS”。我用一个表格总结一下两者的定位差异维度AstroNext.js项目定位内容优先、组件可选的静态站生成器React 全栈应用框架默认产物静态 HTML组件默认不水合SSR/SSG/ISRReact 组件默认参与水合客户端框架不绑定可混用 React/Vue/Svelte以 React 为核心生态API 能力需要搭配 Serverless/Adapter内置 API Routes适合场景博客、文档、营销页、内容站中后台、电商、需要完整 React 生态的应用如果你已经确定主要业务就是 React 应用需要复杂状态和 API选 Next.js 很合理。但如果你的核心资产是文章、文档和营销内容并且不想为了内容页面付出 React runtime 成本Astro 是更直接的选择。5.3 我的自检清单一个新页面要不要用 Astro每次接手一个新页面我都会过一遍这套问题你可以直接拿去用页面内容能不能在构建期就确定用户打开页面后真正要发生的交互是不是只有零星几个这些交互是否彼此独立不需要大量共享状态页面是否对 SEO 和首屏速度有较高要求如果四个问题的答案大多是“是”Astro 就是合适的方案。如果答案是“否”再考虑 React SPA / Next.js 也不迟。从我手头这个项目的数据来看同样的页面从 React 迁移到 Astro 后构建产物缩水到原来的零头Lighthouse 性能分从 68 跳到 96最直观的感受是手机端打开不再白屏等待了。我不会说“以后所有网站都用 Astro”这种话选型本身就是薛定谔式的取舍。但有一点我确定当你开始质疑“静态页面为什么需要 JavaScript 框架”时你已经在做最便宜、收益最大的优化了。下次再有人讨论 React 性能优化你可以把 diff、memo、懒加载背完之后补上一句“先确认这个页面需不需要 React。”这句话往往比重构十个组件更值钱。