OpenMontage 前端工程实践:RSC Props 去重序列化——在客户端做数据变换,避免重复网络载荷
【免费下载链接】OpenMontageWorld's first open-source, agentic video production system. 12 production pipelines, 100+ tools, 700+ agent skill and production-knowledge files. Turn your AI coding assistant into a full video production studio.项目地址: https://gitcode.com/GitHub_Trending/op/OpenMontage
导读
在 React Server Components(RSC)架构下,服务端组件向客户端组件传递 props 时,所有数据都会被序列化并内嵌进 HTML 与后续 RSC 请求中,直接影响页面体积与加载时间。本文聚焦 Vercel React 最佳实践中的一条高频规则——避免 RSC props 重复序列化(server-dedup-props):先讲清 RSC 序列化"按对象引用去重、而非按值去重"的底层机制,再给出将.toSorted()、.filter()、.map()等变换下沉到客户端的正确写法,并通过string[]与object[]的对比揭示不同数据类型的真实开销差异,帮助你在写服务端组件时少传数据、传对数据。
该规则来自当前仓库技能库 vercel-react-best-practices 的"服务端性能(Server-Side Performance)"章节(规则文件:server-dedup-props.md),适合任何使用 Next.js App Router、RSC 或类似服务端组件架构的 React 工程。
RSC 边界上的序列化:一切 props 都会被"打包"传输
在 RSC 架构中,服务端组件(默认无'use client'指令的组件)在服务端执行,并把渲染结果连同 props 数据序列化后下发给浏览器,用于客户端组件的水合与渲染。这意味着:
- 所有传给客户端组件的 props 都会变成字符串内嵌进 HTML 响应和后续的 RSC 请求;
- 序列化数据的大小直接贡献于页面权重与加载时间,因此"传多少"比"怎么传"更值得精打细算。
姊妹规则 server-serialization.md(Minimize Serialization at RSC Boundaries,Impact: HIGH)从另一个角度强调了同样的原则:一个 50 字段的 user 对象如果整包下传而客户端只用 1 个字段,那么 49 个字段就是纯浪费,应当只传name={user.name}。去重序列化规则是它的"量"的补充——关注同一个值被序列化了几次。
核心机制:去重按"对象引用"而非"值"
RSC→客户端的序列化系统会做一次去重,但它的判定依据是对象引用(reference),而不是值(value):
- 同一个引用传给多个 props → 只序列化一次;
- 新产生的引用传给多个 props → 每个引用都重新序列化一次。
由此引出一条关键推论:如果在服务端对同一个数组做.toSorted()、.filter()、.map()等变换,就会创建出新的数组引用,导致同一份数据被序列化两次——一次是原始数组,一次是变换后的副本。
规则原文给出了最典型的反模式:
// RSC: sends 6 strings (2 arrays × 3 items) <ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} />当usernames有 3 个元素时,usernames数组与usernames.toSorted()生成的数组各携带 3 个字符串,总共传输 6 个字符串;而客户端大概率只关心排序后的结果。
正确做法是只传一次,变换放到客户端:
// RSC: send once <ClientList usernames={usernames} /> // Client: transform there 'use client' const sorted = useMemo(() => [...usernames].sort(), [usernames])这样网络载荷从 6 个字符串降到 3 个;客户端侧的排序开销由浏览器承担,且通过useMemo仅在usernames引用变化时重新计算。这里刻意在客户端使用[...usernames].sort()而非usernames.sort(),是为了配合另一条规则 js-tosorted-immutable.md(Use toSorted() Instead of sort() for Immutability)——sort()会原地修改数组,可能污染 RSC 传来的 props,破坏 React 的不可变模型;现代浏览器与 Node.js 20+ 可直接使用toSorted()获得不改变原数组的排序副本。
嵌套去重行为:不同数据类型的开销差异
去重是递归生效的——数组本身及其内部元素都会参与引用去重,因此影响随数据类型而不同:
| 数据类型 | 影响等级 | 重复序列化的内容 |
|---|---|---|
string[]、number[]、boolean[] | HIGH(高) | 数组外壳 + 内部所有原始值全部重复 |
object[] | LOW(低) | 仅数组外壳重复,嵌套对象按引用去重,不会重复 |
用代码直观对比:
// string[] - duplicates everything usernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings // object[] - duplicates array structure only users={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4)string[]案例中,两个数组各带'a'、'b'共 4 个字符串;object[]案例中,users与users.toSorted()两个数组外壳重复了,但内部的{id:1}、{id:2}两个对象因为引用相同只序列化一次,最终传输量为 2 个数组结构 + 2 个唯一对象,而非 4 个对象。
这一对比的价值在于:判断一条"去重失败"是否值得修复,要先看数据类型。对object[]来说,重复的只是数组外壳,影响有限;而对string[]/number[]/boolean[],重复的是全部内容,修复收益显著。
会破坏去重的操作清单
规则明确列出了会创建新引用、从而破坏去重的常见操作:
- 数组:
.toSorted()、.filter()、.map()、.slice()、展开运算符[...arr] - 对象:展开
{...obj}、Object.assign()、structuredClone()、JSON.parse(JSON.stringify())
规则给出的正反示例进一步补充了"派生数据"与"字段摘取"两类典型场景:
// ❌ Bad <C users={users} active={users.filter(u => u.active)} /> <C product={product} productName={product.name} /> // ✅ Good <C users={users} /> <C product={product} /> // Do filtering/destructuring in client注意第二个反例:productName={product.name}本身并不是"重复序列化",它只是多传了一个可从product中派生出的字段,与姊妹规则 server-serialization.md 的"只传客户端真正用到的字段"原则直接呼应。把筛选(filtering)和结构解构(destructuring)都移到客户端,是两条规则的共同结论。
例外情况:什么时候该在服务端传派生数据
规则给出了明确的例外:当变换本身代价高昂(expensive),或客户端根本不需要原始数据时,应在服务端传派生数据。
这背后的权衡是序列化成本与计算成本的互换:
- 默认策略:变换开销小、客户端需要原始数据 → 服务端只传原始引用,客户端自行变换(本规则的主路径);
- 例外策略:变换是重计算(例如大规模排序、聚合、调用了服务端 API)→ 在服务端算好再传,避免把高成本计算重复分发到每个客户端;客户端只用到排序/筛选后的结果时,同样不应把完整原始数据集下传。
类似的"按需取舍"思想在该技能库的服务端章节中贯穿始终:需要跨请求缓存时可参考 server-cache-react.md(Per-Request Deduplication with React.cache(),注意React.cache()也以引用/原语相等性判定缓存命中),静态资源加载则可参考 server-hoist-static-io.md 把 I/O 提升到模块级。
在当前仓库中的实践落点
OpenMontage 仓库内的 React/Remotion 工程 remotion-composer 提供了直观的对照样本:例如 Root.tsx 中定义了大型的ThemeConfig接口与THEMES主题记录,各组合组件通过 props 消费其中的主题字段。在类似场景中,若由服务端向客户端组件整包传递THEMES或大型配置对象,而客户端仅使用primaryColor、fontFamily等少量字段,就同时触发了"字段过度序列化"与"潜在重复引用"两类问题——正确的做法是先只传最小字段集,再对需要变换的数据(排序、筛选、映射)统一放到客户端用useMemo处理。Remotion 的CalculateMetadataFunction(如 CinematicRenderer.tsx 与 TitledVideo.tsx 中的calculateTitledVideoMetadata)这类纯数据推导函数,同样是"在消费端做派生"模式的良好示范:数据在靠近使用处变换,跨端只传输最小必要载荷。
小结:一条可立即执行的自查清单
- 数引用:同一份数据是否以多个 props 传给了同一个客户端组件?若是,检查是否在服务端做了产生新引用的变换;
- 看类型:重复的是
string[]/number[]/boolean[](全量重复,HIGH 影响)还是object[](仅数组外壳重复,LOW 影响),据此决定修复优先级; - 移变换:把
.toSorted()、.filter()、.map()、.slice()、[...arr]、{...obj}等操作下沉到客户端,配合useMemo与toSorted()保持不可变; - 判例外:变换昂贵或客户端不需要原始数据时,才在服务端传派生结果;
- 联动检查:同时对照 server-serialization.md 的"只传用到的字段",两条规则一起落地,才能把 RSC 边界上的网络载荷压到最低。
【免费下载链接】OpenMontageWorld's first open-source, agentic video production system. 12 production pipelines, 100+ tools, 700+ agent skill and production-knowledge files. Turn your AI coding assistant into a full video production studio.项目地址: https://gitcode.com/GitHub_Trending/op/OpenMontage
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考