首页
/ ECC 前端模式技能:React / Next.js 组件、状态、性能与无障碍的完整模式库

ECC 前端模式技能:React / Next.js 组件、状态、性能与无障碍的完整模式库

2026-09-04 15:55:31作者:凌朦慧Richard

本文以 ECC 仓库中的前端模式技能文档 .agents/skills/frontend-patterns/SKILL.md 为主体,系统讲解 React / Next.js 前端的组件组合、自定义 Hook、状态管理、性能优化、表单处理、错误边界、动画与无障碍等核心模式。该文档是 ECC "agent harness" 体系中的一份技能(Skill)文件,供 Claude Code、Codex、Opencode、Cursor 等编码代理在构建或评审 React / Next.js 代码时自动激活。读完后,你将掌握一套可直接复制到项目中的前端模式代码,并理解每个模式背后的原理、适用边界,以及 ECC 仓库中配套的 React 规则集(rules/react/hooks.mdrules/react/patterns.md)如何与技能协同工作。

技能定位与激活条件

frontend-patterns 是一份带 YAML frontmatter 的技能定义,其元数据声明了名称与激活描述:

---
name: frontend-patterns
description: Frontend development patterns for React, Next.js, state management,
  performance optimization, and UI best practices. Use when building or reviewing
  React or Next.js components, state, or render performance.
---

从源码结构看,该技能通过 frontmatter 的 description 字段让代理判断"何时激活"。文档中列出的激活场景(When to Activate)包括:

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

该技能在仓库中不止存在于 .agents/skills/ 下,还有一份镜像 skills/frontend-patterns/SKILL.md,并被安装清单 manifests/install-modules.json 收录("skills/frontend-patterns" 条目),用于按模块选择性安装到目标项目的代理配置中。此外,agents/typescript-reviewer.md 在评审 TS/JS 代码时也会显式引用 frontend-patterns 作为模式来源,说明它在 ECC 的代码评审链路中承担"前端模式事实标准"的角色。

隐私与数据边界

技能文档专设一节(Privacy and Data Boundaries)约束前端示例中的数据处理,要点包括:

  • 前端示例应使用合成数据或领域通用数据;
  • 除非用户明确要求并配套校验、脱敏与访问控制,否则不得收集、记录、持久化或展示凭据、访问令牌、SSN、健康数据、支付细节、私人邮箱、手机号等敏感个人数据;
  • 未经明确批准,不要添加分析、追踪像素、第三方脚本或外部数据回传;
  • 处理用户数据时优先最小权限 API、日志记录前的客户端脱敏,以及每个边界处的服务端校验。

这一节体现了 ECC 作为"安全优先"agent harness 的一贯取向:模式技能不仅回答"怎么写",还回答"哪些数据不能碰"。

组件模式

组合优于继承

React 没有组件继承模型,组合是构建复杂 UI 的基本手段。文档给出的 Card 系列组件演示了以 children 为核心的组合方式,variant 属性提供样式变体而不引入继承层次:

// 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>

这与 skills/react-patterns/SKILL.md 中"Composition Over Inheritance"核心原则一致:用 children、render props 或组件 props 组合,而不是层层继承。

复合组件(Compound Components)

复合组件用 Context 把一组兄弟组件的状态"接线"起来。文档以 Tabs 为例:父组件 Tabs 持有 activeTab 状态并通过 TabsContext 下发,子组件 TabListTab 消费该上下文:

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 的默认值刻意设为 undefined 而非一个"看似正常"的对象,这样子组件脱离子树使用时会立刻抛出 Tab must be used within Tabs 错误,把配置错误暴露在开发期;其二,调用方 API 是声明式的(<Tabs defaultTab=...>),状态封装完全内聚,外部不需要知道 Context 的存在。

Render Props 模式

当组件需要把"内部状态"交给外部决定如何渲染时,用 render props 把渲染函数作为 prop 传入:

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>

DataLoader<T> 是泛型组件,消费方在渲染函数里自行决定 loading / error / success 三态如何呈现,容器与表现彻底解耦。这与 rules/react/patterns.md 中 Container / Presentational 的划分相互呼应:数据获取留在容器(这里是 DataLoader),渲染决策交给表现层。

自定义 Hook 模式

状态管理 Hook: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()

两个要点:setValue(v => !v) 使用函数式更新,保证连续快速点击时始终基于最新值计算;useCallback 依赖数组为空,使 toggle 身份稳定,可以作为 prop 传给 React.memo 包裹的子组件而不破坏其记忆化。这与 rules/react/hooks.md 中"函数式更新 + 只在身份稳定有意义时才用 useCallback"的准则一致。

异步数据获取 Hook:useQuery

