useEffect完整指南(二)

1,427 阅读27分钟

我建议你应该带着问题来看这篇文章我的老伙计

每一次渲染都有它自己的Props and State

在讨论effect之前,我们需要先讨论一下渲染(render)

我们来看一个计数器组件Counter:

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p> /* 注意这一行 */
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

count 会监听状态的变化并且自动更新吗?这么想可能是学习react时第一直觉,但它并不是精确的心智模型 (有空会另出一篇文章 )

count仅是一个数字而已。他不是神奇的“watcher”“proxy”,或者其他任何东西,他就是一个普通的数字像下面这个一样:

const count = 0
// ...
<p>You clicked {count} times</p>
// ...

我们组件第一次渲染的时候,从useState()拿到conut的初始值0。当我们调用setCount(1) ,

react会再次渲染组件,这次的count的是1

// 在第一次渲染期间
function Counter() {
  const count = 0; // 由useState()返回
  // ...
  <p>You clicked {count} times</p>
  // ...
}

// 单击后,再次调用我们的函数
function Counter() {
  const count = 1; // 由useState()返回
  // ...
  <p>You clicked {count} times</p>
  // ...
}

// 再次单击,再次调用我们的函数
function Counter() {
  const count = 2; // 由useState()返回
  // ...
  <p>You clicked {count} times</p>
  // ...
}

在我们更新状态,react会从重新渲染组件,每次渲染都会拿到独立的count状态,这个状态在函数中是一个常量

所以下面的这行代码没有做任何特殊的数据绑定:

<p>You clicked {count} times</p>

仅仅只是在渲染输出中插入count这个数字,这个数字由react提供,当setCount的时候,react会带着一个不同的count再次调用组件,然后,react会更新DOM以保持和渲染输出一致。

这里关键的点在于任意一次渲染中的count常量都不会随着时间改变渲染输出会变是因为我们的组件被一次次调用,而每一次调用引起的渲染中,它包含的count值独立于其他渲染

每次渲染都有它自己的Effects

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

抛出一个问题给你:effect是如何读取最新的count状态值的呢?

也许,是某个“proxy”“watcher”机制使得count能够在effect函数内更新?也或许count是一个可变的值?

react会在我们组件内部修改它以使得我们的effect函数总能拿到最新的值?

都不是

我们已经知道count是某个特定渲染中的常量。事件处理函数“看到”的是属于它那次特定渲染中的count状态值。对于effects也同样如此:

并不是count的值在“不变”的effect中发生了改变,而是effect 函数本身在每一次渲染中都不相同。

每一个effect版本“看到”的count值都来自于它属于的那次渲染:

// 在第一次渲染期间
function Counter() {
  // ...
  useEffect(
    // 从第一个渲染起的效果功能
    () => {
      document.title = `You clicked ${0} times`;
    }
  );
  // ...
}

// 单击后,再次调用我们的函数
function Counter() {
  // ...
  useEffect(
    // 二次渲染的效果功能
    () => {
      document.title = `You clicked ${1} times`;
    }
  );
  // ...
}

// 再次单击,再次调用我们的函数
function Counter() {
  // ...
  useEffect(
    // 第三渲染的效果功能
    () => {
      document.title = `You clicked ${2} times`;
    }
  );
  // ..
}

react会记住你提供的effect函数,并且会在每次更改作用于DOM并让浏览器绘制屏幕后去调用它

所以虽然我们说的是一个 effect(这里指更新documenttitle),但其实每次渲染都是一个不同的函数 — 并且每个effect函数“看到”的props和state都来自于它属于的那次特定渲染。

概念上,你可以想象effects是渲染结果的一部分。

严格地说,它们并不是(为了允许Hook的组合(这里值按顺序执行hooks)并且不引入笨拙的语法或者运行时)。但是在构建的心智模型上,effect函数属于某个特定的渲染,就像事件处理函数一样。


为了确保我们已经有了扎实的理解,我们再回顾一下第一次的渲染过程:

  • react :给我状态为0的ui
  • 你的组件:给你需要渲染的内容:<p>You clicked 0 times</p>
  • 你的组件:记得在渲染完之后调用这个effect:() => { document.title = 'You clicked 0 times' }
  • react:没问题开始更新ui,浏览器!我要给dom添加一些东西
  • 浏览器:我已经把它绘制到屏幕上了
  • react:好的,我现在开始运行我的effect

