首页
/ ECC frontend-patterns 技能详解:React 与 Next.js 组件、Hooks、性能与无障碍模式全景

ECC frontend-patterns 技能详解:React 与 Next.js 组件、Hooks、性能与无障碍模式全景

2026-09-06 17:53:13作者:庞眉杨Will

ECC(Everything Claude Code)仓库中的 frontend-patterns 技能(.kiro/skills/frontend-patterns/SKILL.md)是一份面向 AI 编程助手的 React/Next.js 前端模式手册,覆盖组件组合、复合组件、Render Props、自定义 Hooks、Context + Reducer 状态管理、记忆化与虚拟列表性能优化、表单校验、Error Boundary、Framer Motion 动画与键盘无障碍等完整主题。读完本文,你将掌握这份技能中每个模式的可运行代码实现、关键陷阱(如 useQuery 的引用稳定性问题、sort 原地突变问题)的来源与规避方式,并了解该技能在 ECC 安装体系中的分发位置与调用方式。

一、技能定位:frontmatter 与 ECC 安装体系

该技能文件采用标准的 Kiro Skill 格式,YAML frontmatter 声明了技能的元信息:

---
name: frontend-patterns
description: >
  Frontend development patterns for React, Next.js, state management, performance optimization, and UI best practices.
metadata:
  origin: ECC
---

name 决定技能在 / 菜单中的调用名,description 用于让模型判断何时激活该技能,metadata.origin: ECC 标记其来源。仓库中还存在一份内容几乎完全一致的主副本 skills/frontend-patterns/SKILL.md,两者的差异仅在 description 多了一句激活条件(“Use when building or reviewing React or Next.js components, state, or render performance.”)。

从仓库的 manifests 可以确认该技能的模块化归属:

  • manifests/install-components.json 第 546 行附近,组件 skill:frontend-patterns 被登记为 framework-language 模块下的一个 skill 组件,描述为 “React and frontend engineering patterns.”;
  • manifests/install-modules.json 第 175 行附近,skills/frontend-patterns 被列为 framework-language 模块 paths 数组中的安装路径之一,该模块的 defaultInstalltrue,即默认随框架/语言类技能一起安装。

对于 Kiro 用户,ECC 提供了独立的 .kiro 集成目录。按照 .kiro/install.sh 的说明,安装命令为:

cd .kiro
./install.sh /path/to/your/project   # 安装到指定项目
# 或 ./install.sh 安装到当前目录;./install.sh ~ 全局安装到 ~/.kiro/

