Gutenberg Disabled 组件深度解析:一个 inert 属性如何批量禁用整个交互区域
2026/9/17 19:52:03 网站建设 项目流程

Gutenberg Disabled 组件深度解析:一个 inert 属性如何批量禁用整个交互区域

【免费下载链接】gutenbergThe Block Editor project for WordPress and beyond. Plugin is available from the official repository.项目地址: https://gitcode.com/GitHub_Trending/gu/gutenberg

在 WordPress Gutenberg 的组件库@wordpress/components中,Disabled是一个专门用于"区域级禁用"的工具组件:它把子孙元素整体移出 Tab 焦点序列,同时阻断所有指针交互,而不必逐个给子组件传disabled属性。读完本文,你将掌握该组件的用法与 Props 细节、基于inert属性与pointer-events的底层实现原理、通过Disabled.Context感知禁用状态的子组件模式,以及仓库中真实测试用例所验证的行为边界,从而在编辑器插件、模态框、预览态界面中正确实现整块区域的可逆禁用。

组件定位:与单个disabled属性有何不同

Disabled的官方定义只有一句话:

Disabled is a component which disables descendant tabbable elements and prevents pointer interaction. (禁用后代中可 Tab 聚焦的元素,并阻止指针交互。)

对应源码位于 index.tsx,组件渲染为一个带inert属性的<div>包裹层。与原生disabled属性只能作用于单个表单元素不同,Disabled解决的是整棵子树的禁用问题:表单输入、按钮、contentEditable区域、下拉框等,只要包在<Disabled>里,会同时失去焦点能力和鼠标事件,且整个状态可以通过一个 prop 随时可逆地切换。

基本用法:包裹表单即可整体禁用

文档(README.md)给出的标准示例是一个可切换禁用状态的表单。完整继承如下:

import { useState } from 'react'; import { Button, Disabled, TextControl } from '@wordpress/components'; const MyDisabled = () => { const [ isDisabled, setIsDisabled ] = useState( true ); let input = ( <TextControl label="Input" onChange={ () => {} } /> ); if ( isDisabled ) { input = <Disabled>{ input }</Disabled>; } const toggleDisabled = () => { setIsDisabled( ( state ) => ! state ); }; return ( <div> { input } <Button variant="primary" onClick={ toggleDisabled }> Toggle Disabled </Button> </div> ); };

这个示例演示了两种等价的启用方式:

  • 条件渲染:仅在需要时把内容包进<Disabled>(示例中的写法),组件树中不存在包裹层;
  • prop 切换:始终保留<Disabled>,通过isDisabled={ isDisabled }动态开关(源码与测试均采用此方式,见下文)。

注意示例中的Toggle Disabled按钮放在<Disabled>之外——这一点很关键:被禁用区域内的按钮无法点击,切换开关必须位于禁用区域外部,否则用户将永远无法恢复交互。仓库中 Storybook 的演示故事(stories/index.story.tsx)用TextControlTextareaControlSelectControl组合成一个表单来验证同样的效果,并提供了一个单独的ContentEditable故事,确认contentEditable区域同样会被禁用。

Props 说明

组件的 TypeScript 类型定义在 types.ts:

export interface DisabledProps { /** * Whether to disable all the descendant fields. * * @default true */ isDisabled?: boolean; /** * The children elements. */ children: React.ReactNode; }

逐项说明:

Prop类型必填默认值说明
isDisabledBooleantrue是否禁用所有后代字段。设为false时保留包裹结构但解除inert与禁用样式
childrenReact.ReactNode要被整体禁用的子元素
其余 propsHTMLAttributes透传给内部<div>,如classNamedata-testid

关于最后一点:组件签名是WordPressComponentProps< DisabledProps, 'div' >(见 index.tsx),即除isDisabledchildren外,所有标准 div 属性都会透传到包裹层 DOM 节点上。其中className有特殊处理逻辑,下一节会详细说明。

实现原理:inert属性 + Provider 双层控制

完整实现仅有约 80 行,核心渲染逻辑如下(摘自 index.tsx):