现在我们回顾一下点击之后发生了什么:

  • 你的组件:喂浏览器,把我的状态设置为1
  • react:给我状态为1 时候的ui
  • 你的组件:给你需要渲染的内容: <p>You clicked 1 times</p>
  • 你的组件:记得在渲染完之后调用这个effect() => { document.title = 'You clicked 1 times' }
  • react:没问题。开始更行ui,喂,浏览器我渲染了dom
  • 浏览器:绘制成功
  • react:开始effect

每次渲染都有它自己的...all!

我们现在知道effects会在每次渲染后运行,并且概念上它是组件输出的一部分,可以“看到”属于某次特定渲染的propsstate

思考下面的代码:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      console.log(`You clicked ${count} times`);
    }, 3000);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

如果我点击了很多次并且在effect里设置了延时,打印出来的结果会是什么呢?


你可能会认为这是一个很绕的题并且结果是反直觉的。完全错了!我们看到的就是顺序的打印输出 — 每一个都属于某次特定的渲染,因此有它该有的count值。你可以自己试试

// 点击五次button,浏览器会输出
You clicked 0 times
You clicked 1 times
You clicked 2 times
You clicked 3 times
You clicked 4 times
You clicked 5 times

你可能会想:“它当然应该是这样的。否则还会怎么样呢?”

不过,class中的this.state并不是这样的运行的

componentDidUpdate() {
    setTimeout(() => {
      console.log(`You clicked ${this.state.count} times`);
    }, 3000);
  }

然而,this.state.count总是指向最新的count值,而不是属于某次特定渲染的值。所以你会看到每次打印输出都是5

// 点击五次button,浏览器会输出
You clicked 0 times
You clicked 5 times * 5

当然class也是可以避免这个问题的


我觉得hooks这么依赖javascript闭包是件挺讽刺的事,有时候组件class实现方式也会受闭包的苦,但其实这个例子中真正带来混乱来源的是可变数据react修改了class中this.state使其指向最新的值),这并不是闭包的错

当封闭的值始终不会变的情况下,闭包是非常棒的。这使得它们非常容易思考因为你本质上在引用

,正如我们所讨论的。propsstate在某个特定的渲染下是不会改变的。


在组件内什么时候去读取props或者state是无关紧要的。因为它们不会改变。在单次渲染的范围内,props和state始终保持不变。(解构赋值的props使得这一点更明显。)

那Effect中的清理又是怎样的呢?

文档解释的,有些effect可能需要一个清理步骤。本质上,他的目的是清除副作用,比如取消订阅

useEffect(() => {
    ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
    };
  });

假设第一次渲染的时候props{id: 10},第二次渲染的时候是{id: 20}。你可能会认为发生了下面的这些事:

  • react 清除了{id:10}effect
  • react 渲染{id:20}ui
  • react运行{id:20}effect

事实并不是这样

如果依赖这样的心智模型,你可能会认为清除过程看到的旧的props因为它是在重新渲染之前运行的,新的effect看到的是新的props因为它是在重新渲染之后运行的。

这种心智模型直接来源于class组件的生命周期,不过它并不精确,我们来一探究竟

react只会在浏览器绘制之后运行effects,这使得你的应用更流畅,因为大多数的effect并不会堵塞屏幕的更新,effects的清除同样被延迟了。上一次的effect会在重新渲染后被清除:

  • react渲染{id:20}ui
  • 浏览器绘制。我们屏幕上看到{id:20}ui
  • react清除{id:10}的effect
  • react运行{id:20}的effect

你可能会好奇:如果清除上一次的effect发生在props变成{id:20}之后,那它为什么还能看到旧的{id:10}?

组件内的每一个函数(包括事件处理函数,effects,定时器或者API调用等等)会捕捉定义它们的那次渲染中的props和state

现在答案显而易见了,effect的清除并不会读取最新的props, 它只能读取到定义它的那次渲染中的props

// 首先渲染,props是{id:10}
function Example() {
  // ...
  useEffect(
    // 第一次渲染的效果
    () => {
      ChatAPI.subscribeToFriendStatus(10, handleStatusChange);
      // 从第一次渲染中清除效果
      return () => {
        ChatAPI.unsubscribeFromFriendStatus(10, handleStatusChange);
      };
    }
  );
  // ...
}

