浅谈useEffect

作用

useEffect接收一个函数,可以让用户在函数组件中执行副作用操作,如:

  1. 设置订阅和事件处理
  2. ajax请求等异步操作
  3. 更改DOM对象及其他会对外部产生影响的操作等

使用方式

useEffect(create[, deps]);

第一个参数是要执行的 effect,而第二个参数是依赖项,依赖项是选填的。

例如

function App() {
  useEffect(() => {
    document.title = 'example'; // 副作用操作
  });
  return 
; }

执行时机

传递给useEffect的函数(effect)会在浏览器完成布局与绘制之后延迟(异步)执行,这里的异步实现优先级如下:setImmediate > MessageChannel > setTimeout,并且 React 会保证每次运行 effect 的时候 DOM 都已经更新完毕。虽然 useEffect 会在浏览器绘制后延迟执行,但会保证在任何新的渲染前执行 ,这是官网上的一句描述,很不起眼的一句话,甚至不知道怎么理解这句话,我一开始也这样,直到后面看到这样一个例子:

import "./styles.css";
import { useState, useEffect } from "react";

export default function App() {
  const [a, setA] = useState("a");
  const [b, setB] = useState("b");

  console.log("[render]", a, b);

  useEffect(() => {
    console.log("[useEffect]", a, b);
  });

  function handleClickWithPromise() {
    Promise.resolve().then(() => {
      console.log("async handler1", a, b);
      setA("aa");
      console.log("async handler2", a, b);
      setB("bb");
      console.log("async handler3", a, b);
    });
  }

  function handleClick() {
    console.log("sync handler1", a, b);
    setA("aaa");
    console.log("sync handler2", a, b);
    setB("bbb");
    console.log("sync handler3", a, b);
  }

  return (
    
); }

这个例子不仅关乎到 useEffect 的执行时机,还涉及到 setState 的执行方式。简单的说 setState 的执行会触发组件的重新渲染,即函数的重新执行。setState 本身是同步执行的,但是在 由 React 控制的 事件处理函数,以及生命周期函数(类组件)调用 setState 时会将多个 setState 进行合并然后延迟执行,在 React控制之外的 如 setTimeout/setInterval、Promise等里面执行 setState 则不会合并处理,表现为同步执行。所以上述例子当我们点击 {a} - {b} with Promise 在 Promise 中调用 setState 时,会立即同步执行重渲染,再来看官网这句话,便明白为什么会看到这样的打印结果。

有条件的执行

默认情况下,effect 会在每轮组件渲染完成后执行,但有些时候我们不想要这样,可能只是想挂载完后设置订阅,或者某个数据改变后才执行effect,以此来做一些优化或者避免 bug 的发生。此时我们可以给 useEffect 传递第二个参数,它是 effect 所依赖的值的数组,当设置了第二个参数 deps 后,effect只会在所依赖的值发生变化时(使用 Object.is 进行比较)才运行。

需要清除的effect

有一些副作用是需要清除的,比如我们绑定的事件在组件卸载的时候需要解绑等,不然可能会导致一些意料之外的错误或者内存溢出,在类组件中通常会在 comonponentWillUnmount(vue则为beforeDestory )中清除副作用,而在useEffect中,我们可以使 effect 返回一个函数,在该函数中清除副作用,React将会在执行清除操作(组件卸载的时候)时调用该函数,我们称之为清理函数。例如:

function App() {
  useEffect(() => {
    const handleScroll = () => {};
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  });
 	return 
}
  • 首次渲染组件清理函数不会运行
  • 清理函数的运行时间点是每次运行副作用函数之前
  • 组件被销毁时一定会运行清理函数

在React v17.0之前 useEffect 的清理函数是同步运行的,在React v17.0中清理函数更新为异步运行 —— React v 17.0

看如下代码,首次进入和点击 increase 1 分别打印什么?顺序是怎么样的

