React Hooks 实践指南

React Hooks 实践指南

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

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

State 逻辑的复用

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

const Users = props => {
  return (
    <ul>
      {props.data.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
};

const Posts = props => {
  return (
    <ul>
      {props.data.map(post => <li key={post.id}>{post.title}</li>)}
    </ul>
  );
};

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

const withLoader = (BaseComponent, apiUrl) => {
  class EnhancedComponent extends React.Component {
    state = {
      data: null,
    };

    componentDidMount() {
      fetch(apiUrl)
        .then(res => res.json())
        .then(data => {
          this.setState({ data });
        });
    }

    render() {
      if (!this.state.data) {
        return 'Loading ...';
      }
      return <BaseComponent data={this.state.data}/>;
    }
  }

  return EnhancedComponent;
};

最终使用如下:

import React, { Component } from "react";
import { render } from "react-dom";

const EnhancedUsers = withLoader(
  Users,
  "https://jsonplaceholder.typicode.com/users"
);
const EnhancedPosts = withLoader(
  Posts,
  "https://jsonplaceholder.typicode.com/posts/"
);

class App extends Component {
  render() {
    return (
      <div>
        <h2> users </h2>
        <EnhancedUsers />
        <h2> posts </h2>
        <EnhancedPosts />
      </div>
    );
  }
}

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

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

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

class Loader extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      data: []
    };
  }

  componentDidMount() {
    fetch(this.props.apiUrl)
      .then(res => res.json())
      .then(data => {
        this.setState({ data });
      });
  }

  render() {
    if (!this.state.data) {
      return "Loading ...";
    }
    return this.props.children({data: this.state.data})
  }
}

此时最终使用如下:

import React, { Component } from "react";
import { render } from "react-dom";

class App extends Component {
  render() {
    return (
      <div>
        <h2> users </h2>
        <Loader apiUrl="https://jsonplaceholder.typicode.com/users">
          {({ data }) => <Users data={data} />}
        </Loader>

        <h2> posts </h2>
        <Loader apiUrl="https://jsonplaceholder.typicode.com/posts/">
          {({ data }) => <Posts data={data} />}
        </Loader>
      </div>
    );
  }
}

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

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

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

  1. 代码写起来很复杂,不清爽,复杂业务很容易导致代码量剧增;
  2. 分割在不同生命周期中的 state 逻辑使代码难以理解;
  3. this 问题所带来的困扰;


UI 与 可复用的 State 逻辑分离

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

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

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

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

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

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

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

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

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

function useLoader (apiUrl) {
  const [data, setData] = useState([]);

  useEffect(() => {
      fetch(apiUrl)
        .then(res => res.json())
        .then(data => {
          setData(data);
        });
  }, []);

  return data;
}

最终使用如下:

import React from "react";
import { render } from "react-dom";

const App = () => {
  const users = useLoader('https://jsonplaceholder.typicode.com/users');
  const posts = useLoader("https://jsonplaceholder.typicode.com/posts/");

  return <>
    <Users data={users} />
    <Posts data={posts} />
  </>
}

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

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

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

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

Hooks 的使用

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

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

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

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);
  
  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);
  
  return width;
}

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

function useArray(array) {
  const [value, setValue] = useState(array);

  const operators = useMemo(() => ({
    push: item => {
      setValue(v => [...v, item])
    },
    pop: () => setValue(v => v.slice(0, -1)),
    removeIndex: index => {
      setValue(v => {
        const copy = v.slice();
        copy.splice(index, 1);
        return copy;
      });
    },
    clear: () => setValue([])
  }), []);

  return [value, operators]
}


const TODOS = () => {
  const [todos, operators] = useArray(["hi there", "sup", "world"]);

  return (
    <div>
      <ul>
        {todos.map((item, idx) => <li key={idx}>{item}</li>)}
      </ul>
      <button onClick={() => operators.push(Math.random())}> add </button>
      <button onClick={operators.clear}> clear todos </button>
    </div>
  );
};

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

function useModalVisible() {
  const [visible, setVisible] = useState(false);
  const openModal = useCallback(() => setVisible(true), [])
  const closeModal = useCallback(() => setVisible(false), []);

  return [visible, openModal, closeModal]
}

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

function useModalSize() {
  const width = useWindowWidth();
  const size = useMemo(() => {
    if (width < 800) { return 'small' }
    if (width >= 800 && width < 1366) { return 'middle'}
    if (width > 1366) { return 'large' }
  }, [width])

  return size;
}

const MyModal = () => {
  const [visible, openModal, closeModal] = useModalVisible();
  const size = useModalSize();

  return <>
    <Button onClick={openModal} onCancel={closeModal}>Open Modal</Button>
    <Modal visible={visible} size={size}>
      <p> Modal Content </p>
    </Modal>
  </>
}

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

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}


