精读《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
【精读《Records & Tuples for React》】这段写的很啰嗦,其实和代替 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
如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。
关注 前端精读微信公众号
精读《Records & Tuples for React》
文章图片

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

    推荐阅读