该脚本对 agents skills steering hooks scripts settings 六个子目录执行非破坏性拷贝(目标已存在同名文件时跳过),其中第 66–77 行的技能拷贝逻辑是遍历 $SOURCE_KIRO/skills/*/ 每个技能目录并整体复制到 $TARGET/.kiro/skills/<skill_name>/。安装完成后,技能可在 Kiro 聊天中通过 / 菜单按需调用(.kiro/README.md 将其描述为 “React, Next.js, and frontend architecture patterns. Use when building UI components or optimizing frontend performance.”)。

二、激活场景(When to Activate)

技能文档开篇即列出了 7 类触发场景,这也是 AI 助手应当加载该技能判断的边界:

  • 构建 React 组件(组合、props、渲染);
  • 管理状态(useState、useReducer、Zustand、Context);
  • 实现数据获取(SWR、React Query、Server Components);
  • 性能优化(记忆化、虚拟列表、代码分割);
  • 表单处理(校验、受控输入、Zod schema);
  • 处理客户端路由与导航;
  • 构建可访问、响应式的 UI 模式。

下文按文档骨架逐节展开每个主题的核心模式与实现要点。

三、组件模式(Component Patterns)

3.1 组合优于继承(Composition Over Inheritance)

文档首先给出的范式是用 children 插槽 + 独立子组件替代 props 堆叠或类继承,以 Card 组件族为例:

// PASS: GOOD: Component composition
interface CardProps {
  children: React.ReactNode
  variant?: 'default' | 'outlined'
}

export function Card({ children, variant = 'default' }: CardProps) {
  return <div className={`card card-${variant}`}>{children}</div>
}

export function CardHeader({ children }: { children: React.ReactNode }) {
  return <div className="card-header">{children}</div>
}

export function CardBody({ children }: { children: React.ReactNode }) {
  return <div className="card-body">{children}</div>
}

// Usage
<Card>
  <CardHeader>Title</CardHeader>
  <CardBody>Content</CardBody>
</Card>

要点在于:变体通过 variant?: 'default' | 'outlined' 这种联合类型枚举表达而非布尔开关,布局结构通过嵌套子组件表达而非 headerbodyfooter 等一堆具名 prop。这样组件 API 面保持封闭,扩展新区域只需新增子组件而不改动既有签名。

3.2 复合组件(Compound Components)

复合组件进一步把“共享状态”下沉到 Context,以 Tabs 为例。父组件 Tabs 持有 activeTab 状态并通过 TabsContext 下发,子组件 Tab 通过 useContext 读取,脱离父组件使用时抛出明确错误:

interface TabsContextValue {
  activeTab: string
  setActiveTab: (tab: string) => void
}

const TabsContext = createContext<TabsContextValue | undefined>(undefined)

export function Tabs({ children, defaultTab }: {
  children: React.ReactNode
  defaultTab: string
}) {
  const [activeTab, setActiveTab] = useState(defaultTab)

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      {children}
    </TabsContext.Provider>
  )
}

export function TabList({ children }: { children: React.ReactNode }) {
  return <div className="tab-list">{children}</div>
}

export function Tab({ id, children }: { id: string, children: React.ReactNode }) {
  const context = useContext(TabsContext)
  if (!context) throw new Error('Tab must be used within Tabs')

  return (
    <button
      className={context.activeTab === id ? 'active' : ''}
      onClick={() => context.setActiveTab(id)}
    >
      {children}
    </button>
  )
}

// Usage
<Tabs defaultTab="overview">
  <TabList>
    <Tab id="overview">Overview</Tab>
    <Tab id="details">Details</Tab>
  </TabList>
</Tabs>

两个可复用的防御性细节值得注意:Context 的泛型显式声明为 TabsContextValue | undefined,把“未包裹在 Provider 中”这一状态显式化;子组件在 if (!context) throw new Error(...) 处 fail fast,而不是让 undefined.activeTab 在深层渲染时才炸掉。这与后文 useMarkets hook 的守卫是同一种手法。

3.3 Render Props 模式

DataLoader 展示了用泛型 + 渲染函数把“取数逻辑”与“展示逻辑”解耦:

interface DataLoaderProps<T> {
  url: string
  children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode
}

export function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false))
  }, [url])

  return <>{children(data, loading, error)}</>
}

// Usage
<DataLoader<Market[]> url="/api/markets">
  {(markets, loading, error) => {
    if (loading) return <Spinner />
    if (error) return <Error error={error} />
    return <MarketList markets={markets!} />
  }}
</DataLoader>

从该实现的结构看,<T> 让调用侧在 JSX 中显式给出 Market[],三元组 (data, loading, error) 把三态语义固化成函数签名,调用方无需关心内部 state 结构。局限也很明显:fetch 未带 AbortControllerurl 变化时旧请求不会被取消,生产使用需自行补充(文档未处理这一细节)。

四、自定义 Hooks 模式(Custom Hooks Patterns)

4.1 useToggle:最小状态 Hook

export function useToggle(initialValue = false): [boolean, () => void] {
  const [value, setValue] = useState(initialValue)

  const toggle = useCallback(() => {
    setValue(v => !v)
  }, [])

  return [value, toggle]
}

// Usage
const [isOpen, toggleOpen] = useToggle()

返回值采用 [value, action] 元组,toggle 通过 useCallback(..., []) + 函数式更新 setValue(v => !v) 保证引用稳定且永远基于最新值,不会因闭包捕获旧值而出错。

4.2 useQuery:引用稳定性与无限请求循环

这是文档中注释最密、信息量最大的一个 Hook。核心问题在源码注释中有完整交代:调用方常以内联函数与对象字面量传参,若直接把 fetcher/options 放进 useCallback/useEffect 的依赖,每次渲染都会产生新的 refetch 引用,effect 在下一次 state 更新后重跑,形成无限 fetch 循环。解法是把最新值持续写入 ref,让 refetch 本身保持 [] 依赖:

interface UseQueryOptions<T> {
  onSuccess?: (data: T) => void
  onError?: (error: Error) => void
  enabled?: boolean
}

export function useQuery<T>(
  key: string,
  fetcher: () => Promise<T>,
  options?: UseQueryOptions<T>
) {
  const [data, setData] = useState<T | null>(null)
  const [error, setError] = useState<Error | null>(null)
  const [loading, setLoading] = useState(false)

  // Keep the latest fetcher/options in refs so refetch stays referentially
  // stable even when callers pass inline functions and object literals.
  // Without this, every render creates a new refetch, and the effect below
  // re-runs after each state update - an infinite fetch loop.
  const fetcherRef = useRef(fetcher)
  const optionsRef = useRef(options)
  useEffect(() => {
    fetcherRef.current = fetcher
    optionsRef.current = options
  })

  const refetch = useCallback(async () => {
    setLoading(true)
    setError(null)

    try {
      const result = await fetcherRef.current()
      setData(result)
      optionsRef.current?.onSuccess?.(result)
    } catch (err) {
      const error = err as Error
      setError(error)
      optionsRef.current?.onError?.(error)
    } finally {
      setLoading(false)
    }
  }, [])

  const enabled = options?.enabled !== false

  useEffect(() => {
    if (enabled) {
      refetch()
    }
  }, [key, enabled, refetch])

  return { data, error, loading, refetch }
}

// Usage
const { data: markets, loading, error, refetch } = useQuery(
  'markets',
  () => fetch('/api/markets').then(r => r.json()),
  {
    onSuccess: data => console.log('Fetched', data.length, 'markets'),
    onError: err => console.error('Failed:', err)
  }
)

逐点拆解:

  • fetcherRef/optionsRef 由一个不带依赖数组的 useEffect 在每次渲染后同步最新值,因此 refetch 依赖列表可以为空,引用永久稳定;
  • 触发请求的 effect 依赖为 [key, enabled, refetch],即只有业务 key 变化或开关切换时才重新拉取,key 是缓存/请求身份的唯一标识;
  • enabled !== false 的默认开启语义意味着省略 options 或省略 enabled 都会正常发请求;
  • 回调使用可选链 optionsRef.current?.onSuccess?.(result),任一缺省都不抛错;
  • 局限:该实现没有请求取消(并发下后发请求可能覆盖先发结果)、没有重试与 stale-while-revalidate,文档定位为轻量版 pattern 而非完整数据层。

4.3 useDebounce:搜索防抖

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value)

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => clearTimeout(handler)
  }, [value, delay])

  return debouncedValue
}

// Usage
const [searchQuery, setSearchQuery] = useState('')
const debouncedQuery = useDebounce(searchQuery, 500)

useEffect(() => {
  if (debouncedQuery) {
    performSearch(debouncedQuery)
  }
}, [debouncedQuery])

关键在于 cleanup 中 clearTimeout(handler):每次 value 变化都会先清掉上一轮定时器再重新计时,从而把连续输入压缩为一次下游副作用。文档示例中“先防抖、再在独立 effect 里消费”的两段式写法,把“值稳定”与“发起搜索”两个关注点分离,if (debouncedQuery) 还顺带过滤了空串查询。

五、状态管理模式:Context + Reducer

对于多个组件共享、且变化路径可枚举的领域状态,文档给出的组合是 useReducer + Context + 命名守卫 hook,以市场(market)域为例:

interface State {
  markets: Market[]
  selectedMarket: Market | null
  loading: boolean
}

type Action =
  | { type: 'SET_MARKETS'; payload: Market[] }
  | { type: 'SELECT_MARKET'; payload: Market }
  | { type: 'SET_LOADING'; payload: boolean }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'SET_MARKETS':
      return { ...state, markets: action.payload }
    case 'SELECT_MARKET':
      return { ...state, selectedMarket: action.payload }
    case 'SET_LOADING':
      return { ...state, loading: action.payload }
    default:
      return state
  }
}

const MarketContext = createContext<{
  state: State
  dispatch: Dispatch<Action>
} | undefined>(undefined)

export function MarketProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, {
    markets: [],
    selectedMarket: null,
    loading: false
  })

  return (
    <MarketContext.Provider value={{ state, dispatch }}>
      {children}
    </MarketContext.Provider>
  )
}

export function useMarkets() {
  const context = useContext(MarketContext)
  if (!context) throw new Error('useMarkets must be used within MarketProvider')
  return context
}

模式要点:

  1. Action 联合类型把合法变更收敛为封闭集合,TypeScript 会在 reducer 与调用侧同时给出穷尽检查;
  2. Context value 同时暴露 statedispatch,消费侧拿到的是完整 store 而非拆散的字段;
  3. 命名 hook(useMarkets)替代裸的 useContext,既提供了文档中提到的 “must be used within ...Provider” 运行时守卫,也让调用点语义自解释;
  4. reducer 初始 state 直接写在 useReducer(reducer, initialState) 第二参数,无 lazy init 需求。

文档在 “When to Activate” 中并列提及 Zustand——从该技能的结构推断,其立场是:域内共享且变更可枚举时优先 Context + Reducer 这套 React 原生方案,跨大型组件树的高频更新场景再引入 Zustand 之类的外部 store(文档正文未展开 Zustand 代码)。

六、性能优化(Performance Optimization)

6.1 记忆化(Memoization)

文档用三个 PASS 示例分别对应三种记忆化手段,并附带一个极易踩坑的注释:

// PASS: useMemo for expensive computations
// Copy before sorting - Array.prototype.sort mutates in place
const sortedMarkets = useMemo(() => {
  return [...markets].sort((a, b) => b.volume - a.volume)
}, [markets])

// PASS: useCallback for functions passed to children
const handleSearch = useCallback((query: string) => {
  setSearchQuery(query)
}, [])

// PASS: React.memo for pure components
export const MarketCard = React.memo<MarketCardProps>(({ market }) => {
  return (
    <div className="market-card">
      <h3>{market.name}</h3>
      <p>{market.description}</p>
    </div>
  )
})

三条边界划分:

  • useMemo 用于昂贵计算。特别地,Array.prototype.sort原地突变操作,文档明确要求 [...markets].sort(...) 先拷贝,否则会直接改写 props/state 里的原始数组,破坏 React 的不可变数据流假设;
  • useCallback 用于“传给会被 React.memo 包装的子组件的函数”,它本身不省渲染,而是保证 React.memo 的浅比较能命中——6.3 的 MarketCard 正是这一配合的落点;
  • React.memo 用于纯展示组件,React.memo<MarketCardProps> 的显式泛型写法让 props 类型在匿名箭头函数场景下依然可检查。

三者应视为组合拳而非独立选项:memo 的组件 + useCallback 的回调 + useMemo 的派生数据,缺任何一环都可能让另外两环失效。

6.2 代码分割与懒加载(Code Splitting & Lazy Loading)

import { lazy, Suspense } from 'react'

// PASS: Lazy load heavy components
const HeavyChart = lazy(() => import('./HeavyChart'))
const ThreeJsBackground = lazy(() => import('./ThreeJsBackground'))

export function Dashboard() {
  return (
    <div>
      <Suspense fallback={<ChartSkeleton />}>
        <HeavyChart data={data} />
      </Suspense>

      <Suspense fallback={null}>
        <ThreeJsBackground />
      </Suspense>
    </div>
  )
}

lazy(() => import(...)) 让打包器对每个动态 import 产出独立 chunk,首次导航不加载图表与 3D 背景的 JS。文档展示了两种 fallback 策略:关键内容用 <ChartSkeleton /> 骨架屏占位保持布局稳定,装饰性的 ThreeJsBackgroundfallback={null} 静默加载,避免无意义的闪烁。

6.3 长列表虚拟化(Virtualization)

基于 @tanstack/react-virtual 的完整实现:

import { useVirtualizer } from '@tanstack/react-virtual'

export function VirtualMarketList({ markets }: { markets: Market[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: markets.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100,  // Estimated row height
    overscan: 5  // Extra items to render
  })

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative'
        }}
      >
        {virtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.index}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`
            }}
          >
            <MarketCard market={markets[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  )
}

实现机制可以拆成四层:

  1. count: markets.length 声明数据总量;getScrollElement: () => parentRef.current 把滚动事件源指向外层 600px 高的 overflow: auto 容器;
  2. estimateSize: () => 100预估行高——在测量真实高度之前用于定位计算,overscan: 5 则在可视区上下各多渲染 5 行,缓冲快速滚动时的空白;
  3. 内层包裹 div 用 virtualizer.getTotalSize() 撑起整个列表的“虚拟高度”,滚动条行为与真实长列表一致;
  4. 每个可视行 position: absolute + transform: translateY(virtualRow.start) 绝对定位到计算出的偏移,因此 DOM 中始终只有可视区附近的行节点,把 O(N) 节点数压成 O(1)。

6.4 表单处理(Form Handling Patterns)

受控表单 + 显式校验层,字段为市场创建场景(name / description / endDate):

interface FormData {
  name: string
  description: string
  endDate: string
}

interface FormErrors {
  name?: string
  description?: string
  endDate?: string
}

export function CreateMarketForm() {
  const [formData, setFormData] = useState<FormData>({
    name: '',
    description: '',
    endDate: ''
  })

  const [errors, setErrors] = useState<FormErrors>({})

  const validate = (): boolean => {
    const newErrors: FormErrors = {}

    if (!formData.name.trim()) {
      newErrors.name = 'Name is required'
    } else if (formData.name.length > 200) {
      newErrors.name = 'Name must be under 200 characters'
    }

    if (!formData.description.trim()) {
      newErrors.description = 'Description is required'
    }

    if (!formData.endDate) {
      newErrors.endDate = 'End date is required'
    }

    setErrors(newErrors)
    return Object.keys(newErrors).length === 0
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    if (!validate()) return

    try {
      await createMarket(formData)
      // Success handling
    } catch (error) {
      // Error handling
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={formData.name}
        onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
        placeholder="Market name"
      />
      {errors.name && <span className="error">{errors.name}</span>}

      {/* Other fields */}

      <button type="submit">Create Market</button>
    </form>
  )
}