// 下一次渲染时,props是{id:20}
function Example() {
  // ...
  useEffect(
    // 二次渲染的效果
    () => {
      ChatAPI.subscribeToFriendStatus(20, handleStatusChange);
      // 从第二次渲染中清除效果
      return () => {
        ChatAPI.unsubscribeFromFriendStatus(20, handleStatusChange);
      };
    }
  );
  // ...
}

第一次渲染中effect的清除函数只能看到{id: 10}这个props

同步,并非生命周期

喜欢react的一点是他统一描述了初始化渲染之后的更新

function Greeting({ name }) {
  return (
    <h1 className="Greeting">
      Hello, {name}
    </h1>
  );
}

我先渲染<Greeting name="red" />然后渲染<Greeting name="yellow" />,和我直接渲染<Greeting name="yellow" />并没有什么区别。在这两种情况中,我最后看到的都是“Hello, yellow”

人们总是说:“重要的是旅行过程,而不是目的地”。在React世界中,恰好相反。重要的是目的,而不是过程。

React会根据我们当前的propsstate同步到DOM“mount”“update”之于渲染并没有什么区别。

你应该以相同的方式去思考effectsuseEffect使你能够根据propsstate同步React tree之外的东西。

function Greeting({ name }) {
  useEffect(() => {
    document.title = 'Hello, ' + name;
  });
  return (
    <h1 className="Greeting">
      Hello, {name}
    </h1>
  );
}

这就是和大家熟知的mount/update/unmount心智模型之间细微的区别。理解和消化这一种区别非常重要。

如果你尝试写一个effect会根据是否第一次渲染而表现不一致,你正在逆潮而动。如果我们的结果依赖于过程而不是目的,我们会在同步中犯错

先渲染a,b在渲染c,和直接渲染c并没有什么区别,虽然它们暂时可能会有点不同(比如请求数据),但最终的结果是一样的

不过话说回来,在每一次渲染后都去运行所有的effect这样并不高效。(并且在某些场景下,它可能会导致无限循环。)

所以我们该怎么解决这个问题呢?

告诉React去对比你的Effects

react只会更新dom真正发生改变的部分。而不是每次渲染都大动干戈

当你把

<h1 className="Greeting">
  Hello, one
</h1>

更新到

<h1 className="Greeting">
  Hello, two
</h1>

react能够看到两个对象:

// 省略了一些属性
const oldProps = {className: 'Greeting', children: 'Hello, one'}
const newProps = {className: 'Greeting', children: 'Hello, two'}

它会检测每一个props,并且发现children发生改变需要更新dom,但className并没有,所以它只需要这样做:

domNode.innerText = 'Hello, two';

我们可以用类似的方法处理effects吗?如果能够在不需要的时候避免调用effect就太好了。

举个例子,我们的组件可能因为状态的变更而重新渲染:

function Greeting({ name }) {
  const [counter, setCounter] = useState(0);

  useEffect(() => {
    document.title = 'Hello, ' + name;
  });

  return (
    <h1 className="Greeting">
      Hello, {name}
      <button onClick={() => setCounter(counter + 1)}>
        Increment
      </button>
    </h1>
  );
}

但是我们的effect并没有使用counter这个状态。我们的effect只会同步name属性给document.title,但name并没有变。在每一次counter改变后重新给document.title赋值并不是理想的做法。

好了,那React可以…区分effects的不同吗?

let oldEffect = () => { document.title = 'Hello, name'; };
let newEffect = () => { document.title = 'Hello, name'; };

并不能。React并不能猜测到函数做了什么如果不先调用的话。(源码中并没有包含特殊的值,它仅仅是引用了name属性。)

这是为什么你如果想要避免effects不必要的重复调用,你可以提供给useEffect一个依赖数组参数(deps):

useEffect(() => {
    document.title = 'Hello, ' + name;
  }, [name]);

这好比你告诉react:“我知道你看不到这个函数里的东西,但我可以保证只使用了渲染中的name,别无其他。”

如果当前渲染中的这些依赖项和上一次运行这个effect的时候值一样,因为没有什么需要同步React会自动跳过这次effect:

const oldEffect = () => { document.title = 'Hello, Dan'; };
const oldDeps = ['Dan'];

const newEffect = () => { document.title = 'Hello, Dan'; };
const newDeps = ['Dan'];

// React无法窥视函数内部,但可以比较deps
// 由于所有deps都相同,因此无需运行新的effect。

即使依赖数组中只有一个值在两次渲染中不一样,我们也不能跳过effect的运行。要同步所有