这是技能文档中最有分量的一段实现,它手工实现了一个最小可用的 query Hook,并特别处理了一个高频陷阱——无限请求循环

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)
  }
)

原理剖析(结合 rules/react/hooks.md 的依赖数组与清理章节):

  • 为什么需要 fetcherRef / optionsRef:调用方通常内联传入 () => fetch(...){ onSuccess: ... },每次渲染都会生成新函数/新对象引用。如果把这些直接放进 useCallback/useEffect 的依赖数组,refetch 每次渲染都会重建 → 触发 effect 重跑 → 状态更新引发再次渲染 → 又触发 effect,形成无限 fetch 循环。把最新值存入无依赖的 useEffect 更新的 ref,refetch 的依赖数组才能保持为空、身份稳定。
  • enabled 的默认语义options?.enabled !== false 意味着"未传即启用",只在显式 enabled: false 时跳过首次请求,适合作为条件查询的开关。
  • API 面:返回 { data, error, loading, refetch } 四元组,refetch 可供手动刷新按钮复用。

生产环境中,这个手写 Hook 应被 SWR / TanStack Query 等服务器状态库替代(文档在 When to Activate 中也列出了这些库),但其 ref 技巧对理解任何"内联回调 + 稳定引用"场景(如事件订阅、WebSocket 回调)都通用。

防抖 Hook: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:value 每次变化都会先清除上一个 timer 再开新 timer,实现"最后一次输入后 delay 毫秒才同步"。缺少 clearTimeout 的清理函数会在依赖快速变化时产生竞态。rules/react/hooks.mduseDebounce 的实现与清理要求("Every subscription, interval, listener... must clean up")与此完全一致,该 Hook 也是规则文档中"何时抽取自定义 Hook"(同一段逻辑出现在 2+ 组件、逻辑有可命名目的)的标准示例。

状态管理模式

Context + Reducer

当状态更新存在多种离散 action 且需要在多组件间共享时,useReducer + Context 是文档给出的组合方案(示例以"市场列表"域建模):

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
}

结构上遵循了与前文相同的防御式约定:Context 默认值 undefined + 自定义 Hook(useMarkets)中抛出带指引的错误信息。Action 使用判别联合(discriminated union)类型,payload 字段名统一为 payload,便于扩展新 action 时保持一致性。

需要强调的是配套规则文档 rules/react/patterns.md 中的"State Location Decision Tree"——Context 只适合低频读取、跨远端分支的场景(主题、鉴权、语言环境);高频更新应交给外部 store(Zustand / Jotai / Redux Toolkit),服务器派生数据应交给服务器状态库。"Most pages do not need context or a global store"(大多数页面既不需要 Context 也不需要全局 store),这是对技能示例的重要边界限定:上面的 Market 示例适用于"域级状态确实被多层共享"的场景,而非所有页面。

性能优化

记忆化:useMemo / useCallback / React.memo

// 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>
  )
})

细节与依据:

  • [...markets].sort(...) 先拷贝再排序,注释明确指出 Array.prototype.sort 是原地(in-place)修改——在渲染期原地修改 props 数组既破坏纯函数契约又会产生难以追踪的 bug;
  • 三层记忆化分工:useMemo 保护昂贵计算、useCallback 保护传给 memo 子组件的函数身份、React.memo 保护纯展示组件不随父组件无关状态重渲染。

但必须结合 rules/react/hooks.md 的立场理解本节:规则文档的默认立场是**"不要记忆化"**——只有在(1)值作为 prop 传给 React.memo 包裹的子组件且身份敏感、(2)值是其他 Hook 的依赖、(3)计算被 profile 证实昂贵这三种情况下才加 useMemo/useCallback。过早记忆化"adds noise, hides bugs, and can be slower than the recompute it replaces"(增加噪音、掩盖 bug、甚至比直接重算更慢)。技能示例展示的是"当确有必要时的正确写法",规则文档给出的是"何时才有必要"的判据,二者互补。

代码分割与懒加载

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() 让打包器为每个懒加载模块生成独立 chunk;Suspense 提供加载中的 fallback。示例中两个 Suspense 边界各有侧重:图表用骨架屏 ChartSkeleton 占位,背景动画则 fallback={null}(不占位、直接淡入)。rules/react/patterns.md 补充了边界放置原则:Suspense 边界应贴近数据使用处而非路由根部,多个更窄的边界让已加载内容渐进呈现。

长列表虚拟化

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>
  )
}

