查看原文
其他

【第2037期】React Hooks 实践指南

繁星 前端早读课 2020-08-25

前言

现今还有好玩的东西么?今日早读文章由@繁星授权分享。

正文从这开始~~

在良好抽象的基础上,实现关注分离并合理地复用代码,这是编程的核心。

组件化开发可以帮助前端实现一定程度的关注分离,但其主要解决 UI 的复用,我们日常开发过程中还面临着 state 逻辑的关注分离与复用问题。

State 逻辑的复用

下面是两个纯组件,分别用来展示 users 和 posts 信息:

  1. const Users = props => {

  2. return (

  3. <ul>

  4. {props.data.map(user => <li key={user.id}>{user.name}</li>)}

  5. </ul>

  6. );

  7. };


  8. const Posts = props => {

  9. return (

  10. <ul>

  11. {props.data.map(post => <li key={post.id}>{post.title}</li>)}

  12. </ul>

  13. );

  14. };

users 和 posts 数据获取的方式是一样的,我们通过 HOC 来实现请求数据的 state 逻辑复用:

  1. const withLoader = (BaseComponent, apiUrl) => {

  2. class EnhancedComponent extends React.Component {

  3. state = {

  4. data: null,

  5. };


  6. componentDidMount() {

  7. fetch(apiUrl)

  8. .then(res => res.json())

  9. .then(data => {

  10. this.setState({ data });

  11. });

  12. }


  13. render() {

  14. if (!this.state.data) {

  15. return 'Loading ...';

  16. }

  17. return <BaseComponent data={this.state.data}/>;

  18. }

  19. }


  20. return EnhancedComponent;

  21. };

最终使用如下:

  1. import React, { Component } from "react";

  2. import { render } from "react-dom";


  3. const EnhancedUsers = withLoader(

  4. Users,

  5. "https://jsonplaceholder.typicode.com/users"

  6. );

  7. const EnhancedPosts = withLoader(

  8. Posts,

  9. "https://jsonplaceholder.typicode.com/posts/"

  10. );


  11. class App extends Component {

  12. render() {

  13. return (

  14. <div>

  15. <h2> users </h2>

  16. <EnhancedUsers />

  17. <h2> posts </h2>

  18. <EnhancedPosts />

  19. </div>

  20. );

  21. }

  22. }


  23. render(<App />, document.getElementById("root"));

上面的例子相对简单,如果遇到复杂的业务逻辑,HOC 的缺点很明显:比如属性不能完全一致导致覆盖,又或者遇到黑盒问题,必须到 BaseComponent 查看实现细节等。

另外一种实现 state 逻辑复用的方式是 Render Props:

  1. class Loader extends React.Component {

  2. constructor(props) {

  3. super(props);

  4. this.state = {

  5. data: []

  6. };

  7. }


  8. componentDidMount() {

  9. fetch(this.props.apiUrl)

  10. .then(res => res.json())

  11. .then(data => {

  12. this.setState({ data });

  13. });

  14. }


  15. render() {

  16. if (!this.state.data) {

  17. return "Loading ...";

  18. }

  19. return this.props.children({data: this.state.data})

  20. }

  21. }

此时最终使用如下:

  1. import React, { Component } from "react";

  2. import { render } from "react-dom";


  3. class App extends Component {

  4. render() {

  5. return (

  6. <div>

  7. <h2> users </h2>

  8. <Loader apiUrl="https://jsonplaceholder.typicode.com/users">

  9. {({ data }) => <Users data={data} />}

  10. </Loader>


  11. <h2> posts </h2>

  12. <Loader apiUrl="https://jsonplaceholder.typicode.com/posts/">

  13. {({ data }) => <Posts data={data} />}

  14. </Loader>

  15. </div>

  16. );

  17. }

  18. }


  19. render(<App />, document.getElementById("root"));

使用 Render Props 可以避免 HOC 所遇到的问题,但是很容易陷入标签嵌套地狱。