结构要点:errorsformData 是平行的两个 state,FormErrors 的字段全部可选(name? 等),错误是“存在即显示”而非“清空即删除”;validate 采用“先聚合全部错误再一次性 set”的策略,避免逐字段 setState 造成多次渲染;onChange 使用函数式更新 setFormData(prev => ...) 防止快速输入时闭包读到旧 state;e.preventDefault() 阻止原生提交后才进入校验与 createMarket 异步流程。frontmatter 激活场景提到 Zod,但从源码结构看,正文给出的是零依赖的手工校验版本,Zod 属于调用方可选的升级方向而非文档强制项。

七、错误边界(Error Boundary Pattern)

interface ErrorBoundaryState {
  hasError: boolean
  error: Error | null
}

export class ErrorBoundary extends React.Component<
  { children: React.ReactNode },
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = {
    hasError: false,
    error: null
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('Error boundary caught:', error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-fallback">
          <h2>Something went wrong</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => this.setState({ hasError: false })}>
            Try again
          </button>
        </div>
      )
    }

    return this.props.children
  }
}

// Usage
<ErrorBoundary>
  <App />
</ErrorBoundary>

该实现完整覆盖了错误边界的两个生命周期入口:static getDerivedStateFromError 负责在渲染阶段捕获错误并翻转 hasError 状态(这是显示 fallback 的必要条件),componentDidCatch 负责在提交阶段记录 errorInfo(包含组件堆栈)。Try again 按钮把 hasError 复位为 false 让子树重新渲染——注意这只是复位边界状态,若子组件错误是确定性的,重试仍会再次触发捕获。错误边界是文档正文中唯一必须使用 class 组件的模式,因为 hooks 尚无渲染错误捕获能力。