依赖项不要对React撒谎

关于依赖项对react撒谎会有不好的结果。直觉上,这很好理解

function SearchResults() {
  async function fetchData() {
    // ...
  }

  useEffect(() => {
    fetchData();
  }, []); // 这个可以吗? 并非总是如此-还有一种更好的方式编写它。

  // ...
}

“但我只是想在挂载的时候运行它!”,你可能会说。现在只需要记住:如果你设置了依赖项,effect中用到的所有组件内的值都要包含在依赖中。这包括propsstate函数组件内的任何东西。

有时候你是这样做了,但可能会引起一个问题。比如,你可能会遇到无限请求的问题,或者socket被频繁创建的问题。解决问题的方法不是移除依赖项。我们会很快了解具体的解决方案。

不过在我们深入解决方案之前,我们先尝试更好地理解问题。

如果设置了错误的依赖会怎么样呢?

如果依赖项包含了所有effect中使用到的值,react就能知道何时需要运行它:

useEffect(() => {
    document.title = 'Hello, ' + name;
  }, [name]);

// 当name从one 变为 two 时

//() => {
//    document.title = 'Hello, ' + one;
//  }

//() => {
//    document.title = 'Hello, ' + two;
//  }

(依赖发生了变更,所以会重新运行effect。)

但是如果我们将[]设为effect的依赖,新的effect函数不会运行:

useEffect(() => {
    document.title = 'Hello, ' + name;
  }, [name]);

// 当name从one 变为 two 时

//() => {
//    document.title = 'Hello, ' + one;
//  }

// 不会执行  () => {
//    document.title = 'Hello, ' + one;
//  }

这个例子中,问题看起来显而易见。但在某些情况下如果你脑子里跳出class组件的解决方法,你的直觉可能会欺骗你

举个例子,我们来写每秒递增的计数器。在class组件中,我们的直觉是**“开启一次,清除也是一次”,当我们理所应当地把它用useEffect的方式翻译,直觉上我们会设置依赖为[],我只想运行一次effect,对吗?

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log('loop')
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);// 注意这行

  return <h1>{count}</h1>;
}

F7B5E326DF8B1FAAE0C14F9075A07EDA.png

然而,这个例子只会递增一次。天了噜。

如果你的心智模型是“只有当我想触发effect的时候才需要设置依赖”,这个例子可能会让你产生危机感

但为什么会有这样一个问题?

在第一次渲染中,count0,因此,setCount(count+1),在第一次渲染的时候等价于setCount(0+1)。即使我们设置了[]依赖,effect不会重新运行,它后面每秒都会调用setCount(0+1)

// 第一次渲染,状态为0
function Counter() {
  // ...
  useEffect(
    // 第一次渲染的效果    
   () => {
      const id = setInterval(() => {
        setCount(0 + 1); // 总是setCount(1)
      }, 1000);
      return () => clearInterval(id);
    },
    [] // 永不return
  );
  // ...
}

// 每隔下一个渲染,状态为1
function Counter() {
  // ...
  useEffect(
    // 始终会忽略此效果,因为
    // 我们对依赖撒谎。
    () => {
      const id = setInterval(() => {
        setCount(1 + 1);
      }, 1000);
      return () => clearInterval(id);
    },
    []
  );
  // ...
}

我们对react撒谎说我们的effect不依赖组件内的任何值,可实际上我们的effect有依赖!

const count = // ...

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

因此,设置[]为依赖会引入一个bugreact会对比依赖,并且跳过后面的effect

依赖没有变,所以不会再次运行effect。

两种诚实告知依赖的方法

有两种诚实告知依赖的策略。你应该从第一种开始,然后在需要的时候应用第二种。

第一种策略是在依赖中包含所有effect中用到的组件内的值。让我们在依赖中包含count

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);  }, 1000);
  return () => clearInterval(id);
}, [count]);

现在依赖数组正确了。虽然它可能不是太理想但确实解决了上面的问题。现在,每次count修改都会重新运行effect,并且定时器中的setCount(count + 1)会正确引用某次渲染中的 count值:

这能解决问题但是我们的定时器会在每一次count改变后清除和重新设定。这应该不是我们想要的结果

依赖发生了变更,所以会重新运行effect


第二种策略是修改effect内部的代码以确保它包含的值只会在需要的时候发生变更

我们不想告知错误的依赖 - 我们只是修改effect使得依赖更少。


