3

hooks.jpg

什么是hooks?

hooks 是 react 在16.8版本开始引入的一个新功能,它扩展了函数组件的功能,使得函数组件也能实现状态、生命周期等复杂逻辑。

import React, { useState } from 'react';

function Example() {
  // Declare a new state variable, which we'll call "count"
  const [count, setCount] = useState(0);

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

上面是 react 官方提供的 hooks 示例,使用了内置hookuseState,对应到<u>Class Component</u>应该这么实现

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

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

简而言之,hooks 就是钩子,让你能更方便地使用react相关功能。

hooks解决了什么问题?

看完上面一段,你可能会觉得除了代码块精简了点,没看出什么好处。别急,继续往下看。

过去,我们习惯于使用<u>Class Component</u>,但是它存在几个问题:

  • 状态逻辑复用困难

    • 组件的状态相关的逻辑通常会耦合在组件的实现中,如果另一个组件需要相同的状态逻辑,只能借助<u>render props</u> 和 <u>high-order components</u>,然而这会破坏原有的组件结构,带来 JSX wrapper hell 问题。
  • side effect 复用和组织困难

    • 我们经常会在组件中做一些有 side effect 的操作,比如请求、定时器、打点、监听等,代码组织方式如下
    class FriendStatusWithCounter extends React.Component {
      constructor(props) {
        super(props);
        this.state = { count: 0, isOnline: null };
        this.handleStatusChange = this.handleStatusChange.bind(this);
      }
    
      componentDidMount() {
        document.title = `You clicked ${this.state.count} times`;
        ChatAPI.subscribeToFriendStatus(
          this.props.friend.id,
          this.handleStatusChange
        );
      }
    
      componentDidUpdate() {
        document.title = `You clicked ${this.state.count} times`;
      }
    
      componentWillUnmount() {
        ChatAPI.unsubscribeFromFriendStatus(
          this.props.friend.id,
          this.handleStatusChange
        );
      }
    
      handleStatusChange(status) {
        this.setState({
          isOnline: status.isOnline
        });
      }
      
      render() {
        return (
          <div>
            <p>You clicked {this.state.count} times</p>
            <p>Friend {this.props.friend.id} status: {this.state.isOnline}</p>
            <button onClick={() => this.setState({ count: this.state.count + 1 })}>
              Click me
            </button>
          </div>
        );
      }
    }

复用的问题就不说了,跟状态逻辑一样,主要说下代码组织的问题。1. 为了在组件刷新的时候更新文档的标题,我们在componentDidMountcomponentDidUpdate中各写了一遍更新逻辑; 2. 绑定朋友状态更新和解绑的逻辑,分散在componentDidMountcomponentWillUnmount中,实际上这是一对有关联的逻辑,如果能写在一起最好;3. componentDidMount中包含了更新文档标题和绑定事件监听,这2个操作本身没有关联,如果能分开到不同的代码块中更利于维护。

  • Javascript Class 天生缺陷

    • 开发者需要理解this的指向问题,需要记得手动 bind 事件处理函数,这样代码看起来很繁琐,除非引入@babel/plugin-proposal-class-properties(这个提案目前还不稳定)。
    • 现代工具无法很好地压缩 class 代码,导致代码体积偏大,hot reloading效果也不太稳定。

为了解决上述问题,hooks 应运而生。让我们使用 hooks 改造下上面的例子

import React, { useState, useEffect } from 'react';

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    // Specify how to clean up after this effect:
    return function cleanup() {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });
  return isOnline;
}

