Astro 集成 Preact 实战:从官方示例到 @astrojs/preact 渲染器源码解析
本文以 Astro 仓库中的官方示例 examples/framework-preact 为主体,系统讲解如何在 Astro 项目中引入 Preact 并编写交互式前端组件。文章从示例的创建、配置与组件写法出发,进一步结合 packages/integrations/preact 集成包的源码,剖析 SSR 渲染、客户端水合(hydration)以及 Preact Signals 跨岛共享的底层实现机制,帮助读者不仅会用,还能理解其原理。
一、创建并运行官方 Preact 示例
Astro 仓库内置了 Preact 官方示例,它是演示 “Astro 与 [Preact](https://preactjs.com 生态) 协作” 的权威参照。按照示例 README 的指引,可以用一行命令基于该模板创建自己的项目:
npm create astro@latest -- --template framework-preact
创建完成后,示例提供的标准脚本(见 package.json)如下:
{
"engines": { "node": ">=22.12.0" },
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@astrojs/preact": "^6.0.5",
"@preact/signals": "^2.8.1",
"astro": "^7.2.10",
"preact": "^10.28.4"
}
}
其中两个关键依赖:
@astrojs/preact:Astro 官方 Preact 集成,负责 Preact 组件的服务端渲染与客户端水合,是整个功能的核心;preact+@preact/signals:UI 框架本体及其细粒度响应式状态库 Signals,示例页面用到了后者。
README 还给出了示例最核心的一句话指导:“Write your Preact components as .jsx or .tsx files in your project.” 即 Preact 组件以 .jsx / .tsx 文件形式书写在项目中,由 Astro 自动识别并渲染。
示例目录结构非常精简,便于逐个文件研读:
| 文件 | 作用 |
|---|---|
| astro.config.mjs | 启用 Preact 集成 |
| src/pages/index.astro | 入口页面,创建共享 Signal 并挂载两个 Counter 岛 |
| src/components/Counter.tsx | 交互式计数器组件(含懒加载) |
| src/components/Message.tsx | 被懒加载的子组件 |
| src/components/Counter.css / Message.css | 组件样式 |
| tsconfig.json | Preact JSX 的 TypeScript 配置 |
二、启用 Preact 渲染器:astro.config.mjs 配置
示例的配置只有一个动作——在 integrations 中注册 preact():
// @ts-check
import preact from '@astrojs/preact';
import { defineConfig } from 'astro/config';
// https://astro.build/config
export default defineConfig({
// Enable Preact to support Preact JSX components.
integrations: [preact()],
});
结合集成包源码 packages/integrations/preact/src/index.ts,preact() 返回一个名为 @astrojs/preact 的 AstroIntegration,它在 astro:config:setup 钩子中完成三件关键事情:
- 注册渲染器:调用
addRenderer(getRenderer(command === 'dev')),让 Astro 知道 “遇到.jsx/.tsx组件时用谁来做 SSR 与水合”。开发模式与构建模式获取的渲染器不同(command === 'dev'会传入开发版实现); - 注入 Vite 插件链:接入
@preact/preset-vite提供的preact插件处理 JSX 转换,并追加optionsPlugin(把include/exclude选项序列化为虚拟模块astro:preact:opts)与configEnvironmentPlugin(配置依赖预构建与去重); - 多 JSX 框架冲突检查:在
astro:config:done中检测是否同时启用了多个已知 JSX 渲染器(@astrojs/react、@astrojs/preact、@astrojs/solid-js),若同时启用且未配置include/exclude,会输出告警提示需要指定过滤规则。
preact() 的完整选项定义在 index.ts 中:
export interface Options extends Pick<VitePreactPluginOptions, 'include' | 'exclude' | 'babel'> {
compat?: boolean;
devtools?: boolean;
}
include/exclude:限定由 Preact 渲染的组件范围,多 JSX 框架共存时必配其一;compat:启用preact/compat的 React 别名支持(启用后会自动dedupe: ['preact/compat', 'preact']并把preact/compat加入预构建);devtools:仅开发模式生效,为true时通过injectScript('page', 'import "preact/debug";')注入调试支持(见 index.ts);babel:透传给@preact/preset-vite的 Babel 选项,用于 JSX 转换定制。
三、页面与组件:共享 Signal 的计数器
3.1 入口页面 index.astro
示例页面 展示了 Astro 与 Preact 协作的完整范式——在 Astro 组件的 frontmatter 中创建 Signal,再把它作为 prop 传给两个客户端组件:
---
import { signal } from '@preact/signals';
// Component Imports
import Counter from '../components/Counter';
const count = signal(0);
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<style>
html, body { font-family: system-ui; margin: 0; }
body { padding: 2rem; }
</style>
</head>
<body>
<main>
<Counter count={count} client:visible>
<h1>Hello, Preact 1!</h1>
</Counter>
<Counter count={count} client:visible>
<h1>Hello, Preact 2!</h1>
</Counter>
</main>
</body>
</html>
要点解析:
const count = signal(0)在 frontmatter 中执行:它运行于服务端/构建时的页面求值阶段,Signal 本身无法直接序列化进 HTML,集成包会在 SSR 时将其序列化、并在客户端水合时重建(原理见第四节);- 两个
<Counter>接收同一个count:这是示例想演示的核心——两个独立的 “岛”(island)共享一个响应式状态,点任一计数器,两边数字同步变化; client:visible指令:触发 Astro 的岛屿模式(Islands Architecture),只有进入视口时该 Preact 组件才执行客户端水合;<h1>作为默认插槽传入:Counter 内部的<Message>{children}</Message>最终渲染出 “Hello, Preact 1/2” 标题。
3.2 Counter 组件:Signal prop 与懒加载
Counter.tsx 是示例中唯一含交互逻辑的组件:
import type { Signal } from '@preact/signals';
import type { ComponentChildren } from 'preact';
import { lazy, Suspense } from 'preact/compat';
import './Counter.css';
const Message = lazy(async () => import('./Message'));
const Fallback = () => <p>Loading...</p>;
type Props = {
children: ComponentChildren;
count: Signal<number>;
};
export default function Counter({ children, count }: Props) {
const add = () => count.value++;
const subtract = () => count.value--;
return (
<>
<div class="counter">
<button onClick={subtract}>-</button>
<pre>{count}</pre>
<button onClick={add}>+</button>
</div>
<Suspense fallback={Fallback}>
<Message>{children}</Message>
</Suspense>
</>
);
}
三个值得注意的实现细节:
- props 类型是
Signal<number>:Preact 组件直接以 Signal 为 prop,读写用count.value++/count.value--。<pre>{count}</pre>能直接渲染响应值,依赖的是集成包对 Signals 的适配(Preact 对 Signal 的自动订阅渲染); lazy+Suspense来自preact/compat:演示了 Preact 组件间代码分割的标准写法——Message组件被拆成独立 chunk,加载完成前显示<p>Loading...</p>;- CSS 按组件导入:
import './Counter.css',样式由 Vite 处理并随岛屿打包。
被懒加载的 Message.tsx 本身极简,只把 children 包进带样式的 <div class="message"> 中:
import type { ComponentChildren } from 'preact';
import './Message.css';
export default function Message({ children }: { children: ComponentChildren }) {
return <div class="message">{children}</div>;
}
3.3 TypeScript 配置:把 JSX 指向 Preact
示例的 tsconfig.json 在继承 astro/tsconfigs/strict 之外,仅追加了两项 Preact 专属设置:
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"],
"compilerOptions": {
// Preact specific settings
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}
jsx: "react-jsx"+jsxImportSource: "preact":让 TypeScript 按新的 JSX 转换模式编译,且从preact包(而非 React)解析 JSX 运行时类型。这就是 “.tsx文件能被识别为 Preact 组件” 的语言服务层保证,与运行时由@astrojs/preact完成实际渲染相互对应。
四、源码纵深:SSR 渲染与水合是如何完成的
示例页面看起来只做了 “注册集成 + 写组件”,真正让这一切成立的是 packages/integrations/preact 的渲染器实现。下面沿着 “服务端求值 → 客户端水合” 的调用链拆解。
4.1 服务端:check 判定与 renderToStaticMarkup
SSR 渲染器定义在 packages/integrations/preact/src/server.ts:
const renderer: NamedSSRLoadedRendererValue = {
name: '@astrojs/preact',
check,
renderToStaticMarkup,
supportsAstroStaticSlot: true,
};
check()(server.ts)负责判定 “这个组件是否归 Preact 渲染”:
- 排除非函数组件与 Qwik 组件;
- 若配置了
include/exclude,用createFilter按组件 URL 过滤; - 类组件通过
BaseComponent.isPrototypeOf(Component)判定; - 函数组件则试渲染一次:用
renderToStringAsync渲染,若产出空字符串或<undefined></undefined>则判定不是 Preact 组件。这里还包裹了一个console.error过滤器,用于吞掉试渲染 React 组件时产生的 “Invalid hook call” 噪音日志(多框架共存场景),见 server.ts。
renderToStaticMarkup()(server.ts)是 SSR 主流程:
const newProps = { ...props, ...slots };
const attrs: AstroPreactAttrs = {};
serializeSignals(ctx, props, attrs, propsMap);
const islandId = incrementIslandId(ctx);
attrs['data-preact-island-id'] = islandId.toString();
const vNode: VNode<any> = h(Component, newProps, children != null
? h(StaticHtml, { hydrate: shouldHydrate(metadata), value: children })
: children);
setVNodeMask(vNode, [islandId, 0]);
const html = await renderToStringAsync(vNode);
其中涉及四个关键机制:
- 插槽静态化:具名插槽与默认
children都被包装为StaticHtml虚拟节点——Astro 侧的内容在 Astro 中渲染好后以静态 HTML 注入 Preact 组件,不参与 Preact 的 VDOM 更新,只有metadata.hydrate为真时才允许插槽参与水合(shouldHydrate); - Signal 序列化:
serializeSignals把传入的 Signal prop 记录到输出元素的data-preact-signals属性中(Signal 本身不可序列化,记录的是标识与位置信息),配套实现位于 signals.ts; - 岛屿 ID 注入:每个岛获得自增的
data-preact-island-id属性; setVNodeMask(vNode, [islandId, 0]):向 Preact 根 VNode 写入内部_mask/__m掩码。源码注释解释了原因:Preact 的useId从根 VNode 掩码派生 ID,而 Astro 把每个岛渲染为独立根,若不按岛注入唯一掩码,多个岛会生成相同的useId结果导致 ID 冲突(上游问题 preactjs/preact#3781)。
HTML 字符串最终由 preact-render-to-string 的 renderToStringAsync 生成(支持 async 组件与 Suspense,这也是示例中 lazy 能正常服务端渲染的原因)。
4.2 客户端:共享 Signal 重建与 hydrate/render
客户端入口在 packages/integrations/preact/src/client.ts,它是 Astro 水合流程调用的工厂函数,核心逻辑与示例场景一一对应:
const sharedSignalMap = new Map<string, SignalLike>();
// ...
let signalsRaw = element.dataset.preactSignals;
if (signalsRaw) {
const { signal } = await import('@preact/signals');
let signals = JSON.parse(element.dataset.preactSignals!);
for (const [propName, signalId] of Object.entries(signals)) {
// 数组形态表示 Signal 嵌套在对象/数组 prop 中
if (Array.isArray(signalId)) { /* 按 [id, 下标/键] 定位并替换 */ }
else if (!sharedSignalMap.has(signalId)) {
sharedSignalMap.set(signalId, signal(props[propName]));
}
props[propName] = sharedSignalMap.get(signalId);
}
}
这正是示例中 “两个 Counter 共享同一计数” 的实现原理:
- SSR 时两个 Counter 收到的
count是同一 Signal,序列化后各自携带指向同一 signalId 的data-preact-signals; - 客户端水合时,
sharedSignalMap是一个模块级Map,同一signalId只会signal()一次,两个岛拿到的是同一个 Signal 实例,因此任一岛的count.value++都会同时驱动两边<pre>更新; - Signal 支持嵌套形态:数组形式的
signalId([id, 下标或键])表示 Signal 位于对象/数组 prop 内部,会先定位再替换,@preact/signals也是在此处按需动态导入。
随后按岛屿指令分支执行:
if (client === 'only') {
element.innerHTML = '';
render(child, element);
} else {
hydrate(child, element);
}
element.addEventListener('astro:unmount', () => render(null, element), { once: true });
- 示例使用的
client:visible走hydrate路径:Preact 复用 SSR 已产出的 DOM,只挂载事件与状态,首屏无闪烁;client:only则清空容器后render; - 由于 Preact 没有原生 unmount API,集成包监听 Astro 的
astro:unmount自定义事件,用render(null, element)完成卸载; - 插槽内容在客户端同样以
StaticHtml包装,与服务端对齐;岛级useId掩码也由data-preact-island-id属性读取后经setVNodeMask写回,保证useId在水合后与 SSR 输出一致。
五、小结
examples/framework-preact 用最精简的文件集合,完整演示了 Astro + Preact 的标准工作流:
npm create astro@latest -- --template framework-preact创建项目(要求 Node.js >= 22.12.0);- astro.config.mjs 中
integrations: [preact()]启用渲染器,多 JSX 框架共存时可用include/exclude划界,compat/devtools按需开启; - Preact 组件写为
.jsx/.tsx,配合 tsconfig.json 的jsx: "react-jsx"+jsxImportSource: "preact"; - 通过
client:visible等指令控制水合时机,lazy/Suspense实现组件级代码分割; - 利用 frontmatter 中的
signal()创建状态,经 SSR 序列化(data-preact-signals)与客户端sharedSignalMap重建,实现多岛共享同一响应式状态。
若需进一步深入,建议直接阅读 packages/integrations/preact/src 下的 server.ts、client.ts、signals.ts 三个文件,它们分别对应本文第四节的 SSR、水合与状态共享三条主线。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00