Ripple DOM Refs 实战指南:ref 赋值、回调 Ref、多 Ref、组件转发与 createRefKey 的完整解析
【免费下载链接】ripplethe elegant TypeScript UI framework项目地址: https://gitcode.com/GitHub_Trending/ripple25/ripple
本篇基于 Ripple 官方文档 Referencing DOM Elements 展开,系统讲解 Ripple 中捕获 DOM 节点的完整方案:普通可变标识符 ref、track()产生的Trackedref、回调 ref 及其清理函数、数组形式多 ref、组件转发,以及用于程序化装配 ref 的createRefKey()。读完之后,你不仅会写 ref,还能从源码层面理解 Ripple 是如何在挂载/卸载时赋值与清空 ref、如何在 props 展开(spread)中识别 ref,以及 SSR 场景下的行为差异。
Ref 的基本语法:一个 ref 还是多个 ref
Ripple 中的 ref 使用标准的 JSX 属性形态,用来捕获某个元素背后的 DOM 节点。官方文档给出的两种语法如下:
| 语法 | 用途 |
|---|---|
ref={value} | 为当前元素或组件提供单个 ref。 |
ref={[a, b]} | 为同一个元素提供多个 ref。 |
ref 的值可以是以下几种形态:
- 回调函数(callback):挂载时接收 DOM 节点,可返回清理函数;
track()创建的Tracked值:把 DOM 节点存入响应式信号,可在任意响应式作用域中读取;- 可变标识符或成员表达式(mutable identifier / member expression):由框架在挂载时赋值、卸载时清空。
官方文档给出的完整示例覆盖了这三种形态:
import { track } from 'ripple'; export default function App() @{ let div: HTMLDivElement | undefined; const input = track<HTMLInputElement | null>(null); const state: { button?: HTMLButtonElement } = {}; <> <div ref={div}>Hello world</div> <input ref={input} type="text" /> <button ref={state.button}>Save</button> </> }这里div是普通let变量,input是Tracked,state.button是成员表达式。文档明确了生命周期语义:可变 ref 在元素挂载时被赋值,在元素卸载时被清空(clear)。
这一语义在测试套件 ref.test.tsrx 中有直接验证,例如“clears a plain let variable via ref={var} when the host element unmounts”用例:用@if (show)条件渲染一个<div ref={div}>,初始断言div是HTMLDivElement实例,点击按钮把show置为false后,断言div变为null,确认卸载清空行为成立。
一个值得注意的优先级规则(同样来自该测试文件):当变量同时“看起来像”函数和标识符时,Ripple 走函数路径(function wins over setter);当变量持有Tracked时,走 Tracked 路径(Tracked wins over setter)。也就是说,把track()返回值赋给普通变量再传给ref,Ripple 依然能识别它是Tracked并正确写入。
回调 Ref:挂载赋值、卸载清理、函数工厂
回调 ref 在元素挂载时接收 DOM 节点;如果你在回调中返回一个清理函数,它会在元素被移除时执行。官方文档示例:
export function App() @{ function setup(node: HTMLDivElement) { console.log('mounted', node); return () => { console.log('unmounted', node); }; } <div ref={setup}>Hello world</div> }回调也可以直接内联在属性位置,顺便完成“赋值 + 日志 + 卸载复位”三件事:
export function App() @{ let div: HTMLDivElement | undefined; <div ref={(node) => { div = node; console.log('mounted', node); return () => { div = undefined; }; }} > Hello world </div> }回调 ref 还有一个重要应用场景:函数工厂。当第三方库替你返回 ref 回调、或者 ref 的初始化逻辑需要传参配置时,工厂形式非常合适:
import { fadeIn } from 'some-library'; export function App({ ms }) { return <div ref={fadeIn({ ms })}>Hello world</div> }也就是说,任何“接受配置、返回 ref 回调”的 API(例如动画库、弹窗定位库)都能直接作为ref值使用,而fadeIn({ ms })在运行时求值返回的回调会被框架当作 ref 处理。
多个 Ref:同一个元素挂多个 ref
当一个 DOM 元素需要被多个逻辑同时引用时,ref接受数组形式。官方文档示例中,同一个<input>同时挂了一个普通变量、一个Tracked和一个内联回调:
import { track } from 'ripple'; export function App() @{ let input: HTMLInputElement | undefined; const trackedInput = track<HTMLInputElement | null>(null); <input ref={[input, trackedInput, (node) => console.log(node)]} /> }数组中每一项都遵循前面所述的 ref 值规则:可变标识符会被赋值/清空,Tracked会写入/清空,回调会被调用(并注册其返回的清理函数)。
组件转发:ref 作为普通 prop 传递
Ripple 没有隐式的forwardRef机制——组件把ref={...}当作一个普通 prop 接收。你要么显式转发它,要么把它包含在展开到宿主元素的那个 spread 里。官方文档的第一个示例用 spread 一把转发所有剩余 props:
function Input({ id, ...rest }) { return <input {id} {...rest} /> } export function App() @{ let input: HTMLInputElement | undefined; <Input id="email" ref={input} /> }Input把...rest(包含ref)整体展开到真正的<input>上,于是外部的 ref 落到了宿主元素。测试套件中“works with spreading from composite component”用例验证了这一点:父组件给复合组件Child传ref={componentRef},Child内部把...rest展开到<pre>上,最终捕获的节点就是文档里第一个<pre>。
另一种更常见的模式是命名 prop:比如把 prop 叫inputRef。文档指出,inputRef这类命名 prop 就是普通的组件 API prop,如果你想让它生效为 ref,需要在接收组件内部把它传给ref={...}:
export function Field({ inputRef, ...rest }) { return <label> Search<input type="search" ref={inputRef} {...rest} /> </label> } export function App() @{ let input: HTMLInputElement | undefined; <Field inputRef={input} placeholder="Search docs" /> }注意这里有两层语义差异,测试文件里都有对应用例:
inputRef作为普通 prop 传给组件时,它不会被当作 ref 自动生效,必须由组件内部显式写ref={inputRef}(“forwards a named ref prop explicitly through a component”用例);- 但如果组件内部把该 prop 直接展开到宿主元素上(如
<input {ref} {...rest} />),运行时能识别出它是 ref 并正确挂载(“forwards an ordinary named prop explicitly from a host spread”用例)。
类型层面,RefValue类型由 Ripple 重新导出(见 types/index.d.ts 中export type { RefValue } from '@tsrx/core/runtime/ref'),测试中用PropsWithExtras<{ input_ref: RefValue<HTMLInputElement> }>给组件 props 标注 ref 成员,这是编写可复用组件时推荐的类型写法。
createRefKey:把 ref 放进任意展开的 props 对象
createRefKey()是 Ripple 提供的一个辅助 API:它创建一个唯一的对象键,当含有该键的对象被展开到元素上时,Ripple 会把对应值识别为 ref。这在 ref 需要程序化装配时特别有用——比如你有一个动态构造的 props 对象,想同时包含id、value和一个 ref 回调:
import { createRefKey, track } from 'ripple'; export function App() @{ let &[value] = track(''); let input: HTMLInputElement | undefined; const props = { id: 'example', value, [createRefKey()]: (node: HTMLInputElement) => { input = node; const onInput = () => { value = node.value; console.log(value); }; node.addEventListener('input', onInput); return () => { input = undefined; node.removeEventListener('input', onInput); }; }, }; <> <input type="text" {...props} /> <Input {...props} /> </> } function Input({ id, value, ...rest }) { return <input type="text" {id} {value} {...rest} /> }这个例子里 ref 回调同时做了两件事:把节点存入input变量,并手动绑定input事件监听(卸载时通过返回的清理函数移除监听)。注意这个 props 对象同时被展开到了宿主<input>和复合组件<Input>上——两条路径都能识别 ref,这在测试用例 “should handle spreading into composite refs” 中被验证:断言 ref 回调恰好被调用了两次(host 与 composite 各一次)。
源码层面:ref 键为什么能被识别
从源码结构看,createRefKey()在客户端的实现非常轻量——runtime.js 中它就是一个返回带固定描述的 Symbol 的工厂函数:
export function ref_prop() { return Symbol(REF_PROP); }而REF_PROP常量定义为字符串'ref'(见 constants.js),并在客户端入口以createRefKey的名字导出(index-client.js):
export { ref_prop as createRefKey } from './internal/client/runtime.js';也就是说,createRefKey()每次返回一个description === 'ref'的全新 Symbol,Spread 更新逻辑据此区分“真正的 ref”和“普通属性”。
识别发生在元素 spread 的处理函数apply_element_spread中(render.js)。其核心机制可以概括为三点:
- Symbol 键扫描:遍历 props 对象的所有自有 Symbol,只处理
description === 'ref'的键(即createRefKey()生成的键),为每个 ref 建立独立的响应式 effect(create_spread_ref_effect),ref 函数变化或旧 effect 已销毁时才会销毁重建; - 字符串键中的 ref 值检测:对普通字符串键,用
is_ref_prop(value)判断值是否为 ref 属性。测试用例 “reports ordinary functions and ref objects as non named-ref props” 明确了边界:普通函数和{ current: null }这类 ref-like 对象都不会被误判为 ref(isRefProp(() => {})与isRefProp({ current: null })均返回false),Ripple 识别的是带标记的 ref 值而非任意函数; - 卸载对称性:当某个 ref 键从 props 中消失时,对应的 effect 被销毁,同时之前为该键注册的事件监听也会被移除(
remove_listeners分支)。测试中“clears the Tracked when the host element unmounts”“clears a component ref prop when a host spread changes it to a regular prop”等用例覆盖了 ref 从 spread 中移除、ref 与普通属性互相转换等边角场景,确认赋值与清空是严格配对的。
服务端行为:SSR 下 ref 是 no-op
从源码结构看,ref 是纯客户端概念:服务端运行时把createRefKey导出为noop(index-server.js):
export const createRefKey = noop;因此在使用 SSR 的项目中,含有createRefKey()的 props 对象在服务端渲染阶段不会造成任何副作用,DOM 节点捕获只在客户端挂载(hydrate/挂载)后发生。写跨端组件时,依赖 ref 的逻辑都应放在回调或effect中,而不是组件顶层同步代码里。
与响应式系统的结合
Ripple 的 ref 与响应式体系是打通的,这也是它与“命令式 ref 对象”方案的主要差异点:
Trackedref 让 DOM 节点本身成为响应式数据。const input = track<HTMLInputElement | null>(null)之后,任何effect、事件处理函数或渲染表达式都可以读取input的当前值,节点挂载/卸载会自动反映到该信号上(测试 “captures a host element into a Tracked via ref={tracker}” 与 “clears the Tracked when the host element unmounts” 分别验证了写入与清空);- 读取时机要注意:组件的 setup 阶段先于 DOM 节点创建,所以在 setup 中同步读取
let div仍是undefined,测试中统一用effect(() => { captured = div ?? null; })来观察挂载后的值(见 ref.test.tsrx 中capturing a host element用例的注释说明)。这是一个容易踩的坑:把 DOM 节点存入变量后,请在 effect 或事件回调中读取,不要在组件初始化代码里同步断言它非空。
小结
Ripple 的 DOM refs 设计可以归纳为一句话:ref 值就是数据,框架负责赋值与清理的配对。核心要点回顾:
| 场景 | 写法 | 关键语义 |
|---|---|---|
| 简单捕获 | ref={div}(let变量或成员表达式) | 挂载赋值,卸载清空 |
| 响应式捕获 | ref={track<T \| null>(null)} | 写入/清空Tracked,可被响应式系统读取 |
| 带清理逻辑 | ref={(node) => { ...; return cleanup }} | 挂载调用,卸载执行返回的清理函数 |
| 库提供的 ref | ref={fadeIn({ ms })} | 函数工厂返回 ref 回调,直接可用 |
| 一个元素多个 ref | ref={[a, b, cb]} | 每项独立按规则处理 |
| 组件转发 | <Input ref={x} />+ 组件内{...rest}或ref={inputRef} | ref 是普通 prop,需显式落到宿主元素 |
| 程序化装配 | props[createRefKey()] = cb | spread 时按description === 'ref'的 Symbol 键识别 |
以上所有行为都可以直接对照 ref.test.tsrx 中的 20 余个测试用例复现验证,识别与 spread 更新的实现细节则集中在 render.js 与 runtime.js,可作为进一步阅读源码的入口。
【免费下载链接】ripplethe elegant TypeScript UI framework项目地址: https://gitcode.com/GitHub_Trending/ripple25/ripple
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考