实现机制拆解:外层滚动容器固定 600px 高;内层"幻影容器"高度由 getTotalSize() 撑满全部行数以产生正确滚动条;实际只渲染视口内可见行加 overscan: 5 的缓冲行,每行用 transform: translateY(...) 绝对定位到计算出的 start 偏移。estimateSize: () => 100 是行高估计值,行高固定时它就是精确值;行高不固定时 useVirtualizer 会在滚动过程中用实测尺寸校准。DOM 节点数从 markets.length 降为"视口行数 + 10"量级,这是万级列表保持 60fps 的关键手段。

表单处理模式

受控表单与校验

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>
  )
}

要点:FormData/FormErrors 用可选字段精确建模"错误按字段出现";validate() 以"收集全部错误后一次性 setErrors"的方式实现,避免逐字段触发多次渲染;提交时用 e.preventDefault() 接管浏览器默认行为,校验失败早返回。rules/react/patterns.md 给出了受控/非受控的选择判据:当值驱动其他 UI、需要实时校验或格式化时用受控输入(本例);当表单有明确提交步骤时优先"非受控 + form action"(浏览器持有值,React 通过 FormData 读取);多步骤、动态字段数组、跨字段校验等复杂场景则交给 React Hook Form / TanStack Form 这类表单库。frontmatter 中也提到 Zod schema 作为校验工具链选项。

错误边界模式

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>

职责划分清晰:getDerivedStateFromError 负责"把错误写入状态、触发渲染 fallback"(渲染阶段),componentDidCatch 负责副作用(日志上报)——生产环境应把这里的 console.error 替换为真实监控上报。"Try again" 通过 setState({ hasError: false }) 重置边界,给子树一次重试机会。规则文档 rules/react/patterns.md 强调配套使用:每个 Suspense 边界上方都需要一个 Error Boundary<ErrorBoundary><Suspense>...</Suspense></ErrorBoundary>),且错误边界目前必须是类组件(React 19 尚无函数式等价物),或使用 react-error-boundary 等库封装。

动画模式(Framer Motion)

列表动画

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>
  )
}

AnimatePresence 是关键:它让子元素在从树中移除时仍保留一帧以播放 exit 动画。新增项从下方 20px 淡入、移除项向上 20px 淡出,key={market.id} 保证进出场与数据项身份绑定。

模态框动画

// 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>
  )
}

遮罩层与内容层分别定义动画:遮罩只做透明度渐变,内容层叠加缩放(0.9 → 1)与位移(20px → 0)产生"弹入"手感;isOpen && (...) 条件渲染配合 AnimatePresence,使 isOpen 变 false 时 exit 动画得以完整播放后再卸载。

无障碍模式

键盘导航(ARIA combobox)

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>
  )
}

实现要点:方向键移动 activeIndex 并用 Math.min / Math.max 钳制在选项范围内(不会越界);Enter 选择当前项并关闭;Escape 关闭。ARIA 侧声明 role="combobox" + aria-expanded + aria-haspopup="listbox",让屏幕阅读器理解控件类型与展开状态。

焦点管理(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
}

这是一套最小可用的焦点陷阱(focus trap):打开时记录 document.activeElement 并把焦点移到模态框(tabIndex={-1} 使 div 可编程聚焦但不进入 Tab 顺序),关闭时把焦点归还给触发元素——这对键盘与读屏用户体验至关重要。role="dialog" + aria-modal="true" 声明模态语义,Escape 键触发 onClose。注意它与前一节 Framer Motion 的 Modal 示例互补:一个解决"动起来",一个解决"访问得到"。

技能在 ECC 体系中的位置

从仓库结构看,这份技能与周边文件构成一个前端工程闭环:

使用时应结合的前提:技能文档中的示例假定 TypeScript + React 函数组件环境(部分示例隐含 React 18+,动画示例依赖 framer-motion,虚拟化示例依赖 @tanstack/react-virtual);示例数据域统一使用"markets(市场)"作为演示对象。若你的项目是 Next.js App Router,还应叠加规则文档中 Server/Client Component 边界与 "use client" 的约束。

结语

ECC 的 frontend-patterns 技能把"写前端时该遵循什么"沉淀为代理可直接消费的模式库:组件层给出组合、复合组件、render props 三种解耦手段;Hook 层给出 useToggleuseQuery(含防无限循环的 ref 技巧)、useDebounce 三个可复用单元;状态层给出 Context + Reducer 与状态位置决策树;性能层给出记忆化判据、lazy/Suspense 分割与 TanStack Virtual 虚拟化;表单、错误边界、Framer Motion 动画与键盘导航/焦点管理各成一体。配合 rules/react/ 下的规则文件,这些模式既可直接拷入项目使用,也可作为代码评审(如 /react-review、typescript-reviewer agent)的客观标尺——这正是 ECC "research-first" 开发范式在前端工程上的具体落点。

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