function FriendStatusWithCounter(props) {
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });
  const isOnline = useFriendStatus(props.friend.id);
  
  return (
    <div>
      <p>You clicked {count} times</p>
      <p>Friend {props.friend.id} status: {isOnline}</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

function FriendStatus(props) {
  // 通过自定义hook复用逻辑
  const isOnline = useFriendStatus(props.friend.id);

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

看,问题都解决了!

怎么使用?

hooks 一般配合<u>Function Components</u>使用,也可以在内置 hooks 的基础上封装自定义 hook。

先介绍下 react 提供的内置 hooks。

useState

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

useState接收一个参数作为初始值,返回一个数组,数组的第一个元素是表示当前状态值的变量,第二个参数是修改状态的函数,执行的操作类似于this.setState({ count: someValue }),当然内部的实现并非如此,这里仅为了帮助理解。

useState可以多次调用,每次当你需要声明一个state时,就调用一次。

function ExampleWithManyStates() {
  // Declare multiple state variables!
  const [age, setAge] = useState(42);
  const [fruit, setFruit] = useState('banana');
  const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);

需要更新某个具体状态时,调用对应的 setXXX 函数即可。

useEffect

useEffect的作用是让你在<u>Function Components</u>里面可以执行一些 side effects,比如设置监听、操作dom、定时器、请求等。

  • 普通side effect
useEffect(() => {
  document.title = `You clicked ${count} times`;
});
  • 需要清理的effect,回调函数的返回值作为清理函数
useEffect(() => {
  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }

  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  // Specify how to clean up after this effect:
  return function cleanup() {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
});

需要注意,上面这种写法,每次组件更新都会执行 effect 的回调函数和清理函数,顺序如下:

// Mount with { friend: { id: 100 } } props
ChatAPI.subscribeToFriendStatus(100, handleStatusChange);     // Run first effect

// Update with { friend: { id: 200 } } props
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // Clean up previous effect
ChatAPI.subscribeToFriendStatus(200, handleStatusChange);     // Run next effect

// Update with { friend: { id: 300 } } props
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // Clean up previous effect
ChatAPI.subscribeToFriendStatus(300, handleStatusChange);     // Run next effect

// Unmount
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // Clean up last effect

这个效果等同于在componentDidMountcomponentDidUpdatecomponentWillUnmount实现了事件绑定和解绑。如果只是组件的 state 变化导致重新渲染,同样会重新调用 cleanup 和 effect,这时候就显得没有必要了,所以 useEffect 支持用第2个参数来声明依赖

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]);

第2个参数是一个数组,在数组中传入依赖的 state 或者 props,如果依赖没有更新,就不会重新执行 cleanup 和 effect。

如果你需要的是只在初次渲染的时候执行一次 effect,组件卸载的时候执行一次 cleanup,那么可以传一个空数组[]作为依赖。

useContext

context这个概念大家应该不陌生,一般用于比较简单的共享数据的场景。useContext就是用于实现context功能的 hook。

来看下官方提供的示例

const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee"
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222"
  }
};

const ThemeContext = React.createContext(themes.light);

function App() {
  return (
    <ThemeContext.Provider value={themes.dark}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

function ThemedButton() {
  const theme = useContext(ThemeContext);

  return (
    <button style={{ background: theme.background, color: theme.foreground }}>
      I am styled by theme context!
    </button>
  );
}

代码挺长,但是一眼就能看懂了。把 context 对象传入useContext,就可以拿到最新的 context value。

需要注意的是,只要使用了useContext的组件,在 context value 改变后,一定会触发组件的更新,哪怕他使用了React.memo或是shouldComponentUpdate

useReducer

useReducer(reducer, initialArg)返回[state, dispatch],跟 redux 很像。

const initialState = {count: 0};

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

除此之外,react 内置的 hooks 还包括useCallbackuseMemouseRefuseImperativeHandleuseLayoutEffectuseDebugValue,这里就不再赘述了,可以直接参考官方文档

自定义 hook

基于内置 hook,我们可以封装自定义的 hook,上面的示例中已经出现过useFriendStatus这样的自定义 hook,它能帮我们抽离公共的组件逻辑,方便复用。注意,自定义 hook 也需要以use开头。

我们可以根据需要创建各种场景的自定义 hook,如表单处理、计时器等。后面实战场景的章节中我会具体介绍几个例子。

实现原理

hooks 的使用需要遵循几个规则:

  • 必须在顶层调用,不能包裹在条件判断、循环等逻辑中
  • 必须在 <u>Function Components</u> 或者自定义 hook 中调用

之所以有这些规则限制,是跟 hooks 的实现原理有关。

这里我们尝试实现一个简单的版本的useStateuseEffect用来说明。

const memoHooks = [];
let cursor = 0;

function useState(initialValue) {
  const current = cursor;
  const state = memoHooks[current] || initialValue;
  function setState(val) {
    memoHooks[current] = val;
    // 执行re-render操作
  }
  cursor++;
  return [state, setState];
}

function useEffect(cb, deps) {
  const hasDep = !!deps;
  const currentDeps = memoHooks[cursor];
  const hasChanged = currentDeps ? !deps.every((val, i) => val === currentDeps[i]) : true;
  if (!hasDep || hasChanged) {
    cb();
    memoHooks[cursor] = deps;
  }
  cursor++;
}

此时我们需要构造一个函数组件来使用这2个 hooks

function Example() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  }, [count]);
  const [name, setName] = useState('Joe');
  useEffect(() => {
    console.log(`Your name is ${name}`);
  });
  
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}
  1. 渲染前:memoHooks 为[],cursor 为0
  2. 第一次渲染

    1. 执行const [count, setCount] = useState(0);,memoHooks 为[0],cursor 为0
    2. 执行useEffect(() => { document.title = You clicked ${count} times; }, [count]);,memoHooks 为[0, [0]],cursor 为1
    3. 执行const [name, setName] = useState('Joe');,memoHooks 为[0, [0], 'Joe'],cursor 为2
    4. 执行useEffect(() => { console.log(Your name is ${name}); });,memoHooks 为[0, [0], 'Joe', undefined],cursor 为3
  3. 点击按钮

    1. 执行setCount(count + 1),memoHooks 为[1, [0], 'Joe', undefined],cursor 为0
    2. 执行 re-render
  4. re-render

    1. 执行const [count, setCount] = useState(0);,memoHooks 为[1, [0], 'Joe', undefined],cursor 为0
    2. 执行useEffect(() => { document.title = You clicked ${count} times; }, [count]);,memoHooks 为[1, [1], 'Joe', undefined],cursor 为1。这里由于hooks[1]的值变化,会导致 cb 再次执行。
    3. 执行const [name, setName] = useState('Joe');,memoHooks 为[1, [1], 'Joe', undefined],cursor 为2
    4. 执行useEffect(() => { console.log(Your name is ${name}); });,memoHooks 为[1, [1], 'Joe', undefined],cursor 为3。这里由于依赖为 undefined,导致 cb 再次执行。