除了上面提及的问题之外,日常开发我们还经常面临:

  • 代码写起来很复杂,不清爽,复杂业务很容易导致代码量剧增;

  • 分割在不同生命周期中的 state 逻辑使代码难以理解;

  • this 问题所带来的困扰;

UI 与 可复用的 State 逻辑分离

Componet 在 pros 发生改变时会重新 render,这是 React 组件化设计的一个基础约定。

我们也见过其他形式,例如基于原生 JavaScript 的地图渲染引擎中常常可以看到类似这样的代码:

  1. const map = L.map('map').setView([51.505, -0.09], 13);

如果将其改写为 React 的 Componet 形式,代码会是:

  1. <Map id="map" zoom={13} position={[51.505, -0.09]} />

过去主流的前端架构体系均通过 this 将 state 与生命周期函数绑定,将 state 的逻辑分割在组件的不同生命周期中。在这个基础上, state 的逻辑的复用只能围绕 props 来开展。

HOC 和 Render Props 实现 state 逻辑复用均是建立在 props 传递之上的,所以显得十分笨拙。那么是否有更好的方式呢?

React 团队基于 Function Component 提出了 Hooks 的概念,包含了 useState、useEffect、useContext 等几个关键 API。

使用这些 API 我们可以将可复用的 state 逻辑与 UI 分离,这样我们无需基于 props 实现逻辑复用,而是通过灵活的组合将可复用的 state 逻辑使用在不同的组件中。这种方式不仅用起来非常简单,而且让 React 更 Reactive:

  1. function useLoader (apiUrl) {

  2. const [data, setData] = useState([]);


  3. useEffect(() => {

  4. fetch(apiUrl)

  5. .then(res => res.json())

  6. .then(data => {

  7. setData(data);

  8. });

  9. }, []);


  10. return data;

  11. }

最终使用如下:

  1. import React from "react";

  2. import { render } from "react-dom";


  3. const App = () => {

  4. const users = useLoader('https://jsonplaceholder.typicode.com/users');

  5. const posts = useLoader("https://jsonplaceholder.typicode.com/posts/");


  6. return <>

  7. <Users data={users} />

  8. <Posts data={posts} />

  9. </>

  10. }


  11. render(<App />, document.getElementById("root"));

Hooks 使用闭包来将 state 和处理 state 的方法关联起来,这种方式相比于使用 Class 能降低可观的代码量,且代码看起来十分清爽。

Hooks 的好处非常明显,且十分好用!

但好用并不等于上手快,这一点和 React 框架本身很像:语法和概念简单,API 少,但想很好的驾驭需要一定的内功,对于编程能力不足的人来说有一定的挑战。

Hooks 的使用

Hooks 是 Function,所以我们只要划分好职责,明确输入和输出便可以尽情享受 Hooks 带来的编程乐趣:

可复用的 Custom Hooks 常用于浏览器 API 调用、事件处理等副作用处理上,一般会使用 useState 和 useEffect,上文中用于数据获取的 useLoader 就是一个典型的场景。

下面是随时获取到浏览器窗口宽度的代码:

  1. function useWindowWidth() {

  2. const [width, setWidth] = useState(window.innerWidth);


  3. useEffect(() => {

  4. const handleResize = () => setWidth(window.innerWidth);

  5. window.addEventListener('resize', handleResize);

  6. return () => {

  7. window.removeEventListener('resize', handleResize);

  8. };

  9. }, []);


  10. return width;

  11. }