八、动画模式(Animation Patterns,Framer Motion)

8.1 列表进入/退出动画

AnimatePresence 包裹列表是 Framer Motion 实现退出动画的前提——子项卸载时它负责保留节点直到 exit 动画完成:

import { motion, AnimatePresence } from 'framer-motion'

// PASS: List animations
export function AnimatedMarketList({ markets }: { markets: Market[] }) {
  return (
    <AnimatePresence>
      {markets.map(market => (
        <motion.div
          key={market.id}
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -20 }}
          transition={{ duration: 0.3 }}
        >
          <MarketCard market={market} />
        </motion.div>
      ))}
    </AnimatePresence>
  )
}

三态 initial → animate → exit 分别对应挂载、稳定态、卸载;key={market.id}AnimatePresence 追踪增删的依据,键不稳定会导致进出场动画丢失。

8.2 Modal 双图层动画

// PASS: Modal animations
export function Modal({ isOpen, onClose, children }: ModalProps) {
  return (
    <AnimatePresence>
      {isOpen && (
        <>
          <motion.div
            className="modal-overlay"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
          />
          <motion.div
            className="modal-content"
            initial={{ opacity: 0, scale: 0.9, y: 20 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.9, y: 20 }}
          >
            {children}
          </motion.div>
        </>
      )}
    </AnimatePresence>
  )
}

