跳到主要内容

你可能不需要 Effect

  • Effect 是 React 范式中的一个逃脱方案。
  • 它们让你可以 “逃出” React 并使组件和一些外部系统同步,比如非 React 组件网络浏览器 DOM
  • 如果没有涉及到外部系统(例如,你想根据 props 或 state 的变化来更新一个组件的 state),你就不应该使用 Effect。
  • 移除不必要的 Effect 可以让你的代码更容易理解,运行得更快,并且更少出错。

如何移除不必要的 Effect

有两种不必使用 Effect 的常见情况:

  1. 你不必使用 Effect 来转换渲染所需的数据

    • 例如,你想在展示一个列表前先做筛选。
    • 你的直觉可能是写一个当列表变化时更新 state 变量的 Effect 。然而,这是低效的。
    • 当你更新这个 state 时,React 首先会调用你的组件函数来计算应该显示在屏幕上的内容。
    • 然后 React 会把这些变化“提交”到 DOM 中来更新屏幕。
    • 然后 React 会执行你的 Effect
    • 如果你的 Effect 也立即更新了这个 state,就会重新执行整个流程。
    • 为了避免不必要的渲染流程,应在你的组件顶层转换数据。
    • 这些代码会在你的 propsstate 变化时自动重新执行。
  2. 你不必使用 Effect 来处理用户事件

    • 例如,你想在用户购买一个产品时发送一个 /api/buyPOST 请求并展示一个提示。
    • 在这个购买按钮的点击事件处理函数中,你确切地知道会发生什么。
    • 但是当一个 Effect 运行时,你却不知道用户做了什么(例如,点击了哪个按钮)。
    • 这就是为什么你通常应该在相应的事件处理函数中处理用户事件。

的确 可以使用 Effect 来和外部系统 同步 。

  • 例如,你可以写一个 Effect 来保持一个 jQuery 的组件和 React state 之间的同步。
  • 你也可以使用 Effect 来获取数据:
  • 例如,你可以同步当前的查询搜索和查询结果。
  • 请记住,比起直接在你的组件中写 Effect ,现代 框架 提供了更加高效的,内置的数据获取机制。

为了帮助你获得正确的直觉,让我们来看一些常见的实例吧!

根据 props 或 state 来更新 state

错误写法
function Form() {
const [firstName, setFirstName] = useState('Taylor')
const [lastName, setLastName] = useState('Swift')

// 🔴 避免:多余的 state 和不必要的 Effect
const [fullName, setFullName] = useState('')
useEffect(() => {
setFullName(firstName + ' ' + lastName)
}, [firstName, lastName])
// ...
}
正确写法
function Form() {
const [firstName, setFirstName] = useState('Taylor')
const [lastName, setLastName] = useState('Swift')
// ✅ 非常好:在渲染期间进行计算
const fullName = firstName + ' ' + lastName
// ...
}

如果一个值可以基于现有的 props 或 state 计算得出,不要把它作为一个 state,而是在渲染期间直接计算这个值。这将使你的代码更快(避免了多余的 “级联” 更新)、更简洁(移除了一些代码)以及更少出错(避免了一些因为不同的 state 变量之间没有正确同步而导致的问题)。

缓存昂贵的计算

这个组件使用它接收到的 props 中的 filter 对另一个 prop todos 进行筛选,计算得出 visibleTodos。你的直觉可能是把结果存到一个 state 中,并在 Effect 中更新它:

错误写法
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('')

// 🔴 避免:多余的 state 和不必要的 Effect
const [visibleTodos, setVisibleTodos] = useState([])
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter))
}, [todos, filter])

// ...
}

就像之前的例子一样,这既没有必要,也很低效。首先,移除 state 和 Effect:

正确写法
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('')
// ✅ 如果 getFilteredTodos() 的耗时不长,这样写就可以了。
const visibleTodos = getFilteredTodos(todos, filter)
// ...
}

一般来说,这段代码没有问题!但是,getFilteredTodos() 的耗时可能会很长,或者你有很多 todos。这些情况下,当 newTodo 这样不相关的 state 变量变化时,你并不想重新执行 getFilteredTodos()

你可以使用 useMemo Hook 缓存(或者说 记忆(memoize))一个昂贵的计算。

更优写法,对数据进行缓存
import { useMemo, useState } from 'react'

function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('')
const visibleTodos = useMemo(() => {
// ✅ 除非 todos 或 filter 发生变化,否则不会重新执行
return getFilteredTodos(todos, filter)
}, [todos, filter])
// ...
}
或者写成一行:
import { useMemo, useState } from 'react'

function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('')
// ✅ 除非 todos 或 filter 发生变化,否则不会重新执行 getFilteredTodos()
const visibleTodos = useMemo(
() => getFilteredTodos(todos, filter),
[todos, filter]
)
// ...
}
  • 这会告诉 React,除非 todos 或 filter 发生变化,否则不要重新执行传入的函数。
  • React 会在初次渲染的时候记住 getFilteredTodos() 的返回值。
  • 在下一次渲染中,它会检查 todos 或 filter 是否发生了变化。
  • 如果它们跟上次渲染时一样,useMemo 会直接返回它最后保存的结果。
  • 如果不一样,React 将再次调用传入的函数(并保存它的结果)。

