在 React 组件中有两种逻辑:

  • 渲染代码(Rendering code):使用 propsstate 计算出一段 JSX。渲染代码需要是纯函数,不能有额外的操作。
  • 事件处理(Event handlers):组件内部包含的函数,用于响应用户操作,可以包含副作用。

但是并不是所有逻辑都可以在 Event handlers 中解决,比如一个聊天室,接受消息的逻辑并没有用户交互触发,这个时候就需要 effect。所以 Effect 用于处理由渲染本身而不是由特定事件引起的副作用。

可能不需要用 Effect

以下是一些 Effect 的替代场景。

1、通过 props 和 state 更新视图

可以在渲染代码中计算并使用,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ...
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');

// 🔴 避免额外的state 参数
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);

// ✅ 直接使用计算的变量
const fullName = firstName + ' ' + lastName;

如果是比较复杂的计算获得的变量,可以使用 memo 来进行优化:

1
2
3
4
5
6
7
// ✅ 如果 getFilteredTodos() 计算量不大
const visibleTodos = getFilteredTodos(todos, filter);

const visibleTodos = useMemo(() => {
// ✅ 如果计算量较大,仅 todos 和 filter 变化了才会重新计算
  return getFilteredTodos(todos, filter);
}, [todos, filter]);

2、props 变化时重置 state

例如 userId 变化时清空 comment

1
2
3
4
5
6
7
8
9
export default function Profile({ userId }) {
  const [comment, setComment] = useState('');

  // 🔴 避免在 effect 里面重置监听 props 变化重置 state
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

可以通过父组件传递不同的 key prop 来实现 state 重置的效果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId} 
      key={userId}
    />
  );
}

export default function Profile({ userId }) {
  // ✅ 父组件会对子组件进行重新挂载
  const [comment, setComment] = useState('');
}

3、props 变化时修改部分 state

这种场景也很常见,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // 🔴 items 变化时不需要使用 effect 来取消选中,会触发重新渲染
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

但是我们完全可以在渲染代码中实现这部分逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // Better: 通过判断上一次的 props 和这一次不一样
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

当在渲染期间更新组件时,React 会丢弃之前的 JSX 并立即重新渲染。为了避免非常缓慢的级联重试(cascading retries),React 只允许在渲染期间更新相同组件的状态。如果想在渲染期间更新另一个组件的状态,则会抛出错误。像 items !== prevItems 这样的条件判断可以有效地避免循环。这样设置 state 是 OK 的,但其他副作用(如更改 DOM 或设置定时器)应保留在事件处理程序或 Effect 中,以保证我们的组件的可预测性。

自然还是有更好的方法来解决这个场景,例如:

1
2
3
4
5
6
7
function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ 在渲染期间计算选中态
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

4、初始化 APP

一些逻辑只需要运行一次,例如初始化一个程序,我们可能会这样写:

1
2
3
4
5
6
7
8
function App() {
  // 🔴 这里的逻辑只应该运行一次
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}

但是在开发模式下这段逻辑会跑两次,对于某些程序来说可能会有问题,所以推荐下面的解决方式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
let didInit = false;

function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      // ✅ 保证只会运行一次
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  // ...
}

或者像下面这样:

1
2
3
4
5
6
7
8
9
if (typeof window !== 'undefined') { 
   // ✅ 仅会运行一次
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

5、解决竞态问题

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    // 🔴 下面的逻辑没有 cleanup function
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);

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

这段代码为什么没有放到事件处理函数中呢,因为依赖的字段并不一定是来自用户交互(输入、点击),比如 query 参数来自于页面 url,就不能在事件处理函数中进行响应了。

想象一个场景,query 参数迅速地从 1 变成了 11,然而响应却不一定会按照顺序,如果后面的请求先返回了结果,页面显示就会有问题,即竞态问题(race condition)。

怎么解决这个问题呢,可以看下面的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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);
  }
  // ...
}

竞态问题并不是数据请求现存的唯一问题,还有比如怎么缓存返回数据(以便用户可以单击返回并立即查看上一页内容而不是白屏)、如何在服务器上获取数据(以便初始服务器呈现的 HTML 包含内容而不是白屏)、如何避免瀑布问题(需要获取数据的子组件就不需要等待每个父组件完成数据获取)。这些问题并不是 React 特有的,而是所有 UI 库的通病,所以可以使用一些推荐的数据请求方案来规避这些问题。

参考资料