让我们来看一些移除依赖的常用技巧。


让Effects自给自足

我们想去掉effectcount依赖。

useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, [count]);

为了实现这个目的,我们需要问自己一个问题:我们为什么要用count?可以看到我们只在setCount调用中用到了count。在这个场景中,我们其实并不需要在effect中使用count。当我们想要根据前一个状态更新状态的时候,我们可以使用setState函数形式

useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

我喜欢把这类似的情况叫做“错误依赖” 。

因为我们在effect中写了setCount(count + 1)所以count是一个必须的依赖。

但是。我们真正想要的是把count转换成为count+1,然后返回给react。可是react其实已经知道了当前的count

所以我们需要告知react的仅仅是去递增状态-不管它现在具体是什么值。

注意我们做到了移除依赖,并且没有撒谎。我们的effect不再读取渲染中的count

依赖没有变,所以不会再次运行effect

尽管effect只运行了一次,第一次渲染中的定时器回调函数可以完美的在每次触发的时候给react发送c ⇒ c + 1更新指令。它不再需要知道当前的count值。因为react已经知道了

函数式更新

还记得我们说过同步才算理解effect心智模型吗?同步的一个有趣地方在于你通常想要把同步信息状态解耦

举个例子,当你在编辑修改文档的时候,不应该整个文章发给服务器,那样做会很低效,相反的,我们应该把你的修改以一种形式发送给服务器

尽管我们effect的情况不尽相同,但可以应用类似的思想。只在effect中传递最小的信息会很有帮助。

类似于setCount(c ⇒ c + 1 ) 这样的更新形式比setCount(count + 1)传递更少的消息。

因为它不再被当前的count值污染。

它只是表达了一种行为("递增"),只关注如何更新

然而,即使是setCount(c ⇒ c + 1)也并不完美。它看起来怪怪的。并且非常受限于它能做的事。

举个例子,如果我们有两个互相依赖的状态,或者我们想基于一个props来计算下一个state,它并不能做到。幸运的是,setCount(c ⇒ c + 1)有一个更强的“姐妹”,他的名字叫useReducer

解耦来自Actions的更新

我们来修改上面的例子让它包含两个状态:countstep。我们的定时器会每次在conut上增加一个step的值

function Counter() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + step);
    }, 1000);
    return () => clearInterval(id);
  }, [step]);

  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  );
}

这是demo

注意我们没有撒谎。既然我们在effect中使用了step,我们就把它加到依赖里。所以这也是为什么代码能运行正常。

这个例子目前的行为是修改step会重启定时器-因为它是依赖之一。

在大多数场景下,这正是你所需要的,清除上一次的effect然后重新运行新的effect并没有任何错。

除非我们有很好的理由,我们不应该改变这个默认行为。

不过假如我们不想在step改变后重启定时器,我们该如何移除这个依赖呢?

当你想更新一个状态,并且这个状态更新依赖于另一个状态的值时,你可能需要用useReducer去替换它们

当你写类似setSomethins(something ⇒ ...) 这种代码时,也许就是考虑使用reducer的契机。

reducer可以让你把组件内发生了什么 (action)和状态如何响应并更新分开表述

const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;

useEffect(() => {
  const id = setInterval(() => {
    dispatch({ type: 'tick' }); // Instead of setCount(c => c + step);
  }, 1000);
  return () => clearInterval(id);
}, [dispatch]);

查看demo

你可能会问:“这怎么就更好了?”答案是react会保证dispatch在组件的声明周期内保持不变。所以上面例子中不再需要重新订阅定时器。

我们解决了问题!

(你可以从依赖中去除dispatch, setState, 和useRef包裹的值因为React会确保它们是静态的。不过你设置了它们作为依赖也没什么问题。)

相比于直接在effect中读取状态,它dispatch了一个action来描述发生了什么。

这使得我们effectstep状态解耦。

我们的effect不在关心怎么更新状态

它只负责告诉我们发生了什么。更新逻辑全部交给reducer去统一处理:

const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {
    return { count: count + step, step };
  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();
  }
}

为什么useReducer是hooks的Cheat mode

我们已经学习如何移除effect的依赖,不管状态更新是依赖上一个状态还说依赖另一个状态。但假如我们需要依赖props去计算下一个状态呢?

举个例子,也许我们的组件是<Counter step={1}> 。确定的是,在这种情况下,我们没法避免依赖props.step。是吗?