我们也可以对常见的数据结构进行封装,用以返回 state 和对应的可触发页面更新的 setState ,比如:

  1. function useArray(array) {

  2. const [value, setValue] = useState(array);


  3. const operators = useMemo(() => ({

  4. push: item => {

  5. setValue(v => [...v, item])

  6. },

  7. pop: () => setValue(v => v.slice(0, -1)),

  8. removeIndex: index => {

  9. setValue(v => {

  10. const copy = v.slice();

  11. copy.splice(index, 1);

  12. return copy;

  13. });

  14. },

  15. clear: () => setValue([])

  16. }), []);


  17. return [value, operators]

  18. }



  19. const TODOS = () => {

  20. const [todos, operators] = useArray(["hi there", "sup", "world"]);


  21. return (

  22. <div>

  23. <ul>

  24. {todos.map((item, idx) => <li key={idx}>{item}</li>)}

  25. </ul>

  26. <button onClick={() => operators.push(Math.random())}> add </button>

  27. <button onClick={operators.clear}> clear todos </button>

  28. </div>

  29. );

  30. };

凡是可以跨组件复用的单一职责的 state 逻辑,这些逻辑无论简单与否,当相同的逻辑代码多次出现时,就可以考虑提取出来:

  1. function useModalVisible() {

  2. const [visible, setVisible] = useState(false);

  3. const openModal = useCallback(() => setVisible(true), [])

  4. const closeModal = useCallback(() => setVisible(false), []);


  5. return [visible, openModal, closeModal]

  6. }

Hooks 可以根据需要进行嵌套或组合使用,例如:

  1. function useModalSize() {

  2. const width = useWindowWidth();

  3. const size = useMemo(() => {

  4. if (width < 800) { return 'small' }

  5. if (width >= 800 && width < 1366) { return 'middle'}

  6. if (width > 1366) { return 'large' }

  7. }, [width])


  8. return size;

  9. }


  10. const MyModal = () => {

  11. const [visible, openModal, closeModal] = useModalVisible();

  12. const size = useModalSize();


  13. return <>

  14. <Button onClick={openModal} onCancel={closeModal}>Open Modal</Button>

  15. <Modal visible={visible} size={size}>

  16. <p> Modal Content </p>

  17. </Modal>

  18. </>

  19. }

基于 useRef 存储实现的一些功能性 Hooks,例如:

  1. function usePrevious(value) {

  2. const ref = useRef();

  3. useEffect(() => {

  4. ref.current = value;

  5. });

  6. return ref.current;

  7. }



  8. const Counter = () => {

  9. const [count, setCount] = useState(0);

  10. const prevCount = usePrevious(count);


  11. useEffect(() => setTimeout(() => setCount(10), 2000), []);


  12. return <h1> Now: {count}, before: {prevCount} </h1>

  13. };

基于 useContext 更方便实现跨组件共享 state 的管理

  1. import React, { createContext, useContext } from "react";


  2. const createContainer = (useHook) => {

  3. const Context = createContext();


  4. const useContainer = () => {

  5. return useContext(Context);

  6. };


  7. const Provider = ({ initialState, children }) => {

  8. const value = useHook(initialState);

  9. return <Context.Provider value={value}>{children}</Context.Provider>;

  10. };


  11. return { Provider, useContainer }

  12. };

createContainer 的使用如下:

  1. const useCounter = () => {

  2. let [count, setCount] = useState(0)

  3. let decrement = () => setCount(count - 1)

  4. let increment = () => setCount(count + 1)

  5. return { count, decrement, increment }

  6. }


  7. const Counter = createContainer(useCounter)


  8. const CounterDisplay = () => {

  9. let {decrement, count, increment} = Counter.useContainer()


  10. return (

  11. <div>

  12. <button onClick={decrement}>-</button>

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

  14. <button onClick={increment}>+</button>

  15. </div>

  16. )

  17. }


  18. const APP = () => {

  19. return (

  20. <Counter.Provider>

  21. <CounterDisplay />

  22. <CounterDisplay />

  23. <CounterDisplay />

  24. </Counter.Provider>

  25. )

  26. }

Hooks 的设计缺陷

我们知道,在 Class 组件的设计中是通过 this 将 state 与对应的处理方法关联在一起,这样主要包含两个方面:

  • 处理用户交互的回调里可以通过 this 访问 state 与 setState;

  • 在组件初始化或者更新的过程中,各生命周期方法可以通过 this 访问 state 与 setState;