遮罩层只做透明度淡入淡出,内容层叠加 scale: 0.9 → 1y: 20 → 0 的位移,形成“面板浮出”的层次差异;两个 motion 节点同处一个 AnimatePresence 且由同一个 {isOpen && ...} 条件控制,保证退出时两层同步淡出而不是内容先消失。

九、无障碍模式(Accessibility Patterns)

9.1 键盘导航(Combobox 交互模式)

Dropdown 实现了一套完整的键盘协议,Aria 属性与按键处理一一对应:

export function Dropdown({ options, onSelect }: DropdownProps) {
  const [isOpen, setIsOpen] = useState(false)
  const [activeIndex, setActiveIndex] = useState(0)

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        setActiveIndex(i => Math.min(i + 1, options.length - 1))
        break
      case 'ArrowUp':
        e.preventDefault()
        setActiveIndex(i => Math.max(i - 1, 0))
        break
      case 'Enter':
        e.preventDefault()
        onSelect(options[activeIndex])
        setIsOpen(false)
        break
      case 'Escape':
        setIsOpen(false)
        break
    }
  }

  return (
    <div
      role="combobox"
      aria-expanded={isOpen}
      aria-haspopup="listbox"
      onKeyDown={handleKeyDown}
    >
      {/* Dropdown implementation */}
    </div>
  )
}