实际上,我们可以避免!我们可以把reducer函数放在组件内读取props:

function Counter({ step }) {
  const [count, dispatch] = useReducer(reducer, 0);

  function reducer(state, action) {
    if (action.type === 'tick') {
      return state + step;
    } else {
      throw new Error();
    }
  }

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

  return <h1>{count}</h1>;
}

这种模式会使一些优化失效,所以你应该避免滥用它。不过如果你需要你完全可以在reducer里面访问props

即使是在这个例子中,react也保证dispatch在每次渲染中都是一样的,所以你可以在依赖中去掉它。他不会引起effect不必要的重复运行

你可能会疑惑:这怎么可能?在之前渲染中调用的reducer怎么“知道”新的props?答案是当你dispatch的时候,react只是记住了action - 它会在下一次渲染中再次调用reducer。在那个时候,新的props就可以被访问到,而且reducer调用也不是在effect里。

这就是为什么我倾向认为useReducerHooks“作弊模式”。它可以把更新逻辑和描述发生了什么分开。结果是,这可以帮助我移除不必需的依赖,避免不必要的effect调用。

把函数放在effect里面

一个典型的误解是认为函数不应该作为依赖。

举个例子:下面的代码看上去可以运行正常:

function SearchResults() {
  const [data, setData] = useState({ hits: [] });

  async function fetchData() {
    const result = await axios(
      'https://hn.algolia.com/api/v1/search?query=react',
    );
    setData(result.data);
  }

  useEffect(() => {
    fetchData();
  }, []); // 这个可以吗?

  // ...

需要明确的是,上面的代码可以正常工作。但这样做在组件日渐复杂的迭代过程中我们很难确保它在各种情况下还能正常运行。

想象一下我们的代码做下面这样的分离,并且每一个函数的体量是现在的五倍:

function SearchResults() {
  // 想象一下这个功能很长
  function getFetchUrl() {
    return 'https://hn.algolia.com/api/v1/search?query=react';
  }

  // 想象一下这个功能也很长
  async function fetchData() {
    const result = await axios(getFetchUrl());
    setData(result.data);
  }

  useEffect(() => {
    fetchData();
  }, []);

  // ...
}

然后我们在某些函数内使用了某些state或者prop:

function SearchResults() {
  const [query, setQuery] = useState('react');

  // 想象一下这个功能也很长
  function getFetchUrl() {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }

  // 想象一下这个功能也很长
  async function fetchData() {
    const result = await axios(getFetchUrl());
    setData(result.data);
  }

  useEffect(() => {
    fetchData();
  }, []);

  // ...
}

如果我们忘记去更新使用这些函数(很可能通过其他函数调用)的effects的依赖,我们的effects @就不会同步propsstate带来的变更。这当然不是我们想要的。

幸运的是,对于这个问题有一个简单的解决方案。如果某些函数仅在effect中调用,你可以把它们的定义移到effect中:

function SearchResults() {
  // ...
  useEffect(() => {
    // 我们将这些功能移到了里面!
    function getFetchUrl() {
      return 'https://hn.algolia.com/api/v1/search?query=react';
    }
    async function fetchData() {
      const result = await axios(getFetchUrl());
      setData(result.data);
    }

    fetchData();
  }, []); // ✅ 
  // ...
}

这是demo

这么做有什么好处呢?我们不再需要去考虑这些“间接依赖”。我们的依赖数组也不再撒谎:在我们的effect中确实没有再使用组件范围内的任何东西。

如果我们后面修改 getFetchUrl去使用query状态,我们更可能会意识到我们正在effect里面编辑它 - 因此,我们需要把query添加到effect的依赖里:

function SearchResults() {
  const [query, setQuery] = useState('react');

  useEffect(() => {
    function getFetchUrl() {
      return 'https://hn.algolia.com/api/v1/search?query=' + query;
    }

    async function fetchData() {
      const result = await axios(getFetchUrl());
      setData(result.data);
    }

    fetchData();
  }, [query]); // ✅ 

  // ...
}

这是demo

添加这个依赖,我们不仅仅是在“取悦react”。在query改变后去重新请求数据是合理的。useEffect的设计意图就是要强迫你关注数据流的改变,然后决定我们的effects该如何和它同步 - 而不是忽视它直到我们的用户遇到了**bug**

但是我们不能把这个函数放在effect里

有时候你可能不想把函数移入effect里。比如,组件内有几个effect使用了相同的函数,你不想在每个effect复制黏贴一遍这个逻辑。也或许这个函数是一个prop

这种情况下你应该忽略对函数的依赖吗?我不这么认为。

effect不应该对他的依赖撒谎。

通常我们还有更好的解决办法。一个常见的错误是,“函数从来不会改变

但是这篇文章你读到现在,你知道这显然不是事实,实际上,在组件内定义的函数每一次渲染都在变

函数每次渲染都会改变这个事实本身就是个问题。比如有两个effects会调用getFetchUrl

function SearchResults() {
  function getFetchUrl(query) {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }

  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 提取数据并执行某些操作 ...
  }, []); // 🔴 缺少dep:getFetchUrl

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 提取数据并执行某些操作 ...
  }, []); // 🔴 缺少dep:getFetchUrl

  // ...
}

