Published on

React-Hooks精读

Authors
  • avatar
    Name
    McDaddy(戣蓦)
    Twitter

这是一年前刚开始上手React Hooks时候读了Dan大神的useEffect 完整指南时写的文章,我的有些观点已经被证明是错误的了,这里放一下就当是做了归档

React-Hooks与闭包

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

  const log = () => {
    setCount(count + 1);
    setTimeout(() => {
      console.log(count);
    }, 3000);
  };

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

猜想在此情况下,连续点击按钮3次,控制台输出?

// 预想
3
3
3
// 实际
0
1
2

这是为什么呢?为了解答这个问题我们把这个例子用class重写一遍

class Counter extends Component {
  state = { count: 0 };
  log = () => {
    this.setState({
      count: this.state.count + 1
    });
    setTimeout(() => {
      console.log(this.state.count);
    }, 3000);
  };

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={this.log}>Click me</button>
      </div>
    );
  }
}

同样的操作,得到的结果就是3 3 3

原因在于,对于class来说

  • state是immutable的, this.setState产生的是新的state引用。
  • 在class中state是通过this来获得的,而this得到的内容永远的::唯一的且最新的::。

而对于Function Component来说

  • useState得到的state也是immutable的,而setCount会产生一个state新的引用,但这个引用只会用在下次渲染时。
  • 由于没有this,所以在setTimeout中拿到的state还是当时创建这个setTimeout时渲染闭包中的state。这种性质在React-Hooks中称为Capture Value
// 每次的点击事件都相当于传入常量
setTimeout(() => {
      console.log(0);
    // console.log(1);
    // console.log(2);
}, 3000);

这是React-Hooks特有的性质么?

其实这就是闭包中的局部变量,不论入参怎么变化,setTimeout中的变量都是在初始化setTimeout时,这个函数作用域中的那个常量值。

function sayHi(person) {
  const name = person.name;
  setTimeout(() => {
    alert('Hello, ' + name);
  }, 3000);
}

let someone = {name: 'Dan'};
sayHi(someone);

someone = {name: 'Yuzhi'};
sayHi(someone);

someone = {name: 'Dominic'};
sayHi(someone);

如何让 Function Component 也打印3 3 3?

方法一: useRef

useRef的功能:通过useRef创建的对象,其值只有一份,而且在所有 Rerender 之间共享。
但是他有一个问题就是设置ref.current无法触发渲染。
另一个问题就是它不再是我们熟悉的state状态,而只是一份引用。

function Counter() {
  const count = useRef(0);

  const log = () => {
    count.current++;
    setTimeout(() => {
      console.log(count.current);
    }, 3000);
  };

  return (
    <div>
      <p>You clicked {count.current} times</p>
      <button onClick={log}>Click me</button>
    </div>
  );
}
方法二:保留state,利用useEffect

useEffect 是处理副作用的,其执行时机在每次 Render 渲染完毕后,换句话说就是每次渲染都会执行,只是实际在真实DOM操作完毕后。
可以利用这个特性,在每次渲染完毕后,将count此时最新的值赋给 currentCount.current,这样就使 currentCount 的值自动同步了count的最新值。

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

  useEffect(() => {
    currentCount = count;
  });

  const log = () => {
    setCount(count + 1);
    setTimeout(() => {
      console.log(currentCount);
    }, 3000);
  };

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

React-Hooks的核心 — Effects

问题1:Effect是什么,它有什么意义?

UI = F(props, state, context)

Props和State的变化,从根本上讲只会影响页面组件jsx的渲染,但如果我们需要做除了dom之外的操作(比如call api)该怎么办? 所以Effect可以理解为一种副作用,是区别于纯页面渲染之外的操作。

问题2: 为什么useEffect可以拿到最新的state?是不是有data binding的存在?

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>
  );
}

事实上useEffect和普通的事件处理函数一样,都是通过闭包设常量来定义,所以每次渲染得到的useEffect函数是不一样的。