可复用的细节:ArrowDown/ArrowUpMath.min / Math.max 钳制索引边界,配合 e.preventDefault() 阻止页面滚动等默认行为;Enter 确认选择并关闭,Escape 仅关闭不提交;role="combobox" + aria-expanded + aria-haspopup="listbox" 三件套让辅助技术能正确播报展开状态。文档此处只给到交互骨架({/* Dropdown implementation */} 留白),ARIA 完整实现(aria-activedescendant、选项 role="option" 等)需按 skills/frontend-a11y 等更细粒度的技能补全。

9.2 焦点管理(Focus Management)

Modal 的焦点保存—转移—恢复闭环:

export function Modal({ isOpen, onClose, children }: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null)
  const previousFocusRef = useRef<HTMLElement | null>(null)

  useEffect(() => {
    if (isOpen) {
      // Save currently focused element
      previousFocusRef.current = document.activeElement as HTMLElement

      // Focus modal
      modalRef.current?.focus()
    } else {
      // Restore focus when closing
      previousFocusRef.current?.focus()
    }
  }, [isOpen])

  return isOpen ? (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      tabIndex={-1}
      onKeyDown={e => e.key === 'Escape' && onClose()}
    >
      {children}
    </div>
  ) : null
}

要点:

  • document.activeElement 在打开瞬间快照到 previousFocusRef,关闭时 .focus() 精确回到原触发元素,而不是退回 <body>
  • tabIndex={-1} 让 dialog 根节点可被程序化聚焦却不进入 Tab 序;
  • role="dialog" + aria-modal="true" 告知辅助技术当前处于模态层,背景内容被隐式从可达性树排除;
  • Escape 关闭与 9.1 的 Dropdown 保持一致的关闭键约定。

该 Modal 与 8.2 的动画 Modal 是同一组件的两个切面:组合时把动画版的外层与焦点管理逻辑合并在同一个 ModalProps 契约下即可。

十、仓库内的配套技能与规则

frontend-patterns 在 ECC 技能体系中不是孤立的,从 skills/ 目录与 rules/ 目录的交叉引用看,可搭配使用的邻近技能包括:

Kiro 集成侧,该技能与 33 个 agents、22 个 steering 文件、13 个 hooks 同属 .kiro 安装包的 43 个技能之一,安装后通过 / 菜单按名调用;react-reviewerreact-build-resolver 等 Kiro agent(.kiro/README.md Agent 清单)在评审与排障 React/Next.js 代码时会引用同类模式,与本文第二、三节的组合/复合组件/错误边界写法互为印证。

十一、小结

.kiro/skills/frontend-patterns/SKILL.md 把前端工程约束组织成八组可直接复制的模式:组件层用组合、复合组件与 Render Props 控制 API 面;Hooks 层用 ref 同步技巧解决 useQuery 的引用稳定性问题,用 cleanup 定时器实现防抖;状态层用 Action 联合类型 + 命名守卫 hook 约束 Context 使用;性能层用“拷贝后排序”、lazy + Suspense 分层 fallback 与 TanStack Virtual 绝对定位布局覆盖三条主线;再加上受控表单校验、class 版 Error Boundary、AnimatePresence 双图层动画与 combobox/焦点恢复无障碍骨架。文档结尾给出的原则同样适用:这些模式面向“可维护、高性能的界面”,实际选型应匹配项目复杂度——轻量页面不必上虚拟化,简单弹窗也不必叠加焦点管理,但 sort 前拷贝、refetch 引用稳定、Provider 缺失守卫这几条是任何规模 React 项目的硬性纪律。

登录后查看全文
热门项目推荐
相关项目推荐