在这个例子中,你可能不想把getFetchUrl 移到effects中,因为你想复用逻辑。

另一方面,如果你对依赖很“诚实”,你可能会掉到陷阱里。我们的两个effects都依赖getFetchUrl,而它每次渲染都不同,所以我们的依赖数组会变得无用:

function SearchResults() {
  // 🔴 重新触发每个渲染上的所有效果
  function getFetchUrl(query) {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }

  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 提取数据并执行某些操作 ...
  }, [getFetchUrl]); // 🚧 Deps是正确的,但它们经常更改

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 提取数据并执行某些操作 ...
  }, [getFetchUrl]); // 🚧 Deps是正确的,但它们经常更改
  // ...
}

一个可能的解决办法是把getFetchUrl从依赖中去掉。但是,我不认为这是好的解决方式。

这会使我们后面对数据流的改变很难被发现从而忘记去处理。这会导致类似于上面“定时器不更新值”的问题。

相反的,我们有两个更简单的解决办法

第一个, 如果一个函数没有使用组件内的任何值,你应该把它提到组件外面去定义,然后就可以自由地在effects中使用:

// ✅ 不受数据流的影响
function getFetchUrl(query) {
  return 'https://hn.algolia.com/api/v1/search?query=' + query;
}

function SearchResults() {
  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 提取数据并执行某些操作 ...
  }, []); // ✅ 

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 提取数据并执行某些操作 ...
  }, []); // ✅ 

  // ...
}

你不再需要把它设为依赖,因为它们不在渲染范围内,因此不会受数据流影响

它不可能突然意外的依赖于propsstate

或者你也可以把它包装成useCallBack hooks

function SearchResults() {
  // ✅ 
  const getFetchUrl = useCallback((query) => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, []);  // ✅ 

  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 提取数据并执行某些操作 ...
  }, [getFetchUrl]); // ✅ 

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 提取数据并执行某些操作 ...
  }, [getFetchUrl]); // ✅ 

  // ...
}

useCallback本质上是添加一层依赖检查。它以另一种方式解决问题—我们使函数本身是在需要的时候才改变,而不是去掉对函数的依赖。

我们来看看为什么这种方式是有用的。

之前,我们的例子中展示了两种搜索结果(查询条件分别为'react'和'redux')。

但如果我们想添加一个输入框允许你输入任意的查询条件(query)。不同于传递query参数的方式,现在getFetchUrl会从状态中读取。

我们很快发现它遗漏了query依赖:

function SearchResults() {
  const [query, setQuery] = useState('react');
  const getFetchUrl = useCallback(() => { // 没有query参数
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, []); // 🔴 缺少依赖:query
  // ...
}

如果我把query添加到useCallback 的依赖中,任何调用了getFetchUrleffectquery改变后都会重新运行:

function SearchResults() {
  const [query, setQuery] = useState('react');

  
  const getFetchUrl = useCallback(() => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, [query]);  
  useEffect(() => {
    const url = getFetchUrl();
    
  }, [getFetchUrl]); 

}

如果query保持不变,getFetchUrl也会保持不变,我们的effect也不会重新运行。

如果query修改了,getFetchUrl也会随之改变,因此会重新请求数据。

这正是拥抱数据流同步思维的结果。对于通过属性从父组件传入的函数这个方法也适用:

function Parent() {
  const [query, setQuery] = useState('react');

  const fetchData = useCallback(() => {
    const url = 'https://hn.algolia.com/api/v1/search?query=' + query;
    
  }, [query]);  

  return <Child fetchData={fetchData} />
}

function Child({ fetchData }) {
  let [data, setData] = useState(null);

  useEffect(() => {
    fetchData().then(setData);
  }, [fetchData]); 

}

因为fetchData只有在Parentquery状态变更时才会改变,所以我们的Child只会在需要的时候才去重新请求数据

函数是数据流的一部分吗?

有趣的是,这种模式在class组件中是行不通的,并且这种行不通恰到好处得揭示了effect和生命周期范式之间的区别。

思考下面的转换:

class Parent extends Component {
  state = {
    query: 'react'
  };
  fetchData = () => {
    const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query;
  };
  render() {
    return <Child fetchData={this.fetchData} />;
  }
}

class Child extends Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.props.fetchData();
  }
  render() {
    // ...
  }
}