export default function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    console.log(`count is ${count} effect`);
    return () => console.log(`clear count ${count} effect`);
 	}, [count]);
  console.log(`count is ${count} render`);
  return (
    
{count}
); }

类比生命周期

如果你熟悉 React Class 的生命周期函数,你可以把 useEffect Hook 看做 componentDidMount,componentDidUpdate 和 componentWillUnmount 这三个函数的组合。 —— 使用 Effect Hook。如下面的类组件例子

class App extends React.Component {
	componentDidMount() {
    const { id } = this.props;
    fetchData(id);
    subsribe(id);
    window.addEventListener(...);
    ...
  }
  componentDidUpdate() {
    const { id } = this.props;
    fetchData(id);
    subscribe(id);
    ...
  }
  componentWillUnmount() {
    removeSubscribe(this.props.id);
    window.removeEventListener(...);
    ...         
  }
  ...
}

可以看到,我们在 componentDidMount 和 componentDidUpdate 中书写了相同的代码,这在我们日常开发中是非常常见的,因为我们希望在组件挂载和更新的时候做一些同样的操作,比如重新获取数据,亦或是在组件卸载的时候清除副作用,当我们有很多类似的操作的时候,不仅会书写很多重复的代码,而且相关联的代码分散在不同的生命周期函数中,当代码量多且复杂的时候就会变得不好管理。而改用 useEffect Hooks 的话会变成怎么样呢?

function App(props) {
  const { id } = props;
  useEffect(() => {
    fetchData(id);
  }, [id]);
  
  useEffect(() => {
    subscribe(id);
    return () => removeSubscribe(id);
  }, [id]);
  
  useEffect(() => {
    window.addEventListener(...);
    return () => window.removeEventListener(...);
  }, [...]);
      
  ...
}

基于 useEffect 的这种设计,我们不用再去考虑当前的 effect 是“挂载”还是“更新”,可以很好的实现 关注点分离 ,还可以在 effect 中返回一个函数,函数里面清除该 effect 中存在的副作用影响,相关代码都汇聚到了一块。当代码量和复杂度提高的时候甚至可以提取成自定义Hooks进行使用。

模拟componentDidMount

useEffect(() => {
  console.log('模拟componentDidMount')
}, [])

模拟componentDidUpdate

const isUpdated = useRef(false);
useEffect(() => {
  if (!isUpdated.current) {
    isUpdated.current = true;
 } else {
    // 这里编写componentDidUpdate相关代码
    console.log('模拟componentDidUpdate')
 }
})

事实上,useEffect 并不完全等价于 componentDidMount 和 componentDidUpdate(如运行时机),前者是在渲染器执行完当前任务后(即在浏览器将所有变化渲染到屏幕后) 才会被 异步执行,而后者是 渲染器执行当前渲染界面任务同步执行。这样做的好处是什么呢?让我们对比下面两个程序,相信你会找到答案

export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
   	};
 	}

  componentDidMount() {
    console.log('componentDidMount start', new Date());
    new Array(500000000).fill(true).forEach(() => {});
    console.log('componentDidMount end', new Date());
 	}

  componentDidUpdate() {
    console.log('componentDidUpdate start', new Date());
    new Array(500000000).fill(true).forEach(() => {});
    console.log('componentDidUpdate end', new Date());
 	}

  render() {
    console.log('render', new Date());
    const { count } = this.state;
    return 

synchronize {Math.random()}


{count}
; } }

可以看到上述例子,在点击按钮后很长时间都处于卡顿状态,因为 componentDidMoun 和 componentDidUpdate 都是同步执行到底,当里面有一些高消耗的操作时,长时间的运行会让页面渲染器一直处在等待中,页面迟迟得不到更新,而换成useEffect呢?

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('useEffect start', new Date());
    new Array(50000000).fill(true).forEach(() => {});
    console.log('useEffect end', new Date());
 	});

  return (
    

synchronize {Math.random()}


{count}
); }