但 Function Component 不在有生命周期的概念:Hooks 是通过闭包实现 state 与对应的处理方法关联在一起,而且每一次更新时 Function Component 的所有部分都会执行。

我们把 Function Component 每一次更新后所对应的 state 称作一次快照,React 的 Hooks 会根据执行顺序在内部维护一个递增的 index 来将闭包里的变量映射到对应的 state,并且只在第一次 render 时接受 initState, 之后每次 render 都通过 index 从闭包里获取对应的 state 值。例如以下代码:

  1. const [dataA, setDataA] = useState(0);

  2. const [dataB, setDataB] = useState('string');

  3. const [dataC, setDataC] = useState({});

每次快照:


副作用 useEffect 在每一次快照中会将其 Array Dependency 中的 state 和 返回的 cleanup 方法存储在自己的 hooks[index] 中。在下一次更新时会先执行 cleanup 方法,然后对比依赖的state 与上一次相比是否发生变化,进而决定副作用回调方法是否执行。

useEffect 的代码实现大致如下:

  1. useEffect(cb, depArray) {

  2. const hasNoDeps = !depArray;

  3. hooks[idx] = hooks[idx] || {};

  4. const {deps, cleanup} = hooks[idx]; // undefined when first render

  5. const hasChanged = deps

  6. ? !depArray.every((el, i) => el === deps[i])

  7. : true;

  8. if (hasNoDeps || hasChanged) {

  9. cleanup && cleanup();

  10. hooks[idx].cleanup = cb();

  11. hooks[idx].deps = depArray;

  12. }

  13. idx++;

  14. }

useMemo 与 useCallback 原理与 useEffect 类似,会存储所依赖的 state 并在下一次更新时做对比,再根据依赖是否发生变化返回对应的结果。

这样 Hooks 就可以基于 Function Component 做到:

  • 每次 render 通过递增的 index 访问闭包里所有的 state 与 setState,且它们可以被处理用户交互的回调方法或 useEffect 的回调方法所使用;

  • 副作用代码不再被分割到生命周期方法中,而是在分离关注后形成单一职责的 effect 回调函数,并在每次更新时通过判断所依赖 state 是否发生变化而决定是否执行;

这样我们就可以让 Function Component 拥有与 Class Component 一样的能力。

但需要注意的是,这样的设计并不完美,缺陷非常明显:

  • 由于通过递增的 index 访问, Hooks 的执行顺序要在每次 render 时必须保持一致,对于新手来说是一个大坑;

  • useEffect 解决了 Function Component 无生命周期时所面临问题,但 useEffect 并不是干掉了生命周期的概念,而是隐藏了生命周期概念,尤其是约定当依赖数组为 [] 时 返回的 cleanup 方法等价于 componentWillUnmount,这理解起来有些突兀,新手很容易在使用 useEffect 返回 cleanup 时踩坑;

  1. useEffect(() => {

  2. console.log("I'm mounted!");

  3. return () => console.log("I'm going to unmount!");

  4. }, []);

这些从设计根源上所带来的问题,需要我们在利用 React Hooks 优点简化代码,提高代码可读性和复用性时,努力避免踏入其缺陷误区。

令人困惑的 Dependency Array

从上文我们可以得知,Dependency Array 在 Hooks 中的作用主要有两点:

  • 更新依赖,例如 useCallback、useMemo;

  • 触发 useEffect 执行;

Dependency Array 在 useEffect 中的滥用比较多。新手往往会在 useEffect 的 Dependency Array 里放入许多本不应该放入的依赖变量,从而导致许多副作用回调被过多或异常触发。

错误的依赖变量对比
  1. const data = {a: 1, b: 2};

  2. // 改为 const data = useMemo(() => ({a: 1, b: 2}), [])


  3. useEffect(() => {

  4. // do something

  5. }, [data]);

