ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

Remix UI 两阶段组件模型深入解析:Setup / Render 结构与运行时行为 Remix UI 两阶段组件模型深入解析Setup / Render 结构与运行时行为【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remixRemix 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: HandleProps) { // 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 ComponentProps ElementProps, ContextValue NoContext ( handle: HandleProps, 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} labelCount /这一稳定对象 属性原地更新的设计在 syncProps 中实现先将旧 props 中不存在于新 props 的属性删除再把新 props 的属性逐一拷贝到目标对象上。因此你可以放心地拿handle.props与别的对象做引用比较引用不会变渲染函数里读到的永远是最新的属性值由于handle.props不是每次渲染重新创建的对象避免了不必要的引用变化。Setup 中初始化状态与 Render 中读取最新值正因为 props 对象稳定、值会刷新Setup 阶段可以安全地用它做一次性初始化如let count handle.props.initialCount而渲染函数在每次更新时通过同一个handle.props读到最新的 props。二者各司其职组件函数—— 只运行一次可从handle.props初始化状态。渲染阶段—— 每次渲染都运行读取handle.props的最新值。基础渲染最简单的组件直接返回 JSXfunction Greeting(handle: Handle{ name: string }) { return () divHello, {handle.props.name}!/div } let el Greeting nameWorld /渲染函数返回的是RemixNode它可以是 JSX 元素、字符串、数字、数组等任何可渲染内容。与许多框架不同这里不需要return一个受管理的虚拟 DOM 树后再由框架对比——渲染函数产出的内容直接进入 reconciler 的 diff 流程见 diff-dom.ts 与 reconcile.ts。属性传递Props 从父到子Props 通过 JSX 属性从父组件流向子组件function Parent() { return () Child messageHello from parent count{42} / } function Child(handle: Handle{ message: string; count: number }) { return () ( div p{handle.props.message}/p pCount: {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 spanCount: {count}/span button mix{[ on(click, () { count handle.update() }), ]} Increment /button /div ) }注意这里几个关键点count是 Setup 闭包里的普通变量按钮点击时直接count无需 settermix属性用来挂载on(click, ...)等 mixin事件、样式、ref 等 DOM 相关能力都以 mixin 形式注入handle.update()只是调度一次更新返回值是一个 Promiseresolve 后可以得到本次更新完成的AbortSignal。从 Handle.update 的源码可以看出update()会把一个用于 resolve 的任务压入任务队列并触发调度#scheduleUpdate()。若组件已被移除则直接 resolve 一个已中止的信号避免悬挂的 Promise。等待更新完成后再操作 DOMupdate()返回的 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 typecheckbox 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 APIhandle.context祖先/后代间通信的 Context APIset/gethandle.frame组件最近的 frame调用reload()可刷新其服务端内容handle.frames.top当前运行时树的根 framehandle.frames.get(name)按名称查找已挂载的命名 framehandle.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 typetext 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 dont accept a signal if (signal.aborted) return results data.results loading false handle.update() }), ]} / {loading divLoading.../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 h3Counter/h3 div button contenteditable="false">【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表