news 2026/9/10 3:37:21

Remix UI 两阶段组件模型深入解析:Setup / Render 结构与运行时行为

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Remix UI 两阶段组件模型深入解析:Setup / Render 结构与运行时行为

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 组件都遵循一致的两阶段结构:

  1. Setup 阶段(Setup Phase)—— 组件首次创建时执行一次。适合做一次性初始化:从handle.props读取初始状态、创建缓存实例、初始化第三方 SDK、注册需要常驻的监听器等。
  2. 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)会按照以下规则驱动组件生命周期。

首次渲染

  1. 组件函数被调用,传入handle
  2. 返回的渲染函数被存储(只调用一次组件函数)。
  3. handle.props被填充后调用渲染函数。
  4. 通过handle.queueTask()排队的任务在渲染完成后执行。

对应到 ComponentRuntime.render 的实现:第一次渲染时#renderFnundefined,运行时会调用组件函数取得渲染函数并缓存;若返回值不是函数,会抛出形如`${name} must return a render function, received ${typeof result}`的错误。随后调用渲染函数,并返回this.#dequeueTasks()取出本次要执行的任务队列。

后续更新

  1. 只调用渲染函数。
  2. Setup 阶段被跳过,闭包在整个组件实例生命周期内持续存在(这是状态得以保留的根本原因)。
  3. handle.props在渲染函数被调用前更新。
  4. 更新期间排队的任务在渲染后执行。

组件移除

  1. handle.signal被中止(aborted)。
  2. 使用{ signal: handle.signal }注册的事件监听器被自动移除。
  3. 任何已排队的任务以中止信号执行,从而让异步任务有机会感知自己被取消。

在 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。二者各司其职:

  1. 组件函数—— 只运行一次,可从handle.props初始化状态。
  2. 渲染阶段—— 每次渲染都运行,读取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,除本文重点的propsupdate()queueTask()之外还包括:

成员说明
handle.id组件实例的稳定标识,适合htmlForaria-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.signalabort事件完成清理:

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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 3:35:19

基于交错网格有限差分的双相介质波场模拟与Matlab实现

简介&#xff1a;这套基于MATLAB平台的双相介质交错网格有限差分波场模拟程序&#xff0c;面向地球物理、声学、光学等领域研究波动传播的工程师与学生。程序将速度与压力分配到交错网格不同位置&#xff0c;可提高计算精度与稳定性&#xff0c;并针对双相介质交界面反射折射以…

作者头像 李华
网站建设 2026/9/10 3:33:22

Ubuntu LTS与非LTS怎么选?版本差异、内核与运维成本全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 3:32:58

CANN/ge算子输入描述获取API

GetInputDesc 【免费下载链接】ge GE&#xff08;Graph Engine&#xff09;是面向昇腾的图编译器和执行器&#xff0c;提供了计算图优化、多流并行、内存复用和模型下沉等技术手段&#xff0c;加速模型执行效率&#xff0c;减少模型内存占用。 GE 提供对 PyTorch、TensorFlow 前…

作者头像 李华
网站建设 2026/9/10 3:29:27

环保监督系统实战:Java核心框架与海量监测数据优化

简介&#xff1a;一份基于 Java 语言的东软环保监督系统设计源码&#xff0c;面向 Java 后端开发者与环境信息化项目人员&#xff0c;用于学习企业级业务系统的完整搭建思路。资源共 129 个文件&#xff0c;压缩包约 223KB&#xff0c;其中以 108 个 Java 源文件为核心&#xf…

作者头像 李华