通过这个例子,能很好的解释为什么 useEffect 是在将变化都渲染到屏幕后才异步运行,很重要的一个原因就是避免 effect 的执行阻塞UI渲染,让页面看起来响应更快。除此之外也是为了保证每次运行 effect 的同时,DOM 都已经更新完毕。

useEffect 的兄弟

useEffect 有一个兄弟叫 useLayoutEffect ,他和 useEffect 几乎完全相同,不同的就是 effect 的执行时机,useLayoutEffect 的 effect 和清理函数均是同步调用,并且与 componentDidMount 和 componentDidUpdate 的调用阶段一致,使其可以等价于componentDidMount 和 componentDidUpdate 。这在一些用户可见DOM变更的场景下会比较适用,React 保证其会在浏览器执行下一次渲染之前被同步执行,保证用户视觉上的一致 —— useLayoutEffect

"监听"数据变化?

这是很多 vue 转 React 的同学在刚接触 useEffect 时常有的一种理解方式,useEffect 的表现就像是 vue 中的监听属性 watch ,可以用来 “监听” 数据的变化。例如这个模拟百度实时搜索的例子,在输入框的值发生变化之后重新请求数据:

vue watch

<template>
  <div>
    <input type="text" :value="inputVal" @input="handleOnInput" />
    <p>result: {{ result }}</p>
  </div>
</template>
<script>
let timer = null;
function _debounce(fn, delay) {
  clearTimeout(timer);
  timer = setTimeout(fn, delay);
}
export default {
  data() {
    return {
      inputVal: "",
      result: "",
    };
  },
  watch: {
    inputVal: function (cur) {
      _debounce(
        () => this.fetchData(cur).then((res) => (this.result = res)),
        1000
      );
    },
  },
  methods: {
    handleOnInput(e) {
      this.inputVal = e.target.value;
    },
    fetchData(val) {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve(val.repeat(3));
        }, 1000);
      });
    },
  },
};
</script>

useEffect Hooks

const fetchData = val => new Promise(resolve => {
  setTimeout(() => {
    resolve(val.repeat(3));
 }, 1000);
});

export default function App() {
  const [inputVal, setInputVal] = useState('');
  const [result, setResult] = useState('');

  const handleOnChange = e => setInputVal(e.target.value);

  useEffect(() => {
    const timer = inputVal && setTimeout(() => 
      fetchData(inputVal).then(res => setResult(res)),
    1000);
    return () => clearTimeout(timer);
 }, [inputVal]);

  return (
    

result: {result}

); }

这里的“监听”并不是像 vue 中那样做了数据劫持或者代理结合发布订阅(观察者)模式,事实上 React 也并没有做任何的监听操作,上面讲有条件的执行的时候其实已经提到了,React 做的只是把所依赖的数据的旧值和新值进行比较,发生了变化便重新运行 effect。

那依赖项究竟有什么作用?我能不能忽略它?

有一天产品说要在页面上加一个计时器,在进入页面后从0开始每秒自增1,先让我们看看类组件是如何实现的

import React from "react";
export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  componentDidMount() {
    // 在组件挂载后开启一个定时器
    this.timer = setInterval(() => {
      this.setState({
        count: this.state.count + 1
      });
    }, 1000);
  }

  componentWillUnmount() {
    // 组件卸载前清除定时器
    clearInterval(this.timer);
  }
  render() {
    return 
{this.state.count}
; } }

但是你刚学了 Hooks,想要尝试用 useEffect 去实现,结合上面讲到的 useEffect 模拟 componentDidMount ,很容易的就把把这个定时器例子用 React Hooks 重构了出来,如下:

export default function App() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count);
      setCount(count + 1);
   }, 1000);
    return () => clearInterval(timer);
 }, []);
  
  return 
{count}
; }