你可能会想:“少来了,我们都知道useEffect就像componeDidMountcomponentDidUpdata的结合,你不能老是破坏这一条规则把”

好吧,就算加了componentDidUpdata照样无用:

class Child extends Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.props.fetchData();
  }
  componentDidUpdate(prevProps) {
    // 🔴 这种情况永远不会成立
    if (this.props.fetchData !== prevProps.fetchData) {
      this.props.fetchData();
    }
  }
  render() {
    // ...
  }
}

当然如此,fetchData是一个class方法!(或者你也可以说是class属性 — 但这不能改变什么。)

它不会因为状态的改变而不同,所以this.props.fetchDataprevProps.fetchData始终相等,因此不会重新请求。那我们删掉条件判断怎么样?

componentDidUpdate(prevProps) {
    this.props.fetchData();
  }

桥豆麻袋!这样会在每次渲染后都去请求。(添加一个加载动画可能是一种有趣的发现这种情况的方式),也许我们可以绑定一个特定的query

render() {
    return <Child fetchData={this.fetchData.bind(this, this.state.query)} />;
  }

但这样一来,this.props.fetchData !== prevProps.fetchData 表达式永远是true,即使query未改变。这会导致我们总是去请求。

想要解决这个class组件中的难题,唯一现实可行的办法是硬着头皮把query本身传入 Child 组件。 Child 虽然实际并没有直接使用这个query的值,但能在它改变的时候触发一次重新请求:

class Parent extends Component {
  state = {
    query: 'react'
  };
  fetchData = () => {
    const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query;
  };
  render() {
    return <Child fetchData={this.fetchData} query={this.state.query} />;
  }
}

class Child extends Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.props.fetchData();
  }
  componentDidUpdate(prevProps) {
    if (this.props.query !== prevProps.query) {
      this.props.fetchData();
    }
  }
  render() {
    // ...
  }
}

我已经如此习惯于把不必要的props传递下去并且破坏父组件的封装以至于我在一周之前才意识到我为什么要这样做

在class组件中,函数属性本身并不是数据流的一部分。

组件的方法包含了可变的this变量导致我们不能确定无疑地认为它是不变的(此文解释了class中为什么this是可变的)。

因此,即使我们只需要一个函数,我们也必须把一堆数据传递下去仅仅只是为了做”diff

我们无法知道传入的this.props.fetchData是否依赖状态,并且不知道它依赖的状态是否改变

使用useCallback ,函数完全可以参与到数据流中。

可以说如果一个函数的输入改变了,那么这个函数就变了。

如果没有,函数也不会改变

类似的,useMemo可以让我们对复杂对象做类似的事情

function ColorPicker() {
  // 不会打破Child的浅层props检查
  // 除非color实际发生变化。
  const [color, setColor] = useState('pink');
  const style = useMemo(() => ({ color }), [color]);
  return <Child style={style} />;
}

我想强调的是,到处使用useCallback是件挺笨拙的事,当我们需要将函数传递下去并且函数会在子组件的effect中被调用的时候,useCallback是很好的技巧非常好用。

或者你想试图减少对子组件的记忆负担,也不妨一试,但总的来说hooks本省能很好的避免传递回调函数

在上面的例子中,我更倾向于fetchData放在我的effect里(它可以抽离成一个自定义hook)或者是从顶层引入。

我想让effects保持简单,而在里面调用回调会让事情变得复杂。

如果某个props.onComplete回调改变了而请求还在进行中会怎么样?

你可以模拟class的行为但那样并不能解决竞态的问题。

思考一下,篇幅问题,下篇会讲解”有关竞态“,”提高水准“敬请期待