精读《Records & Tuples for React》

继前一篇 精读《Records & Tuples 提案》,已经有人在思考这个提案可以帮助 React 解决哪些问题了,比如这篇 Records & Tuples for React,就提到了许多 React 痛点可以被解决。

其实我比较担忧浏览器是否能将 Records & Tuples 性能优化得足够好,这将是它能否大规模应用,或者说我们是否放心把问题交给它解决的最关键因素。本文基于浏览器可以完美优化其性能的前提,一切看起来都挺美好,我们不妨基于这个假设,看看 Records & Tuples 提案能解决哪些问题吧!

概述

Records & Tuples Proposal 提案在上一篇精读已经介绍过了,不熟悉可以先去看一下提案语法。

保证不可变性

虽然现在 React 也能用 Immutable 思想开发,但大部分情况无法保证安全性,比如:

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

Hello {profile.name}

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

归根结底,我们不会总使用 freeze 来冻结对象,大部分情况下需要人为保证引用不被修改,其中的潜在风险依然存在。但使用 Record 表示状态,无论 TS 还是 JS 都会报错,立刻阻止问题扩散。

部分代替 useMemo

比如下面的例子,为了保障 apiFilters 引用不变,需要对其 useMemo:

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

但 Record 模式不需要 memo,因为 js 引擎会帮你做类似的事情:

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

用在 useEffect

这段写的很啰嗦,其实和代替 useMemo 差不多,即:

const apiFilters = #{ userFilter, companyFilter };

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

你可以把 apiFilters 当做一个引用稳定的原始对象看待,如果它确实变化了,那一定是值改变了,所以才会引发取数。如果把上面的 # 号去掉,每次组件刷新都会取数,而实际上都是多余的。

用在 props 属性

可以更方便定义不可变 props 了,而不需要提前 useMemo:

;

将取数结果转化为 Record

这个目前还真做不到,除非用性能非常差的 JSON.stringifydeepEqual,用法如下:

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

生成查询参数

也是利用了 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,
  ]);
};

还提到一个有趣的点,即到时候配套工具库可能提供类似 qs.parseRecord(search) 的方法把 JSON.parseImmutable 包装掉,也就是这些生态库想要 “无缝” 接入 Record 提案其实需要做一些 API 改造。

避免循环产生的新引用

即便原始对象引用不变,但我们写几行代码随便 .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 ;
};

const UserList = React.memo(({ users }) => (
  
    {users.map((user) => (
  • {user.name}
  • ))}
));

要避免这个问题就必须 useMemo,但在 Record 提案下不需要:

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

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

作为 React key

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

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

当然这依然建立在浏览器非常高效实现 Record 的前提,假设浏览器采用 deepEqual 作为初稿实现这个规范,那么上面这坨代码可能导致本来不卡的页面直接崩溃退出。

TS 支持

也许到时候 ts 会支持如下方式定义不可变变量:

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

那我们就可以真的保证 usersFilters 是不可变的了。因为在目前阶段,编译时 ts 是完全无法保障变量引用是否会变化。

优化 css-in-js

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

const Component = () => (
  
This has a hotpink background.
);

由于 css-in-js 框架对新的引用会生成新 className,所以如果不主动保障引用不可变,会导致渲染时 className 一直变化,不仅影响调试也影响性能,而 Record 可以避免这个担忧。

精读

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

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

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

快速判真可以采用类似 hash-cons 快速判断结构相等,可能是将一些关键判断信息存在 hash 表中,进而不需要真的对结构进行递归判断。

快速判假可以通过维护散列表快速判断,或者我觉得也可以用上数据结构一些经典算法,比如布隆过滤器,就是用在高效快速判否场景的。

Record 降低了哪些心智负担

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

;

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

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

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

当然这个提案面临的最大问题就是 “如何将拥有子结构的类型看作原始类型”,也许 JS 引擎将它看作一种特别的字符串更贴合其原理,但难点是这又违背了整个语言体系对子结构的默认认知,Box 装箱语法尤其别扭。

总结

看了这篇文章的畅想,React 与 Records & Tulpes 结合的一定会很好,但前提是浏览器对其性能优化必须与 “引用对比” 大致相同才可以,这也是较为少见,对性能要求如此苛刻的特性,因为如果没有性能的加持,其便捷性将毫无意义。

讨论地址是: 精读《Records & Tuples for React》· Issue #385 · dt-fe/weekly

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

版权声明:自由转载-非商用-非衍生-保持署名( 创意共享 3.0 许可证

你可能感兴趣的