在这个定时器例子中,一部分同学的想法是在组件挂载完后开启一个定时器,那么就意味着我这个 effect 只需执行一次就行,那么我的依赖就是一个空数组。如果是使用类组件实现,乍一想这个逻辑似乎并没有什么问题,但是 useEffect 这里存在一个陷阱,而如果你的心智模型是“只有当我想重新触发 effect 的时候才需要去设置依赖”,所以该程序我不需要设置依赖,那么就落入了这个陷阱,让我们运行这个程序看看最后结果如何。

可以看到,该程序并没有像我们预想的那样执行,而是卡在了数字1,并且每次打印的 count 都是 0,这是为什么呢?我不是更新了 count 了吗?为什么我会在我的函数中看到旧的 state ? 这些都是因为你欺骗 React,不告诉 React 依赖或者告诉了错误的依赖。在组件首次渲染的时候,我们定义一个 常量count 以及设置了一个 effect 只在首次渲染完成后执行。这里的 effect 本质就是函数,是函数组件 App(函数)运行的产物,它抓住了 App 首次运行的作用域链不释放,尽管 effect 里面重新赋值了 count 使得 App 重新执行,尽管产生了新的 state 和 effect,但是因为依赖项是空的,React 并不会重新执行 effect,所以 setInterval 里面访问的 count 始终是第一次渲染时的 count 值 0。有没有觉得这现象似曾相识?没错,是闭包在作祟。那既然是因为我们没有告诉React正确的依赖,那如果我们诚实的告诉 React 我们在 effect 中用到了哪些值呢?

export default function App() {
  const [count, setCount] = useState(0);

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

  useEffect(() => {
    const timerId = setTimeout(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearTimeout(timerId);
  }, [count]);

  return 
{count}
; }

一切都正常了,现在,每次 count 的修改都会重新运行 effect ,并且定时器中的 setCount(count + 1) 会获取到最新的 count 值。这可以解决问题,但是并不完美,并且只是诚实的告诉 React 依赖有可能并不会使得程序如你所想的运行,这又是怎么回事?别着急,让我们继续看下面的例子加深对 useEffect 中闭包的印象。在进入页面后5秒内连续点击5次 Click me ,分别打印什么?

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

  useEffect(() => {
    setTimeout(() => {
      console.log(count);
   }, 5000);
 }, []);

  return (
    

You clicked {count} times

); } function Comp2() { const [count, setCount] = useState(0); useEffect(() => { setTimeout(() => { console.log(count); }, 5000); }, [count]); return (

You clicked {count} times

); }

Comp1 打印了一个 0 ,Comp2 打印 0 1 2 3 4 5

没有答对的同学不要失望,虽然闭包的确可以访问外围的变量,但是,每次渲染的 effect 都是不同的,组件的每一次渲染都是相互独立的(函数组件的每一次渲染本质就是函数的执行),每次渲染都有固定不变的 props、state、事件处理函数以及 effects。它们会"捕获"定义它们的那次渲染中的 props 和 state 。这并不难理解,传递给 useEffect 的函数是一个局部变量,每次 App 被执行时,函数都会重新创建,再根据依赖是否发生了变化决定是否需要执行新的effect,所以 Comp1 它首先只会执行一次 effect,其次因为依赖为空数组,所以捕获的是首次渲染时的 count 0,故只会打印一个0;而 Comp2 的依赖是 count,意味着 count 的每一次改变都会重新运行 effect,即会重新开启一个定时器,首先可以确认必然会打印 6 个数字,其次React并不会保存 effect 函数,每次运行的 effect 都是该次渲染新生成的,这可以确保 effect 中可以获取到最新的状态,故会打印 0 1 2 3 4 5。某种意义上讲,effect 就是渲染结果的一部分 —— 每个 effect 属于一次特定的渲染。

从上述的两个例子可以得知:依赖项的作用是告诉 React 在 effect 中用到了哪些值,决定本次渲染是否需要执行 effect,并且不能忽略他们,忽略依赖项可能会导致一些意想不到的 bug。

移除依赖

