React: 自定义 Hooks

最后更新:2026-08-26

1. 你将学到



2. 一个表单复用需求的故事

自定义 Hook 的复用模式

100%
flowchart TD
    A[重复逻辑] --> B[提取为 useXxx]
    B --> C[组件A useXxx]
    B --> D[组件B useXxx]
    B --> E[组件C useXxx]
    
    F[useXxx内部] --> G[useState]
    F --> H[useEffect]
    F --> I[useRef]
    F --> J[useContext]
    
    K[三次法则] --> L{逻辑重复次数?}
    L -->|1次| M[不需要提取]
    L -->|2次| N[考虑提取]
    L -->|3次+| O[必须提取 ✅]
    
    style B fill:#e8f5e9,stroke:#2e7d32
    style O fill:#c8e6c9,stroke:#2e7d32

(1) 痛点:同样的逻辑在 5 个页面重复

Bob 要在 5 个不同页面都需要"获取本地存储的用户偏好":

JSX
// 页面 A:设置页
function SettingsPage() {
  const [prefs, setPrefs] = useState(() => {
    const saved = localStorage.getItem('preferences')
    return saved ? JSON.parse(saved) : { theme: 'light', lang: 'zh' }
  })

  useEffect(() => {
    localStorage.setItem('preferences', JSON.stringify(prefs))
  }, [prefs])

  // ... 页面 A 的具体 UI
}

// 页面 B:仪表盘
function Dashboard() {
  const [prefs, setPrefs] = useState(() => {
    const saved = localStorage.getItem('preferences')
    return saved ? JSON.parse(saved) : { theme: 'light', lang: 'zh' }
  })

  useEffect(() => {
    localStorage.setItem('preferences', JSON.stringify(prefs))
  }, [prefs])

  // ... 页面 B 的具体 UI(完全相同的逻辑!)
}
▶ 试一试

问题localStorage 读写逻辑在 5 个页面复制粘贴了 5 次。如果要加一个"监听 storage 事件"的功能,要改 5 个文件。

(2) 自定义 Hook 的解法

JSX
// ---- 自定义 Hook:封装 localStorage 读写 ----
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const saved = localStorage.getItem(key)
      return saved ? JSON.parse(saved) : initialValue
    } catch {
      return initialValue
    }
  })

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value))
    } catch (e) {
      console.error('保存到 localStorage 失败:', e)
    }
  }, [key, value])

  return [value, setValue]
}

// ---- 所有页面统一使用 ----
function SettingsPage() {
  const [prefs, setPrefs] = useLocalStorage('preferences', {
    theme: 'light', lang: 'zh'
  })
  // ... 具体 UI
}

function Dashboard() {
  const [prefs, setPrefs] = useLocalStorage('preferences', {
    theme: 'light', lang: 'zh'
  })
  // ... 具体 UI
}
▶ 试一试

收益:20 行逻辑 → 1 行调用。改逻辑时只改 useLocalStorage 一个文件。



3. 自定义 Hook 的规则

规则 说明 违反后果
use 开头 React 依赖命名识别 Hook linter 不检查规则,条件调用不会报错
只在顶层调用 不在循环/条件/嵌套中调用 Hooks 链表错位,状态混乱
只在函数组件/自定义 Hook 中调用 不在普通函数/类组件中使用 运行时报错:Invalid Hook call
返回数据/函数,不返回 JSX Hook 返回逻辑,组件返回 UI 违反职责分离,难以复用
清理副作用 useEffect return 清理函数 内存泄漏、事件监听累积

(1) 命名规范

JSX
// ✅ 必须以 use 开头
function useOnlineStatus() { }
function useWindowSize() { }
function useDebounce() { }

// ❌ 不以 use 开头 → React 不会检查 Hooks 规则
function onlineStatus() { }
function fetchData() { }
▶ 试一试

(2) 返回值设计

JSX
// 方式 1:返回数组(像 useState)
function useToggle(initial = false) {
  const [value, setValue] = useState(initial)
  const toggle = useCallback(() => setValue(v => !v), [])
  return [value, toggle]  // 数组,解构命名灵活
}

// 方式 2:返回对象(多个返回值时更清晰)
function useUser() {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  return { user, loading, error }  // 对象,属性名明确
}

// 方式 3:返回单个值
function useDocumentTitle(title) {
  useEffect(() => { document.title = title }, [title])
  // 不需要返回值
}
▶ 试一试

(3) 内部可以使用任何 Hooks

