继前一篇 精读《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 <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} />;
}
归根结底,我们不会总使用 freeze
来冻结对象,大部分情况下需要人为保证引用不被修改,其中的潜在风险依然存在。但使用 Record 表示状态,无论 TS 还是 JS 都会报错,立刻阻止问题扩散。
比如下面的例子,为了保障 apiFilters
引用不变,需要对其 useMemo
:
const apiFilters = useMemo(
() => ({ userFilter, companyFilter }),
[userFilter, companyFilter],
);
const { apiData, loading } = useApiData(apiFilters);
但 Record 模式不需要 memo,因为 js 引擎会帮你做类似的事情:
const {apiData,loading} = useApiData(#{ userFilter, companyFilter })
这段写的很啰嗦,其实和代替 useMemo 差不多,即:
const apiFilters = #{ userFilter, companyFilter };
useEffect(() => {
fetchApiData(apiFilters).then(setApiDataInState);
}, [apiFilters]);
你可以把 apiFilters
当做一个引用稳定的原始对象看待,如果它确实变化了,那一定是值改变了,所以才会引发取数。如果把上面的 #
号去掉,每次组件刷新都会取数,而实际上都是多余的。
可以更方便定义不可变 props 了,而不需要提前 useMemo:
<ExpensiveChild someData={#{ attr1: 'abc', attr2: 'def' }} />;
这个目前还真做不到,除非用性能非常差的 JSON.stringify
或 deepEqual
,用法如下:
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 <UserList users={users} />;
};
const UserList = React.memo(({ users }) => (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
));
要避免这个问题就必须 useMemo
,但在 Record 提案下不需要:
const AllUsers = #[
#{ id: 1, name: 'Sebastien' },
#{ id: 2, name: 'John' },
];
const filteredUsers = AllUsers.filter(() => true);
AllUsers === filteredUsers;
// true
这个想法更有趣,如果 Record 提案保证了引用严格不可变,那我们完全可以拿 item
本身作为 key
,而不需要任何其他手段,这样维护成本会大大降低。
const list = #[
#{ country: 'FR', localPhoneNumber: '111111' },
#{ country: 'FR', localPhoneNumber: '222222' },
#{ country: 'US', localPhoneNumber: '111111' },
];
<>
{list.map((item) => (
<Item key={item} item={item} />
))}
</>
当然这依然建立在浏览器非常高效实现 Record 的前提,假设浏览器采用 deepEqual
作为初稿实现这个规范,那么上面这坨代码可能导致本来不卡的页面直接崩溃退出。
也许到时候 ts 会支持如下方式定义不可变变量:
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 是完全无法保障变量引用是否会变化。
采用 Record 与普通 object 作为 css 属性,对 css-in-js 的区别是什么?
const Component = () => (
<div
css={#{
backgroundColor: 'hotpink',
}}
>
This has a hotpink background.
</div>
);
由于 css-in-js 框架对新的引用会生成新 className,所以如果不主动保障引用不可变,会导致渲染时 className 一直变化,不仅影响调试也影响性能,而 Record 可以避免这个担忧。
总结下来,其实 Record 提案并不是解决之前无法解决的问题,而是用更简洁的原生语法解决了复杂逻辑才能解决的问题。这带来的优势主要在于 “不容易写出问题代码了”,或者让 Immutable 在 js 语言的上手成本更低了。
现在看下来这个规范有个严重担忧点就是性能,而 stage2 并没有对浏览器实现性能提出要求,而是给了一些建议,并在 stage4 之前给出具体性能优化建议方案。
其中还是提到了一些具体做法,包括快速判断真假,即对数据结构操作时的优化。
快速判真可以采用类似 hash-cons 快速判断结构相等,可能是将一些关键判断信息存在 hash 表中,进而不需要真的对结构进行递归判断。
快速判假可以通过维护散列表快速判断,或者我觉得也可以用上数据结构一些经典算法,比如布隆过滤器,就是用在高效快速判否场景的。
其实如果应用开发都是 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 装箱语法尤其别扭。
看了这篇文章的畅想,React 与 Records & Tulpes 结合的一定会很好,但前提是浏览器对其性能优化必须与 “引用对比” 大致相同才可以,这也是较为少见,对性能要求如此苛刻的特性,因为如果没有性能的加持,其便捷性将毫无意义。
讨论地址是:精读《Records & Tuples for React》· Issue #385 · dt-fe/weekly
版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证)