但有些时候依赖会频繁发生变化,变得让人头疼,比如上一个倒计时的例子,我们的定时器会在每一次 count 改变后清除和重新设置,这可能不是我们想要的结果;又比如将对象或函数作为依赖项,这时不将这些数据写进依赖项里可能会导致错误,eslint 也会警告,写进依赖项里又会频繁发生变化,导致 effect 多次执行,那在日常开发中有哪些常见的减少依赖或移除依赖的技巧呢?

使用函数更新状态,如刚才的计时器例子

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('setInterval');
      setCount(prevCount => prevCount + 1);
   }, 1000);
    return () => {
      console.log('clearInterval');
      clearInterval(timer);
   };
 }, []);

  return 
{count}
; }

这时我们已经掌握了第一个移除依赖的技巧,但是隔天产品说计时器自增的数是随机的,需要通过接口获取,如下:

export default function App() {
  const [count, setCount] = useState(0);

  const fetchStep = () => Promise.resolve(Math.ceil(Math.random() * 10));

  useEffect(() => {
    let timerId;
    (async () => {
     	const step = await fetchStep();
      timerId = setInterval(() => {
        setCount((prevCount) => prevCount + step);
      }, 1000);
     })();
    return () => clearInterval(timerId);
  }, []);

  return 
{count}
; }

上面的代码可以正常运行,但是会有 eslint 警告,我们必须把 fetchStep 作为 useEffect 的依赖,当我们将 fetchStep 添加进 useEffect 的依赖后,eslint 又会提示我们,为了避免依赖频繁发生变化,导致 effect 重复执行/无限执行,你也可以把它包装成 useCallback Hook (向子组件传递的函数必须要用 useCallback Hook 包装)这就确保了它不会随渲染而改变,除非它自身的依赖发生了改变。或者如果某些函数仅在 effect 中调用,可以把它们的定义移到 effect 中。经过观察我们可以发现,fetchStep 仅在 useEffect 中使用到了,所以我们完全可以把 fetchStep 函数的定义移到 useEffect 内。

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const fetchStep = () => Promise.resolve(Math.ceil(Math.random() * 10));
    let timerId;
    (async () => {
     	const step = await fetchStep();
      timerId = setInterval(() => {
        setCount((prevCount) => prevCount + step);
      }, 1000);
     })();
    return () => clearInterval(timerId);
  }, []);

  return 
{count}
; }

这样便消除了依赖。有时候你可能不想把函数移入 effect 里,又或许是组件内有其他地方使用了相同的函数,你不想在每个 effect 里复制黏贴一遍这个逻辑。这时我们可以仔细看看这个函数的实现,如果一个函数没有使用组件内的任何值,你可以尝试把那个函数移动到你的组件之外,那样一来,这个函数就肯定不会依赖任何 props 或 state,并且也不用出现在依赖列表中了。刚好我们这个例子就满足这个场景,所以可以改造为:

const fetchStep = () => Promise.resolve(Math.ceil(Math.random() * 10));

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    let timerId;
    (async () => {
     	const step = await fetchStep();
      timerId = setInterval(() => {
        setCount((prevCount) => prevCount + step);
      }, 1000);
     })();
    return () => clearInterval(timerId);
  }, []);

  return 
{count}
; }

所以一般建议把不依赖 props 和 state 的函数提到你的组件外面,并且把那些仅被 effect 使用的函数放到 effect 里面,万不得已的情况下才将函数包装成 useCallback Hook。隔天产品又说希望限制随机 step 的最大值,并且用户可以修改这个最大值,虽然你很不情愿,但是你还是咬着牙对程序进行了修改,如下:

function App() {
  const [count, setCount] = useState(0);
  const [max, setMax] = useState(10);
  
	const fetchStep = useCallback(() => {
    return Promise.resolve(Math.ceil(Math.random() * max));
 	}, [max]);
  
  useEffect(() => {
    let timerId;
    (async () => {
     	const step = await fetchStep();
      timerId = setInterval(() => {
        setCount((prevCount) => prevCount + step);
      }, 1000);
     })();
    return () => clearInterval(timerId);
 	}, [fetchStep]);

  return (
  	
max: setMax(e.target.value)} />
count: {count}
); }