JSX
function useData(url) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    let cancelled = false
    setLoading(true)
    
    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error('请求失败')
        return res.json()
      })
      .then(data => { if (!cancelled) { setData(data); setLoading(false) }})
      .catch(err => { if (!cancelled) { setError(err.message); setLoading(false) }})

    return () => { cancelled = true }
  }, [url])

  return { data, loading, error }
}
// 内部用了 useState + useEffect + useCallback(如果需要)
// 自定义 Hook 可以组合任何内置 Hooks
▶ 试一试

4. 6 个实战自定义 Hook

Hook 功能 内部使用的 Hooks 返回值
useToggle 布尔开关切换 useState, useCallback { value, toggle, setTrue, setFalse }
useDebounce 输入防抖 useState, useEffect debouncedValue
useWindowSize 窗口尺寸监听 useState, useEffect { width, height }
useFetch 数据请求三态 useState, useEffect, useCallback { data, loading, error, refetch }
useIntersectionObserver 可见性检测 useState, useEffect isIntersecting
useMediaQuery 响应式断点 useState, useEffect matches

(1) Hook 1:useToggle(开关切换)

JSX
function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue)
  const toggle = useCallback(() => setValue(v => !v), [])
  const setTrue = useCallback(() => setValue(true), [])
  const setFalse = useCallback(() => setValue(false), [])

  return { value, toggle, setTrue, setFalse }
}

// 使用
function ModalExample() {
  const { value: isOpen, setTrue: open, setFalse: close } = useToggle(false)
  return (
    <div>
      <button onClick={open}>打开弹窗</button>
      {isOpen && (
        <div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
          <div style={{ backgroundColor: 'white', padding: '24px', borderRadius: '8px' }}>
            <h2>弹窗标题</h2>
            <p>弹窗内容</p>
            <button onClick={close}>关闭</button>
          </div>
        </div>
      )}
    </div>
  )
}
▶ 试一试

(2) Hook 2:useDebounce(防抖)

JSX
function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value)

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

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

  return debouncedValue
}

// 使用:搜索输入防抖
function SearchBox() {
  const [query, setQuery] = useState('')
  const debouncedQuery = useDebounce(query, 500)

  // 只在 debouncedQuery 变化时才请求(用户停止输入 500ms 后)
  useEffect(() => {
    if (debouncedQuery) {
      fetch('/api/search?q=' + debouncedQuery).then(/* ... */)
    }
  }, [debouncedQuery])

  return <input value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索..." />
}
▶ 试一试

(3) Hook 3:useWindowSize(窗口尺寸)

JSX
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  })

  useEffect(() => {
    let timeoutId

    function handleResize() {
      // 防抖:50ms 内多次 resize 只执行一次
      clearTimeout(timeoutId)
      timeoutId = setTimeout(() => {
        setSize({ width: window.innerWidth, height: window.innerHeight })
      }, 50)
    }

    window.addEventListener('resize', handleResize)
    return () => {
      window.removeEventListener('resize', handleResize)
      clearTimeout(timeoutId)
    }
  }, [])

  return size
}

// 使用
function ResponsiveComponent() {
  const { width } = useWindowSize()
  return (
    <p>
      当前窗口宽度:{width}px
      {width < 768 ? '(移动端视图)' : width < 1024 ? '(平板视图)' : '(桌面视图)'}
    </p>
  )
}
▶ 试一试

(4) Hook 4:useFetch(数据请求)

JSX
function useFetch(url, options = {}) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    let cancelled = false
    setLoading(true)
    setError(null)

    fetch(url, options)
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
        return res.json()
      })
      .then(data => { if (!cancelled) { setData(data); setLoading(false) }})
      .catch(err => { if (!cancelled) { setError(err.message); setLoading(false) }})

    return () => { cancelled = true }
  }, [url])

  // 重新请求
  const refetch = useCallback(() => {
    setLoading(true)
    fetch(url, options)
      .then(res => res.json())
      .then(data => { setData(data); setLoading(false) })
      .catch(err => { setError(err.message); setLoading(false) })
  }, [url])

  return { data, loading, error, refetch }
}

// 使用
function UserProfile({ userId }) {
  const { data: user, loading, error, refetch } = useFetch(`/api/users/${userId}`)

  if (loading) return <p>加载中...</p>
  if (error) return <p>错误:{error} <button onClick={refetch}>重试</button></p>
  return <h1>{user.name}</h1>
}
▶ 试一试

(5) Hook 5:useIntersectionObserver(可见性检测)