你传入 useMemo 的函数会在渲染期间执行,所以它仅适用于 纯函数 场景。

初始化应用

有些逻辑只需要在应用加载时执行一次。

错误写法
function App() {
// 🔴 避免:把只需要执行一次的逻辑放在 Effect 中
useEffect(() => {
loadDataFromLocalStorage()
checkAuthToken()
}, [])
// ...
}
  • 然后,你很快就会发现它在 开发环境会执行两次。

  • 这会导致一些问题——例如,它可能使身份验证 token 无效,因为该函数不是为被调用两次而设计的。

  • 一般来说,当组件重新挂载时应该具有一致性。包括你的顶层 App 组件。

  • 尽管在实际的生产环境中它可能永远不会被重新挂载,但在所有组件中遵循相同的约束条件可以更容易地移动和复用代码。

  • 如果某些逻辑必须在 每次应用加载时执行一次

  • 而不是在 每次组件挂载时执行一次

  • 可以添加一个顶层变量来记录它是否已经执行过了:

正确写法
let didInit = false

function App() {
useEffect(() => {
if (!didInit) {
didInit = true
// ✅ 只在每次应用加载时执行一次
loadDataFromLocalStorage()
checkAuthToken()
}
}, [])
// ...
}
你也可以在模块初始化和应用渲染之前执行它:
if (typeof window !== 'undefined') {
// 检测我们是否在浏览器环境
// ✅ 只在每次应用加载时执行一次
checkAuthToken()
loadDataFromLocalStorage()
}

function App() {
// ...
}
  • 顶层代码会在组件被导入时执行一次——即使它最终并没有被渲染。
  • 为了避免在导入任意组件时降低性能或产生意外行为,请不要过度使用这种方法。
  • 将应用级别的初始化逻辑保留在像 App.js 这样的根组件模块或你的应用入口中。

通知父组件有关 state 变化的信息

错误写法
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false)

// 🔴 避免:onChange 处理函数执行的时间太晚了
useEffect(() => {
onChange(isOn)
}, [isOn, onChange])

function handleClick() {
setIsOn(!isOn)
}

function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
setIsOn(true)
} else {
setIsOn(false)
}
}

// ...
}
正确写法
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false)

function updateToggle(nextIsOn) {
// ✅ 非常好:在触发它们的事件中执行所有更新
setIsOn(nextIsOn)
onChange(nextIsOn)
}

function handleClick() {
updateToggle(!isOn)
}

function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
updateToggle(true)
} else {
updateToggle(false)
}
}

// ...
}

你也可以完全移除该 state,并从父组件中接收 isOn:

// ✅ 也很好:该组件完全由它的父组件控制
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn)
}

function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
onChange(true)
} else {
onChange(false)
}
}

// ...
}

将数据传递给父组件

Child 组件获取了一些数据并在 Effect 中传递给 Parent 组件:

错误写法
function Parent() {
const [data, setData] = useState(null)
// ...
return <Child onFetched={setData} />
}

function Child({ onFetched }) {
const data = useSomeAPI()
// 🔴 避免:在 Effect 中传递数据给父组件
useEffect(() => {
if (data) {
onFetched(data)
}
}, [onFetched, data])
// ...
}
正确写法,在父组件获得数据,传递给子组件渲染
function Parent() {
const data = useSomeAPI()
// ...
// ✅ 非常好:向子组件传递数据
return <Child data={data} />
}

function Child({ data }) {
// ...
}

订阅外部 store

  • 有时候,你的组件可能需要订阅 React state 之外的一些数据。
  • 这些数据可能来自第三方库或内置浏览器 API。由于这些数据可能在 React 无法感知的情况下发变化,你需要在你的组件中手动订阅它们。
  • 这经常使用 Effect 来实现,例如:
function useOnlineStatus() {
// 不理想:在 Effect 中手动订阅 store
const [isOnline, setIsOnline] = useState(true)
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine)
}

updateState()

window.addEventListener('online', updateState)
window.addEventListener('offline', updateState)
return () => {
window.removeEventListener('online', updateState)
window.removeEventListener('offline', updateState)
}
}, [])
return isOnline
}

function ChatIndicator() {
const isOnline = useOnlineStatus()
// ...
}
  • 这个组件订阅了一个外部的 store 数据(在这里,是浏览器的 navigator.onLine API)。
  • 由于这个 API 在服务端不存在(因此不能用于初始的 HTML),因此 state 最初被设置为 true。
  • 每当浏览器 store 中的值发生变化时,组件都会更新它的 state。

尽管通常可以使用 Effect 来实现此功能,但 React 为此针对性地提供了一个 Hook 用于订阅外部 store。 删除 Effect 并将其替换为调用 useSyncExternalStore

function subscribe(callback) {
window.addEventListener('online', callback)
window.addEventListener('offline', callback)
return () => {
window.removeEventListener('online', callback)
window.removeEventListener('offline', callback)
}
}