此时我们就无法将 fetchStep 函数移除到组件外部了,但是我们还是可以将函数移入 useEffect 内,这时可能有同学会有疑问?为什么呢?移入 useEffect 内好像也无法消除依赖啊!没错,有时候这种方法做不到完全消除依赖,但是这样做的好处是我们不再需要去考虑这些“间接依赖”;可以看到,useEffect 依赖了 fetchStep,fetchStep 依赖了max,当代码复杂到一定程度的时候,我们经常会因为是哪个依赖的改变导致的 effect 重复运行而苦恼,程序变得难以维护。当我们将 fetchStep 移入 useEffect 后可以很清晰的看到依赖的数据,只有max一个。

function App() {
  const [count, setCount] = useState(0);
  const [max, setMax] = useState(10);
  useEffect(() => {
    let timerId;
    const fetchStep = () => Promise.resolve(Math.ceil(Math.random() * max));
    (async () => {
     	const step = await fetchStep();
      timerId = setInterval(() => {
        setCount((prevCount) => prevCount + step);
      }, 1000);
     })();
    return () => clearInterval(timerId);
 	}, [max]);
  return (
  	
max: setMax(e.target.value)} />
count: {count}
); }

到这里似乎就结束了,你愉快的提交了代码,过了一阵子产品过来找你说程序有问题,计时器在 max 修改的时候并没有按1秒钟的时间增长,你很疑惑,我写的程序怎么会有问题呢?然后你又仔细看了下实现,发现了端倪,max 的改变会导致定时器重新开启,从而导致定时器没有按1秒钟的时间增长,比如过了0.9秒后,max 改变,旧的定时器被清除,新的定时器开启,重新计时,到下一次 count 改变时,历时最少1.9秒,而且理论上我可以重复这样,致使 count 长时间不会变化。你心想这不是很正常吗?你开始跟产品 battle,说其中的原因,但是产品不听并给你提了个缺陷,你很困惑,既然是 max 依赖搞的鬼,那我怎么消除这个依赖呢?

本质上我们只需要将count的更新逻辑移到effect外部即可,其实官网已经给出了答案 ,useState 有一个强大的替代方案 useReducer Hook(事实上 useState 是一个简易版的 useReducer ) ,useReducer 接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。需要注意的是React 会确保 dispatch 函数的标识是稳定的,并且不会在组件重新渲染时改变。单看这个定义好像不太能理解它是如何解决依赖频繁变化的问题,让我们直接往下看

import React, { useReducer, useEffect } from "react";

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

const ACTION_TYPES = {
  UPDATE_MAX: "updateMax",
  UPDATE_COUNT: "updateCount",
  UPDATE_STEP: "updateStep"
};

const fetchStep = (max) => Promise.resolve(Math.ceil(Math.random() * max));

function reducer(state, action) {
  const { type, payload } = action;
  const { count, step } = state;
  switch (type) {
    case ACTION_TYPES.UPDATE_COUNT:
      return {
        ...state,
        count: count + step
      };
    case ACTION_TYPES.UPDATE_MAX:
      return {
        ...state,
        max: payload
      };
    case ACTION_TYPES.UPDATE_STEP:
      return {
        ...state,
        step: payload
      };
    default:
      throw new Error("type is required");
  }
}

export default function App() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { count, max, step } = state;

  const handleInputChange = (e) => {
    const value = e.target.value;
    dispatch({
      type: ACTION_TYPES.UPDATE_MAX,
      payload: value
    });
    fetchStep(max).then((res) => {
      dispatch({
        type: ACTION_TYPES.UPDATE_STEP,
        payload: res
      });
    });
  };

  useEffect(() => {
    const timerId = setInterval(() => {
      dispatch({ type: ACTION_TYPES.UPDATE_COUNT });
    }, 1000);
    return () => clearInterval(timerId);
  }, []);

  return (
    
max:
step: {step}
count: {count}
); }

