原文链接:anita-app.com/blog/articl...
作者:ilDon
本文反映了作者的个人观点与思考。由于作者并非英语母语者,最终表述经人工智能编辑以确保清晰度与准确性。
React编译器现已稳定并可投入生产环境(React博客,2025年10月7日),它显著减少了手动使用useMemo、useCallback和React.memo的需求。
这对大多数 React 代码库而言是重大利好,尤其适用于采用纯净函数组件和不可变数据的架构。但存在一种模式正变得日益棘手:依赖类实例计算衍生值的类密集型对象模型。
若渲染时逻辑依赖类实例,编译器备忘录机制的精确度可能无法满足需求,开发者往往不得不重新引入手动备忘录机制以恢复控制权。
React编译器通过可观察依赖关系进行优化
官方文档说明React编译器会基于静态分析和启发式算法自动对组件和值进行备忘存储:
关键细节在于:备忘存储仍取决于React能观察到的输入内容。
在 React 中,对象的备忘比较基于引用(采用 Object.is 的语义)。memo 和 useMemo 的文档都明确说明了这一点:
因此,如果有效值隐藏在对象实例内部,而该实例引用发生变化,React 就会认为值也发生了变化。
ElementClass 示例
假设你将元素建模如下:
JavaScript
class ElementClass {
constructor(private readonly isoDate: string) {}
public getFormattedDate(): string {
const date = new Date(this.isoDate);
if (Number.isNaN(date.getTime())) {
return 'Invalid date';
}
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
}
}
而在一个组件中:
JavaScript
export function Row({ elementInstance }: { elementInstance: ElementClass }) {
const formattedDate = elementInstance.getFormattedDate();
return <span>{formattedDate}</span>;
}
这段代码是可读的。但从外部来看,相关的响应式输入实际上是 elementInstance(对象引用)。
如果状态管理层返回了一个新的 ElementClass 实例,React/编译器会检测到新的依赖关系,并重新计算格式化后的值------即使底层的 isoDate 字符串并未改变。
手动逃生舱门功能正常,但噪音较大
你可以强制使用更窄的依赖项:
JavaScript
class ElementClass {
constructor(public readonly isoDate: string) {} // <-- expose isoDate as a public property
// unchanged
}
export function Row({ elementInstance }: { elementInstance: ElementClass }) {
const formattedDate = useMemo(
() => elementInstance.getFormattedDate(),
[elementInstance.isoDate],
);
return <span>{formattedDate}</span>;
}
这确实可行,React 明确将 useMemo/useCallback 作为编译器环境下的逃生通道:
但此时我们又陷入了手动处理依赖关系的困境,还不得不将内部逻辑暴露给 UI。
编译器友好的替代方案:纯数据 + 纯辅助函数
若 UI 接收纯粹的不可变数据,依赖关系将变得显式且低成本:
JavaScript
type Element = {
isoDate: string;
};
export function Row({ element }: { element: Element }) {
const formattedDate = DateHelpers.formatDate(element.isoDate);
return <span>{formattedDate}</span>;
}
现在,DateHelpers.formatDate 的相关输入是一个基本类型(isoDate),而非隐藏在类实例方法调用背后的状态。这样,编译器就能将formatDate的输出进行备忘存储,仅将 isoDate 作为唯一依赖项------这个基本值在发生变化时会正确触发备忘存储机制。
有人可能会提出异议:即便在这个简单的对象示例中,整个element仍会被传递给组件。因此Row组件终究会重新渲染,唯一实质区别在于formattedDate不再被重新计算。
这种说法没错:若传递整个对象且其引用发生变化,该组件就会重新渲染。我们稍后将详细探讨这个问题。
在探讨该问题的解决方案之前,我想强调:对于大型应用而言,即使仅考虑派生值的备忘录化,类实例与普通数据之间的差异依然显著。React编译器会注入备忘录单元和依赖项检查。若依赖项是不稳定的对象引用,缓存命中率将很低:
- 你仍需为备忘录槽位支付额外内存成本,
- 仍需执行依赖项检查,
- 仍需因引用变更而重新计算。
换言之,当渲染路径中充斥着类实例且未进行手动备忘时,编译器的优化往往会变成额外开销而非性能提升。
现在,让我们回到传递整个对象的问题。若传递对象后其引用发生变化,组件将重新渲染。无论对象是类实例还是普通对象,此特性均成立。若需避免因对象引用变更导致的冗余渲染,可仅传递子组件实际需要的原始值,而非完整对象。如此,组件仅在相关原始值变更时重新渲染,而非对象引用变更时:
JavaScript
export function Row({ isoDate }: { isoDate: string }) {
const formattedDate = DateHelpers.formatIsoDate(isoDate);
return <span>{formattedDate}</span>;
}
现在依赖关系已显式化且采用原始类型(isoDate),而非隐藏在实例方法背后。
可能的反对意见是:即使采用面向对象的方法,仍可将element.getFormattedDate()的结果传递给子组件,而该结果本质上仍是字符串:
JavaScript
function Parent({ element }: { element: ElementClass }) {
return <Row formattedDate={element.getFormattedDate()} />;
}
function Row({ formattedDate }: { formattedDate: string }) {
return <span>{formattedDate}</span>;
}
Row 组件现在接收原始属性,但耗时或重复的计算只是向上移了一层,转移到了 Parent 组件中。
如果 element 组件频繁通过引用发生变化,element.getFormattedDate() 方法仍会频繁重新执行。因此瓶颈并未消除,只是转移了位置。
采用数据优先的架构后,你可以直接跨边界传递 isoDate 数据,并将衍生计算作为纯函数保留在需求附近。
这更契合 React 的纯粹性与不可变性模型:
实用经验法则
在 React 渲染路径中,优先采用数据优先模型而非行为丰富的类实例。
仅在边界处使用类(如领域模型、解析器、适配器),但向组件传递可序列化的纯数据,并将渲染时推导保持为纯函数。
借助 React Compiler,这通常能带来:
- 更高的自动备忘录命中率
- 更少的手动
useMemo逃逸机制 - 更清晰的依赖推理
- 更少因对象身份变化导致的意外重计算
React Compiler 消除了大量优化工作,但仍会奖励依赖关系明确的代码。在现代 React 的 UI 渲染中,普通对象加纯辅助函数往往是更具可扩展性的选择。