useEffect(
    // 第一次渲染
    () => {
      document.title = `You clicked ${0} times`;
    }
  );
useEffect(
    // 第二次渲染(第一次点击)
    () => {
      document.title = `You clicked ${1} times`;
    }
  );
useEffect(
    // 第三次渲染(第二次点击)
    () => {
      document.title = `You clicked ${2} times`;
    }
  );

React会记住你提供的effect函数,并且会在每次更改作用于DOM并让浏览器绘制屏幕后去调用它。每个effect函数都从属于某个特定的渲染,就像事件处理函数一样。

问题3: Effect中的依赖是什么?

如果没有依赖,那么每次渲染都会执行useEffect函数,那会造成无畏的性能损失,所以deps是一种类似virtual dom对比的策略。

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>
  );
}
// 没有依赖的情况下
let oldEffect = () => { document.title = 'Hello, Dan'; };
let newEffect = () => { document.title = 'Hello, Dan'; };

useEffect(() => {
   document.title = 'Hello, ' + name;
}, [name]); // 加上依赖
// 有依赖的情况下
const oldEffect = () => { document.title = 'Hello, Dan'; };
const oldDeps = ['Dan'];

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

问题4:是否必须要把依赖列出来?

将上面的setTimeout改为setInterval,结果会有什么变化?useEffect的依赖为[]

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

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

  return <h1>{count}</h1>;
}
// 期望
0 1 2 3...
// 实际
1 1 1 1...

思考原因,因为依赖是[],意味着这个useEffect不依赖于任何变量,只会在组件挂载和销毁时调用,所以这个const id = setInterval(...)只执行了一次,结合之前闭包的原理,在setInterval中的count永远是初始的0,所以实际的执行效果就是每一秒钟发生一次setCount(0 + 1),count的最新值也就永远是1。由此可见是需要把相关的依赖都列出来的。
但如果把count列在依赖中,这个useEffect就会不断重复执行,这和初衷不符。

问题5:可不可以绕过依赖检查?

思路一:去掉count依赖,利用传函数给setCount方法,在setCount的回调函数中,c值永远指向最新的count值,因此没有逻辑漏洞。

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

不足:但存在两个以上变量需要使用时,这招就没有用武之地了。

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

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

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

思路二: useReducer

const [state, dispatch] = useReducer(reducer, initialState); 不论有多少依赖,最终都化为dispatch这一个依赖,useEffect只管发出action,而不需要关心实际依赖的那些数据是否变化。

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

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

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

function reducer(state, action) {
  switch (action.type) {
    case "tick":
      return {
        ...state,
        count: state.count + state.step
      };
  }
}

useReducer的最大意义在于它配合useEffect可以将状态和行为分离,在useEffect中不需要关系去更新哪个状态,只知道发什么什么action,剩下的交给reducer做,而useEffect自己关注与自己到底要做哪些副作用就好。这点在处理复杂逻辑的组件时很有效。

// 无限循环bug
useEffect(() => {
	setStateB(stateA + 1);
}, [stateA]);

useEffect(() => {
	fetchData(stateB);
	setStateC(stateB + 1);
}, [stateB]);

useEffect(() => {
	setStateA(stateC + 1);
}, [stateC]);

// 使用useReducer来解决
const reducer = (state, action) => {
	switch (action.type) {
    case "update":
      return {
        ...state,
        // propA: state.propC + 1,
        propB: state.propA + 1,
		  propC: state.propB + 1,
      };
  }
}
const [state, dispatch] = useReducer(reducer, initialState);
const { propA, propB, propC } = state;
useEffect(() => {
	dispatch({type: 'update'});
	fetchData(propA + 1);
}, [propA]);

问题6:函数需不需要成为依赖?

如果我们把Effect中的内容作为函数提出来

