React 的记录和元组远不止于不变性
记录与元组 101
React 的记录和元组
结论
记录和元组是一个非常有趣的提案,刚刚进入TC39的第二阶段。
它们为 JavaScript 带来了深度不可变的数据结构。
但不要忽视它们的相等属性,这对于React来说非常有趣。
一整类React 错误都与不稳定的对象身份有关:
- 性能:可以避免的重新渲染
- 行为:无用效果的重新执行,无限循环
- API 表面:无法表达稳定对象身份何时重要
我将解释记录和元组的基础知识,以及它们如何解决现实世界的 React 问题。
记录与元组 101
本文是关于React 的记录和元组。我在这里只介绍基础知识。
它们看起来像常规的对象和数组,带有#前缀。
const record = #{a: 1, b: 2};
record.a;
// 1
const updatedRecord = #{...record, b: 3};
// #{a: 1, b: 3};
const tuple = #[1, 5, 2, 3, 4];
tuple[1];
// 5
const filteredTuple = tuple.filter(num => num > 2)
// #[5, 3, 4];
默认情况下,它们是不可改变的。
const record = #{a: 1, b: 2};
record.b = 3;
// throws TypeError
它们可以被视为“复合原语”,并且可以通过值进行比较。
非常重要:两个深度相等的记录将始终返回。true
===
{a: 1, b: [3, 4]} === {a: 1, b: [3, 4]}
// with objects => false
#{a: 1, b: #[3, 4]} === #{a: 1, b: #[3, 4]}
// with records => true
我们可以以某种方式认为记录的身份是它的实际值,就像任何常规的 JS 原语一样。
正如我们将看到的,这个属性对于 React 来说有着深远的影响。
它们可以与 JSON 互操作:
const record = JSON.parseImmutable('{a: 1, b: [2, 3]}');
// #{a: 1, b: #[2, 3]}
JSON.stringify(record);
// '{a: 1, b: [2, 3]}'
它们只能包含其他记录和元组,或者原始值。
const record1 = #{
a: {
regular: 'object'
},
};
// throws TypeError, because a record can't contain an object
const record2 = #{
b: new Date(),
};
// throws TypeError, because a record can't contain a Date
const record3 = #{
c: new MyClass(),
};
// throws TypeError, because a record can't contain a class
const record4 = #{
d: function () {
alert('forbidden');
},
};
// throws TypeError, because a record can't contain a function
注意:您可以通过使用符号作为 WeakMap 键(单独的提案)向记录添加此类可变值,并引用记录中的符号。
想了解更多?直接阅读提案,或者阅读Axel Rauschmayer 的这篇文章。
React 的记录和元组
React 开发人员现在已经习惯了不变性。
每次以不可变的方式更新某些状态时,都会创建新的对象标识。
不幸的是,这种不可变性模型在 React 应用程序中引入了一整套全新的 bug 和性能问题。有时,组件只有在假设 props能够尽可能地保持其身份不变的情况下
才能正常工作并保持高性能。
我喜欢将记录和元组视为使对象身份更加“稳定”的便捷方法。
让我们通过实际用例看看该提案将如何影响您的 React 代码。
注意:有一个Records & Tuples 游乐场,可以运行 React。
不变性
可以通过递归Object.freeze()
调用来实现强制不变性。
但在实践中,我们经常使用不可变性模型,但并未严格执行,因为Object.freeze()
每次更新后应用它并不方便。然而,对于 React 新手来说,直接修改状态是一个常见的错误。
记录和元组提案将强制执行不变性,并防止常见的状态变异错误:
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} />;
}
不可变更新
在 React 中有很多方法可以执行不可变状态更新:vanilla JS、Lodash set、ImmerJS、ImmutableJS……
记录和元组支持与 ES6 对象和数组相同的不可变更新模式:
const initialState = #{
user: #{
firstName: "Sebastien",
lastName: "Lorber"
}
company: #{
name: "Lambda Scale",
}
};
const updatedState = {
...initialState,
company: {
...initialState.company,
name: 'Freelance',
},
};
到目前为止,ImmerJS赢得了不可变更新之战,因为它可以简单处理嵌套属性,并且与常规 JS 代码具有互操作性。
目前尚不清楚 Immer 如何处理记录和元组,但这是提案作者正在探索的内容。
Michael Weststrate 本人也强调,一个单独但相关的提案可能会使 ImmerJS 对于记录和元组来说变得不再必要:
const initialState = #{
counters: #[
#{ name: "Counter 1", value: 1 },
#{ name: "Counter 2", value: 0 },
#{ name: "Counter 3", value: 123 },
],
metadata: #{
lastUpdate: 1584382969000,
},
};
// Vanilla JS updates
// using deep-path-properties-for-record proposal
const updatedState = #{
...initialState,
counters[0].value: 2,
counters[1].value: 1,
metadata.lastUpdate: 1584383011300,
};
使用备忘录
除了记忆昂贵的计算之外,useMemo()
还有助于避免创建新的对象标识,因为这可能会触发无用的计算、重新渲染或影响树中更深层的执行。
让我们考虑以下用例:您有一个带有多个过滤器的 UI,并且想要从后端获取一些数据。
现有的 React 代码库可能包含如下代码:
// Don't change apiFilters object identity,
// unless one of the filter changes
// Not doing this is likely to trigger a new fetch
// on each render
const apiFilters = useMemo(
() => ({ userFilter, companyFilter }),
[userFilter, companyFilter],
);
const { apiData, loading } = useApiData(apiFilters);
有了记录和元组,这就变成了:
const {apiData,loading} = useApiData(#{ userFilter, companyFilter })
useEffect
让我们继续我们的 api 过滤器用例:
const apiFilters = { userFilter, companyFilter };
useEffect(() => {
fetchApiData(apiFilters).then(setApiDataInState);
}, [apiFilters]);
不幸的是,获取效果会被重新执行apiFilters
,因为每次重新渲染此组件时,对象的身份都会发生变化。setApiDataInState
将触发重新渲染,最终导致无限的获取/渲染循环。
这个错误在 React 开发人员中非常常见,以至于在 Google 上搜索useEffect +“无限循环”的结果有数千条。
Kent C Dodds甚至创建了一个工具来打破开发中的无限循环。
非常常见的解决方案:apiFilters
直接在效果的回调中创建:
useEffect(() => {
const apiFilters = { userFilter, companyFilter };
fetchApiData(apiFilters).then(setApiDataInState);
}, [userFilter, companyFilter]);
另一个创造性的解决方案(性能不是很好,在Twitter上找到):
const apiFiltersString = JSON.stringify({
userFilter,
companyFilter,
});
useEffect(() => {
fetchApiData(JSON.parse(apiFiltersString)).then(
setApiDataInState,
);
}, [apiFiltersString]);
我最喜欢的一个:
// We already saw this somewhere, right? :p
const apiFilters = useMemo(
() => ({ userFilter, companyFilter }),
[userFilter, companyFilter],
);
useEffect(() => {
fetchApiData(apiFilters).then(setApiDataInState);
}, [apiFilters]);
有很多奇特的方法可以解决这个问题,但是随着过滤器数量的增加,它们都变得令人讨厌。
use-deep-compare-effect(来自Kent C Dodds)可能不那么烦人,但在每次重新渲染时运行深度平等会产生我不想支付的成本。
它们比记录和元组更加冗长且不太符合地道用法:
const apiFilters = #{ userFilter, companyFilter };
useEffect(() => {
fetchApiData(apiFilters).then(setApiDataInState);
}, [apiFilters]);
Props 和 React.memo
在 props 中保留对象身份对于 React 性能也非常有用。
另一个非常常见的性能错误:在渲染中创建新的对象身份。
const Parent = () => {
useRerenderEverySeconds();
return (
<ExpensiveChild
// someData props object is created "on the fly"
someData={{ attr1: 'abc', attr2: 'def' }}
/>
);
};
const ExpensiveChild = React.memo(({ someData }) => {
return <div>{expensiveRender(someData)}</div>;
});
大多数时候,这不是问题,并且 React 足够快。
但有时你希望优化你的应用,而这种新对象的创建却让这些操作变得React.memo()
毫无用处。最糟糕的是,它实际上会让你的应用变慢一点(因为它现在必须运行额外的浅层相等性检查,并且总是返回 false)。
我在客户端代码库中经常看到的另一种模式:
const currentUser = { name: 'Sebastien' };
const currentCompany = { name: 'Lambda Scale' };
const AppProvider = () => {
useRerenderEverySeconds();
return (
<MyAppContext.Provider
// the value prop object is created "on the fly"
value={{ currentUser, currentCompany }}
/>
);
};
尽管事实是currentUser
或currentCompany
从未更新,但每次此提供程序重新渲染时,您的上下文值都会发生变化,从而触发所有上下文订阅者的重新渲染。
所有这些问题都可以通过记忆来解决:
const someData = useMemo(
() => ({ attr1: 'abc', attr2: 'def' }),
[],
);
<ExpensiveChild someData={someData} />;
const contextValue = useMemo(
() => ({ currentUser, currentCompany }),
[currentUser, currentCompany],
);
<MyAppContext.Provider value={contextValue} />;
使用记录和元组,可以编写高性能代码:
<ExpensiveChild someData={#{ attr1: 'abc', attr2: 'def' }} />;
<MyAppContext.Provider value={#{ currentUser, currentCompany }} />;
获取和重新获取
在 React 中有很多方法可以获取数据:useEffect
HOC、Render props、Redux、SWR、React-Query、Apollo、Relay、Urql 等。
通常,我们向后端发出请求,然后返回一些 JSON 数据。
为了说明本节,我将使用react-async-hook,这是我自己的非常简单的获取库,但这也适用于其他库。
让我们考虑一个经典的异步函数来获取一些 API 数据:
const fetchUserAndCompany = async () => {
const response = await fetch(
`https://myBackend.com/userAndCompany`,
);
return response.json();
};
此应用程序获取数据,并确保该数据随着时间的推移保持“新鲜”(非陈旧):
const App = ({ id }) => {
const { result, refetch } = useAsync(
fetchUserAndCompany,
[],
);
// We try very hard to not display stale data to the user!
useInterval(refetch, 10000);
useOnReconnect(refetch);
useOnNavigate(refetch);
if (!result) {
return null;
}
return (
<div>
<User user={result.user} />
<Company company={result.company} />
</div>
);
};
const User = React.memo(({ user }) => {
return <div>{user.name}</div>;
});
const Company = React.memo(({ company }) => {
return <div>{company.name}</div>;
});
问题:出于性能原因,您已经使用了React.memo
,但是每次重新获取时,您都会得到一个新的 JS 对象,具有新的标识,并且所有内容都会重新渲染,尽管获取的数据与以前相同(深度相等的有效载荷)。
让我们想象一下这个场景:
- 使用“Stale-While-Revalidate”模式(首先显示缓存/陈旧数据,然后在后台刷新数据)
- 您的页面很复杂,渲染密集,显示大量后端数据
你导航到一个页面,该页面首次渲染(包含缓存数据)的成本已经很高。一秒钟后,刷新后的数据返回。尽管深度等于缓存数据,但所有内容仍会重新渲染。如果没有并发模式和时间片,一些用户甚至可能会注意到他们的 UI 卡顿几百毫秒。
现在,让我们将 fetch 函数转换为返回 Record:
const fetchUserAndCompany = async () => {
const response = await fetch(
`https://myBackend.com/userAndCompany`,
);
return JSON.parseImmutable(await response.text());
};
碰巧的是,JSON 与记录和元组兼容,并且您应该能够使用JSON.parseImmutable将任何后端响应转换为记录。
注:该提案的作者之一Robin Ricard正在推动一项新response.immutableJson()
功能。
使用记录和元组,如果后端返回相同的数据,则根本不需要重新渲染任何内容!
另外,如果响应中只有一部分发生了变化,响应的其他嵌套对象仍将保持其标识。这意味着,如果仅user.name
更改了部分内容,则User
组件将重新渲染,但Company
组件本身不会!
我让你想象一下这一切对性能的影响,考虑到“Stale-While-Revalidate”之类的模式正变得越来越流行,并且由 SWR、React-Query、Apollo、Relay 等库开箱即用。
读取查询字符串
在搜索 UI 中,最好在查询字符串中保存过滤器的状态。这样,用户可以将链接复制/粘贴给其他人、刷新页面或添加书签。
如果您有 1 或 2 个过滤器,这很简单,但是一旦您的搜索 UI 变得复杂(10 多个过滤器,能够使用 AND/OR 逻辑组成查询......),您最好使用良好的抽象来管理您的查询字符串。
我个人喜欢qs:它是少数几个处理嵌套对象的库之一。
const queryStringObject = {
filters: {
userName: 'Sebastien',
},
displayMode: 'list',
};
const queryString = qs.stringify(queryStringObject);
const queryStringObject2 = qs.parse(queryString);
assert.deepEqual(queryStringObject, queryStringObject2);
assert(queryStringObject !== queryStringObject2);
queryStringObject
和queryStringObject2
是完全相等的,但是它们不再具有相同的身份,因为qs.parse
创建了新的对象。
useMemo()
您可以将查询字符串解析集成到一个钩子中,并使用或诸如之类的库来“稳定”查询字符串对象use-memo-value
。
const useQueryStringObject = () => {
// Provided by your routing library, like React-Router
const { search } = useLocation();
return useMemo(() => qs.parse(search), [search]);
};
现在,想象一下在树的深处你有:
const { filters } = useQueryStringObject();
useEffect(() => {
fetchUsers(filters).then(setUsers);
}, [filters]);
这有点令人讨厌,但同样的问题一次又一次地发生。
尽管使用useMemo()
来尝试保护queryStringObject
身份,但您最终还是会接到不想要的fetchUsers
电话。
当用户更新时displayMode
(这应该只会改变渲染逻辑,而不会触发重新获取),查询字符串将会改变,导致查询字符串再次被解析,从而导致属性出现新的对象标识filter
,从而导致不必要的useEffect
执行。
再次,记录和元组可以防止此类事情发生。
// 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,
]);
};
现在,即使用户更新displayMode
,filters
对象也将保留其身份,并且不会触发任何无用的重新获取。
注意:如果记录和元组提案被接受,诸如此类的库qs
可能会提供一种qs.parseRecord(search)
方法。
深度平等的 JS 转换
想象一下组件中的以下 JS 转换:
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>
));
每次Parent
组件重新渲染时,UserList
组件也会重新渲染,因为filter
总是会返回一个新的数组实例。
userIdsToHide
即使为空,并且身份稳定,情况也是如此AllUsers
!在这种情况下,过滤操作实际上不会过滤任何内容,它只是创建了新的无用数组实例,从而退出我们的React.memo
优化。
这些类型的转换在 React 代码库中非常常见,在组件、reducer、选择器、Redux 中都有诸如map
或 之类的操作符……filter
记忆化可以解决这个问题,但使用记录和元组更为惯用:
const AllUsers = #[
#{ id: 1, name: 'Sebastien' },
#{ id: 2, name: 'John' },
];
const filteredUsers = AllUsers.filter(() => true);
AllUsers === filteredUsers;
// true
记录为 React 键
假设您有一个要渲染的项目列表:
const list = [
{ country: 'FR', localPhoneNumber: '111111' },
{ country: 'FR', localPhoneNumber: '222222' },
{ country: 'US', localPhoneNumber: '111111' },
];
你会使用什么钥匙?
考虑到country
和在列表中localPhoneNumber
都不是独立唯一的,您有两种可能的选择。
数组索引键:
<>
{list.map((item, index) => (
<Item key={`poormans_key_${index}`} item={item} />
))}
</>
这总是有效的,但远非理想,特别是如果列表中的项目被重新排序。
复合键:
<>
{list.map((item) => (
<Item
key={`${item.country}_${item.localPhoneNumber}`}
item={item}
/>
))}
</>
该解决方案可以更好地处理列表重新排序,但只有当我们确信该对/元组是唯一的时才有可能。
这样的话,直接用Records作为key不是更方便吗?
const list = #[
#{ country: 'FR', localPhoneNumber: '111111' },
#{ country: 'FR', localPhoneNumber: '222222' },
#{ country: 'US', localPhoneNumber: '111111' },
];
<>
{list.map((item) => (
<Item key={item} item={item} />
))}
</>
这是Morten Barklund提出的建议。
显式 API 表面
让我们考虑这个 TypeScript 组件:
const UsersPageContent = ({
usersFilters,
}: {
usersFilters: UsersFilters,
}) => {
const [users, setUsers] = useState([]);
// poor-man's fetch
useEffect(() => {
fetchUsers(usersFilters).then(setUsers);
}, [usersFilters]);
return <Users users={users} />;
};
正如我们已经看到的,这段代码可能会或可能不会创建一个无限循环,这取决于 usersFilters 属性的稳定性。这会创建一个隐式的 API 契约,该契约应该被记录下来,并让父组件的实现者清晰地理解。尽管使用了 TypeScript,但这并没有反映在类型系统中。
下面的代码将导致无限循环,但是 TypeScript 没有办法阻止它:
<UsersPageContent
usersFilters={{ nameFilter, ageFilter }}
/>
通过记录和元组,我们可以告诉 TypeScript 期望一条记录:
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} />;
};
注意:这#{nameFilter: string, ageFilter: string}
是我自己的发明:我们还不知道 TypeScript 语法是什么。
TypeScript 编译将因以下情况失败:
<UsersPageContent
usersFilters={{ nameFilter, ageFilter }}
/>
而 TypeScript 可以接受:
<UsersPageContent
usersFilters={#{ nameFilter, ageFilter }}
/>
使用记录和元组,我们可以在编译时防止这种无限循环。
我们有一种明确的方法来告诉编译器我们的实现是对象标识敏感的(或依赖于按值比较)。
注意:readonly
无法解决这个问题:它只能防止变异,但不能保证稳定的身份。
序列化保证
您可能需要确保团队中的开发人员不会将不可序列化的内容放入全局应用状态中。如果您计划将状态发送到后端,或者将其持久化到本地localStorage
(或AsyncStorage
针对 React-Native 用户),这一点非常重要。
为了确保这一点,你只需要确保根对象是一条记录。这将保证所有嵌套属性也都是原始类型,包括嵌套记录和元组。
下面是与 Redux 集成的示例,以确保 Redux 存储随着时间的推移保持可序列化:
if (process.env.NODE_ENV === 'development') {
ReduxStore.subscribe(() => {
if (typeof ReduxStore.getState() !== 'record') {
throw new Error(
"Don't put non-serializable things in the Redux store! " +
'The root Redux state must be a record!',
);
}
});
}
注意:这并不是一个完美的保证,因为Symbol
可以放入记录中,并且不可序列化。
CSS-in-JS 性能
让我们考虑一下来自流行库的一些 CSS-in-JS,使用 css prop:
const Component = () => (
<div
css={{
backgroundColor: 'hotpink',
}}
>
This has a hotpink background.
</div>
);
您的 CSS-in-JS 库在每次重新渲染时都会收到一个新的 CSS 对象。
首次渲染时,它会将此对象哈希处理为唯一的类名,并插入 CSS。
样式对象在每次重新渲染时都会有不同的标识,因此 CSS-in-JS 库必须反复对其进行哈希处理。
const insertedClassNames = new Set();
function handleStyleObject(styleObject) {
// computeStyleHash re-executes every time
const className = computeStyleHash(styleObject);
// only insert the css for this className once
if (!insertedClassNames.has(className)) {
insertCSS(className, styleObject);
insertedClassNames.add(className);
}
return className;
}
通过记录和元组,这种样式对象的身份会随着时间的推移而保留。
const Component = () => (
<div
css={#{
backgroundColor: 'hotpink',
}}
>
This has a hotpink background.
</div>
);
记录和元组可以用作Map 的键。这可以使你的 CSS-in-JS 库的实现更快:
const insertedStyleRecords = new Map();
function handleStyleRecord(styleRecord) {
let className = insertedStyleRecords.get(styleRecord);
if (!className) {
// computeStyleHash is only executed once!
className = computeStyleHash(styleRecord);
insertCSS(className, styleRecord);
insertedStyleRecords.add(styleRecord, className);
}
return className;
}
我们还不知道记录和元组的性能(这将取决于浏览器供应商的实现),但我认为可以肯定地说它比创建等效对象然后将其散列到 className 更快。
注意:一些具有良好 Babel 插件的 CSS-in-JS 库可能能够在编译时将静态样式对象转换为常量,但对于动态样式,它们会很难做到这一点。
const staticStyleObject = { backgroundColor: 'hotpink' };
const Component = () => (
<div css={staticStyleObject}>
This has a hotpink background.
</div>
);
结论
许多 React 性能和行为问题都与对象身份有关。
通过提供某种“自动记忆” ,记录和元组将确保对象标识开箱即用“更稳定”,并帮助我们更轻松地解决这些 React 问题。
使用 TypeScript,我们可能能够更好地表达您的 API 表面是对象标识敏感的。
我希望您现在和我一样对这个提议感到兴奋!
感谢您的阅读!
感谢Robin Ricard、Rick Button、Daniel Ehrenberg、Nicolò Ribaudo和Rob Palmer为这项出色的提案所做的工作以及对我的文章的审阅。
如果您喜欢它,请通过转推、Reddit或HackerNews来传播。
浏览器代码演示,或在博客 repo 上更正我的帖子拼写错误
要获得更多类似内容,请订阅我的邮件列表并在Twitter上关注我。
鏂囩珷鏉ユ簮锛�https://dev.to/sebastienlorber/records-tuples-for-react-way-more-than-immutability-2iic