☰
wp-calypso 的 createSelector 详解:用 @automattic/state-utils 构建带缓存失效机制的 Redux 记忆化选择器
2026/10/9 7:31:08 网站建设 项目流程
  • 前端
  • CMS

【免费下载链接】wp-calypso

The JavaScript and API powered WordPress.com

项目地址:https://gitcode.com/gh_mirrors/wp/wp-calypso
点击查看免费下载

wp-calypso(WordPress.com 的前端应用)的 Redux 状态树刻意保持精简,避免冗余数据存储,但精简状态意味着大量数据需要在选择器中即时派生与过滤。本文基于packages/state-utils/src/create-selector/README.md及其源码实现,完整讲解createSelector的三个参数、缓存失效原理、缓存键生成规则、多选择器依赖的数组简写,以及如何在 wp-calypso 的代码库中落地使用这个记忆化工具。

什么是记忆化选择器,为什么 wp-calypso 需要它

从项目状态设计的角度看,wp-calypso 力求让 Redux 状态树中不存放冗余数据,代价是:如果选择器本身求值耗时(比如要在成百上千条 post 中过滤出某一个站点的帖子),同样的计算会被反复执行,带来性能问题。

记忆化选择器(memoized selector)正是为此设计的:它把计算结果缓存起来,当能够确认"派生所依赖的那部分状态没有变化"时,直接跳过昂贵的派生计算,返回上一次的结果。@automattic/state-utils包提供的createSelector就是 wp-calypso 中实现这一模式的标准工具,实现文件位于 create-selector/index.ts,包的入口 index.ts 将其与extendAction、getInitialState、withStorageKey一并导出。

createSelector 的函数签名与三个参数

createSelector接受以下参数(第一个必选,后两个可选):

  1. 选择函数(selector):从 state 中选取数据的函数。它接收一个 state 对象和任意数量的其他参数,计算出结果,该结果会被缓存供后续复用。
  2. 依赖函数(getDependants):返回该选择器所依赖的状态树片段的函数。它可以是单个函数,也可以是一个返回状态值数组的函数;此外还支持直接传入"依赖选择器数组"的简写形式(见下文)。
  3. (可选)缓存键函数(getCacheKey):自定义内部记忆化函数所用缓存键的函数。