或者由于 props 的错误传入导致

  1. const Component = ({arr}) => {

  2. useEffect(() => {

  3. // do something

  4. }, [arr]);


  5. return (...)

  6. }


  7. <Component arr={[1, 2]} frequent={frequent} />


  8. // 改为 const arr = useMemo(() => [1, 2], []); <Component arr={arr} frequent={frequent} />

不遵循单一职责,没有使用多个 effect 来分离问题;

  1. useEffect(() => {

  2. solveProblem1(a);

  3. solveProblem2(b);

  4. }, [a, b]);

问题 1 依赖变量 a,问题 2 依赖变量 b,但如果放在同一个 useEffect 中,b 的变更也会导致问题 1 逻辑的执行。

未将 effect 触发执行的 action 与真实的回调执行逻辑解耦;
  1. const [title, setTitle] = useState(null);

  2. const [abstract, setAbstract] = useState(null);

  3. const [content, setContent] = useState(null);


  4. useEffect(() =>

  5. window.addEventListener('beforeunload', () => {

  6. save(title, abstract, content);

  7. });

  8. }, [title, content, content]);


  9. <input value={title} />

  10. <input value={abstract} />

  11. <textarea value={content} /></textarea>

上面的例子中,每次 form 输入都会触发一次事件监听。下面这段更隐晦的代码是等效的:

  1. const supSave = useCallback(() => {

  2. save(title, abstract, content);

  3. }, [title, abstract, content]);


  4. useEffect(() =>

  5. window.addEventListener('beforeunload', supSave);

  6. }, [subSave]);

比较糙的解决方法是

  1. const [article, setArticle] = useState({ title, abstract, content});


  2. const supSave = useCallback(() => {

  3. setArticle(article => {

  4. const {title, abstract, content} = article;

  5. save(title, abstract, content);

  6. });

  7. }, []);


  8. useEffect(() =>

  9. window.addEventListener('beforeunload', supSave);

  10. }, [supSave]);


  11. <input value={article.title} />

  12. <input value={article.abstract} />

  13. <textarea value={article.content} /></textarea>

如果希望代码更加优雅,可以使用 useReducer,可以达到上面代码相同的效果。他们解决问题的本质是:拒绝从 useEffect 的 Array dependency 中获取副作用回调执行所需要的 state!

我们知道,在每一次 render 时取到的 setState 或 useReducer 返回的 dispatch 都是第一次 render 生成并留在内存中的对象,所以 stateState 或 dispatch 是稳定不变的,我们可以放心使用。

我们可以利用 setState 的 callback 参数获取 state,甚至你可以通过以下代码实现类似 useReducer 的效果:

  1. const [state, setState] = useState({});


  2. function dispatch(type, value) {

  3. if (type === 'type1') {

  4. setState(state => ({

  5. ...state,

  6. a: 'value'

  7. }));

  8. }


  9. if (type === 'type2') {

  10. setState(state => value)

  11. }

  12. }

我们在使用 useEffect 时应该优先思考的原则是:

  • 在复杂的副作里,将逻辑拆分为 action 和 callback 两部分,这与 flux 思想类似:useEffect 中避免直接修改 state,只能触发 action。管理 state 的逻辑放在 callback 中,通过侦听 action 来执行具体的操作;

  • 管理 state 逻辑的 callback 要么通过 setState 的参数获取所需 state,要么通过 useReducer,我们可以在 reducer 参数里获取到全部 state;

  • useEffect 的 Array Dependency 里只包含触发 action 的变量;

只要我们按照这三个原则去使用 useEffect,就一定可以避免绝大部分误区!

关于本文 作者:@繁星 原文:https://zhuanlan.zhihu.com/p/163381120

@繁星曾分享过


【第1508期】深入浅出 Javascript Decorators 和 AOP 编程


【第1505期】谈谈代理


欢迎自荐投稿,前端早读课等你来

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存