function useOnlineStatus() {
// ✅ 非常好:用内置的 Hook 订阅外部 store
return useSyncExternalStore(
subscribe, // 只要传递的是同一个函数,React 不会重新订阅
() => navigator.onLine, // 如何在客户端获取值
() => true // 如何在服务端获取值
)
}

function ChatIndicator() {
const isOnline = useOnlineStatus()
// ...
}

获取数据

许多应用使用 Effect 来发起数据获取请求。像这样在 Effect 中写一个数据获取请求是相当常见的:

避免:没有清除逻辑的获取数据
function SearchResults({ query }) {
const [results, setResults] = useState([])
const [page, setPage] = useState(1)

useEffect(() => {
// 🔴 避免:没有清除逻辑的获取数据
fetchResults(query, page).then((json) => {
setResults(json)
})
}, [query, page])

function handleNextPageClick() {
setPage(page + 1)
}
// ...
}

你 不需要 把这个数据获取逻辑迁移到一个事件处理函数中。

这可能看起来与之前需要将逻辑放入事件处理函数中的示例相矛盾!但是,考虑到这并不是 键入事件,这是在这里获取数据的主要原因。搜索输入框的值经常从 URL 中预填充,用户可以在不关心输入框的情况下导航到后退和前进页面。

page 和 query 的来源其实并不重要。只要该组件可见,你就需要通过当前 page 和 query 的值,保持 results 和网络数据的 同步。这就是为什么这里是一个 Effect 的原因。

然而,上面的代码有一个问题。假设你快速地输入 “hello”。那么 query 会从 “h” 变成 “he”,“hel”,“hell” 最后是 “hello”。这会触发一连串不同的数据获取请求,但无法保证对应的返回顺序。例如,“hell” 的响应可能在 “hello” 的响应 之后 返回。由于它的 setResults() 是在最后被调用的,你将会显示错误的搜索结果。这种情况被称为 “竞态条件”:两个不同的请求 “相互竞争”,并以与你预期不符的顺序返回。

为了 修复 这个问题,你需要添加一个 清理函数 来忽略较早的返回结果:

function SearchResults({ query }) {
const [results, setResults] = useState([])
const [page, setPage] = useState(1)
useEffect(() => {
let ignore = false
fetchResults(query, page).then((json) => {
if (!ignore) {
setResults(json)
}
})
return () => {
ignore = true
}
}, [query, page])

function handleNextPageClick() {
setPage(page + 1)
}
// ...
}

这确保了当你在 Effect 中获取数据时,除了最后一次请求的所有返回结果都将被忽略。

处理竞态条件并不是实现数据获取的唯一难点。你可能还需要考虑缓存响应结果(使用户点击后退按钮时可以立即看到先前的屏幕内容),如何在服务端获取数据(使服务端初始渲染的 HTML 中包含获取到的内容而不是加载动画),以及如何避免网络瀑布(使子组件不必等待每个父组件的数据获取完毕后才开始获取数据)。

这些问题适用于任何 UI 库,而不仅仅是 React。解决这些问题并不容易,这也是为什么现代 框架 提供了比在 Effect 中获取数据更有效的内置数据获取机制的原因。

如果你不使用框架(也不想开发自己的框架),但希望使从 Effect 中获取数据更符合人类直觉,请考虑像这个例子一样,

将获取逻辑提取到一个自定义 Hook 中:

function SearchResults({ query }) {
const [page, setPage] = useState(1)
const params = new URLSearchParams({ query, page })
const results = useData(`/api/search?${params}`)

function handleNextPageClick() {
setPage(page + 1)
}
// ...
}

function useData(url) {
const [data, setData] = useState(null)
useEffect(() => {
let ignore = false
fetch(url)
.then((response) => response.json())
.then((json) => {
if (!ignore) {
setData(json)
}
})
return () => {
ignore = true
}
}, [url])
return data
}
  • 你可能还想添加一些错误处理逻辑以及跟踪内容是否处于加载中。

  • 你可以自己编写这样的 Hook,也可以使用 React 生态中已经存在的许多解决方案。

  • 虽然仅仅使用自定义 Hook 不如使用框架内置的数据获取机制高效,但将数据获取逻辑移动到自定义 Hook 中将使后续采用高效的数据获取策略更加容易。

  • 一般来说,当你不得不编写 Effect 时,请留意是否可以将某段功能提取到专门的内置 API 或一个更具声明性的自定义 Hook 中,比如上面的 useData。

  • 你会发现组件中的原始 useEffect 调用越少,维护应用将变得更加容易

摘要

  • 如果你可以在渲染期间计算某些内容,则不需要使用 Effect。
  • 想要缓存昂贵的计算,请使用 useMemo 而不是 useEffect。
  • 想要重置整个组件树的 state,请传入不同的 key。
  • 想要在 prop 变化时重置某些特定的 state,请在渲染期间处理。
  • 组件 显示 时就需要执行的代码应该放在 Effect 中,否则应该放在事件处理函数中。
  • 如果你需要更新多个组件的 state,最好在单个事件处理函数中处理。
  • 当你尝试在不同组件中同步 state 变量时,请考虑状态提升。
  • 你可以使用 Effect 获取数据,但你需要实现清除逻辑以避免竞态条件。