# useScript: 一个优雅的 React 动态脚本加载 Hook
Table of Contents
在 React 开发中,我们经常需要动态加载外部 JavaScript 脚本,比如第三方库、分析工具、支付系统等。传统的脚本加载方式往往需要手动管理 DOM 操作、事件监听和清理逻辑。今天,我们将深入探讨一个自定义 Hook —— useScript
,它能够优雅地处理动态脚本加载的完整生命周期。
为什么需要 useScript?
在 React 中,当我们使用传统的脚本加载方式时,会遇到以下问题:
// 传统方式加载脚本const loadScript = (src: string) => { const script = document.createElement('script') script.src = src script.async = true document.body.appendChild(script)
// 需要手动处理加载状态 script.onload = () => { console.log('Script loaded') }
script.onerror = () => { console.error('Script failed to load') }}
// 在组件中使用useEffect(() => { loadScript('https://example.com/script.js')}, [])
这种方式存在以下问题:
- 状态管理复杂: 需要手动管理加载状态(loading、ready、error)
- 重复加载: 没有检测脚本是否已经存在
- 内存泄漏: 组件卸载时没有正确清理事件监听器
- 类型安全: 缺乏 TypeScript 类型支持
- 代码重复: 每次使用都需要重复相同的逻辑
useScript
就是为了解决这些问题而生的。
useScript 的实现原理
让我们来看看 useScript
的核心实现:
import { useEffect, useRef, useState } from 'react'
type Options = { removeOnUnmount: boolean}
export default function useScript(src: string, options: Options) { const [status, setStatus] = useState<'loading' | 'ready' | 'error' | 'unknown'>( 'loading', ) const optionRef = useRef(options)
useEffect(() => { // 检查是否已经加载了脚本 let script: HTMLScriptElement | null = document.querySelector(`script[src="${src}"]`)
// 检查脚本状态 const domStatus = script?.getAttribute('data-status')
// 如果脚本已经加载,则设置状态 if (domStatus) { setStatus(domStatus as 'loading' | 'ready' | 'error' | 'unknown') return }
if (script === null) { script = document.createElement('script') script.src = src script.async = true script.setAttribute('data-status', 'loading')
document.body.appendChild(script)
const handleScriptLoad = () => { script?.setAttribute('data-status', 'ready') setStatus('ready') removeEventListeners() }
const handleScriptError = () => { script?.setAttribute('data-status', 'error') setStatus('error') removeEventListeners() }
const removeEventListeners = () => { script?.removeEventListener('load', handleScriptLoad) script?.removeEventListener('error', handleScriptError) }
script.addEventListener('load', handleScriptLoad) script.addEventListener('error', handleScriptError)
const removeOnUnmount = optionRef.current.removeOnUnmount
return () => { if (removeOnUnmount) { // 移除脚本 script?.remove() removeEventListeners() } } } else { setStatus('unknown') } }, [src])
return status}
关键特性分析
- 智能检测: 使用
document.querySelector
检查脚本是否已经存在 - 状态持久化: 通过
data-status
属性在 DOM 中保存脚本状态 - 事件管理: 自动处理
load
和error
事件 - 内存管理: 支持在组件卸载时自动清理脚本和事件监听器
- 类型安全: 完整的 TypeScript 类型支持
核心功能详解
1. 脚本检测机制
// 检查是否已经加载了脚本let script: HTMLScriptElement | null = document.querySelector(`script[src="${src}"]`)
// 检查脚本状态const domStatus = script?.getAttribute('data-status')
// 如果脚本已经加载,则设置状态if (domStatus) { setStatus(domStatus as 'loading' | 'ready' | 'error' | 'unknown') return}
这个机制确保:
- 避免重复加载相同的脚本
- 快速恢复已加载脚本的状态
- 支持页面刷新后的状态恢复
2. 动态脚本创建
if (script === null) { script = document.createElement('script') script.src = src script.async = true script.setAttribute('data-status', 'loading')
document.body.appendChild(script)}
创建新脚本时:
- 设置为异步加载,不阻塞页面渲染
- 标记初始状态为
loading
- 添加到
document.body
中
3. 事件监听管理
const handleScriptLoad = () => { script?.setAttribute('data-status', 'ready') setStatus('ready') removeEventListeners()}
const handleScriptError = () => { script?.setAttribute('data-status', 'error') setStatus('error') removeEventListeners()}
const removeEventListeners = () => { script?.removeEventListener('load', handleScriptLoad) script?.removeEventListener('error', handleScriptError)}
事件处理确保:
- 加载成功时更新状态为
ready
- 加载失败时更新状态为
error
- 自动清理事件监听器,避免内存泄漏
4. 清理机制
return () => { if (removeOnUnmount) { // 移除脚本 script?.remove() removeEventListeners() }}
清理逻辑支持:
- 根据
options.removeOnUnmount
决定是否移除脚本 - 自动清理事件监听器
- 防止内存泄漏
使用示例
基本用法
import useScript from './hooks/useScript'
function GoogleAnalytics() { const status = useScript('https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID', { removeOnUnmount: false })
if (status === 'loading') { return <div>Loading Google Analytics...</div> }
if (status === 'error') { return <div>Failed to load Google Analytics</div> }
if (status === 'ready') { return <div>Google Analytics loaded successfully!</div> }
return null}
条件加载
function PaymentForm({ paymentMethod }: { paymentMethod: 'stripe' | 'paypal' }) { const stripeStatus = useScript( 'https://js.stripe.com/v3/', { removeOnUnmount: true } )
const paypalStatus = useScript( 'https://www.paypal.com/sdk/js', { removeOnUnmount: true } )
if (paymentMethod === 'stripe' && stripeStatus === 'ready') { return <StripePaymentForm /> }
if (paymentMethod === 'paypal' && paypalStatus === 'ready') { return <PayPalPaymentForm /> }
return <div>Loading payment form...</div>}
动态加载第三方库
function ChartComponent({ chartType }: { chartType: 'chartjs' | 'd3' }) { const chartjsStatus = useScript( 'https://cdn.jsdelivr.net/npm/chart.js', { removeOnUnmount: true } )
const d3Status = useScript( 'https://d3js.org/d3.v7.min.js', { removeOnUnmount: true } )
useEffect(() => { if (chartType === 'chartjs' && chartjsStatus === 'ready') { // 初始化 Chart.js const ctx = document.getElementById('chart') as HTMLCanvasElement new Chart(ctx, { // 图表配置 }) } }, [chartType, chartjsStatus])
return <canvas id="chart" />}
高级用法
1. 批量脚本加载
function useMultipleScripts(sources: string[]) { const statuses = sources.map(src => useScript(src, { removeOnUnmount: true }) )
const allReady = statuses.every(status => status === 'ready') const hasError = statuses.some(status => status === 'error')
return { statuses, allReady, hasError, isLoading: statuses.some(status => status === 'loading') }}
// 使用示例function AdvancedComponent() { const { allReady, hasError, isLoading } = useMultipleScripts([ 'https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js', 'https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js', 'https://cdn.jsdelivr.net/npm/axios@1.6.0/dist/axios.min.js' ])
if (isLoading) return <div>Loading libraries...</div> if (hasError) return <div>Failed to load some libraries</div> if (allReady) return <div>All libraries loaded!</div>
return null}
2. 脚本加载超时处理
function useScriptWithTimeout(src: string, timeout: number = 10000) { const [status, setStatus] = useState<'loading' | 'ready' | 'error' | 'timeout'>( 'loading', )
useEffect(() => { const timer = setTimeout(() => { setStatus('timeout') }, timeout)
return () => clearTimeout(timer) }, [timeout])
const scriptStatus = useScript(src, { removeOnUnmount: true })
useEffect(() => { if (scriptStatus === 'ready') { setStatus('ready') } else if (scriptStatus === 'error') { setStatus('error') } }, [scriptStatus])
return status}
3. 脚本加载重试机制
function useScriptWithRetry(src: string, maxRetries: number = 3) { const [retryCount, setRetryCount] = useState(0) const [key, setKey] = useState(0)
const status = useScript(`${src}?v=${key}`, { removeOnUnmount: true })
const retry = useCallback(() => { if (retryCount < maxRetries) { setRetryCount((prev) => prev + 1) setKey((prev) => prev + 1) } }, [retryCount, maxRetries])
useEffect(() => { if (status === 'error' && retryCount < maxRetries) { const timer = setTimeout(retry, 1000 * Math.pow(2, retryCount)) // 指数退避 return () => clearTimeout(timer) } }, [status, retryCount, retry, maxRetries])
return { status, retryCount, retry, canRetry: retryCount < maxRetries }}
最佳实践
1. 选择合适的 removeOnUnmount 选项
// 对于全局库,通常设置为 falseconst analyticsStatus = useScript('https://analytics.js', { removeOnUnmount: false })
// 对于特定功能的库,通常设置为 trueconst chartStatus = useScript('https://chart.js', { removeOnUnmount: true })
2. 错误处理和用户反馈
function ScriptLoader({ src, children }: { src: string, children: React.ReactNode }) { const status = useScript(src, { removeOnUnmount: false })
switch (status) { case 'loading': return <div className="loading-spinner">Loading...</div> case 'error': return ( <div className="error-message"> <p>Failed to load required script</p> <button onClick={() => window.location.reload()}>Retry</button> </div> ) case 'ready': return <>{children}</> default: return <div>Unknown status</div> }}
3. 性能优化
// 使用 React.memo 避免不必要的重新渲染const ScriptStatus = React.memo(({ src }: { src: string }) => { const status = useScript(src, { removeOnUnmount: true }) return <span className={`status-${status}`}>{status}</span>})
// 延迟加载非关键脚本function LazyScriptLoader({ src, delay = 2000 }: { src: string, delay?: number }) { const [shouldLoad, setShouldLoad] = useState(false)
useEffect(() => { const timer = setTimeout(() => setShouldLoad(true), delay) return () => clearTimeout(timer) }, [delay])
const status = useScript(shouldLoad ? src : '', { removeOnUnmount: true })
return shouldLoad ? <div>Script status: {status}</div> : null}
常见问题与解决方案
1. 脚本加载顺序问题
// 确保依赖脚本按顺序加载function useOrderedScripts(scripts: string[]) { const [currentIndex, setCurrentIndex] = useState(0) const [statuses, setStatuses] = useState<Record<string, string>>({})
const currentScript = scripts[currentIndex] const currentStatus = useScript(currentScript, { removeOnUnmount: true })
useEffect(() => { if (currentStatus === 'ready' && currentIndex < scripts.length - 1) { setCurrentIndex((prev) => prev + 1) } }, [currentStatus, currentIndex, scripts.length])
useEffect(() => { setStatuses((prev) => ({ ...prev, [currentScript]: currentStatus })) }, [currentScript, currentStatus])
return { statuses, currentIndex, allReady: currentIndex === scripts.length - 1 }}
2. 跨域脚本加载
// 处理跨域脚本加载function useCrossOriginScript(src: string, options: Options & { crossorigin?: string }) { const status = useScript(src, options)
useEffect(() => { const script = document.querySelector(`script[src="${src}"]`) as HTMLScriptElement if (script && options.crossorigin) { script.crossOrigin = options.crossorigin } }, [src, options.crossorigin])
return status}
3. 脚本加载状态同步
// 在多个组件间同步脚本状态function useSharedScript(src: string, options: Options) { const status = useScript(src, options)
// 使用 Context 或状态管理库共享状态 useEffect(() => { // 更新全局状态 window.__scriptStatuses = window.__scriptStatuses || {} window.__scriptStatuses[src] = status }, [src, status])
return status}
总结
useScript
是一个功能强大且设计优雅的 React Hook,它解决了动态脚本加载中的常见问题:
主要优势
- 简化开发: 将复杂的脚本加载逻辑封装在一个 Hook 中
- 状态管理: 提供完整的加载状态反馈
- 内存安全: 自动处理事件监听器的清理
- 类型安全: 完整的 TypeScript 支持
- 性能优化: 避免重复加载和内存泄漏
适用场景
- 第三方库的动态加载
- 分析工具的按需加载
- 支付系统的条件加载
- 图表库的延迟加载
- 国际化资源的动态加载
扩展方向
- 支持 CSS 文件的动态加载
- 添加加载进度指示器
- 实现脚本预加载策略
- 支持 Service Worker 缓存
- 添加脚本版本管理
通过合理使用 useScript
,我们可以构建更加灵活、高效的 React 应用,为用户提供更好的体验。
本文介绍了 useScript
Hook 的完整实现和使用方法。如果你有任何问题或建议,欢迎在评论区讨论!