JSX
function useIntersectionObserver(ref, options = {}) {
  const [isIntersecting, setIsIntersecting] = useState(false)

  useEffect(() => {
    if (!ref.current) return

    const observer = new IntersectionObserver(([entry]) => {
      setIsIntersecting(entry.isIntersecting)
    }, { threshold: options.threshold || 0.1, ...options })

    observer.observe(ref.current)

    return () => observer.disconnect()
  }, [ref, options.threshold])

  return isIntersecting
}

// 使用:图片懒加载
function LazyImage({ src, alt }) {
  const imgRef = useRef(null)
  const isVisible = useIntersectionObserver(imgRef)
  const [loaded, setLoaded] = useState(false)

  return (
    <div ref={imgRef} style={{ minHeight: '200px', backgroundColor: '#f5f5f5' }}>
      {isVisible && (
        <img
          src={src}
          alt={alt}
          onLoad={() => setLoaded(true)}
          style={{ width: '100%', opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
        />
      )}
    </div>
  )
}
▶ 试一试

(6) Hook 6:useMediaQuery(响应式断点)

JSX
function useMediaQuery(query) {
  const [matches, setMatches] = useState(() => window.matchMedia(query).matches)

  useEffect(() => {
    const mql = window.matchMedia(query)
    function handler(e) { setMatches(e.matches) }
    mql.addEventListener('change', handler)
    return () => mql.removeEventListener('change', handler)
  }, [query])

  return matches
}

// 使用
function ResponsiveLayout() {
  const isMobile = useMediaQuery('(max-width: 768px)')
  const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)')
  const isDesktop = useMediaQuery('(min-width: 1025px)')

  return (
    <div>
      {isMobile && <MobileMenu />}
      {isDesktop && <FullSidebar />}
      <p>
        {isMobile ? '📱 手机' : isTablet ? '📟 平板' : '💻 电脑'}
      </p>
    </div>
  )
}
▶ 试一试

5. 自定义 Hook 复用原则

原则 说明 坏例子 好例子
单一职责 一个 Hook 只做一件事 useUserAndTheme() useUser() + useTheme()
参数灵活 提供合理的默认值 useFetch('/api/data') 硬编码 URL useFetch(url) URL 参数化
返回值完整 返回 loading/error/data 三态 useFetch() 只返回 data useFetch() 返回 { data, loading, error }
资源清理 组件卸载时清理副作用 不清理定时器/事件监听 useEffect return cleanup
可组合 小 Hook 可以组合成大 Hook useComplexData() 100 行 useFetch() + useDebounce() 组合

▶ 示例:组合多个小 Hook

JSX
// ============================================
// 示例:组合多个小 Hook 构建复杂的搜索功能
// ============================================

// 1. 小 Hook:输入防抖
function useDebounce(value, delay) { /* ... */ }

// 2. 小 Hook:数据请求
function useFetch(url) { /* ... */ }

// 3. 小 Hook:本地存储
function useLocalStorage(key, initial) { /* ... */ }

// 4. 组合成大 Hook:搜索
function useSearch(defaultQuery = '') {
  const [query, setQuery] = useState(defaultQuery)
  const [history, setHistory] = useLocalStorage('search_history', [])
  const debouncedQuery = useDebounce(query, 500)
  const { data, loading, error } = useFetch(
    debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
  )

  function search(newQuery) {
    setQuery(newQuery)
    // 保存搜索历史
    if (newQuery.trim()) {
      setHistory(prev => [newQuery, ...prev.filter(h => h !== newQuery)].slice(0, 10))
    }
  }

  return { query, setQuery: search, results: data, loading, error, history }
}
// useSearch 内部组合了 useState + useDebounce + useFetch + useLocalStorage
▶ 试一试

6. 完整示例:图片画廊(组合多个自定义 Hook)

JSX
// ============================================
// 完整示例:图片画廊(组合 3 个自定义 Hook)
// ============================================

import { useState, useEffect, useCallback, useRef } from 'react'

// ---- 自定义 Hook 1:无限滚动 ----
function useInfiniteScroll(callback) {
  const sentinelRef = useRef(null)

  useEffect(() => {
    const sentinel = sentinelRef.current
    if (!sentinel) return

    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) callback()
    }, { threshold: 0.1 })

    observer.observe(sentinel)
    return () => observer.disconnect()
  }, [callback])

  return sentinelRef
}