function Disabled( { className, children, isDisabled = true, ...props }: WordPressComponentProps< DisabledProps, 'div' > ) { return ( <Provider value={ isDisabled }> <div // @ts-expect-error `inert` is not declared in React 18's HTML attribute types. inert={ isDisabled ? 'true' : undefined } className={ clsx( className, isDisabled && [ styles.disabled, 'components-disabled' ] ) || undefined } { ...props } > { children } </div> </Provider> ); }

它同时做了两件事:

1. 给 DOM 挂上inert属性

inert是 HTML 标准属性,浏览器对带inert的容器会执行两件事:容器内所有后代不可聚焦(Tab 导航直接跳过),且不接收任何指针事件。这正是"disable descendant tabbable elements"的实现基础,完全由浏览器原生行为完成,无需 JS 拦截事件。

两个源码细节值得注意:

  • 属性以字符串形式inert={ isDisabled ? 'true' : undefined }写入,且源码中带有@ts-expect-error注释——因为 React 18 的 HTML 属性类型定义尚未包含inert,这是刻意的类型绕过而非临时 hack;
  • isDisabledfalse时属性值为undefined,React 会直接移除该属性,实现可逆切换。

2. 通过 Context 广播禁用状态

外层的<Provider value={ isDisabled }>把当前禁用状态写进 React Context,供子组件自行读取。Context 定义在 context.ts:

import { createContext } from '@wordpress/element'; const Context = createContext< boolean >( false ); Context.displayName = 'DisabledContext'; export default Context;

默认值是false——这意味着没有被<Disabled>包裹时,所有消费方拿到的都是"未禁用",语义与inert未挂载时的浏览器行为一致。组件还把 Context 挂载为静态属性导出(Disabled.Context = Context; Disabled.Consumer = Consumer;,见 index.tsx),两种消费方式都可用。

className 的合并策略:只切换禁用类,不动业务类

源码注释明确写道:"Only the disabled styling is conditional. The consumer's own className has to stick around so the wrapper stays targetable whether or not it is currently disabled."(只有禁用样式是条件性的,使用者自己的 className 必须保留,以便包裹层无论禁用与否都可被选择器定位。)

具体实现是用clsx合并:className无条件保留;styles.disabled和全局类components-disabled仅在isDisabledtrue时附加。jsdom 测试专门验证了这一点(test/index.jsdom.test.tsx):

rerender 后 wrapper 仍然有 my-wrapper 类, 但不再带有 components-disabled 和 styles.disabled 类

这一设计对样式定位很实用:外层样式表可以用固定 class 稳定命中包裹层,而禁用态的视觉反馈(透明度、指针样式)由条件类独立控制。

CSS 层:pointer-events如何兜底拦截鼠标

inert负责焦点与事件,而视觉上的"不可点击"由 style.module.scss 保证,全文只有几行:

.disabled { position: relative; pointer-events: none; &::after { content: ""; position: absolute; inset: 0; } // Also make nested blocks unselectable. * { pointer-events: none; } }

拆解一下各行的作用:

  • pointer-events: none:包裹层自身不再命中鼠标事件;
  • * { pointer-events: none }:注释写明目的是"让嵌套块也无法被选中",即子树内所有元素都不响应鼠标,包括文本选中;
  • ::after伪元素铺满整个容器(inset: 0):配合position: relative形成一个覆盖层。浏览器测试(test/index.browser.test.tsx)验证的结果是:被禁用区域内按钮的计算样式pointer-eventsnone,即指针事件确实到达不了后代。

这种"CSS 拦截 + inert 属性"的双保险结构,保证了即便个别浏览器对inert的指针处理有差异,视觉上与交互上仍然表现为不可操作。

Disabled.Context:子组件如何知道自己被禁用了

README 中给出的消费模式是:

function CustomButton( props ) { const isDisabled = useContext( Disabled.Context ); return <button { ...props } style={ { opacity: isDisabled ? 0.5 : 1 } } />; }

<Disabled>包裹时读到true,未包裹时读到false。适合用它实现"淡化显示、切换提示文案、隐藏某些控件"等视觉态调整——因为inert只处理交互,不处理外观。

Gutenberg 仓库中有真实的使用者。在导航块编辑器里:

  • use-generate-default-navigation-title.js 用useContext( Disabled.Context )判断编辑区是否被禁用,从而控制自动生成标题的行为;
  • unsaved-inner-blocks.jsx 同样读取该 Context,在禁用状态下调整未保存内部块的显示逻辑。

从源码结构看,这是编辑器的典型场景:当某块被"禁用编辑"(例如正在编辑其他块、或只读预览)时,整棵子树包进<Disabled>,而子块内部组件无需逐个传参,只需读一次 Context 即可同步视觉与行为状态。

测试(test/index.jsdom.test.tsx)覆盖了三条 Context 路径:

  1. 包裹在默认<Disabled>中 → 消费方读到true,渲染 "Disabled";
  2. 包裹在<Disabled isDisabled={ false }>中 → 读到false
  3. 完全没有<Disabled>包裹 → 读到默认值false,不会报错。

测试用例揭示的行为边界

jsdom 测试文件(test/index.jsdom.test.tsx)还验证了几个实际开发中容易踩坑的点:

  • 干净地取消禁用(reconciliation):当用"有/无<Disabled>包裹"两种结构切换时(对应 README 示例的条件渲染写法),取消后包裹节点从文档中完全移除,inert属性随之消失;
  • prop 切换不丢用户输入:测试先往<input>contentEditable中分别输入文本,再反复切换isDisabled,断言两次切换后输入内容都原样保留。也就是说禁用是非破坏性的——它只锁交互,不卸载、不清空子组件的受控状态;
  • 包裹层属性完整性inert属性、components-disabled类与 CSS Module 的styles.disabled类三者同进同出。

浏览器端测试(test/index.browser.test.tsx)则在真实浏览器环境中验证了两条核心承诺:

  1. 被禁用区域按钮的getComputedStyle(...).pointerEvents'none'
  2. 按 Tab 键时焦点跳过整个禁用子树,直接落在区域外的 "Next action" 按钮上——这正是"disables descendant tabbable elements"的可执行定义。

浏览器兼容性与inertpolyfill

README 结尾有一条兼容性提示,此处完整保留:

Note: this component may not behave as expected in browsers that don't support theinertHTML attribute. We recommend adding the official WICG polyfill when using this component in your project.(注意:在不支持inertHTML 属性的浏览器中,该组件可能表现不符合预期。在项目中使用此组件时,建议引入官方的 WICG inert polyfill。)

含义是:

  • 在支持inert的浏览器中,焦点隔离与事件屏蔽由浏览器原生保证,组件本身零 JS 运行时开销;
  • 在不支持的旧浏览器中,inert属性会被当作无效属性忽略,此时焦点隔离失效,仅剩pointer-events: none的 CSS 兜底拦截鼠标;若目标环境包含此类浏览器,需要自行引入 WICG 的官方 inert polyfill。

小结与速查

要点结论依据
禁用机制包裹层 DOM 挂inert属性 + CSSpointer-events: none+::after覆盖层index.tsx、style.module.scss
可逆切换isDisabledfalse即移除inert与禁用类,子组件状态不受影响test/index.jsdom.test.tsx
状态感知useContext( Disabled.Context ),默认falseDisabled.Consumer等价可用context.ts
导出位置export { default as Disabled } from './disabled';packages/components/src/index.ts
PropsisDisabled(Boolean,默认true)、children,其余 div 属性透传types.ts
兼容前提依赖浏览器对inert的支持,旧浏览器需 WICG polyfillREADME.md

使用Disabled的决策建议:只需禁用单个表单控件时,优先用原生disabled/ 组件自带的disabledprop;需要把一整块区域(表单组、子块、模态框内容、预览态界面)连同焦点和鼠标一起"锁住"、并且要随时可逆地解锁时,才用<Disabled>包裹,并让子组件通过Disabled.Context同步自己的视觉状态。

【免费下载链接】gutenbergThe Block Editor project for WordPress and beyond. Plugin is available from the official repository.项目地址: https://gitcode.com/GitHub_Trending/gu/gutenberg

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

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

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

立即咨询