React Effects的生命周期

71 阅读9分钟

Effect是一个响应式的,当依赖prop和state改变的时候,执行Effect。一个Effect做两件事情:

  1. 依赖改变则同步执行一些任务
  2. 停止同步

linter检查传入的依赖是否和Effect内部使用到的依赖一致,如果不一致就会报错。这样保证了依赖和同步任务是同步的。

一个Effect的生命周期

React组件的生命周期:

  • mount,组件挂载到屏幕上后调用的函数
  • update,当props或者stae改变后触发重新渲染,重新渲染后调用的函数
  • unmount,组件从屏幕上移除后调用的函数

函数式编程的useEfect是上面3个生命周期的集合,当mount、update和unmount时触发执行Effect。

我们看看Effect连接服务器的例子:

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]);
  // ...
}

Effect的body定义了如何同步:

    const connection = createConnection(serverUrl, roomId);
    connection.connect();

Effect返回的清除函数定义了如何停止同步:

return () => {  
    connection.disconnect();  
};

直觉上你可能觉得React在组件挂载时开始同步在挂载时停止同步,事实上不只是这样。在组件挂载后卸载前期间,组件更新的时候可能或多次执行和停止同步。

为什么同步不只需要一次

假设有一个聊天室组件ChatRoom, 接受prop参数roomId。给roomId赋初始值为"general"。界面显示的是"general"聊天室:

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId /* "general" */ }) {
  // ...
  return <h1>Welcome to the {roomId} room!</h1>;
}

聊天室信息展示在界面后,React将执行Effect开始执行同步任务,建立连接连接"general"房间:

function ChatRoom({ roomId /* "general" */ }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Connects to the "general" room
    connection.connect();
    return () => {
      connection.disconnect(); // Disconnects from the "general" room
    };
  }, [roomId]);
  // ...

然后,用户从下拉框中选择另外一个聊天室(例如"travel")。首先React将会更新UI:

function ChatRoom({ roomId /* "travel" */ }) {
  // ...
  return <h1>Welcome to the {roomId} room!</h1>;
}

界面从"general"聊天室更新到"travel"聊天室。但是上次执行的Effect一直连接的是"general"聊天室。prop roomId改变后,界面显示的和实际连接的不一致。所以你希望React做两件事:

  1. 停止旧的"genarel"聊天室连接
  2. 开始新的"travel"连接

Effect的body定义如何同步,清除函数定义如何停止同步。React接下来要做的是:

  1. 如何以正确的顺序调用它们
  2. 如何根据正确的props和state触发去调用

如何重新同步Effect

聊天室ChatRoom组件的prop roomId改变为"travel"后,React需要重新同步Effec去重新连接不同的聊天室。

React调用Effect上次返回的清除函数停止同步,清除函数断开"general"连接:

function ChatRoom({ roomId /* "general" */ }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Connects to the "general" room
    connection.connect();
    return () => {
      connection.disconnect(); // Disconnects from the "general" room
    };
    // ...

然后,React同步新的连接,建立"travel"聊天室连接:

function ChatRoom({ roomId /* "travel" */ }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Connects to the "travel" room
    connection.connect();
    // ...

现在建立的连接和界面显示的聊天室是一样的。

每一次改变roomId重新渲染以后,Effect将会重新同步。例如用户又把roomId的"travel"改为"music"。React将会再次调用清理函数断开"travel"聊天室的连接,然后执行Effect的body建立新的"music"连接。

最后,当用户转向其他的界面时,卸载ChatRoom组件。现在不需要建立任何连接。React调用上次Effect返回的清除函数断开"music"聊天室连接。

我们从聊天室组件ChatRoom角度看:

  1. roomId初始值为"general",挂载组件
  2. roomId设置为"travel",更新组件
  3. roomId设置为"music",更新组件
  4. 组件卸载

在上面的组件的生命周期的每个阶段,Effect做了不同的事情:

  1. Effect连接"general"聊天室
  2. Effect断开"general"连接,连接"travel"聊天室
  3. Effect断开"travel"连接,连接"music"聊天室
  4. effect断开"music"聊天室

如何触发Effect重新同步

React根据依赖列表的值是否改变,来重新同步。当依赖列表中的元素改变时,Effect就会重新同步。roomId在依赖列表中:

function ChatRoom({ roomId }) { // The roomId prop may change over time
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // This Effect reads roomId 
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]); // So you tell React that this Effect "depends on" roomId
  // ...

下面看看roomId如何使用的:

  1. roomId是一个prop,可能以后值会改变。
  2. Effect使用到了roomId变量,逻辑依赖roomId的值
  3. 所以把roomId写到Effect依赖列表中,当roomId改变时同步

每一次在组件重新渲染后,React将会查看依赖列表。如果依赖列表中的任何一个元素值和上次渲染的值不一样,React将同步Effect。

例如,在初始化渲染时传递的是["general"],后来在下次渲染的值为["travel"],React将会对比"general"和"travel",这两个值是不同的,所以将会重新同步Effect。如果你的组件重新渲染了,但是roomId没有改变,不会同步effect。

每个Effect代表一个单独的同步过程

不要在Effect添加不相关的逻辑,因为这个逻辑也会在Effect一起执行。比如,你想分析用户访问房间的情况,你已经有一个依赖roomId的Effect,所以你可能想把写日志逻辑写到里面:

function ChatRoom({ roomId }) {
  useEffect(() => {
    logVisit(roomId);
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]);
  // ...
}

假如以后你向这个Effect添加了另外一个依赖来重建连接,如这个Effect重新同步,将会调用logVisisit(roomId),即使roomId没有改变。记录访问日志是一个单独的过程。下面写两个单独的Effects:

function ChatRoom({ roomId }) {
  useEffect(() => {
    logVisit(roomId);
  }, [roomId]);

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    // ...
  }, [roomId]);
  // ...
}

