@ lient- compose/ react 客户端
React and React 原始绑定 客户端编译数字 : 由客户拥有的岛屿组成的服务器屏幕协议.
等级 : 核心项目 议定书: 页:1 反应 18+ 与 反应 DOM、 反应原生、 服务器 渲染和反应服务器组件
npm install @client-compose/react
@client-compose/react 仅使用 React 本身:没有 DOM API 和 包裹元素。 一样
软件包和同样的登记模式在网络、iOS和Android上通过React Introduction,以及
与任何其他 React 渲染器,在客户端,服务器或服务器组件上渲染:
| 渲染 | 使用 | 获取数据 |
|---|---|---|
| 仅客户端( Vite, CRA, React End) | <ClientComposeProvider> + 键 <ComposedScreen> 或者说 useScreen | 在浏览器/ app 中 |
服务器渲染( Remix / React router, Next.js Pages router, Express +) renderToString(中文(简体) ) | loadScreen 在你的装填器里 initialResponse | 在服务器上; 客户端水合物不进行回推 |
| React 服务器组件( Next.js App Router) | ComposedScreen 从 @client-compose/react/server | 在服务器上; 岛屿在那里渲染 |
登记岛屿组成部分
岛屿道具类型来自 @client-compose/codegen
(一组) "protocolImport": "@client-compose/react" 在其 TypeScript 目标中,所以只生成文件
需要这个包). registerComponent 使该组成部分成为一个要素,因此岛屿组成部分
可以用钩.
import { ReactIslandRegistry } from "@client-compose/react";
import { FollowButtonV1, ProfileHeaderV2 } from "./islands.generated";
export const registry = new ReactIslandRegistry()
.registerComponent(ProfileHeaderV2, ProfileHeader) // ProfileHeader: (props: ProfileHeaderV2) => ReactNode
.registerComponent(FollowButtonV1, FollowButton)
.registerComponent(PostGridV1, PostGrid, { placeholder: PostGridSkeleton }); // while it streams in
提供配置
<ClientComposeProvider
config={{
registry,
baseUrl: "https://api.example.com/compose",
client: { platform: "web", appVersion: "1.4.0" },
headers: async () => ({ Authorization: `Bearer ${await getToken()}` }),
onSkipped: (entry, reason) => log("island skipped", entry.key, reason),
onRenderError: (key, error) => report(error, { island: key }),
}}
>
<App />
</ClientComposeProvider>
{\fn黑体\fs22\bord1\shad0\3aHBE\4aH00\fscx67\fscy66\2cHFFFFFF\3cH808080}请记住useMemo)如果在组件内构建它,因为一个新的配置对象反切.
显示屏幕
<ComposedScreen> 获取屏幕并将其岛屿排列为:
<ComposedScreen
screen="profile"
params={{ userId }}
loading={<Spinner />}
renderError={(error, reload) => <Retry onPress={reload} />}
>
{(islands) => <main className="profile">{islands}</main>} {/* optional; default: no wrapper */}
</ComposedScreen>
useScreen + 键 <Island> 给予您对布局的完全控制, 例如反应原生 FlatList 与
重新拉动 :
const screen = useScreen("profile", { userId });
if (screen.status === "loading") return <ActivityIndicator />;
if (screen.status === "error") return <Retry onPress={screen.reload} />;
return (
<FlatList
data={screen.islands}
keyExtractor={(island) => island.key}
renderItem={({ item }) => <Island island={item} />}
refreshing={screen.refreshing}
onRefresh={screen.reload}
/>
);
useScreen 返回时 { status, islands, response, error, refreshing, streaming, reload }。在
第一次响应, 重新装入失败保留当前岛屿( q)status: "ready" 与 error 页:1.
流线
屏幕溪流(第4.6段),并照原样渲染:一旦屏幕的头部到来,其岛屿显示,
每个人仍然在路上 作为它的占位符, 并填入它的到来. streaming 真实存在直到
最后一次到达.
- 占位符 是你的:通过
placeholder改为registerComponent,通常是一个骨架 岛的大小,所以屏幕不会跳。 一个没有岛屿的岛屿在到达之前什么都看不到. 说在装货aria-busy,或accessibilityState={{ busy: true }}在“反应土著”中: 占位符是所有的东西. - 等待整个屏幕 并立即显示它,像流出之前,设定
progressive: false在配置中,或在一个屏幕上:useScreen(screen, params, { progressive: false }), (中文(简体) ),<ComposedScreen progressive={false}>屏幕的loading显示直到全部 在这里. - 重装 将显示在岛上的东西保留到新岛到达为止 而不是闪回 对它的占位符.
- 一个从未到达的岛屿(连接已中断)是一个错误,
incomplete,可重试 :renderIslandError如果设置一个,请显示. - 原生反应: 内置
fetch立刻把所有的反应都交出来, 所以屏幕显示整个. 传出博览会的流出 显示他们到达时:fetch: expoFetch在配置中,以import { fetch as expoFetch } from "expo/fetch"。 。 。 . - 您的服务器决定了头部是什么: 它标记需要的岛屿, 以及任何条件 需要数据, 请按住它( 参见您的作曲家的 README) .
服务器渲染
在装入器中获取屏幕 loadScreen,并将回复传递给 <ComposedScreen> (或
useScreen,作为 initialResponse。服务器使群岛进入 HTML,客户端
水合物不用再取出来. loadScreen 阅读屏幕的整条流,所以反应是
普通JSON,装货员可以按原样返回.
// e.g. a React Router / Remix loader
export async function loader({ params, request }) {
return { screen: await loadScreen(serverConfig(request), "profile", { userId: params.userId }) };
}
export default function Profile() {
const { screen } = useLoaderData<typeof loader>();
const { userId } = useParams();
return <ComposedScreen screen="profile" params={{ userId }} initialResponse={screen} />;
}
loadScreen(config, screen, params) 使用与提供者相同的配置( n)registry, (中文(简体) ), baseUrl, (中文(简体) ),
client, (中文(简体) ), headers,...),因此可以在服务器上从收到的请求中读取认证. 当你
框架重运行加载器(导航、再验证),新的 initialResponse 用于无
浏览器请求; 无服务器响应的参数会像往常一样从浏览器中获取 .
反应服务器组件
@client-compose/react/server 导出一个 async 服务器组件 。 屏幕被取出,它的
服务器上的岛屿; 岛屿可以是服务器组件( 没有客户端 JavaScript) 或客户端
组件( 文件标有 "use client") (中文(简体) ).
// app/profile/[userId]/page.tsx
import { ComposedScreen } from "@client-compose/react/server";
export default async function Page({ params }: { params: Promise<{ userId: string }> }) {
const { userId } = await params;
return (
<ComposedScreen
config={{
registry,
baseUrl: process.env.COMPOSER_URL!,
client: { platform: "web", appVersion: "1.4.0" },
headers: async () => ({ Authorization: `Bearer ${await tokenFromCookies()}` }),
requestInit: { cache: "no-store" }, // personalised screens: don't share Next's fetch cache
}}
screen="profile"
params={{ userId }}
fallback={<Spinner />} // streamed while loading; omit to use your loading.tsx
renderError={() => <Oops />} // omit to throw to error.tsx
/>
);
}
-
从服务器组件中导入注册模块;它没有钩子,所以那里安全. 这个 软件包的主条目也可以安全地从 Server 组件导入:它的钩和组件 住在一个
"use client"模块. -
requestInit传递到每个请求,所以框架获取选项,例如{ next: { revalidate: 30 } }工作也一样. -
要刷新,请使用您框架的机制(例如:.
router.refresh()) (中文(简体) ). -
动作的更改和更新到达浏览器中的屏幕:客户端组件岛屿应用 他们自己。 浏览器
<ClientComposeProvider>需要一个知道每个 岛屿,它们拥有哪些实体,哪些岛屿的行动可以更新。 群岛 服务器组件不能在那里被导入, 因此声明它们:browserRegistry.declare(PageHeaderV1, TodoListV1)当服务器已过时(其屏幕已失效,或 改变感动了浏览器无法改变的岛屿,onStale名为:呼叫router.refresh()在这里。 没什么需要服务器的 路由器从缓存带回的一页( 返回) 跟踪自显示以来采取的行动:其客户部分岛屿适用 他们错过,和onStale如果其中之一 或岛屿是一个服务器组件, 错过了只有服务器可以显示的东西 . -
屏幕溪流:岛屿头部作为占位者仍然悬而未决,每个岛屿位于 拥有
<Suspense>,然后在他们到达时进入页面.progressive={false}(或 配置的progressive: false等待整个屏幕代替. -
如果页面也需要屏幕(例如:
meta在下一个.js的generateMetadata),打开其 流经一次streamScreen并传入,所以组件不再取取. 元数据 只需要头,所以岛屿仍然溪流:const openArticle = cache((id: string) => streamScreen(config, "article", { articleId: id })); export async function generateMetadata({ params }) { const { head } = await openArticle((await params).articleId); return { title: head.meta?.title, description: head.meta?.description }; } export default async function Page({ params }) { const { articleId } = await params; return <ComposedScreen config={config} screen="article" params={{ articleId }} stream={openArticle(articleId)} />; }或通过整个
response从loadScreen,使不流畅. -
serverComposer(config, { client })问作曲家 请求最多一次需要什么, 不管很多组件都问( React'scache:openScreen(screen, params)页 次 元数据,appStrings()传递给<AppStringsProvider initial>,以及strings(AppStrings)用于服务器组件本身的单词.client找出每个请求是谁要求的 比如 其语言来自 cookie 或 Accept-Language(preferredLocale(header)) (中文(简体) ). 做一次,在一个 模块:export const composer = serverComposer(config, { client: async () => ({ ...config.client, locale: preferredLocale((await headers()).get("accept-language")) }), }); export async function generateMetadata({ params }) { const { head } = await composer.openScreen("article", await params); return { title: head.meta?.title }; } export default async function Page({ params }) { const p = await params; return <ComposedScreen config={config} screen="article" params={p} stream={composer.openScreen("article", p)} />; } -
onRenderError在此配置中看到在创建岛屿元素时抛出错误 服务器。 服务器组件岛内出错被重新扔入浏览器并捕获 那里;报告这些,放一个<ClientComposeProvider>与onRenderError上页.
字符串
应用程序显示的每个词都来自作曲家(spec §4.7),所以复制更改,以及新语言
船,没有释放。 岛上的申报 让他们成为 t数字 :
function ArticleList(props: IslandProps<typeof ArticleListV1>) {
return <h2>{props.t.heading}: {props.t.articleCount({ count: props.total })}</h2>;
}
registry.registerComponent(ArticleListV1, ArticleList);
IslandProps<typeof ArticleListV1> 是岛上的道具,有 t 当它宣布弦.
带有链接的句子对翻译者来说是完整的:用自己的来填充字符串的参数
占位符,以及 richText 将节点放入( 它没有钩子, 因此服务器组件可以使用 ) :
richText(t.newHere({ link: "{link}" }), { link: <a href="/sign-up">{t.signUp}</a> })
useLocale() 是客户端要求的语言(config.client.locale对于什么应用
格式本身: new Intl.DateTimeFormat(useLocale())。 。 。 .
t 它不能从服务器组件发送到客户端组件,所以
客户组件岛 @client-compose/react/server 获得它 t 新建于
浏览器代替。 岛上没什么变化.
应用字符串(任何岛屿外的词)来自 <AppStringsProvider>,在提供者内部:
<ClientComposeProvider config={config}>
<AppStringsProvider
cache={stringsCache} // e.g. over AsyncStorage or localStorage
loading={<Splash />}
failed={(retry) => <CouldNotConnect onRetry={retry} />}
>
<App />
</AppStringsProvider>
</ClientComposeProvider>
function RetryButton() {
const t = useAppStrings(AppStrings);
return <button>{t.retry}</button>;
}
带他们进来的 config.client.locale与一个 cache,应用程序从它保持的字符串开始
最后一次,然后在背景中再次取出它们;没有一次,每一次发射都会等待它们.
failed 仅当没有字符串并且无法联系到作曲家时才会显示:
一台屏幕,它的字 应用必须坚持自己。 过 initial 已获取字符串
(例如由服务器组件 fetchStrings 从 @client-compose/react/server),以及
viaProxy({ loadStrings }) 通过您的服务器获取 .
有页的岛屿
页面岛(spec §4.5)作为道具获得第一页,以及 next 输入时
再来. usePages 把他们从屏幕上拿来 打开
网络,例如带有“显示更多”按钮:
function Results(props: ResultListV1) {
const { pages, status, loadMore } = usePages(props);
return (
<section>
<ul>
{pages.flatMap((page) => page.items).map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
{status === "more" && <button onClick={loadMore}>Show more</button>}
{status === "loading" && <p role="status">Loading…</p>}
{status === "failed" && <button onClick={loadMore}>Try again</button>}
</section>
);
}
或者在 React Industrial 中, 将下一页作为列表的结尾加载 :
function Results(props: ResultListV1) {
const { pages, status, loadMore } = usePages(props);
return (
<FlatList
data={pages.flatMap((page) => page.items)}
renderItem={renderResult}
onEndReached={loadMore}
ListFooterComponent={status === "loading" ? <Spinner /> : null}
/>
);
}
status 实值 more, (中文(简体) ), loading, (中文(简体) ), end 或者说 failed (与 error: 岛上的错误
作曲家,或请求的; loadMore 再来一次). 当岛上有新的道具时
(屏幕重新装入,或用其他参数打开)它又从它们开始,一个页面仍在打开
它的老道具的方式被放弃.
页取自 ${baseUrl}/screens/{screen}/islands/{key} 还有你的 headers()获取
他们的另一种方式,设置 loadIsland(request) 在配置中; 通过您的服务器, viaProxy 给
你一个人(以下).
在测试中,回答来自 handlers.islands["{screen}/{key}"] 与
islandResponse(screen, island(key, ResultListV1, props, next))。 。 。 .
行动
useAction 使用生成的描述符执行动作(第5段). run 绝不拒绝:
它解决了结果, 一个动作宣布的错误,或者 { status: "failed" }。 。 。 .
import { useAction } from "@client-compose/react";
import { FollowUserV1 } from "./actions.generated";
function FollowButton({ userId }: { userId: string }) {
const follow = useAction(FollowUserV1);
const onPress = async () => {
const res = await follow.run({ userId });
if (res.status === "error" && res.error.code === "blocked") showToast("You can't follow this user");
else if (res.status === "failed") showToast("Something went wrong");
};
return <button disabled={follow.pending} onClick={onPress}>Follow</button>;
}
当行动成功时,其效果在 run 解决,按此顺序(第5.3段):
changes: 改变的每个实体的动作在显示它的每一个岛屿上都会被改变(从 生成的描述符'entities),在每一个屏幕上,应用程序都有:显示,缓存,每个页面a 呼唤的岛屿。 无所取取. 从持有的列表中删除一个被删除的实体; a 屏幕显示在其它任何地方重新装入。 客户端服务器组件在屏幕上渲染 部分岛屿在浏览器中改变,以及onStale(effects){\fn黑体\fs22\bord1\shad0\3ahbe\4ah00\fscx67\fscy66\2chffffff\3ch808080}是给其他的.update:每个岛屿的新条目与显示的或 已缓存;其余屏幕会保留. 无法承受的屏幕(没有带钥匙的岛屿,或者 一个仍然流入)再装入. 在服务器组件渲染的屏幕上,例如:changes。 。 。 .invalidate: 对匹配屏幕的缓存响应被删除 。 显示的屏幕useScreen编号 :<ComposedScreen>重新装入,以及onInvalidate(refs)名为. 一个页面服务器 拨打的电话onStale当屏幕失效时.navigate数字 :onNavigate(ref)名为. 将屏幕映射到您的路由器中的路径 .
每个动作请求都说明应用程序可以绘制哪个岛屿(registry.capabilities()),所以更新内容
来到版本 它知道。 将动作附加到 ${baseUrl}/actions/{action} 还有你的 headers()送他们另一个
方式,设置 performAction(request) 在配置中.
通过您的服务器
要将会话保留在 httply Only cookie 中, 请通过您自己的服务器发送屏幕和动作
补之. createComposerProxy (从) @client-compose/client/proxy)是服务器的一半:它检查
浏览器发送的内容,设置客户端上下文和信头,并返回失败作为数据.
viaProxy 是浏览器的一半: 它将服务器的功能转换为配置 fetch, (中文(简体) ),
performAction 财务报告和审定财务报表 loadIsland。使用 Next.js 服务器动作 :
// actions.ts
"use server";
const proxy = createComposerProxy({
baseUrl: COMPOSER_URL,
client: async () => ({ platform: "web", appVersion, locale: await getLocale() }),
headers: async () => ({ Authorization: `Bearer ${await getSessionToken()}` }),
// e.g. keep a new session in a cookie, and take its token out of the response
onActionResponse: keepSession,
});
export async function performComposerAction(body: unknown) { return proxy.performAction(body); }
export async function loadComposerScreen(body: unknown) { return proxy.loadScreen(body); }
export async function loadComposerIsland(body: unknown) { return proxy.loadIsland(body); }
// in a client component
const transport = viaProxy({
performAction: performComposerAction,
loadScreen: loadComposerScreen,
loadIsland: loadComposerIsland, // leave out if no island has pages
});
const config = { registry, baseUrl: "", client, ...transport };
为了显示动作的错误, declaredError(follow.last) 给已声明错误的最后一个呼叫
结束于,以及 isUnexpected(follow.last, ["blocked"]) 说它是否结束了其他方式:
调用失败, 或者由于宣布的错误, 应用程序不会处理( 例如在它被调用后添加的代码)
页:1 为这些显示一个通用消息 .
const error = declaredError(follow.last);
{error?.code === "blocked" && <Notice>You can't follow this user</Notice>}
{isUnexpected(follow.last, ["blocked"]) && <Notice>Something went wrong</Notice>}
测试
@client-compose/react/testing 使岛屿在测试 应用的方式,针对假
作曲家们. 它没有自己的测试框架或渲染器,因此它与React Testing Library合作,
反应原生测试库等.
import { ClientComposeProvider } from "@client-compose/react";
import { actionOk, createTestConfig, island, screen } from "@client-compose/react/testing";
const { config, gateway, navigations } = createTestConfig({
registry,
handlers: {
screens: { profile: () => screen("profile", [island("header", ProfileHeaderV2, { name: "Ada" })]) },
actions: { follow: () => actionOk(FollowUserV1, { followers: 8 }, { navigate: { screen: "feed" } }) },
},
});
render(<ClientComposeProvider config={config}><FollowButton userId="u1" /></ClientComposeProvider>);
// ...click Follow, then:
expect(gateway.actionRequests()[0].input).toEqual({ userId: "u1" });
expect(navigations).toEqual([{ screen: "feed" }]);
-
createTestConfig给一个提供者配置fetch是假作曲家的, 与缓冲关闭. 它记录navigate财务报告和审定财务报表invalidate效果; 通过onNavigate或者说onInvalidate运行您的 和任何其他配置(例如:.client)来推翻默认值. -
响应构建器由生成的描述符键入:
island, (中文(简体) ),islandError, (中文(简体) ),screen, (中文(简体) ),screenError, (中文(简体) ),actionOk, (中文(简体) ),actionError财务报告和审定财务报表actionFailure。 。 。 . -
A 类
screen到达整个。 为了测试占地者和流入岛屿的岛屿,请回答一个请求:streamedScreen("profile", [island("header", …), pending("posts", PostGridV1)]),然后使send(island("posts", …))财务报告和审定财务报表end()(或cut(),如一个中断的连接. -
请求没有处理器拒绝, 测试失败 。 回答其他问题(例如上传到 (存储)与
other。 。 。 . -
将提供者包装在您的应用程序自己的提供者中(主题、翻译、会话) a
renderIsland自己的帮手. -
一个有呼号的岛屿从屏幕上取出页面, 所以测试一下:
pagedIsland给 与岛相接的屏幕element和假作曲家的答案(handlers: 屏幕上显示它的第一页,然后用光标显示它的后页.const paged = pagedIsland({ island: ResultListV1, first: { props: { items: [ada, grace] }, next: "c2" }, pages: { c2: { props: { items: [linus] } } }, }); const { config, gateway } = createTestConfig({ registry, handlers: paged.handlers }); render(<ClientComposeProvider config={config}>{paged.element}</ClientComposeProvider>);
为了测试人们会看到的话, catalogStrings(catalog) 读取目录文件( codegen's)
catalog,比如说. catalog/en.json正如作曲家会寄来的: english.t(ArticleListV1) 用于
岛上 t, (中文(简体) ), english.island(key, ArticleListV1, props) 对于一个有弦的岛屿,
english.appStrings 页:1 <AppStringsProvider initial>,以及 english.stringsResponse 伪造的
编曲 strings 处理者。 这是英语,有英语复数规则,除非你通过一种语言.
假作曲家本身(无反应)是 @client-compose/client/testing。 。 。 .
岛屿预览
岛屿将所显示的一切当作道具,包括文字,这样每一个都可以单独预览,在
使用以下工具: 故事书测试工具箱
他们在那里,就像在测试中一样:用一个 createTestConfig 配置,该
应用程序字符串来自 catalogStrings和任何你的应用程序的提供者。 一个故事的岛屿
执行动作说明假作曲者如何回答它们,所以失败也可以预览.
// .storybook/composer.tsx
const english = catalogStrings(catalog);
export function withComposer(handlers: FakeGatewayHandlers = {}): Decorator {
const { config } = createTestConfig({ registry, handlers });
return function WithComposer(Story) {
return (
<ClientComposeProvider config={config}>
<AppStringsProvider initial={english.appStrings}>
<Story />
</AppStringsProvider>
</ClientComposeProvider>
);
};
}
// src/islands/NewTodo.stories.tsx
export const TooLong: Story = {
args: { maxLength: 80, t: english.t(NewTodoV1) },
decorators: [
withComposer({
actions: {
"add-todo": () => actionError(AddTodoV1, { code: "title_too_long", details: { maxLength: 80 } }),
},
}),
],
};
添加 withComposer() 将您预览中的每个故事 decorators,一个故事自己的装饰者,与
它的操纵者, 包在它内部。 在React Industrial的"故事本"中,同样的作品,客户端设定到
app的平台 (createTestConfig({ registry, client: { platform: "ios", … } })) (中文(简体) ). 一个呼号岛是
从屏幕上预览, 如测试: 渲染 pagedIsland是因为 element,带有 handlers。 。 。 .
这个 todo example's Next.js app
有一本故事书是这样设置的: pnpm storybook 内 examples/todo/next。 。 。 .
行为
- 环岛相撞隔离. 每个岛屿都会在自己的错误边界内
<Suspense>. 投出只隐藏那座岛屿的部件onRenderError; 数据 其余的屏幕都留下 在服务器渲染( React 忽略了错误边界) 时 岛屿一无所获,在浏览器中恢复,而不是将屏幕取下. - 错误岛屿 (作曲家们)
status: "error"除非设置renderIslandError。 。 。 . - 未知岛屿 (类型、版本或状态此应用程序不知道)被跳过并报告
结束
onSkipped。 。 。 . - 夹克. 作曲者的反应被重复使用
cache.maxAge每个提供者,按键 屏幕和参数。 返回屏幕会立即产生 无需请求.reload()总是反弹。 设定cache: false把它关掉. - 参数 可以是每次渲染的新对象;请求按下其内容键.
- 请求被取消 当屏幕或参数改变,或组件卸载时.
实例
这个 待办示例 每类都有一个应用, 每一个都与它的作曲家合作:
web: Vite + 反应 DOMnext: Next.js App 路由器,由服务器组件提供(serverComposer(中文(简体) )react-native: 博览会+反应原生