首页
/ Astro 集成 Preact 实战:从官方示例到 @astrojs/preact 渲染器源码解析

Astro 集成 Preact 实战:从官方示例到 @astrojs/preact 渲染器源码解析

2026-09-04 12:39:20作者:羿妍玫Ivan

本文以 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.tspreact() 返回一个名为 @astrojs/preactAstroIntegration,它在 astro:config:setup 钩子中完成三件关键事情:

  1. 注册渲染器:调用 addRenderer(getRenderer(command === 'dev')),让 Astro 知道 “遇到 .jsx/.tsx 组件时用谁来做 SSR 与水合”。开发模式与构建模式获取的渲染器不同(command === 'dev' 会传入开发版实现);
  2. 注入 Vite 插件链:接入 @preact/preset-vite 提供的 preact 插件处理 JSX 转换,并追加 optionsPlugin(把 include/exclude 选项序列化为虚拟模块 astro:preact:opts)与 configEnvironmentPlugin(配置依赖预构建与去重);
  3. 多 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>
		</>
	);
}

三个值得注意的实现细节:

  1. props 类型是 Signal<number>:Preact 组件直接以 Signal 为 prop,读写用 count.value++ / count.value--<pre>{count}</pre> 能直接渲染响应值,依赖的是集成包对 Signals 的适配(Preact 对 Signal 的自动订阅渲染);
  2. lazy + Suspense 来自 preact/compat:演示了 Preact 组件间代码分割的标准写法——Message 组件被拆成独立 chunk,加载完成前显示 <p>Loading...</p>
  3. 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);

其中涉及四个关键机制:

  1. 插槽静态化:具名插槽与默认 children 都被包装为 StaticHtml 虚拟节点——Astro 侧的内容在 Astro 中渲染好后以静态 HTML 注入 Preact 组件,不参与 Preact 的 VDOM 更新,只有 metadata.hydrate 为真时才允许插槽参与水合(shouldHydrate);
  2. Signal 序列化serializeSignals 把传入的 Signal prop 记录到输出元素的 data-preact-signals 属性中(Signal 本身不可序列化,记录的是标识与位置信息),配套实现位于 signals.ts
  3. 岛屿 ID 注入:每个岛获得自增的 data-preact-island-id 属性;
  4. setVNodeMask(vNode, [islandId, 0]):向 Preact 根 VNode 写入内部 _mask/__m 掩码。源码注释解释了原因:Preact 的 useId 从根 VNode 掩码派生 ID,而 Astro 把每个岛渲染为独立根,若不按岛注入唯一掩码,多个岛会生成相同的 useId 结果导致 ID 冲突(上游问题 preactjs/preact#3781)。

HTML 字符串最终由 preact-render-to-stringrenderToStringAsync 生成(支持 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,序列化后各自携带指向同一 signalIddata-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:visiblehydrate 路径: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 的标准工作流:

  1. npm create astro@latest -- --template framework-preact 创建项目(要求 Node.js >= 22.12.0);
  2. astro.config.mjsintegrations: [preact()] 启用渲染器,多 JSX 框架共存时可用 include/exclude 划界,compat/devtools 按需开启;
  3. Preact 组件写为 .jsx/.tsx,配合 tsconfig.jsonjsx: "react-jsx" + jsxImportSource: "preact"
  4. 通过 client:visible 等指令控制水合时机,lazy/Suspense 实现组件级代码分割;
  5. 利用 frontmatter 中的 signal() 创建状态,经 SSR 序列化(data-preact-signals)与客户端 sharedSignalMap 重建,实现多岛共享同一响应式状态。

若需进一步深入,建议直接阅读 packages/integrations/preact/src 下的 server.tsclient.tssignals.ts 三个文件,它们分别对应本文第四节的 SSR、水合与状态共享三条主线。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
903
1.82 K
docsdocs
暂无描述
Markdown
888
5.78 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.51 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384
flutter_flutterflutter_flutter
本仓库是 Flutter SDK 与 Flutter Engine 的 OpenHarmony 适配版本,由 CPF-Flutter 团队维护。开发者可使用熟悉的 Flutter 技术栈开发 OpenHarmony 应用,3.35.7 及以后的适配版本可基于本仓库源码构建支持 OpenHarmony 的 Flutter Engine。
Dart
1.17 K
341