Flow Render提出Promise-based UI渲染范式,将交互逻辑转为线性async/await控制流,提升代码可维护性。
Flow Render 提供了一种基于 Promise 的 UI 渲染方案。它让你像调用异步函数一样渲染组件、await 其结果,并在同一个异步流程中继续执行。
它将分散在状态、回调和组件层级中的交互逻辑整合为线性的 async/await 控制流。业务工作流和 UI 组件各自保持高内聚,而 Promise 结果在二者之间建立了清晰的边界,降低了耦合度,使复杂交互保持专注、可读和可维护。
const confirmed = await render(ConfirmDialog, {
title: 'Delete your workspace?'
})
if (!confirmed) return
await deleteWorkspace()
当代码确实需要暂停等待用户操作时,使用 Flow Render:
Flow Render 专注于交互流程。你的页面、本地状态、服务端状态和路由仍然留在它们原本所属的位置。
完整的 React 配置包含两个部分:挂载一个 Viewport,然后从事件处理器中 await 一个组件。
npm install @flow-render/react
将 Viewport 放在应用 providers 内部,靠近组件树的根部。
import { Viewport } from '@flow-render/react'
export function App() {
return (
<>
<AppProviders>
<Routes />
<Viewport />
</AppProviders>
</>
)
}
import type { PromiseResolvers } from '@flow-render/react'
type ConfirmDialogProps = PromiseResolvers<boolean> & {
title: "string"
description?: string
}
export function ConfirmDialog({
title,
description,
resolve,
reject,
}: ConfirmDialogProps) {
return (
<dialog open aria-labelledby="confirm-title">
<h2 id="confirm-title">{title}</h2>
{description && <p>{description}</p>}
<footer>
<button type="button" onClick={() => resolve(false)}>
Cancel
</button>
<button type="button" onClick={() => resolve(true)}>
Confirm
</button>
<button type="button" onClick={() => reject(new Error('Dismissed'))}>
Dismiss
</button>
</footer>
</dialog>
)
}
import { render } from '@flow-render/react'
async function handleDelete() {
const confirmed = await render(ConfirmDialog, {
title: "'Delete this project?',"
description: 'This cannot be undone.'
})
if (!confirmed) return
await deleteProject()
}
这就是整体思路。render() 将组件添加到已挂载的 Viewport 中并返回一个 Promise。调用 resolve(value) 用该值结算 Promise;调用 reject(reason) 拒绝它。当 Promise 结算后,Flow Render 会移除该组件。
JavaScript 已经为异步操作赋予了直接、可读的形态:
const response = await fetch('/api/projects')
const project = await response.json()
await animation.finished
await sleep(300)
但 UI 通常表现为断连的状态转换:
setConfirmDialogOpen(true)
这一行没有说明接下来会发生什么。后续逻辑分散在别处:回调函数、effect、prop 链、reducer 分支或状态机转换中。
用户交互同样是异步的。用户看到 UI、做出决定,最终产生多种结果之一。Flow Render 让这种交互使用与现代 JavaScript 其余部分相同的控制流原语:
const answer = await askUser()
const confirmed = await render(ConfirmDialog, { title: 'Ship this release?' })
if (!confirmed) return
await publishRelease()
这并不是说每个组件都应该被 await。持久的侧边栏、内联编辑器、可排序表格或页面级过滤器通常是普通的有状态 UI。有用的边界更窄:
当异步流程的下一步依赖于一个有限的用户交互完成时,await UI。
考虑一个典型的危险操作。快乐路径很短:询问、等待、删除。而状态驱动版本必须将这个路径分散到多个地方。
function ProjectActions({ projectId }: { projectId: string }) {
const [confirmOpen, setConfirmOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [error, setError] = useState<Error | null>(null)
async function handleConfirm() {
setIsDeleting(true)
setError(null)
try {
await deleteProject(projectId)
setConfirmOpen(false)
} catch (error) {
setError(error as Error)
} finally {
setIsDeleting(false)
}
}
return (
<>
<button onClick={() => setConfirmOpen(true)}>Delete project</button>
<ConfirmDialog
open={confirmOpen}
busy={isDeleting}
error={error}
onCancel={() => setConfirmOpen(false)}
onConfirm={handleConfirm}
/>
</>
)
}
这段代码本身没有错。React 状态是处理长生命周期、交互式页面的正确工具。当一个事件启动一个短生命周期的交互并需要继续时,摩擦就出现了。原始的故事被拆分到了状态声明、事件处理器、props 和清理逻辑中。
Flow Render 将编排逻辑保持在一起,同时让对话框本身保持为一个普通组件:
async function handleDelete(projectId: string) {
const confirmed = await render(ConfirmDialog, {
title: 'Delete project?',
description: 'All project data will be permanently removed.'
})
if (!confirmed) return
await deleteProject(projectId)
}
随着流程增长,这种差异变得更加明显。一连串的对话框、表单、权限检查和网络请求从上往下阅读,而不是从状态转换到状态转换。
Flow Render 有三个活动部件。
sequenceDiagram
participant H as Event handler
participant R as render()
participant V as Viewport
participant U as User
H->>R: render(ConfirmDialog, props)
R->>V: add ConfirmDialog
V->>U: show dialog
U->>V: click Confirm
V->>R: resolve(true)
R-->>H: Promise resolves with true
H->>H: continue flow
该组件仍然是框架组件。它可以使用你的设计系统、hooks、context、CSS、无障碍原语和动画库。Flow Render 只负责其临时挂载和 Promise 边界。
一个感知 Promise 的 UI 组件应该通过 resolve 或 reject 表达其结果。
type PlanPickerProps = PromiseResolvers<{ planId: string }> & {
plans: Array<{ id: string; name: string }>
}
function PlanPicker({ plans, resolve, reject }: PlanPickerProps) {
return (
<section aria-label="Choose a plan">
{plans.map((plan) => (
<button key={plan.id} onClick={() => resolve({ planId: plan.id })}>
Choose {plan.name}
</button>
))}
<button onClick={() => reject(new Error('No plan selected'))}>
Close
</button>
</section>
)
}
调用方获得一个类型化的值,并拥有接下来的处理权:
const { planId } = await render(PlanPicker, { plans })
await changeSubscription(planId)
这使得临时组件更容易复用:选择器负责选择方案;它不需要知道每个调用方如何更新账单、路由、分析或通知。
Executor 模式是推荐的默认方式。在组件 props 中声明 resolve 和 reject。Flow Render 在渲染组件时注入它们。
type NameEditorProps = PromiseResolvers<string> & {
initialValue: string
}
function NameEditor({ initialValue, resolve, reject }: NameEditorProps) {
const [name, setName] = useState(initialValue)
return (
<form
onSubmit={(event) => {
event.preventDefault()
resolve(name.trim())
}}
>
<label>
Project name
<input value={name} onChange={(event) => setName(event.target.value)} />
</label>
<button type="button" onClick={() => reject(new Error('Editing cancelled'))}>
Cancel
</button>
<button type="submit">Save</button>
</form>
)
}
Adapter 模式适用于你无法或不希望修改其回调 props 的已有组件。
type LegacyConfirmProps = {
open: boolean
title: string
onCancel: () => void
onConfirm: () => void
}
function LegacyConfirmDialog(props: LegacyConfirmProps) {
return (
<dialog open={props.open}>
<p>{props.title}</p>
<button onClick={props.onCancel}>Cancel</button>
<button onClick={props.onConfirm}>Confirm</button>
</dialog>
)
}
const confirmed = await render<LegacyConfirmProps, boolean>(
LegacyConfirmDialog,
(resolve) => ({
open: true,
title: 'Archive this project?',
onCancel: () => resolve(false),
onConfirm: () => resolve(true)
})
)
Adapter 模式特意充当一座桥。它让 Flow Render 能与组件库或遗留组件协同工作,同时保留其现有 API。
没有 Promise 边界时,父组件通常需要同时管理可见性状态和最终表单值。
const [editOpen, setEditOpen] = useState(false)
const [pendingProfile, setPendingProfile] = useState<Profile | null>(null)
function handleEdit() {
setEditOpen(true)
}
function handleProfileSaved(profile: Profile) {
setEditOpen(false)
setPendingProfile(profile)
}
useEffect(() => {
if (!pendingProfile) return
void saveProfile(pendingProfile)
setPendingProfile(null)
}, [pendingProfile])
使用 Flow Render 后,表单结果就是一个值。
async function handleEdit() {
const profile = await render(ProfileEditor, { initialProfile: currentProfile })
await saveProfile(profile)
}
状态驱动的代码通常需要在对话框打开时单独存储目标路径。
const [nextPath, setNextPath] = useState<string | null>(null)
function requestNavigation(path: string) {
if (!isDirty) {
navigate(path)
return
}
setNextPath(path)
setLeaveDialogOpen(true)
}
function confirmNavigation() {
setLeaveDialogOpen(false)
if (nextPath) navigate(nextPath)
setNextPath(null)
}
使用 Flow Render 后,目标路径保留在需要它的流程内部。
async function requestNavigation(path: string) {
if (!isDirty) {
navigate(path)
return
}
const shouldLeave = await render(DiscardChangesDialog)
if (shouldLeave) navigate(path)
}
线性版本使产品决策点清晰可见。
async function inviteMember() {
const member = await render(MemberFormDialog)
const role = await render(RolePickerDialog, {
email: member.email
})
const confirmed = await render(ConfirmDialog, {
title: `Invite ${member.email} as ${role.name}?`
})
if (!confirmed) return
await sendInvite({ ...member, roleId: role.id })
}
无需 reducer 状态来记住下一步是什么。每个组件保持独立可测试,并负责一个交互。
本指南使用 React,因为它是最常见的起点。同样的模型也适用于 Vue、Preact、Svelte 和 Solid。
npm install @flow-render/react
或使用其他包管理器:
pnpm add @flow-render/react
yarn add @flow-render/react
bun add @flow-render/react
默认的 render 函数使用该包导出的默认 Viewport。将该 viewport 挂载在树中一个对需要运行的交互始终可用的位置。
import { Viewport } from '@flow-render/react'
export function Root() {
return (
<ThemeProvider>
<AuthProvider>
<App />
<Viewport />
</AuthProvider>
</ThemeProvider>
)
}
不要将 viewport 放在对话框所依赖的 provider 之外。Flow Render 渲染的组件出现在 Viewport 下,因此它会接收树中该位置的上下文。
构建一个可等待的组件
将 PromiseResolvers<Value> 添加到 props 中。它的 resolve 回调接受返回给调用者的值;reject 则拒绝待处理的 Promise。
import { useState } from 'react'
import type { PromiseResolvers } from '@flow-render/react'
type QuantityDialogProps = PromiseResolvers<number> & {
initialQuantity: number
}
export function QuantityDialog({
initialQuantity,
resolve,
reject,
}: QuantityDialogProps) {
const [quantity, setQuantity] = useState(initialQuantity)
return (
<dialog open>
<form
onSubmit={(event) => {
event.preventDefault()
resolve(quantity)
}}
>
<label>
Quantity
<input
type="number"
min="1"
value={quantity}
onChange={(event) => setQuantity(Number(event.target.value))}
/>
</label>
<button type="button" onClick={() => reject(new Error('Quantity not selected'))}>
Cancel
</button>
<button type="submit">Continue</button>
</form>
</dialog>
)
}
从事件处理器中等待它
import { render } from '@flow-render/react'
async function handleAddToCart() {
const quantity = await render(QuantityDialog, {
initialQuantity: 1
})
await addToCart({ sku: 'starter-kit', quantity })
}
render() 在允许异步的函数中最自然:按钮处理器、mutation 回调、路由守卫、命令处理器,或从客户端调用的应用服务。
有意识地处理 dismissal
有两个好的约定。为每个组件系列选择一个,并为其团队编写文档。
当取消是一个预期的产品选择时,解析一个中性值。
type ConfirmDialogProps = PromiseResolvers<boolean>
function ConfirmDialog({ resolve }: ConfirmDialogProps) {
return (
<dialog open>
<button onClick={() => resolve(false)}>Cancel</button>
<button onClick={() => resolve(true)}>Confirm</button>
</dialog>
)
}
当调用者必须区分已完成的交互与 dismissal 或中断时,使用 reject。
try {
const profile = await render(ProfileEditor)
await saveProfile(profile)
} catch (error) {
reportDismissal(error)
}
不要仅仅为了建模一个正常的"否"答案而使用 reject。像 false、undefined 或可辨识别的结果这样的值通常会产生更清晰的调用者代码。
下面的示例故意很小。它们展示 Promise 边界应该放在哪里;组件可以使用任何 UI 库或视觉风格。
确认破坏性操作
async function handleRemoveMember(member: Member) {
const confirmed = await render(ConfirmDialog, {
title: `Remove ${member.name}?`,
description: 'They will lose access immediately.'
})
if (!confirmed) return
await removeMember(member.id)
toast.success(`${member.name} was removed`)
}
在模态框中创建资源
async function handleCreateProject() {
const draft = await render(ProjectFormDialog, {
initialValues: {
name: '',
visibility: 'private'
}
})
const project = await createProject(draft)
navigate(`/projects/${project.id}`)
}
表单拥有验证和编辑状态。调用者拥有网络请求和导航,因为这些是流程中的下一步。
继续前要求登录
async function handleExport() {
if (!session.user) {
const authenticated = await render(LoginDialog, {
redirectTo: location.pathname
})
if (!authenticated) return
}
await exportReport()
}
LoginDialog 可以解析布尔值、用户对象或认证结果。返回调用者所需的最小值。
async function enableNotifications() {
const approved = await render(NotificationPermissionDialog)
if (!approved) return
const permission = await Notification.requestPermission()
if (permission === 'granted') {
await subscribeToNotifications()
}
}
产品解释是 UI;浏览器权限请求是一个异步平台操作。它们自然组合。
选择支付方式,然后支付
async function checkout(invoice: Invoice) {
const paymentMethod = await render(PaymentMethodPicker, {
methods: invoice.availablePaymentMethods
})
const confirmed = await render(ConfirmPaymentDialog, {
amount: invoice.total,
paymentMethod
})
if (!confirmed) return
await payInvoice({ invoiceId: invoice.id, paymentMethodId: paymentMethod.id })
}
运行多步骤向导
async function createWorkspace() {
const details = await render(WorkspaceDetailsStep)
const members = await render(InviteMembersStep, { workspaceName: details.name })
const plan = await render(PlanPicker)
const workspace = await createWorkspace({
...details,
memberEmails: members.map((member) => member.email),
planId: plan.id
})
navigate(`/workspaces/${workspace.id}`)
}
对于紧密耦合的向导,具有共享的后退按钮和持久化草稿状态,一个有状态向导组件可能是更好的选择。当每个交互都有不同的、可重用的结果,并且编排可以在一个函数中可见时,使用单独的等待步骤。
防护未保存的更改
async function requestCloseEditor() {
if (!editor.isDirty) {
closeEditor()
return
}
const action = await render(UnsavedChangesDialog)
if (action === 'save') {
await editor.save()
}
if (action === 'discard' || action === 'save') {
closeEditor()
}
}
可辨识别的结果使多个选择变得明确。
type UnsavedAction = 'save' | 'discard' | 'stay'
type UnsavedChangesDialogProps = PromiseResolvers<UnsavedAction>
重试失败的操作
async function publishWithRecovery(post: DraftPost) {
try {
await publishPost(post)
} catch (error) {
const nextAction = await render(PublishFailedDialog, { error })
```javascript
if (nextAction === 'retry') {
await publishPost(post)
}
if (nextAction === 'save-draft') {
await saveDraft(post)
}
}
}
选择上传目标
async function handleUpload(file: File) {
const folder = await render(FolderPickerDialog, {
initialFolderId: currentFolder.id
})
await uploadFile({ file, folderId: folder.id })
}
async function completeOnboarding() {
const goal = await render(GoalPicker)
const preferences = await render(NotificationPreferences)
await updateOnboarding({ goal, preferences })
navigate('/home')
}
代码之所以像产品旅程,是因为它就是产品旅程。
Flow Render 提供三种渲染器作用域。选择与要启动的 UI 匹配的存活周期。
默认渲染器在匹配的 Viewport 挂载后即可使用。
import { render, Viewport } from '@flow-render/react'
export function App() {
return (
<>
<Routes />
<Viewport />
</>
)
}
export async function openGlobalConfirm() {
return render(ConfirmDialog, { title: 'Continue?' })
}
将其用于应用级别的交互,这类交互应该在特定页面组件卸载后继续存活。
useRenderer() 为组件提供一个绑定到其自身生命周期的渲染器。
import { useRenderer } from '@flow-render/react'
function BillingPanel() {
const [render, Viewport] = useRenderer()
async function editInvoice() {
const update = await render(InvoiceEditorDialog)
await updateInvoice(update)
}
return (
<section>
<button onClick={editInvoice}>Edit invoice</button>
<Viewport />
</section>
)
}
当本地 viewport 卸载时,该渲染器中未完成的任务会被拒绝并抛出取消错误。这防止了临时 UI 在其所有者消失后泄漏。
import { isCancelError, useRenderer } from '@flow-render/react'
function SearchPanel() {
const [render, Viewport] = useRenderer()
async function chooseFilter() {
try {
const filter = await render(FilterPicker)
applyFilter(filter)
} catch (error) {
if (isCancelError(error)) return
throw error
}
}
return <><button onClick={chooseFilter}>Filter</button><Viewport /></>
}
createRenderer() 创建一个独立的渲染函数和 Viewport 对。当库希望暴露一个聚焦的 API 而不希望消费者与应用级渲染器耦合时,这很有用。
import { createRenderer } from '@flow-render/react'
const [renderBillingUi, BillingViewport] = createRenderer()
export function BillingProvider({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<BillingViewport />
</>
)
}
export function openUpgradeDialog() {
return renderBillingUi(UpgradeDialog)
}
这样库的实现细节就被隐藏在它自己的 openUpgradeDialog() 函数背后。
公共 API 有意保持精简。React 包导出了以下原语:
import {
createRenderer,
isCancelError,
render,
useRenderer,
Viewport,
type PromiseResolvers,
type RenderOptions
} from '@flow-render/react'
render(Component, propsOrAdapter?, options?) => Promise<Value>
将 Component 渲染到关联的 viewport 并返回其结果的 Promise。
const name = await render(NameEditor, {
initialValue: 'Untitled project'
})
对于一个感知 Promise 的组件,普通对象包含除 resolve 和 reject 之外的所有 props;Flow Render 提供这些回调。
type NameEditorProps = PromiseResolvers<string> & {
initialValue: string
}
当组件不暴露 resolver props 时,改为传入一个适配器。
const option = await render<ExistingPickerProps, Option>(ExistingPicker, (resolve, reject) => ({
options,
onSelect: resolve,
onClose: () => reject(new Error('Picker closed'))
}))
PromiseResolvers<Value>
interface PromiseResolvers<Value = unknown> {
readonly resolve: (value: Value) => void
readonly reject: (reason?: unknown) => void
}
将此类型添加到组件 props 中,以便在调用点推断 Value 的类型。
type ColorPickerProps = PromiseResolvers<{ hex: string }> & {
initialHex: string
}
async function chooseColor() {
const color = await render(ColorPicker, { initialHex: '#0ea5e9' })
// color is { hex: string }
}
对于只确认完成的组件,使用 PromiseResolvers<void>。
type NoticeProps = PromiseResolvers<void> & { message: string }
function Notice({ message, resolve }: NoticeProps) {
return <button onClick={() => resolve()}>{message}</button>
}
Viewport 是默认渲染器的挂载点。在调用默认渲染函数之前,将其放入框架树中。
import { Viewport } from '@flow-render/react'
function RootLayout() {
return (
<Providers>
<App />
<Viewport />
</Providers>
)
}
它直接渲染待处理的组件。它不强制使用 portal、overlay 或视觉系统。这是刻意设计的:你的对话框组件决定它的外观和行为。
useRenderer() => [render, Viewport]
为当前组件创建一个渲染器对。当临时 UI 应由本地功能拥有时使用它。
const [render, Viewport] = useRenderer()
参见本地渲染器获取完整示例。
createRenderer() => [render, Viewport]
创建一个独立于组件的渲染器对。用于封装子系统或可复用包。
const [renderNotifications, NotificationsViewport] = createRenderer()
renderNotifications 函数只渲染到 NotificationsViewport。
interface RenderOptions {
exitDelay?: number
}
当组件需要在解决或拒绝后保持挂载一小段时间以便运行退出动画时,传入以毫秒为单位的 exitDelay。
await render(FadeOutDialog, null, { exitDelay: 180 })
Promise 会立即 settled。exitDelay 仅延迟 DOM 移除;它