const Counter = () => {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

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

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

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

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

const createContainer = (useHook) => {
  const Context = createContext();

  const useContainer = () => {
    return useContext(Context);
  };

  const Provider = ({ initialState, children }) => {
    const value = useHook(initialState);
    return <Context.Provider value={value}>{children}</Context.Provider>;
  };

  return { Provider, useContainer }
};

createContainer 的使用如下:

const useCounter = () => {
  let [count, setCount] = useState(0)
  let decrement = () => setCount(count - 1)
  let increment = () => setCount(count + 1)
  return { count, decrement, increment }
}

const Counter = createContainer(useCounter)

const CounterDisplay = () => {
  let {decrement, count, increment} = Counter.useContainer()
  
  return (
    <div>
      <button onClick={decrement}>-</button>
      <p>You clicked {count} times</p>
      <button onClick={increment}>+</button>
    </div>
  )
}

const APP = () => {
  return (
    <Counter.Provider>
      <CounterDisplay />
      <CounterDisplay />
      <CounterDisplay />
    </Counter.Provider>
  )
}

Hooks 的设计缺陷

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

  1. 处理用户交互的回调里可以通过 this 访问 state 与 setState;
  2. 在组件初始化或者更新的过程中,各生命周期方法可以通过 this 访问 state 与 setState;

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

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

const [dataA, setDataA] = useState(0);
const [dataB, setDataB] = useState('string');
const [dataC, setDataC] = useState({});

每次快照:

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

useEffect 的代码实现大致如下:

useEffect(cb, depArray) {
  const hasNoDeps = !depArray;
  hooks[idx] = hooks[idx] || {};
  const {deps, cleanup} = hooks[idx]; // undefined when first render
  const hasChanged = deps
    ? !depArray.every((el, i) => el === deps[i])
    : true;
  if (hasNoDeps || hasChanged) {
    cleanup && cleanup();
    hooks[idx].cleanup = cb();
    hooks[idx].deps = depArray;
  }
  idx++;
}

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

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

  1. 每次 render 通过递增的 index 访问闭包里所有的 state 与 setState,且它们可以被处理用户交互的回调方法或 useEffect 的回调方法所使用;
  2. 副作用代码不再被分割到生命周期方法中,而是在分离关注后形成单一职责的 effect 回调函数,并在每次更新时通过判断所依赖 state 是否发生变化而决定是否执行;

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

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

  1. 由于通过递增的 index 访问, Hooks 的执行顺序要在每次 render 时必须保持一致,对于新手来说是一个大坑;
  2. useEffect 解决了 Function Component 无生命周期时所面临问题,但 useEffect 并不是干掉了生命周期的概念,而是隐藏了生命周期概念,尤其是约定当依赖数组为 [] 时 返回的 cleanup 方法等价于 componentWillUnmount,这理解起来有些突兀,新手很容易在使用 useEffect 返回 cleanup 时踩坑;
useEffect(() => {
  console.log("I'm mounted!");
  return () =>  console.log("I'm going to unmount!");
}, []);

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

令人困惑的 Dependency Array

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

  1. 更新依赖,例如 useCallback、useMemo;
  2. 触发 useEffect 执行

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

  • 错误的依赖变量对比
const data = {a: 1, b: 2};
// 改为 const data = useMemo(() => ({a: 1, b: 2}), [])

useEffect(() => {
  // do something
}, [data]);

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

const Component = ({arr}) => {
  useEffect(() => {
    // do something
  }, [arr]);

  return (...)
}

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

// 改为 const arr = useMemo(() => [1, 2], []); <Component arr={arr} frequent={frequent} />
  • 不遵循单一职责,没有使用多个 effect 来分离问题;
useEffect(() => {
 solveProblem1(a);
 solveProblem2(b); 
}, [a, b]);

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

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

useEffect(() =>
 window.addEventListener('beforeunload', () => {
    save(title, abstract, content);
 });
}, [title, content, content]);

<input value={title} />
<input value={abstract} />
<textarea value={content} /></textarea>

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

const supSave = useCallback(() => {
  save(title, abstract, content);
}, [title, abstract, content]);  

useEffect(() =>
 window.addEventListener('beforeunload', supSave);
}, [subSave]);

比较糙的解决方法是

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

const supSave = useCallback(() => {
  setArticle(article => {
    const {title, abstract, content} = article;
    save(title, abstract, content);
  });
}, []);  

useEffect(() =>
  window.addEventListener('beforeunload', supSave);
}, [supSave]);

<input value={article.title} />
<input value={article.abstract} />
<textarea value={article.content} /></textarea>

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

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

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

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

function dispatch(type, value) {
  if (type === 'type1') {
    setState(state => ({
      ...state,
      a: 'value'
    }));
  }

  if (type === 'type2') {
    setState(state => value)
  }
}

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

  1. 在复杂的副作里,将逻辑拆分为 action 和 callback 两部分,这与 flux 思想类似:useEffect 中避免直接修改 state,只能触发 action。管理 state 的逻辑放在 callback 中,通过侦听 action 来执行具体的操作;
  2. 管理 state 逻辑的 callback 要么通过 setState 的参数获取所需 state,要么通过 useReducer,我们可以在 reducer 参数里获取到全部 state;
  3. useEffect 的 Array Dependency 里只包含触发 action 的变量;

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

发布于 2020-08-14 16:43