通过上述示例,应该可以解答为什么 hooks 要有这样的使用规则了。

  • 必须在顶层调用,不能包裹在条件判断、循环等逻辑中:hooks 的执行对于顺序有强依赖,必须要保证每次渲染组件调用的 hooks 顺序一致。
  • 必须在 <u>Function Components</u> 或者自定义 hook 中调用:不管是内置 hook,还是自定义 hook,最终都需要在 <u>Function Components</u> 中调用,因为内部的memoHookscursor其实都跟当前渲染的组件实例绑定,脱离了<u>Function Components</u>,hooks 也无法正确执行。

当然,这些只是为了方便理解做的一个简单demo,react 内部实际上是通过一个单向链表来实现,并非 array,有兴趣可以自行翻阅源码。

实战场景

操作表单

实现一个hook,支持自动获取输入框的内容。

function useInput(initial) {
  const [value, setValue] = useState(initial);
  const onChange = useCallback(function(event) {
    setValue(event.currentTarget.value);
  }, []);
  return {
    value,
    onChange
  };
}

// 使用示例
function Example() {
  const inputProps = useInput('Joe');
  return <input {...inputProps} />
}

网络请求

实现一个网络请求hook,能够支持初次渲染后自动发请求,也可以手动请求。参数传入一个请求函数即可。

function useRequest(reqFn) {
  const initialStatus = {
    loading: true,
    result: null,
    err: null
  };
  const [status, setStatus] = useState(initialStatus);
  function run() {
    reqFn().then(result => {
      setStatus({
        loading: false,
        result,
        err: null
      })
    }).catch(err => {
      setStatus({
        loading: false,
        result: null,
        err
      });
    });
  }
  // didMount后执行一次
  useEffect(run, []);
  return {
    ...status,
    run
  };
}

// 使用示例
function req() {
  // 发送请求,返回promise
  return fetch('http://example.com/movies.json');
}
function Example() {
  const {
    loading,
    result,
    err,
    run
  } = useRequest(req);
  return (
    <div>
      <p>
        The result is {loading ? 'loading' : JSON.stringify(result || err)}
      </p>
      <button onClick={run}>Reload</button>
    </div>
  );
}

上面2个例子只是实战场景中很小的一部分,却足以看出 hooks 的强大,当我们有丰富的封装好的 hooks 时,业务逻辑代码会变得很简洁。推荐一个github repo,这里罗列了很多社区产出的 hooks lib,有需要自取。

使用建议

根据官方的说法,在可见的未来 react team 并不会停止对 class component 的支持,因为现在绝大多数 react 组件都是以 class 形式存在的,要全部改造并不现实,而且 hooks 目前还不能完全取代 class,比如getSnapshotBeforeUpdatecomponentDidCatch这2个生命周期,hooks还没有对等的实现办法。建议大家可以在新开发的组件中尝试使用 hooks。如果经过长时间的迭代后 function components + hooks 成为主流,且 hooks 从功能上可以完全替代 class,那么 react team 应该就可以考虑把 class component 移除,毕竟没有必要维护2套实现,这样不仅增加了维护成本,对开发者来说也多一份学习负担。

参考文章


JoeRay61
2.3k 声望182 粉丝

下一篇 »
WebGL 初印象