// ---- 自定义 Hook 2:键盘导航 ----
function useKeyPress(targetKey, callback) {
  useEffect(() => {
    function handler(e) {
      if (e.key === targetKey) callback(e)
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  }, [targetKey, callback])
}

// ---- 自定义 Hook 3:点击外部 ----
function useClickOutside(callback) {
  const ref = useRef(null)

  useEffect(() => {
    function handler(e) {
      if (ref.current && !ref.current.contains(e.target)) {
        callback()
      }
    }
    document.addEventListener('mousedown', handler)
    return () => document.removeEventListener('mousedown', handler)
  }, [callback])

  return ref
}

// ---- 图片画廊组件 ----
function ImageGallery() {
  const [images, setImages] = useState([])
  const [page, setPage] = useState(1)
  const [selected, setSelected] = useState(null)
  const [loading, setLoading] = useState(false)

  // 加载图片
  const loadMore = useCallback(() => {
    if (loading) return
    setLoading(true)
    const newImages = Array.from({ length: 8 }, (_, i) => ({
      id: images.length + i + 1,
      url: `https://picsum.photos/seed/${images.length + i + 1}/300/200`,
      title: `图片 ${images.length + i + 1}`
    }))
    setTimeout(() => {
      setImages(prev => [...prev, ...newImages])
      setPage(p => p + 1)
      setLoading(false)
    }, 800)
  }, [loading, images.length])

  // 首次加载
  useEffect(() => { loadMore() }, [])

  // 无限滚动加载更多
  const sentinelRef = useInfiniteScroll(loadMore)

  // 点击外部关闭预览
  const previewRef = useClickOutside(() => setSelected(null))

  // Escape 键关闭预览
  useKeyPress('Escape', () => setSelected(null))
  // 左右箭头键切换图片
  useKeyPress('ArrowLeft', () => {
    if (selected) {
      const idx = images.findIndex(i => i.id === selected.id)
      if (idx > 0) setSelected(images[idx - 1])
    }
  })
  useKeyPress('ArrowRight', () => {
    if (selected) {
      const idx = images.findIndex(i => i.id === selected.id)
      if (idx < images.length - 1) setSelected(images[idx + 1])
    }
  })

  return (
    <div style={{ maxWidth: '900px', margin: '0 auto' }}>
      <h2>🖼️ 图片画廊</h2>
      <p style={{ color: '#666', fontSize: '13px' }}>已加载 {images.length} 张 | 键盘:← 上一张 → 下一张 Escape 关闭</p>

      {/* 图片网格 */}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '12px' }}>
        {images.map(img => (
          <div key={img.id} onClick={() => setSelected(img)}
            style={{ cursor: 'pointer', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
            <img src={img.url} alt={img.title}
              style={{ width: '100%', height: '150px', objectFit: 'cover', transition: 'transform 0.3s' }}
              onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.05)'}
              onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'} />
            <p style={{ padding: '8px', margin: 0, fontSize: '13px', textAlign: 'center' }}>{img.title}</p>
          </div>
        ))}
      </div>

      {/* 加载更多触发器 */}
      <div ref={sentinelRef} style={{ textAlign: 'center', padding: '20px' }}>
        {loading ? <p>⏳ 加载中...</p> : <p style={{ color: '#999' }}>↓ 滚动加载更多</p>}
      </div>

      {/* 图片预览(点击外部关闭) */}
      {selected && (
        <div style={{
          position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
          backgroundColor: 'rgba(0,0,0,0.8)',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          zIndex: 1000
        }}>
          <div ref={previewRef} style={{ maxWidth: '80vw', maxHeight: '80vh' }}>
            <img src={selected.url.replace('300/200', '600/400')} alt={selected.title}
              style={{ maxWidth: '100%', maxHeight: '80vh', borderRadius: '8px' }} />
            <p style={{ color: 'white', textAlign: 'center', marginTop: '12px' }}>
              {selected.title}(点击外部关闭)
            </p>
          </div>
        </div>
      )}
    </div>
  )
}

预期输出:图片画廊,首次加载 8 张图片,滚动到底部自动加载更多,点击图片可预览(支持键盘导航),点击预览外部区域关闭。


▶ 示例 2:useClipboard Hook——复制到剪贴板

JSX
function useClipboard() {
  const [copied, setCopied] = useState(false)

  async function copy(text) {
    try {
      await navigator.clipboard.writeText(text)
      setCopied(true)
      setTimeout(() => setCopied(false), 2000)
    } catch {
      setCopied(false)
    }
  }

  return { copied, copy }
}

function CopyButton({ text }) {
  const { copied, copy } = useClipboard()
  return (
    <button onClick={() => copy(text)}
      style={{ padding: '4px 12px', cursor: 'pointer', background: copied ? '#52c41a' : '#f0f0f0', color: copied ? 'white' : '#333', border: 'none', borderRadius: 4 }}>
      {copied ? 'Copied!' : 'Copy'}
    </button>
  )
}
▶ 试一试