// 原始版
function SearchResults() {
  const [query, setQuery] = useState('react');
  useEffect(() => {
    const data = props.fetchData(query);
  }, [query]);

// 第一版
function SearchResults() {
  const [query, setQuery] = useState('react');
  const getFetchData = () => {
    return props.fetchData(query);
  };
  useEffect(() => {
    const data = getFetchData();
  }, [getFetchData]);
// 结果: getFetchData会在每次渲染后执行,因为这样定义方法每次渲染都会重新执行定义,导致它成为一个新的方法实例。

// 第二版
function SearchResults() {
  const [query, setQuery] = useState('react');
  const getFetchData = () => {
    return props.fetchData(query);
  };
  useEffect(() => {
    const data = getFetchData();
  }, [query]);
// 结果是正确的,但是违反了对依赖诚实的规则

// 最终版
function SearchResults() {
  const [query, setQuery] = useState('react');
  const getFetchData = useCallback(() => {
    return props.fetchData(query);
  }, [query]);
  useEffect(() => {
    const data = getFetchData();
  }, [getFetchData]);
}

useCallback本质上是添加了一层依赖检查。它是从另一种角度来实现依赖,Effect从依赖变量转为依赖函数。
说到这里其实useCallback的作用只是为了绕过lint检查,就是个辣鸡。
其实接下来才是useCallback真正有意义的地方

  1. 它可以做class不能做的事情,从父传方法给子调用,同时方法中用到父的状态,仅当父状态变化时子调用。在class中除了把状态传下来没有别的办法。
class Parent extends Component {
  state = {
    query: 'react'
  };
  fetchData = () => {
    return this.props.fetchData(this.state.query);
  };
  render() {
    return <Child fetchData={this.fetchData} />;
  }
}

class Child extends Component {
  state = {
    data: null
  };
  componentDidMount() {
    this.props.fetchData();
  }
  componentDidUpdate(prevProps) {
    // This condition will never be true
    if (this.props.fetchData !== prevProps.fetchData) {
      this.props.fetchData();
    }
  }
  render() {
    // ...
  }
}

// Parent
render() {
    return <Child fetchData={this.fetchData} query={this.state.query} />;
  }
// Child
componentDidUpdate(prevProps) {
    if (this.props.query !== prevProps.query) {
      this.props.fetchData();
    }
}

而useCallback可以合理得将父状态和父方法封装在一起,传给子组件,子组件只需要监听函数变化即可。

function Parent() {
  const [query, setQuery] = useState('react');
  const fetchData = useCallback(() => {
    return props.fetchData(query)
  }, [query]); 
  return <Child fetchData={fetchData} />
}

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

  useEffect(() => {
    fetchData().then(setData);
  }, [fetchData]); 
  // ...
}
  1. useCallback可以用于性能提升
//在class中如果定义onClick的回调用() => {}的形式,
//那么这个Button会因为props的改变每次随组件一起更新
//解决方法是把方法单独抽出,用onClick={this.handle}来解决
//在Function中handle是每次执行函数都会重新定义的,所以会遇到同样的问题
function Foo() {
  const handleClick = () => {
    console.log('Click happened');
  }
  return <Button onClick={handleClick}>Click Me</Button>;
}
//利用useCallback,他依赖于某个变量或者不依赖变量来决定这个callback是否变化,从而达到和this一样的效果。
function Foo() {
  const memoizedHandleClick = useCallback(
    () => console.log('Click happened'), [],
  ); // Tells React to memoize regardless of arguments.
  return <Button onClick={memoizedHandleClick}>Click Me</Button>;
}
  1. 在Function Component中 add/removeEventListerner
  • 在纯函数组件中为某个domRef添加删除eventHandler必须考虑:每次渲染时如果这个eventHandler是没法提出到组件外的话,那这个handler总是变化的,这样的话add/removeEventListerner无法删除。
  • 为了解决这个问题,必须在handler外包一个useCallback来保持handler不变化。
  • 但这样又引出新问题,如果handler内用到了state,那在触发event时拿到的state永远是初始值。
  • 为了解决这个问题,此时用到的state全部改成useRef, 改值时都用ref.current = ‘value’。
  • 但如此又引出新的问题,ref的改值是不会触发渲染的。
  • 为了解决这个问题,只能再添加一个为了渲染而存在的state,但有点反设计。

问题7: 在Effect中依赖为[]?是不是就是componentDidMount?

答案是否定的。Effect定义的先后顺序决定了在每次渲染结束之后的执行顺序。

React.useEffect(() => {
    console.log('exec state effect', count);
}, [count]);

React.useEffect(() => {
    console.log('exec init effect');
}, []);

// 结果
// exec state effect
// exec init effect

问题8: Effect中的清理是什么?是不是就是componentWillUnmount?

Effect永远是在渲染完成之后执行的,同时清理中的函数读的state同Effect函数一样是固定在渲染时的。每次渲染结束,先执行清理(清理上一次的effect),再执行effect函数。正是因为渲染优先,所以hooks在理论上会比class性能更好,因为它不会阻塞渲染的线程。

const Counter = () => {
  const [count, setCount] = React.useState(1);
  console.log('render', count);

  React.useEffect(() => {
    console.log('exec effect', count);
    return () => {
      console.log('clear effect', count);
    };
  }, [step]);
	
	<div>
      <p>You clicked {count} times</p>
      <Button onClick={() => setCount(count + 1)}>Click me</Button>
  </div>
}
// 预想输出
render 1
exec effect 1
clear effect 1
render 2
exec effect 2
clear effect 2
render 3
...
// 实际输出
render 1
exec effect 1
render 2
clear effect 1
exec effect 2
render 3
clear effect 2
...

问题9: 如何实现setState之后的回调?

事实上这个功能还没有官方的实现方法,从React作者的角度来讲他建议所有的回调都应该归结到Effect中,但现实中还是有它实际的用途的。 open issue链接

// class component
fetchData = () => {
	this.props.callApi(this.state.count);
}
...
this.setState({count: this.state.count + 1}, () => this.fetchData());
...
// function component
const [shouldFetch, setShouldFetch] = useState(false);
const fetchData = React.useCallback(() => {
	props.callApi(count);
  setShouldFetch(false);
}, [count]);
...
setCount(count + 1);
setShouldFetch(true);
...

问题10:要不要打开react-hooks/exhaustive-deps

个人建议不要打开,原因主要是

  1. 在Effect中经常要调用props里的异步方法,把这个方法加入依赖会造成不必要的调用。
  2. 在Effect函数的逻辑里,经常会有state之间的对比if count !== step ...then, 这段逻辑事实上只想在count变化时触发,但lint会强制把step也加入而引起不必要的调用。

如果不开这条rule就必须开发自己保证在Effect中避免上面setInterval的错误。

用 useMemo 做局部 PureRender

useMemo对比memo的好处是useMemo可以更细粒度得控制哪些props会加入到渲染条件中。

// memo
const Child = memo((props) => {
  useEffect(() => {
    props.fetchData()
  }, [props.fetchData])

  return (
    // ...
  )
})
// useMemo
const Child = (props) => {
  useEffect(() => {
    props.fetchData()
  }, [props.fetchData])

  return useMemo(() => (
    // ...
  ), [props.fetchData])
}

使用 Context 做批量透传

const Store = createContext(null);

function Parent() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(0);
  const fetchData = useFetch(count, step);

  return (
    <Store.Provider value={{ count, setCount, setStep, fetchData }}>
      <Child />
    </Store.Provider>
  );
}

// memo不会生效,需要用useMemo替换
const Child = memo((props) => {
  const { count, setCount } = useContext(Store)

  function onClick() {
    setCount(count => count + 1)
  }

  return (
	   <div>
       { count }
     </div>
    // ...
  )
})

总结

Class Component or Function Component?

参考文档: 精读《Function Component 入门》 useEffect 完整指南