如何高效使用 useSelect Hook
本文是关于 @wordpress/data 库中的 useSelect React Hook。该 Hook 常用于 UI 组件、区块编辑器以及自定义区块中,用于从区块编辑器数据存储中读取数据,并在数据变更时获得通知。文章提供了多个关于如何最高效使用它的技巧,并解答了关于其实际工作原理的许多微妙问题。
如果您之前有一些使用 @wordpress/data 包的实践经验,并且熟悉如何使用数据存储、如何选择数据和分发操作,您将从本文中获得最大收益。区块编辑器手册中有优秀的资源,即 @wordpress/data 参考 和 数据模块参考。本文将帮助您加深理解,并从进阶用户成长为专家。
始终声明 useSelect 依赖项
useSelect Hook 有一个可选的最后一个参数,用于指定依赖项数组。这类似于 useEffect 或 useMemo 等 React Hook 也有声明依赖项的参数。
您应该始终指定依赖项数组。它之所以是可选的,仅出于向后兼容的原因,以继续支持在依赖项数组参数添加之前编写的遗留代码。
如果您在 useSelect 上指定一个空依赖项数组,这意味着什么?
useSelect( ( select ) => select( 'editor' ).isSidebarOpened(), [] );
需要注意的最重要一点是,在每次渲染时,useSelect 回调函数都是不同的。函数体相同,但每次调用时的 JavaScript 值都不同。useSelect 无法知道这是同一个函数,对于相同的参数会返回相同的结果。因此,每当组件因任何原因(本地状态更改、props 更改……)重新渲染时,useSelect 都需要重新计算其返回值,即使底层状态并未改变。这是低效的。如果我们知道状态没有改变,并且回调函数也没有改变,我们就可以重用之前计算的值。
依赖项数组允许 useSelect 可靠地确定它正在处理的是同一个回调函数。如果自上次调用以来依赖项数组没有改变,它可以重用之前的函数及其计算出的先前值。
如果回调函数在重新渲染之间发生变化,您需要声明触发更改的依赖项。例如,在以下代码中,区块的 clientId 可能会改变,回调函数具有不同的含义:它返回的是不同区块的信息。
useSelect( ( select ) => {
return select( 'block-editor' ).getBlock( clientId );
}, [ clientId ] );
像 core/block-editor 这样大型、繁忙的数据存储有数百或数千个订阅者监视着每一次更改。声明依赖项允许这些订阅者跳过许多 useSelect 回调的执行,从而使区块编辑器显著加快。
在回调内部调用选择器函数,而不是外部
假设站点存储有一个 getTitle() 选择器。在 React 组件中像这样调用它可能很诱人:
const { getTitle } = useSelect( ( select ) => select( 'site' ), [] );
return <h1>{ getTitle() }</h1>;
但这段代码不可靠。当存储的 title 状态改变时,组件将不会重新渲染。它会继续显示旧值。
为什么?useSelect 不仅仅是从存储中读取所需的值(这是它功能的直接可见部分)。它还建立了对存储的订阅,观察存储中的更改,并在存储状态以相关方式更改时触发调用组件的重新渲染。“相关”是什么意思?精确的条件是:当更改导致 useSelect 回调的新返回值与先前返回值不浅相等时。
什么是浅相等?在 JavaScript 中比较复杂的结构化值时,在许多情况下使用 === 运算符不是很有用。该运算符检查两个值是否是同一对象的引用(即同一块内存),但它不会告诉你两个不同的值是否具有相同的内容。要检查内容是否相同,你需要:
- 浅相等: 逐个比较对象的字段或数组的元素,并检查它们是否指向相同(
===)的值。这非常快,而且通常就足够了。WordPress 有一个名为@wordpress/is-shallow-equal的辅助包,实现了这种类型的比较。 - 深相等: 这种比较递归地检查字段值,深入到嵌套的对象和数组中,并相互比较。它能给出两个对象内容是否相同的最精确答案,但检查可能非常慢。
在我们的例子中,返回值是 { getTitle } 对象。getTitle 值是一个函数,并且永远不会改变。它仍然是同一个函数。改变的是调用该函数时返回的内容,但这不是我们正在检查的内容。useSelect 的返回值总是与先前的值浅相等。
我们的 React 组件只有在被其他东西触发重新渲染时,才会更新以显示新的 getTitle() 值。例如,当组件有一些内部状态改变、props 改变或父组件重新渲染时。这使得错误更难被发现,因为即使 useSelect 调用是错误的,getTitle() 也会被更新,但这是随机的而不是可靠的。
修复后的组件如下所示:
const { title } = useSelect( ( select ) => ( {
title: select( 'site' ).getTitle()
} ), [] );
return <h1>{ title }</h1>;
在这里,每当旧的 title 和新的 title 不同时,useSelect 就会触发重新渲染,正如我们所期望的那样。
但在事件处理程序中时,在外部调用它们
然而,有一种情况从 useSelect 返回选择器函数本身是个好主意:当选择器在事件处理程序内部被调用时。那时我们希望从存储中读取事件处理程序执行时有效的数据,而不是使用组件上次渲染时读取的可能过时的值。那么这段代码是有效的:
const { getTitle } = useSelect( ( select ) => select( 'site' ), [] );
function onClick() {
recordAnalyticsEvent( 'click', { site: getTitle() } );
}
return <Button onClick={ onClick } />;
这段代码有效,但我们可以做得更好!一个次优之处是,useSelect 调用将建立对 site 存储的订阅,并且 select( 'site' ) 回调将在该存储的每次更新时执行。但这些都是无意义的工作,因为存储选择器的集合在存储的整个生命周期内是恒定的。getTitle 函数保证始终相同。
useSelect 有一种特殊形式,它只返回选择器集合,没有任何反应性:
const { getTitle } = useSelect( 'site' );
您也可以这样写,效果相同:
const { getTitle } = useRegistry().select( 'site' );
这里您检索 registry 对象并调用其 .select 方法。
这种从存储中选择数据的方式不仅在事件处理程序中很有用,在其他由外部事件(如计时器或 Promise 解析)触发的处理程序中也很有用。
在回调内部,精确选择您需要的数据
现在假设 site 存储维护着 title、theme 和 domain 等几个字段的值。并且它提供了一个 getSite() 选择器,返回一个包含所有这些字段的对象。那么您可能会像这样读取 title 值:
const site = useSelect( ( select ) => select( 'site' ).getSite(), [] );
return <h1>{ site.title }</h1>;
这段代码行为正确,不会导致错过更新,就像之前的 getTitle 例子那样。但其性能是次优的,因为它会过于频繁地重新渲染。
尽管组件只对 title 值感兴趣,但 useSelect 并不知道这一点。它被要求返回整个 getSite(),包括 theme 和 domain 值。并且当其中任何一个发生变化时,即使 title 保持不变,它也会触发重新渲染。
一个性能更好的版本是:
const { title } = useSelect( ( select ) => {
const site = select( 'site' ).getSite();
return { title: site.title };
}, [] );
return <h1>{ title }</h1>;
编写较慢的版本很诱人,因为它可能看起来更直观和优雅。较快的版本不那么简洁,而且您经常会遇到 ESLint 错误,提示“title 已在上级作用域中声明”,因为您想在 useSelect 回调内部和外部都使用名为 title 的变量。但它确实更快。
同一原则的另一个变体是这个组件:
const { title } = useSelect( ( select ) => ( {
title: select( 'site' ).getTitle()
} ), [] );
return <button disabled={ title.length === 0 }>Continue</button>;
这个按钮每次 title 改变时都会重新渲染,例如,当您在输入字段中键入时。但这些重新渲染大多数都是浪费的,因为 disabled prop 将保持为 true。最好在 useSelect 回调内部计算布尔派生值:
const { hasTitle } = useSelect( ( select ) => ( {
hasTitle: select( 'site' ).getTitle().length > 0
} ), [] );return <button disabled={ ! hasTitle }>Continue</button>;
这仅在真正需要时(即当 disabled 属性将要改变时)才重新渲染。
下一节将警告一个注意事项,即计算“派生值”不应移入回调,而应移出回调。
注意在回调内部转换数据
下面的 useSelect 调用将具有令人惊讶的行为。它将导致组件在 taxonomies 存储的每次更新时重新渲染,即使 tags 根本没有改变:
const { tagNames } = useSelect( ( select ) => {
const tags = select( 'taxonomies' ).getTags();
return { tagNames: tags.map( ( t ) => t.name ) };
}, [] );
发生这种情况是因为每次调用回调都会在 tags 数组上调用 .map,而每次这样的调用都会返回一个新的数组实例。即使存储中的原始 tags 数组没有改变,并且返回的数组在语义上是相同的,但返回的数组与先前的值不相等(===)。因此,它被检测为需要触发重新渲染的更改。
这个问题的解决方案是将数据转换移到 useSelect Hook 外部,并用 useMemo 包装它:
const { tags } = useSelect( ( select ) => ( {
tags: select( 'taxonomies' ).getTags()
} ), [] );
const tagNames = useMemo( () => {
return tags.map( ( t ) => t.name );
}, [ tags ] );
这将仅在原始 tags 真正改变时导致重新渲染。
最后两节给出了一些看似矛盾的建议:要么将计算移入 useSelect 回调内部,要么将其移出!要决定适用哪一种,您需要查看计算值的类型。如果是基本类型(数字、字符串、布尔值),那么即使是不同的实例也可以彼此 === 相等。但对于类对象类型(对象和数组),不同的实例永远不会相同。
优先从回调返回带有值属性的对象
您可以通过两种略有不同的方式选择 title 值。useSelect 可以直接返回值:
const title = useSelect( ( select ) => select( 'site' ).getTitle(), [] );
或者可以将其作为对象的属性包装后返回。
const { title } = useSelect( ( select ) => ( {
title: select( 'site' ).getTitle()
} ), [] );
第一个版本当然更短,但使用它是个好主意吗?
答案是,短版本几乎总是有效的,title 的更改几乎总是能被正确检测到,但并非 100% 的时间。
返回值将使用 @wordpress/is-shallow-equal 中的浅比较函数进行比较,这取决于该库是否能正确比较您数据类型的值。考虑这个库会失败的反例:
import eq from '@wordpress/is-shallow-equal';
class NumberBox {
#value;
constructor( value ) { this.#value = value; }
get() { return this.#value; }
}
const one = new NumberBox( 1 );
const two = new NumberBox( 2 );
console.log( `Are ${ one.get() } and ${ two.get() } equal? ${ eq( one, two ) }` );
如果您尝试在 Node.js 中运行此脚本,您会看到一个令人惊讶的结果:
Are 1 and 2 equal? true
#value 属性是私有的,Object.keys 看不到它,它总是返回一个空的 [] 数组。浅比较函数在比较值时将只使用这个空数组,并得出结论:所有 NumberBox 实例彼此相等。
如果您在数据存储中存储 NumberBox 的实例,useSelect 可能无法检测到更改的值。
还有许多其他方法可以“隐藏”对象中的数据,使其在公共可枚举属性上不可见。私有字段远非唯一的方法。
您可能会说您只在状态中使用漂亮的对象、字符串和数字,您是对的。直接从 useSelect 返回它们是可以的。但您需要意识到这些限制,因为有一天它可能会适得其反。
另一方面,返回一个对象进行解构保证总是安全的。顺便说一下,返回数组也是安全的,并且可以节省一些输入:
const [ title ] = useSelect( ( select ) => [ select( 'site' ).getTitle() ], [] );
毕竟,数组也是一个对象,它只是使用数字键(0)而不是字符串键(title)。
在同一回调中完成来自同一存储的所有选择
以下哪种方式更好?
const { title } = useSelect( ( select ) => ( {
title: select( 'site' ).getTitle()
} ), [] );
const { theme } = useSelect( ( select ) => ( {
theme: select( 'site' ).getTheme()
} ), [] );
或者
const { title, theme } = useSelect( ( select ) => {
const store = select( 'site' );
return {
title: store.getTitle(),
theme: store.getTheme(),
};
}, [] );
答案是第二种方式更快,并且也更经济地使用资源。两次调用 useSelect 将使组件建立两个对数据存储的订阅。每次更改时,useSelect 内部的订阅处理程序将被调用两次,并且至少其中一次调用将是冗余和浪费的。
每个 useSelect Hook 调用都建立自己的存储订阅,这是 Redux 架构在性能方面的弱点。如果您正在编写一个可能在编辑器中多次挂载的组件,例如注册一个包装每个区块每个实例的 editor.BlockEdit 过滤器时,您应该知道正在创建多少个存储订阅。如果不加注意,它们的数量可能会增长到数千甚至数万。如果可能,尝试在最少次数的 useSelect 调用中读取尽可能多的数据。
从多个存储中选择时更为微妙
当您的组件从多个存储中选择数据,并且如果某些选定的值仅在条件性使用时,需要考虑两个事实:
- 存储订阅是细粒度的,
useSelectHook 单独订阅每个存储。 - 存储订阅仅在相应的
select( store )调用真正执行时才建立。
例如,考虑这个 useSelect 调用:
const showBlockSidebar = useSelect( ( select ) => {
const sidebarOpened = select( 'editor' ).isSidebarOpened();
if ( ! sidebarOpened ) {
return false;
}
return select( 'block-editor' ).hasSelection();
}, [] );
这个代码返回一个布尔值,表示“块侧边栏被打开”。这是两个条件的结合:编辑器侧边栏是否被打开,以及编辑器侧边栏是否显示区块侧边栏UI(例如,当没有选择区块时,也可以显示帖子侧边栏UI)。true
关于这个挂钩叫声,有几个值得注意的细节。首先,如果从存储中选择的值为 ,则调用不会被执行。这意味着钩子不会订阅商店,回调也不会在商店更新时执行。因为这些更新本来就无关紧要:它们无法改变返回值。只有当价值变为 时,商店订阅才会在这样我们可以优化商店订阅数量,剔除那些注定会重复的订阅。sidebarOpenededitorfalseselect( 'block-editor' )block-editorblock-editorblock-editorsidebarOpenedtrue
其次,在这种情况下,我们在同一个钩子里从两个存储中选择。另一种选择是:useSelect
const sidebarOpened = useSelect( ( select ) => select( 'editor' ).isSidebarOpened(), [] );
const hasSelection = useSelect( ( select ) => select( 'block-editor' ).hasSelection(), [] );
const showBlockSidebar = sidebarOpened && hasSelection;
这效率较低,因为这两个值并非独立使用。每次React组件的重新渲染都会触发,尽管组件实际使用的值并未改变。hasSelectionshowBlockSidebar
但当两个值单独用于渲染组件时,你会更倾向于使用两个独立调用,比如:useSelect
return (
<>
<div>sidebar: { sidebarOpened }</div>
<div>selection: { hasSelection }</div>
</>
);
这样你用两个调用会更好,因为每个调用都会在自己的商店更新后,自己负责,而不会浪费时间去另一个数据筛选。useSelectselect