Remix UI 两阶段组件模型深入解析:Setup / Render 结构与运行时行为
【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix
Remix UI(本仓库 packages/ui)提供了一套建立在 JavaScript 与 DOM 原生能力之上的极简组件系统,其核心是所有组件遵循统一的两阶段结构:Setup 阶段只运行一次,Render 阶段在首次渲染及每次更新后反复执行。本文以 packages/ui/docs/components.md 为主体,结合 运行时实现 与 测试用例,系统讲解这套组件模型的写法、运行时机、props 传递与有状态更新,帮助你写出结构清晰、可预测、易于调试的 Remix UI 组件。
组件结构:Setup 阶段与 Render 阶段
所有 Remix UI 组件都遵循一致的两阶段结构:
- Setup 阶段(Setup Phase)—— 组件首次创建时执行一次。适合做一次性初始化:从
handle.props读取初始状态、创建缓存实例、初始化第三方 SDK、注册需要常驻的监听器等。 - Render 阶段(Render Phase)—— 首次渲染与之后每一次更新都会执行。组件函数返回的这个渲染函数负责产出 JSX,并读取每次更新前被刷新的最新 props。
function MyComponent(handle: Handle<Props>) { // Setup phase: runs once let state = initializeState(handle.props) // Return render function: runs on every update return () => { return <div>{/* render content */}</div> } }从源码结构看,组件函数本质上是一个Component工厂函数:接收Handle,返回一个零参数的RenderFn。在 component.ts 中有明确的类型定义:
export type Component<Props = ElementProps, ContextValue = NoContext> = ( handle: Handle<Props, ContextValue>, ) => RenderFn export type RenderFn = () => RemixNode这套设计与传统 class 组件或 hooks 组件都不同:状态就是 Setup 作用域内的普通 JavaScript 变量,渲染函数通过闭包捕获它们;没有useState、没有 setter 函数,更新完全由开发者显式触发。
运行时行为:首次渲染、后续更新与移除
当组件被渲染到树中时,运行时(reconciler)会按照以下规则驱动组件生命周期。
首次渲染
- 组件函数被调用,传入
handle。 - 返回的渲染函数被存储(只调用一次组件函数)。
- 在
handle.props被填充后调用渲染函数。 - 通过
handle.queueTask()排队的任务在渲染完成后执行。
对应到 ComponentRuntime.render 的实现:第一次渲染时#renderFn为undefined,运行时会调用组件函数取得渲染函数并缓存;若返回值不是函数,会抛出形如`${name} must return a render function, received ${typeof result}`的错误。随后调用渲染函数,并返回this.#dequeueTasks()取出本次要执行的任务队列。
后续更新
- 只调用渲染函数。
- Setup 阶段被跳过,闭包在整个组件实例生命周期内持续存在(这是状态得以保留的根本原因)。
handle.props在渲染函数被调用前更新。- 更新期间排队的任务在渲染后执行。
组件移除
handle.signal被中止(aborted)。- 使用
{ signal: handle.signal }注册的事件监听器被自动移除。 - 任何已排队的任务以中止信号执行,从而让异步任务有机会感知自己被取消。
在 ComponentRuntime.remove 中可以看到:组件被移除时先置#removed = true,然后中止连接信号与渲染信号,再把尚未执行的任务以已中止的信号出队执行。这也意味着你在任务里做的if (signal.aborted) return检查在卸载场景下同样有效。
关于内存安全
值得注意的一个细节:ComponentRuntime.render在组件已被移除后再次被调用时会打印警告render called after component was removed, potential application memory leak并返回[null, []]。这说明 Remix UI 对"移除后再渲染"的情况有显式的防护,避免应用出现隐性内存泄漏。
Props 在 Handle 上
Props 通过handle.props获取。这个对象是稳定的——对象引用在组件生命周期内保持不变,但其属性值会在每次渲染前被更新:
function Counter(handle: Handle<{ initialCount: number; label: string }>) { let count = handle.props.initialCount return () => { return ( <div> {handle.props.label}: {count} </div> ) } } // Usage let element = <Counter initialCount={10} label="Count" />这一"稳定对象 + 属性原地更新"的设计在 syncProps 中实现:先将旧 props 中不存在于新 props 的属性删除,再把新 props 的属性逐一拷贝到目标对象上。因此:
- 你可以放心地拿
handle.props与别的对象做引用比较(引用不会变); - 渲染函数里读到的永远是最新的属性值;
- 由于
handle.props不是每次渲染重新创建的对象,避免了不必要的引用变化。
Setup 中初始化状态与 Render 中读取最新值
正因为 props 对象稳定、值会刷新,Setup 阶段可以安全地用它做一次性初始化(如let count = handle.props.initialCount),而渲染函数在每次更新时通过同一个handle.props读到最新的 props。二者各司其职:
- 组件函数—— 只运行一次,可从
handle.props初始化状态。 - 渲染阶段—— 每次渲染都运行,读取
handle.props的最新值。
基础渲染
最简单的组件直接返回 JSX:
function Greeting(handle: Handle<{ name: string }>) { return () => <div>Hello, {handle.props.name}!</div> } let el = <Greeting name="World" />渲染函数返回的是RemixNode,它可以是 JSX 元素、字符串、数字、数组等任何可渲染内容。与许多框架不同,这里不需要return一个受管理的虚拟 DOM 树后再由框架对比——渲染函数产出的内容直接进入 reconciler 的 diff 流程(见 diff-dom.ts 与 reconcile.ts)。
属性传递:Props 从父到子
Props 通过 JSX 属性从父组件流向子组件:
function Parent() { return () => <Child message="Hello from parent" count={42} /> } function Child(handle: Handle<{ message: string; count: number }>) { return () => ( <div> <p>{handle.props.message}</p> <p>Count: {handle.props.count}</p> </div> ) }子组件在 Setup 阶段(或渲染阶段)通过handle.props消费这些值。若要组合更复杂的组件树(如children插槽、key列表、refDOM 引用),可参考 packages/ui/docs/composition.md。
有状态更新:普通变量 +handle.update()
状态用普通 JavaScript 变量管理,调用handle.update()触发重新渲染:
function Counter(handle: Handle) { let count = 0 return () => ( <div> <span>Count: {count}</span> <button mix={[ on('click', () => { count++ handle.update() }), ]} > Increment </button> </div> ) }注意这里几个关键点:
count是 Setup 闭包里的普通变量,按钮点击时直接count++,无需 setter;mix属性用来挂载on('click', ...)等 mixin(事件、样式、ref 等 DOM 相关能力都以 mixin 形式注入);handle.update()只是调度一次更新,返回值是一个 Promise,resolve 后可以得到本次更新完成的AbortSignal。
从 Handle.update 的源码可以看出:update()会把一个用于 resolve 的任务压入任务队列并触发调度(#scheduleUpdate())。若组件已被移除,则直接 resolve 一个已中止的信号,避免悬挂的 Promise。
等待更新完成后再操作 DOM
update()返回的 Promise 在更新完成后 resolve,因此你可以 await 它,拿到AbortSignal之后再做依赖新 DOM 的操作(如聚焦、滚动、测量):
function Player(handle: Handle) { let isPlaying = false let stopButton: HTMLButtonElement return () => ( <button disabled={isPlaying} mix={[ on('click', async () => { isPlaying = true await handle.update() stopButton.focus() }), ]} > Play </button> ) }在渲染后执行任务:handle.queueTask()
如果需要"DOM 变化之后"的副作用(移动焦点、滚动到新渲染的区块、测量元素),应在事件处理器里调用handle.queueTask(task)。任务接收一个AbortSignal,当组件重新渲染(新一轮渲染周期开始)或组件被移除时会被中止:
function Form(handle: Handle) { let showDetails = false let detailsSection: HTMLElement return () => ( <form> <input type="checkbox" checked={showDetails} mix={[ on('change', (event) => { showDetails = event.currentTarget.checked handle.update() if (showDetails) { // Queue DOM operation after the new section renders handle.queueTask(() => { detailsSection.scrollIntoView({ behavior: 'smooth' }) }) } }), ]} /> {showDetails && ( <section mix={[ref((node) => (detailsSection = node))]}>Details content</section> )} </form> ) }从 ComponentRuntime.render 可以看到,每次渲染结束都会调用#dequeueTasks()把任务出队执行——这正是queueTask的任务总在渲染之后运行的原因。若任务函数声明了参数(task.length >= 1),运行时还会为它准备一个专门的渲染信号(#renderController),用于在下一轮渲染开始时中止上一轮的异步任务(详见 #dequeueTasks)。
深入原理:Handle 的完整能力面
Handle是组件与框架交互的唯一接口,定义于 component.ts,除本文重点的props、update()、queueTask()之外还包括:
| 成员 | 说明 |
|---|---|
handle.id | 组件实例的稳定标识,适合htmlFor、aria-owns等 HTML API |
handle.context | 祖先/后代间通信的 Context API(set/get) |
handle.frame | 组件最近的 frame,调用reload()可刷新其服务端内容 |
handle.frames.top | 当前运行时树的根 frame |
handle.frames.get(name) | 按名称查找已挂载的命名 frame |
handle.signal | 组件断开连接时被中止的AbortSignal,用于清理 |
handle.signal与全局监听器清理
对于组件内的定时器、SDK 连接等资源,可以在 Setup 阶段监听handle.signal的abort事件完成清理:
function Clock(handle: Handle) { let interval = setInterval(handle.update, 1000) handle.signal.addEventListener('abort', () => clearInterval(interval)) return () => <span>{new Date().toString()}</span> }signal由#connectedController管理(#connectedSignal),组件从树中移除时(remove())会abort()该 controller,因此所有监听器与基于 signal 的清理逻辑都会自动生效。
卸载时的任务执行
remove()返回的正是未执行完的任务列表(若为空则返回共享的EMPTY_TASKS,零分配)。移除时任务会拿到一个已中止的信号执行,这让开发者可以在组件卸载时安全地做收尾工作,而不必担心异步回调在卸载后触碰已销毁的 DOM。
事件处理器中的信号:避免竞态
事件处理器(on('click', ...)/on('input', ...))会收到一个可选的AbortSignal,它在处理器被重新触发(用户在下一次异步完成前再次操作)或组件被移除时中止,天然规避了"旧请求覆盖新结果"的竞态:
function SearchInput(handle: Handle) { let results: string[] = [] let loading = false return () => ( <div> <input type="text" mix={[ on('input', async (event, signal) => { let query = event.currentTarget.value loading = true handle.update() // Passing signal automatically aborts previous requests let response = await fetch(`/search?q=${query}`, { signal }) let data = await response.json() // Manual check for APIs that don't accept a signal if (signal.aborted) return results = data.results loading = false handle.update() }), ]} /> {loading && <div>Loading...</div>} {!loading && results.length > 0 && ( <ul> {results.map((result, i) => ( <li key={i}>{result}</li> ))} </ul> )} </div> ) }要点:fetch支持signal选项时直接传入;对不支持 signal 的 API,则在await之后手动检查signal.aborted。更多事件模式见 packages/ui/docs/events.md。
测试验证:两阶段模型的可观测行为
仓库中的 render.test.tsx 直接验证了本文描述的组件行为:
function Counter(handle: Handle<{ count?: number }>) { let count = handle.props.count ?? 0 return () => ( <div> <h3>Counter</h3> <div> <button contenteditable="false">【免费下载链接】remixThe fully-stacked web framework
项目地址: https://gitcode.com/GitHub_Trending/re/remix创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考