▶ 示例 3:useOnlineStatus Hook——网络状态检测

JSX
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine)

  useEffect(() => {
    function handleOnline() { setIsOnline(true) }
    function handleOffline() { setIsOnline(false) }
    window.addEventListener('online', handleOnline)
    window.addEventListener('offline', handleOffline)
    return () => {
      window.removeEventListener('online', handleOnline)
      window.removeEventListener('offline', handleOffline)
    }
  }, [])

  return isOnline
}

function StatusBar() {
  const isOnline = useOnlineStatus()
  return (
    <div style={{ padding: '8px 16px', background: isOnline ? '#f6ffed' : '#fff2f0', borderBottom: `1px solid ${isOnline ? '#b7eb8f' : '#ffccc7'}` }}>
      {isOnline ? '🟢 Online' : '🔴 Offline — changes will sync when reconnected'}
    </div>
  )
}
▶ 试一试

▶ 示例 4:useLocalStorage Hook——持久化状态

JSX
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const saved = localStorage.getItem(key)
      return saved !== null ? JSON.parse(saved) : initialValue
    } catch { return initialValue }
  })

  useEffect(() => {
    try { localStorage.setItem(key, JSON.stringify(value)) }
    catch (e) { console.error('localStorage save failed:', e) }
  }, [key, value])

  return [value, setValue]
}

function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light')
  return (
    <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
      style={{
        padding: '8px 16px', cursor: 'pointer', borderRadius: 4, border: 'none',
        background: theme === 'dark' ? '#333' : '#f0f0f0',
        color: theme === 'dark' ? 'white' : '#333',
      }}>
      {theme === 'dark' ? '🌙 Dark' : '☀️ Light'} (saved)
    </button>
  )
}
▶ 试一试

▶ 示例 5:useCountdown Hook——倒计时

JSX
function useCountdown(seconds, autostart = false) {
  const [timeLeft, setTimeLeft] = useState(seconds)
  const [isRunning, setIsRunning] = useState(autostart)

  useEffect(() => {
    if (!isRunning || timeLeft <= 0) return
    const timer = setInterval(() => {
      setTimeLeft(prev => {
        if (prev <= 1) { setIsRunning(false); return 0 }
        return prev - 1
      })
    }, 1000)
    return () => clearInterval(timer)
  }, [isRunning, timeLeft])

  function start() { setTimeLeft(seconds); setIsRunning(true) }
  function pause() { setIsRunning(false) }
  function resume() { setIsRunning(true) }

  const minutes = Math.floor(timeLeft / 60)
  const secs = timeLeft % 60
  const formatted = `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`

  return { timeLeft, formatted, isRunning, start, pause, resume }
}

function CountdownTimer() {
  const { formatted, isRunning, start, pause, resume } = useCountdown(300)
  return (
    <div style={{ textAlign: 'center', padding: 20 }}>
      <p style={{ fontSize: 48, fontFamily: 'monospace', margin: '0 0 16px' }}>{formatted}</p>
      <div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
        {!isRunning ? <button onClick={start} style={{ padding: '8px 16px', cursor: 'pointer' }}>Start</button>
          : <button onClick={pause} style={{ padding: '8px 16px', cursor: 'pointer' }}>Pause</button>}
        {isRunning || <button onClick={resume} style={{ padding: '8px 16px', cursor: 'pointer' }}>Resume</button>}
      </div>
    </div>
  )
}
▶ 试一试

❓ 常见问题

Q 自定义 Hook 里可以使用 useEffect 吗?
A 可以,而且很常见。自定义 Hook 可以使用任何内置 Hook(useState、useEffect、useRef、useContext 等)。例如 useFetch Hook 内部用 useState 管理数据/加载/错误状态,用 useEffect 在挂载时请求数据,用 useRef 存储 AbortController。这正是自定义 Hook 的价值——把多个 Hook 组合成一个可复用的逻辑单元。

📖 小节


📝 作业

  1. 基础题(难度⭐):创建一个 useCounter Hook,返回 { count, increment, decrement, reset },初始值可配置。
  2. 进阶题(难度⭐⭐):创建一个 useClipboard Hook,封装 navigator.clipboard API,返回 { copied, copy(text) },支持复制文本到剪贴板。
  3. 挑战题(难度⭐⭐⭐):创建一个 useWebSocket Hook,封装 WebSocket 连接,返回 { data, isConnected, send(message), reconnect() },支持自动重连和心跳检测。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