每一个Effect代表一个单独和独立的同步过程。删除一个Effect不会影响另一个Effect的逻辑。这样代码看起来很干净。但是不能过于去拆分Effect。如果是一个高内聚的逻辑,拆分开让代码难维护。

Effect对React的响应类型数据做出响应

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId]);
  // ...
}

Effect使用了两个变量serverUrl和roomId,但是只把roomId定义为一个依赖,serverUrl不需要是一个依赖吗?

因为serverUrl不会因为重新渲染而改变值。无论组件重新渲染多少次serverUrl总是相同的值,把它设置为依赖是没有意义的。

Props,state和与它们相关的值都是reactive类型的数据,因为在渲染期间它们被计算并且参与到React数据流中。

如果serverUrl设置为一个state变量,那么它是reactive数据,reactive数据必须包含在依赖列表中:

function ChatRoom({ roomId }) { // Props change over time
  const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // State may change over time

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Your Effect reads props and state
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId, serverUrl]); // So you tell React that this Effect "depends on" on props and state
  // ...
}

把serverUrl放在依赖列表中,确保serverUl改变时Effect同步。

依赖列表为空

如果依赖列表为空,是怎么执行的?

const serverUrl = 'https://localhost:1234';
const roomId = 'general';

function ChatRoom() {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, []); // All dependencies declared
  // ...
}

现在Effect没有使用任何reactive值,因此依赖列表是可以为空的。 如果是空的依赖列表,只组件挂载和组件卸载时执行Effect,组件更新时不执行Effect.

Props和State以及从它们计算的得到值都是响应式的

除了Props和state是响应式的,从它们计算得到的值也是响应式的。所以从它们计算得到的变量也应该添加到依赖列表中。

有一个这样的场景,有一个默认的serverUrl存储在context中,用户通过下拉列表选择连接的serverUrl,如果用户没有通过下拉框选择serverUrl就使用默认的serverUrl:

function ChatRoom({ roomId, selectedServerUrl }) { // roomId is reactive
  const settings = useContext(SettingsContext); // settings is reactive
  const serverUrl = selectedServerUrl ?? settings.defaultServerUrl; // serverUrl is reactive
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // Your Effect reads roomId and serverUrl
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, [roomId, serverUrl]); // So it needs to re-synchronize when either of them changes!
  // ...
}

在上面的例子中,serverUrl不是一个prop或state, 它是在渲染期间计算的常规变量。它在渲染期间被计算,所以在重新渲染期间它可以改变。所以它是响应式的。

props,state和从它们计算的变量都是响应式的,任何响应值在重新渲染期间可以改变,你需要把响应值放入依赖列表中。

React Linter检查依赖项是否在依赖列表中

如果你配置了React linter,linter将检查在Effect中使用的响应值是否在依赖列表中,如果不在将会报错。例如,roomId和serverUrl是响应值,并在Effect中使用,lint会报错:

function ChatRoom({ roomId }) { // roomId is reactive
  const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // serverUrl is reactive

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []); // <-- Something's wrong here!
  //...
}

11.png

React指出代码的错误。roomId和serverUrl可能会改变,但是你忘记当它们改变的时候同步Effect。即使用户在界面选择了不同的值后,仍然连接的是初始的roomId和serverUrl。

修复bug,把roomId和serverUrl放入Effect的依赖列表中:

function ChatRoom({ roomId }) { // roomId is reactive
  const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // serverUrl is reactive

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []); // <-- Something's wrong here!
  //...
}

如何不同步

在前面的例子中,你修复lint检查错误,把roomId和serverUrl放入依赖类表中。

但是,你可以告诉linter这些值不是响应值。也就是说,它们不因为重新渲染而改变。例如,如果serverUrl和roomId不依赖渲染并且总是相同的值,你可以把它移到组件的外面,现在它们不需要是依赖:

const serverUrl = 'https://localhost:1234'; // serverUrl is not reactive
const roomId = 'general'; // roomId is not reactive

function ChatRoom() {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, []); // ✅ All dependencies declared
  // ...
}

你也可以把它们移到Effect里面。它们在渲染期间不被计算,所以它们不是响应式的:

function ChatRoom() {
  useEffect(() => {
    const serverUrl = 'https://localhost:1234'; // serverUrl is not reactive
    const roomId = 'general'; // roomId is not reactive
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, []); // ✅ All dependencies declared
  // ...
}

Effects是响应式代码块。当里面的值改变时则会重新同步逻辑。不像事件处理函数,在交互时仅仅执行一次,当无论什么时候需要同步的时候Effects都会执行。

Effect内部使用到的响应值都要放到依赖列表中,linter检查强制检查依赖列表。

参考文献:

Lifecycle of Reactive Effects