故在一些更加复杂的场景中(比如一个 state 依赖于另一个 state 或 props),尝试用 useReducer Hook 把 state 更新逻辑移到 effect 之外,它可以把更新逻辑和描述发生了什么分开。

最后,在万不得已的情况下,如果你想要类似 class 中的 this 的功能,你可以 使用一个 ref 来保存一个可变的变量。然后你就可以对它进行读写了。但是要注意的是给 ref 重新赋值不会导致组件重新渲染。

结论

  1. 使用函数更新状态
  2. 如果某些函数仅在 effect 中调用,把它们的定义移到 effect 中,虽然有时候做不到完全消除依赖,但是这样做的好处是我们不再需要去考虑这些“间接依赖”
  3. 有时候你可能不想把函数移入 effect 里。比如,组件内有几个 effect 使用了相同的函数,你不想在每个 effect 里复制黏贴一遍这个逻辑,也或许这个函数是一个 prop。
    1. 如果一个函数没有使用组件内的任何值,你可以尝试把那个函数移动到你的组件之外
    2. 或者, 将函数作为 useEffect 依赖,为了避免依赖频繁发生变化,导致 effect 重复执行/无限执行,把它包装成 useCallback Hook (向子组件传递的函数必须要用 useCallback Hook包装)
  4. 在一些更加复杂的场景中(比如一个 state 依赖于另一个 state),尝试用 useReducer Hook 把 state 更新逻辑移到 effect 之外
  5. 万不得已的情况下,可以 使用一个 ref 来保存一个可变的变量

扩展

其实最后我们用 useReducer 完成的程序其实并不完美,其中一个是 change 事件的每次触发都会发出请求,这个可以通过防抖或节流来进行优化。再一个是在真实的网络环境当中step的更新可能存在问题,在 max 的连续变化中,例如上一次的请求耗时3秒,最新一次的请求耗时2秒,那么最新的数据就会先于旧数据到达,就会导致 step 其实是根据上一个 max 得到的,这种现象我们称之为竟态。

通常在类组件中我们可以在请求到达的时候通过比对现在的值和发起请求时的值是否一致来解决,如下

class App extends React.Component {
  ...
  getData() {
	const max = this.state.max;
	fetchStep(max).then((res) => {
	  if (max === this.state.max) {
		this.setState({
		  step: res,
		});
	  }
	});
  }
  ...
}

我们回看最后完成的程序会发现怎么都不好解决,写起来很麻烦,因为必包的关系,我们不能像类组件那样在 handleInputChange 中获取到最新的 max ,要解决这个问题,我们必须使用一个 Ref 来保存最新的 max

const lastestMax = useRef(max);

const handleInputChange = (e) => {
  const value = e.target.value;
  dispatch({
    type: ACTION_TYPES.UPDATE_MAX,
    payload: value
  });
  lastestMax.current = value;
  fetchStep(value).then((res) => {
    if (value === lastestMax.current) {
      dispatch({
        type: ACTION_TYPES.UPDATE_STEP,
        payload: res
      });
    }
  });
};

除此之外我们可以将 handleInputChange 中的数据请求部分放在 effect 中请求数据,在 useEffect 中我们可以轻松的解决竞态问题

useEffect(() => {
  let didCancel = false;
  fetchStep(max).then((res) => {
    if (!didCancel) {
      dispatch({
        type: ACTION_TYPES.UPDATE_STEP,
        payload: res
      });
    }
  });
  return () => {
    didCancel = true;
  };
}, [max]);

参考:

React官方文档

useEffect完整指南

How to fetch data with React Hooks

细烤useEffect

被提前执行的useEffect

React的useEffect和useLayoutEffect执行机制剖析

你可能感兴趣的:(React,javascript,react.js,前端)