前端精读《Records & Tuples for React》:用原生不可变类型重塑 React 引用稳定与渲染性能

原创2026-10-01 20:51:531,428 阅读
文章标签:文档技术博客前端

前端精读《Records & Tuples for React》:用原生不可变类型重塑 React 引用稳定与渲染性能

本文是前端精读周刊对 Sebastian Lorber《Records & Tuples for React》一文的精读。核心观点是:TC39 的 Records & Tuples 提案(Record #{}、Tuple #[] 两种深度不可变原始类型)一旦落地,React 开发中大量靠 useMemo、memo、Object.is 人工维持的"引用稳定"问题,可以交给语言级语法与 JS 引擎去解决。读完本文,你将掌握用 Record/Tuple 保证 props、state、effect 依赖引用稳定、以值判等替代引用对比的具体写法,以及这套方案对取数、查询参数、列表渲染、css-in-js 等场景带来的改造思路与性能风险。

为什么 React 对"引用"如此敏感

React 判断状态、依赖、props 是否变化,靠的是引用是否变化:useMemo 的依赖数组、useEffect 的依赖数组、React.memo 的 props 浅比较,本质上都在做 Object.is 级别的引用对比。因此,只要在每次渲染中新建一个内容相同但引用不同的对象,React 就会认为"变化了",从而触发多余的取数、多余的 effect 或多余的重渲染。

这个问题正是 精读《Records & Tuples 提案》 中描述的核心痛点:js 原生没有 immutable 数据类型,Object.freeze 只能冻结一层,而 {} 与 {} 永远不相等,我们既无法保证对象真的不可变,也无法从肉眼判断两个长得一样的对象是否来自同一引用。Records & Tuples 提案直接新增三种原始类型——Record(#{ x: 1 })、Tuple(#[1, 2])、Box(Box(obj)),让 JS 原生支持深度不可变,并且 #{ a: 1 } === #{ a: 1 } 按值判等。

本篇在这个基础上,逐场景讨论提案能为 React 解决哪些真实痛点,以及它成立的最关键前提:浏览器的性能优化。

保证不可变性:从"自觉约束"到"语法强制"

虽然现在 React 也能用 Immutable 思想开发,但大部分情况无法保证安全性,因为归根结底,我们不会总使用 Object.freeze 来冻结对象,大部分情况下需要人为保证引用不被修改,其中的潜在风险依然存在。比如下面的代码,无论 props 还是 state,都可能被直接修改:

const Hello = ({ profile }) => {
  // prop mutation: throws TypeError
  profile.name = 'Sebastien updated';
  return <p>Hello {profile.name}</p>;
};

function App() {
  const [profile, setProfile] = React.useState(#{
    name: 'Sebastien',
  });
  // state mutation: throws TypeError
  profile.name = 'Sebastien updated';
  return <Hello profile={profile} />;
}

一旦 profile 是 Record(#{}),上面的赋值语句会直接抛出 TypeError。这与 Object.freeze 有本质区别:Object.freeze 只能保证第一层不可变,深层对象依然可以被修改(详见 精读《Records & Tuples 提案》 中"为什么是深度不可变"一节);而 Record 的不可变性是深度、语法级的——只要用 #{} 定义,TS 与 JS 都会在赋值时报错,立刻阻止问题扩散。

部分代替 useMemo:值相同即引用相同

在 React 中,为了让子组件或取数 Hook 拿到"稳定引用",我们常常要为临时组装的对象做 useMemo,例如:

const apiFilters = useMemo(
  () => ({ userFilter, companyFilter }),
  [userFilter, companyFilter],
);
const { apiData, loading } = useApiData(apiFilters);

useMemo 的依赖数组本质上是 React 用 Object.is 对比 userFilter、companyFilter 是否变化,若依赖未变,则复用上一次的对象引用。这套逻辑正确但啰嗦:既要写 deps,又要祈祷 deps 写全,否则引用可能在值未变时被重建。

Record 模式下不需要 memo,因为 JS 引擎会帮你做类似的事情——按值判等,值没变,引用就不变:

const { apiData, loading } = useApiData(#{ userFilter, companyFilter })

#{ userFilter, companyFilter } 每一次渲染都会求值,但只有当内部值真正变化时,Record 的"值"才不同,于是对依赖 Record 的 useApiData 而言,引用是稳定的。

用在 useEffect:依赖数组直接传 Record

这个场景和代替 useMemo 差不多,即把 Record 直接作为 useEffect 的依赖:

const apiFilters = #{ userFilter, companyFilter };

useEffect(() => {
  fetchApiData(apiFilters).then(setApiDataInState);
}, [apiFilters]);

你可以把 apiFilters 当做一个引用稳定的原始对象看待,如果它确实变化了,那一定是值改变了,所以才会引发取数。如果把上面的 # 号去掉,改成普通对象字面量,那么每次组件刷新都会重新创建引用、触发取数,而实际上绝大多数都是多余的请求。

用在 props 属性:免 memo 的不可变 props

同理,给"昂贵"子组件传内联对象 props 时,不再需要提前 useMemo,直接用 Record 字面量即可:

<ExpensiveChild someData={#{ attr1: 'abc', attr2: 'def' }} />;

只要 someData 的值在两次渲染之间没有变化,React.memo 的浅比较就能正确判断"props 没变",跳过子组件重渲染。

将取数结果转化为 Record:让后端数据也参与值判等

这个目前还真做不到,除非用性能非常差的 JSON.stringify 或 deepEqual。提案给出的设想是引入 JSON.parseImmutable,其 API 与 JSON.parse 无异,但返回的是 Record/Tuple 而不是普通对象:

const fetchUserAndCompany = async () => {
  const response = await fetch(
    `https://myBackend.com/userAndCompany`,
  );
  return JSON.parseImmutable(await response.text());
};

即利用 Record 提案的 JSON.parseImmutable 将后端返回值也转化为 Record,这样即便重新查询,但如果返回结果完全不变,也不会导致重渲染,或者局部变化也只会导致局部重渲染;而目前我们只能放任这种情况全量重渲染。

然而这对浏览器实现 Record 的性能优化提出了非常严苛的要求,因为假设后端返回的数据有几十 MB,我们不知道这种内置 API 会导致多少的额外开销。假设浏览器使用非常 Magic 的办法做到了几乎零开销,那么我们应该在任何时候都用 JSON.parseImmutable 解析而不是 JSON.parse。

生成查询参数:精确请求与生态 API 改造

同样利用 parseImmutable 方法,可以让前端精确发送请求,而不是每次 qs.parse 生成一个新引用就发一次请求:

// This is a non-performant, but working solution.
// Lib authors should provide a method such as qs.parseRecord(search)
const parseQueryStringAsRecord = (search) => {
  const queryStringObject = qs.parse(search);
  // Note: the Record(obj) conversion function is not recursive
  // There's a recursive conversion method here:
  // https://tc39.es/proposal-record-tuple/cookbook/index.html
  return JSON.parseImmutable(
    JSON.stringify(queryStringObject),
  );
};

const useQueryStringRecord = () => {
  const { search } = useLocation();
  return useMemo(() => parseQueryStringAsRecord(search), [
    search,
  ]);
};

注意这里特意把 JSON.parseImmutable 与 JSON.stringify 组合使用,是因为 Record(obj) 转换函数本身不递归。原文还提到一个有趣的点:到时候配套工具库可能提供类似 qs.parseRecord(search) 的方法把 JSON.parseImmutable 包装掉——也就是说,这些生态库想要"无缝"接入 Record 提案,其实需要做一些 API 改造,而非简单地换一个解析函数。

避免循环产生的新引用:filter 不再是引用炸弹

即便原始对象引用不变,但我们写几行代码随便 .filter 一下引用就变了,而且无论返回结果是否变化,引用都一定会改变:

const AllUsers = [
  { id: 1, name: 'Sebastien' },
  { id: 2, name: 'John' },
];

const Parent = () => {
  const userIdsToHide = useUserIdsToHide();
  const users = AllUsers.filter(
    (user) => !userIdsToHide.includes(user.id),
  );
  return <UserList users={users} />;
};

const UserList = React.memo(({ users }) => (
  <ul>
    {users.map((user) => (
      <li key={user.id}>{user.name}</li>
    ))}
  </ul>
));

普通数组每次 filter 都会产生新引用,导致 React.memo 的浅比较永远失效,要避免这个问题就必须 useMemo。但在 Record 提案下不需要——因为 Tuple 的方法返回的仍是 Tuple,且按值判等:

const AllUsers = #[
  #{ id: 1, name: 'Sebastien' },
  #{ id: 2, name: 'John' },
];

const filteredUsers = AllUsers.filter(() => true);
AllUsers === filteredUsers;
// true

这正是 精读《Records & Tuples 提案》 中"标准库支持"一节的推论:#[1, 2, 3].map(x => x * 2) 返回的是 #[2, 4, 6] 而非数组,所有 Tuple 方法都不会修改引用值,而是创建新的值相等即引用相同的 Tuple。

作为 React key:拿 item 本身当 key

这个想法更有趣。如果 Record 提案保证了引用严格不可变,那我们完全可以拿 item 本身作为 key,而不需要任何其他手段(id、index、手工拼接的字符串),这样维护成本会大大降低:

const list = #[
  #{ country: 'FR', localPhoneNumber: '111111' },
  #{ country: 'FR', localPhoneNumber: '222222' },
  #{ country: 'US', localPhoneNumber: '111111' },
];
<>
  {list.map((item) => (
    <Item key={item} item={item} />
  ))}
</>

但这也依然是建立在浏览器非常高效实现 Record 的前提下:假设浏览器采用 deepEqual 作为初稿实现这个规范,那么上面这坨代码可能导致本来不卡的页面直接崩溃退出——因为每次渲染都要对 key 做深比较,列表越长开销越大。

TS 支持:编译期就能锁定不可变类型

也许到时候 TS 会支持如下方式定义不可变变量,类型层面直接声明 Record 形状:

const UsersPageContent = ({
  usersFilters,
}: {
  usersFilters: #{nameFilter: string, ageFilter: string}
}) => {
  const [users, setUsers] = useState([]);
  // poor-man's fetch
  useEffect(() => {
    fetchUsers(usersFilters).then(setUsers);
  }, [usersFilters]);
  return <Users users={users} />;
};

那我们就可以真的保证 usersFilters 是不可变的了。因为在目前阶段,编译时 TS 是完全无法保障变量引用是否会变化——类型系统里没有"引用是否会被替换"这个概念,只能靠运行时 Object.freeze 或开发纪律兜底。

优化 css-in-js:稳定引用带来稳定 className

采用 Record 与普通 object 作为 css 属性,对 css-in-js 的区别是什么?

const Component = () => (
  <div
    css={#{
      backgroundColor: 'hotpink',
    }}
  >
    This has a hotpink background.
  </div>
);

由于 css-in-js 框架对新的引用会生成新 className,所以如果不主动保障引用不可变,会导致渲染时 className 一直变化,不仅影响调试也影响性能(每次渲染都重新生成样式类名、重新注入样式),而 Record 可以避免这个担忧——样式对象值不变,则引用不变,className 稳定。

精读:性能才是决定成败的最大变量

总结下来,其实 Record 提案并不是解决之前无法解决的问题,而是用更简洁的原生语法解决了复杂逻辑才能解决的问题。这带来的优势主要在于"不容易写出问题代码了",或者让 Immutable 在 js 语言的上手成本更低了。

现在看下来这个规范有个严重担忧点就是性能,而 stage2 并没有对浏览器实现性能提出要求,而是给了一些建议,并在 stage4 之前给出具体性能优化建议方案。

其中还是提到了一些具体做法,包括快速判断真假,即对数据结构操作时的优化:

  • 快速判真可以采用类似 hash-cons 快速判断结构相等,可能是将一些关键判断信息存在 hash 表中,进而不需要真的对结构进行递归判断。
  • 快速判假可以通过维护散列表快速判断,或者用上数据结构一些经典算法,比如布隆过滤器——它本来就是被设计用在高效快速判否场景的。

这与 精读《Immutable 结构共享》 中提到的思路一脉相承:immutablejs 等库通过 Hash maps tries 与 vector tries 的树形结构实现"以空间换时间",让更新与查询保持平衡。Record/Tuple 如果要在引擎层面做到近乎引用对比的成本,势必要在结构共享、hash 索引等技术上做类似的深度优化。

Record 降低了哪些心智负担

其实如果应用开发都是 hello world 复杂度,那 React 也可以很好的契合 immutable,比如我们给 React 组件传递的 props 都是 boolean、string 或 number:

<ExpensiveChild userName="nick" age={18} isAdmin />;

上面的例子完全不用关心引用会变化,因为我们用的原始类型本身引用就不可能变化,比如 18 不可能突变成 19,如果子组件真的想要 19,那一定只能创建一个新的,总之就是没办法改变我们传递的原始类型。

如果我们永远在这种环境下开发,那 React 结合 immutable 会非常美妙。但好景不长,我们总是要面对对象、数组的场景,然而这些类型在 js 语法里不属于原始类型,我们了解到还有"引用"这样一种说法,两个值不一样的对象可能是 === 全等的。

可以认为,Record 就是把这个顾虑从语法层面消除了,即 #{ a: 1 } 也可以看作像 18、19 一样的数字,不可能有人改变它,所以从语法层面你就会像对 19 这个数字一样放心 #{ a: 1 } 不会被改变。

当然这个提案面临的最大问题就是"如何将拥有子结构的类型看作原始类型",也许 JS 引擎将它看作一种特别的字符串更贴合其原理,但难点是这又违背了整个语言体系对子结构的默认认知,Box 装箱语法尤其别扭——普通对象必须用 Box(obj) 包裹才能放进 Record,取值还要 .unbox(),这一层额外的仪式感确实不太自然。

总结

看了这篇文章的畅想,React 与 Records & Tuples 结合一定会很好,但前提是浏览器对其性能优化必须与"引用对比"大致相同才可以。这也是较为少见、对性能要求如此苛刻的特性,因为如果没有性能的加持,其便捷性将毫无意义——毕竟,一个慢的"原生不可变类型",还不如一个快的 useMemo。从当前仓库的配套解读看,精读《Records & Tuples 提案》 已经系统梳理了提案的语法、判等规则、标准库支持与 FAQ,而本篇则是把目光投向 React 侧:值判等如何替代引用对比,如何让 useMemo、React.memo、useEffect 依赖这些 React 引用敏感机制在更少的代码下正确工作。提案目前仍处于 stage2 阶段,语法细节与性能实现都存在调整可能,上文所有代码示例均基于提案当前设计,属于前瞻性讨论而非可直接运行的正式 API。

登录后查看全文
weekly