从源码可以确认完整类型定义(见 index.ts#L74-L112):

export default function createSelector< TState, TProps extends any[], TDepProps extends TProps, TDerivedState, >( selector: ( state: TState, ...props: TProps ) => TDerivedState, getDependants: | Dependant< TState, TDepProps, any > | Dependant< TState, TDepProps, any >[] = DEFAULT_GET_DEPENDANTS, getCacheKey: ( state: TState, ...props: TProps ) => string = DEFAULT_GET_CACHE_KEY ): ( state: TState, ...props: TProps ) => TDerivedState

两个可选参数都有默认值,这一点值得注意:

  • 默认依赖函数DEFAULT_GET_DEPENDANTS(index.ts#L27-L28)直接返回整个state。也就是说,不传第二个参数时,选择器默认监视整个状态树,任何顶层 state 对象的变更都会导致缓存失效。测试用例中专门验证了这一默认行为:相同 state 重复调用时底层选择器只执行 1 次,state 变化后再调用则执行 2 次(见 test/index.js#L202-L256)。
  • 默认缓存键函数DEFAULT_GET_CACHE_KEY则是把参数join()成字符串(细节见下文)。

典型用法:为站点过滤文章列表

README 中的经典例子:状态中包含 post 对象,每个 post 归属于某个站点。要拿到某站点的全部文章,就需要过滤所有已知 post——这是一次昂贵操作。用createSelector创建记忆化函数后,只要state.posts不变,这次昂贵计算就只做一次:

export const getSitePosts = createSelector( ( state, siteId ) => state.posts.filter( ( post ) => post.site_ID === siteId ), ( state ) => [ state.posts ] );

使用时只需关注第一个参数的函数签名——这里需要传入 state 和站点 ID:

const sitePosts = getSitePosts( state, siteId );

只要state.posts保持不变,该结果只会被计算一次。这一点有直接测试佐证:相同( state, siteId )连续调用两次后,expect( selector ).toHaveBeenCalledTimes( 1 )(见 test/index.js#L48-L64);而传入不同siteId时会产生不同的缓存键,底层选择器分别计算(test/index.js#L82-L113)。

wp-calypso 仓库中真实的选择器也是这个形态,例如 client/state/posts/selectors/get-site-post.js:

import { createSelector } from '@automattic/state-utils'; import 'calypso/state/posts/init'; export const getSitePost = createSelector( ( state, siteId, postId ) => { if ( ! siteId ) { return null; } const manager = state.posts.queries[ siteId ]; if ( ! manager ) { return null; } return manager.getItem( postId ); }, ( state ) => state.posts.queries );

这里第二个参数把依赖范围收窄到state.posts.queries,而不是整个状态树——这是避免缓存频繁失效的关键写法。

缓存失效机制:如何知道该重新计算

这是 README FAQ 中第二个问题的核心:记忆化选择器如何知道何时重新计算结果?

因为 Redux 不鼓励直接修改 state,所以可以确信:只有当所关心的状态树片段与之前严格不相等时,状态才算发生了变化。因此createSelector要求传入一个函数,返回一个值或一组值,作为"本选择器所依赖的状态片段"。

源码中的失效逻辑非常清晰(index.ts#L96-L112):

return Object.assign( function ( state: TState, ...args: TProps ) { let currentDependants = getDependantsFn( state, ...( args as TDepProps ) ); if ( ! Array.isArray( currentDependants ) ) { currentDependants = [ currentDependants ]; } if ( lastDependants && ! isShallowEqual( currentDependants, lastDependants ) ) { memoizedSelector.cache.clear(); } lastDependants = currentDependants; return memoizedSelector( state, ...args ); }, { memoizedSelector } );

可以拆成三步:

  1. 求当前依赖值:每次调用时执行getDependantsFn( state, ...args );如果返回值不是数组,会被包装成单元素数组。
  2. 浅比较判断失效:用@wordpress/is-shallow-equal的isShallowEqual与上一次的依赖值逐项比较。注意这是浅比较——只要依赖片段中某一项的引用变了(Redux 不可变更新天然会产生新引用),就认为状态已变化,执行memoizedSelector.cache.clear()清空整个缓存。
  3. 执行记忆化选择器:缓存被清空后,第一次以新依赖计算并写回缓存;后续相同参数则命中缓存。

依赖函数返回数组时,数组中的每一项都会参与浅比较,因此可以精确声明多个依赖片段。测试用例覆盖了这条链路:state.posts引用变化后再次调用,结果正确且底层选择器被重新执行(test/index.js#L115-L155);数组依赖值同样生效(test/index.js#L157-L176)。

另一个值得留意的行为:依赖函数会被传入与选择器完全相同的参数。测试should call dependant state getter with arguments验证了getDeps收到的正是( state, 1, 2, 3 )(test/index.js#L272-L280)。这意味着依赖函数本身也可以基于siteId之类的参数来定位要监视的状态片段。

参数传递与缓存键:与 reselect 的关键区别

FAQ 的第三个问题:能否给记忆化选择器传参数?答案是肯定的,而且这是 wp-calypso 选择器中非常常见的模式。README 特别指出,这是它与 reselect 这类实现类似目标的主流工具的关键差异之一:reselect 的输入选择器只接收 state,而createSelector允许把siteId、postId这类业务参数直接穿进选择器与依赖函数。

但传参有一个约束,README 与源码一致地强调了它:

内部记忆化函数通过一次简单的Array.prototype.join调用计算缓存键,因此参数不应是复杂对象。

源码中默认缓存键函数分开发/生产两种形态(index.ts#L36-L52):

const DEFAULT_GET_CACHE_KEY = ( () => { if ( 'production' === process.env.NODE_ENV ) { return ( _: unknown, ...args: unknown[] ) => args.join(); } return ( _: unknown, ...args: unknown[] ) => { const hasInvalidArg = args.some( ( arg ) => { return arg && ! VALID_ARG_TYPES.includes( typeof arg ); } ); if ( hasInvalidArg ) { warn( 'Do not pass complex objects as arguments for a memoized selector' ); } return args.join(); }; } )();

要点:

  • 缓存键由除 state 外的参数join()成字符串生成(_吃掉 state,...args收集其余参数)。因此getSitePosts( state, 2916284 )与getSitePosts( state, 38303081 )是不同的缓存条目。
  • 生产环境下只做纯粹的join(),零额外开销。
  • 开发环境下,若任何参数是复杂对象(typeof不在VALID_ARG_TYPES = [ 'number', 'boolean', 'string' ],index.ts#L13),会通过@wordpress/warning输出警告 "Do not pass complex objects as arguments for a memoized selector"。测试用例传入了{}、[]、( 1, [] )三个非法场景,断言警告恰好被调用 3 次(test/index.js#L66-L80)。

之所以复杂对象不可靠,根源就在join():两个内容相同但引用不同的对象会拼出不同的键,缓存命中就会失效;而两个键相同但对象不同的极端情况则可能导致错误命中。因此约定俗成的做法是:参数只传字符串、数字、布尔这类可稳定序列化的原始值(wp-calypso 的选择器普遍传siteId、postId这类 ID 值,正符合该约定)。

如果默认的join键确实不够用,第三个参数允许传入自定义缓存键函数,它同样接收state和全部参数。测试中的例子:

const getSitePostsWithCustomGetCacheKey = createSelector( selector, ( state ) => state.posts, ( state, siteId ) => `CUSTOM${ siteId }` ); getSitePostsWithCustomGetCacheKey( { posts: {} }, 2916284 ); expect( getSitePostsWithCustomGetCacheKey.memoizedSelector.cache.has( 'CUSTOM2916284' ) ).toBe( true );

见 test/index.js#L258-L270。

声明对多个选择器的依赖:数组简写

FAQ 的第四个问题:如果新选择器依赖foo、bar、baz三个状态选择器的结果,怎么写?标准写法是把三个选择器都放进依赖函数:

createSelector( ( state ) => foo( state ) && bar( state ), ( state ) => [ foo( state ), bar( state ), baz( state ) ] );

由于这是反复出现的模式,源码提供了简写:第二个参数直接传一个选择器数组(index.ts#L60-L63 的makeSelectorFromArray):

createSelector( ( state ) => foo( state ) && bar( state ), [ foo, bar, baz ] );

makeSelectorFromArray的实现是:把数组中的每个依赖选择器 map 一遍,返回它们的结果数组——

const makeSelectorFromArray = < TState, TProps extends any[] >( dependants: ( ( state: TState, ...args: TProps ) => any )[] ) => ( state: TState, ...args: TProps ) => dependants.map( ( dependant ) => dependant( state, ...args ) );

于是createSelector内部用typeof getDependants === 'function' ? getDependants : makeSelectorFromArray( getDependants )统一处理两种输入(index.ts#L93-L94)。两个测试用例分别验证了"依赖选择器数组"的缓存语义(state 变化前后各计算一次,共 2 次,test/index.js#L178-L200),以及数组中每个选择器都会被传入全部参数(test/index.js#L282-L291)。

访问与管理内部缓存

FAQ 的最后一个问题:虽然很少需要这样做,但可以管理内部缓存——它是以.cache属性暴露的Map,挂在返回函数的memoizedSelector属性上:

getSitePosts.memoizedSelector.cache; // 一个 Map getSitePosts.memoizedSelector.cache.clear(); // 手动清空

测试文件把这个 API 用得很自然:beforeEach中通过getSitePosts.memoizedSelector.cache.clear()隔离各用例(test/index.js#L17-L20),并断言typeof getSitePosts.memoizedSelector为'function'(test/index.js#L22-L24)。这个逃生口在写测试、或需要在某个业务事件(如数据重置)时强制刷新派生数据时会派上用场。

底层支撑:@automattic/js-utils 的 memoize

createSelector内部对选择函数做记忆化时,调用的是同仓库@automattic/js-utils包导出的memoize(index.ts#L90),实现在 packages/js-utils/src/memoize.ts:

const memoized = function ( this: unknown, ...args: Args ): Return { const key = resolver ? resolver.apply( this, args ) : args[ 0 ]; const { cache } = memoized; if ( cache.has( key ) ) { return cache.get( key ) as Return; } const result = func.apply( this, args ); cache.set( key, result ); return result; } as MemoizedFunction< Args, Return >; memoized.cache = new Map< unknown, Return >();

这解释了createSelector的两层缓存结构如何协作:

  • 内层(memoize 的 Map):按缓存键(默认是参数join()结果)存储"同一组参数"下的计算结果。多个siteId的值可以并存于同一 Map 中。
  • 外层(createSelector 的 lastDependants 浅比较):一旦依赖片段变化,就调用cache.clear()把内层 Map 整体清空,从而让所有参数的旧结果一次性失效。

也就是说,"参数不同"走 Map 的多键并存,"依赖状态变了"走整表清空,两者配合构成了完整的缓存策略。

依赖与适用前提

使用createSelector时的实际前提,可以从 packages/state-utils/package.json 确认:

  • 包名为@automattic/state-utils,描述为 "A collection of Redux state utilities",构建产物分 CJS/ESM 两套(main指向dist/cjs/index.js,module指向dist/esm/index.js,源码入口为src/index.ts)。
  • 运行时依赖包括@automattic/js-utils(提供memoize)、@wordpress/is-shallow-equal(依赖浅比较)、@wordpress/warning(开发环境参数警告),以及redux/redux-thunk。
  • 在 wp-calypso 仓库内通过 workspace 方式引用("@automattic/state-utils": "workspace:^"形态),客户端代码直接import { createSelector } from '@automattic/state-utils',如 get-site-post.js 所示。

需要遵守的使用约束汇总:

  1. 依赖片段必须按引用比较失效——Redux 不可变更新(reducer 返回新引用)是这套机制生效的前提;
  2. 传给选择器的参数不要用复杂对象,开发环境会警告,生产环境则只有join()行为、无警告保护;
  3. 默认监视整个 state,除非你有理由接受"任何状态变化都失效",否则建议显式传入精确的getDependants;
  4. 依赖函数会与选择器收到完全相同的参数,可以据此把依赖范围进一步收窄到参数相关的状态片段。

小结

createSelector是 wp-calypso 在"精简状态树 + 昂贵派生计算"矛盾下的标准答案:一个参数定义计算,一个参数声明依赖(支持函数或选择器数组两种形态),一个可选参数定制缓存键。源码层面用"参数 join 成缓存键 + 依赖浅比较触发整表清空"两层机制保证了缓存既能在同一状态下命中复用,又能在依赖变化时彻底失效。配合.memoizedSelector.cache这个可观测、可管理的Map,这套工具在 create-selector 测试套件 中得到了从命中、失效、多参数到自定义缓存键的完整行为验证。

  • 前端
  • CMS

【免费下载链接】wp-calypso

The JavaScript and API powered WordPress.com

项目地址:https://gitcode.com/gh_mirrors/wp/wp-calypso
点击查看免费下载
上一篇:终极图片批量处理指南:Umi-CUT让你的图片工作流效率翻倍
下一篇:Claude Subconscious跨会话连续性体验:如何一句话接上昨天没写完的重构

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询