blog icon indicating copy to clipboard operation
blog copied to clipboard

React Hooks 实践指南

Open wang2lang opened this issue 5 years ago • 0 comments

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

组件化开发可以帮助前端实现一定程度的关注分离,但其主要解决 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 所遇到的问题,但是很容易陷入标签嵌套地狱

v2-e933125b91ada677d15429fed816334f_1440w

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

  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 来开展。

HOC 和 Render 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 的好处非常明显,且十分好用!

但好用并不等于上手快,这一点和 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;
}

  1. 我们也可以对常见的数据结构进行封装,用以返回 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>
  );
};
  1. 凡是可以跨组件复用的单一职责的 state 逻辑,这些逻辑无论简单与否,当相同的逻辑代码多次出现时,就可以考虑提取出来:
function useModalVisible() {
  const [visible, setVisible] = useState(false);
  const openModal = useCallback(() => setVisible(true), [])
  const closeModal = useCallback(() => setVisible(false), []);

  return [visible, openModal, closeModal]
}
  1. 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>
  </>
}
  1. 基于 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>
};

  1. 基于 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({});

每次快照:

v2-f855912a8b8294b252a6ad3f12cb755d_r

副作用 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,就一定可以避免绝大部分误区!

wang2lang avatar Sep 01 '20 04:09 wang2lang