Better

Ethan的博客,欢迎访问交流

React Hooks 深入与状态逻辑复用发展

最近在公司一个小应用上尝试使用了一下 React Hooks,当然还只是浅尝辄止,于是这个周末计划好好学习一下,无奈周末效率很一般呀,与此同时本文还引申到了 React 状态逻辑复用的发展,其实之前的博客也有谈到过,但回过头来学习,感觉还是有很多不一样的地方。

内置 hooks

React 内置的 hooks

  • Basic Hooks
    • useState
    • useEffect
    • useContext
  • Additional Hooks
    • useReducer
    • useCallback
    • useMemo
    • useRef
    • useImperativeHandle
    • useLayoutEffect
    • useDebugValue

useState

最基本的 api,传入初始值,返回一个数组,得到存储该 state 的变量和修改函数,例子如下

const [count, setCount] = useState(0)

setState 函数用于更新 state。它接收一个新的 state 值并将组件的一次重新渲染加入队列。

如果新的 state 需要通过使用先前的 state 计算得出,那么还可以将函数传递给 setState。该函数将接收先前的 state,并返回一个更新后的值。

惰性初始 state:如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数中计算并返回初始的 state,此函数只在初始渲染时被调用。

setState 的函数式更新形式,允许我们指定 state 该如何改变而不用引用当前 state

把所有 state 都放在同一个 useState 调用中,或是每一个字段都对应一个 useState 调用,这两方式都能跑通。当你在这两个极端之间找到平衡,然后把相关 state 组合到几个独立的 state 变量时,组件就会更加的可读。如果 state 的逻辑开始变得复杂,我们推荐用 reducer 来管理它,或使用自定义 Hook。

由于默认情况下,每一次修改状态都会造成重新渲染,可以通过一个不使用的 set 函数来当成 forceUpdate。

const forceUpdate = () => useState(0)[1];

useReducer

useReducer 和 useState 几乎是一样的,需要外置外置 reducer (全局),通过这种方式可以对多个状态同时进行控制。

function reducer(state, action) {
  switch (action.type) {
    case 'up':
      return { count: state.count + 1 };
    case 'down':
      return { count: state.count - 1 };
  }
}
function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 1 })
  return (
    <div>
      {state.count}
      <button onClick={() => dispatch({ type: 'up' })}>+</button>
      <button onClick={() => dispatch({ type: 'down' })}>+</button>
    </div>
  );
}

核心源代码如下,基于 useState 实现

function useReducer(reducer, initialState) {
  const [state, setState] = useState(initialState);

  function dispatch(action) {
    const nextState = reducer(state, action);
    setState(nextState);
  }

  return [state, dispatch];
}

惰性初始化:将 init 函数作为 useReducer 的第三个参数传入,这样初始 state 将被设置为 init(initialArg)。

useEffect

在函数组件主体内(这里指在 React 渲染阶段)改变 DOM、添加订阅、设置定时器、记录日志以及执行其他包含副作用的操作都是不被允许的,因为这可能会产生莫名其妙的 bug 并破坏 UI 的一致性。因此需要使用 useEffect 完成副作用操作。

给函数组件增加了操作副作用的能力。它跟 class 组件中的 componentDidMount、componentDidUpdate 和 componentWillUnmount 具有相同的用途,只不过被合并成了一个 API。

用于处理各种状态变化造成的副作用,也就是说只有在特定的时刻,才会执行的逻辑。React 保证在 DOM 已经更新完成之后才会运行回调。

const [count, setCount] = useState(0);
useEffect(() => {
    // update
    document.title = `You clicked ${count} times`;
    // => componentWillUnMount
    return function cleanup() {
        document.title = 'app';
    }
}, [count]);

回调函数中,我们可以返回一个常用用于清理工作,这是 effect 可选的清除机制,比如

  • 事件监听
  • 取消订阅
  • 计时器
  • 中断 Ajax
  • ……

其中第二个参数的不同形式,会导致不同的执行情况,具体如下

useEffect(fn) // all state
useEffect(fn, []) // no state
useEffect(fn, [these, states])

如果你要使用此优化方式,请确保数组中包含了所有外部作用域中会随时间变化并且在 effect 中使用的变量,否则你的代码会引用到先前渲染中的旧变量。

函数中读取到旧的 props 和 state 的两个原因

  • 组件内部函数,包括事件处理函数和 effect,都是从它被创建的那次渲染中读取数据的,因此如果你刻意地想要从某些异步回调中读取最新的 state,你可以用 一个 ref 来保存它,修改它,并从中读取。
  • 使用了「依赖数组」优化但没有正确地指定所有的依赖。

如果 effect 的依赖频繁变化,但有些情况你会试图在依赖列表中省略哪个 state

  • setState 的函数形式
  • 更复杂的场景,一个 state 依赖另一个 state,则尝试用 useReducer 把 state 更新逻辑移到 effect 之外
  • 万不得已的情况下,可以 使用一个 ref 来保存一个可变的变量。

使用多个 Effect 实现关注点分离,解决面向生命周期编程的问题

useCallback

返回一个 memoized 函数。

把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。

当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。因为函数引用未发生改变。

useMemo

返回一个 memoized 值。

主要用于渲染过程优化,两个参数依次是计算函数(通常是组件函数)和依赖状态列表,当依赖的状态发生改变时,才会触发计算函数的执行。如果没有指定依赖,则每一次渲染过程都会执行该计算函数。

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

const memorizedChildComponent = useMemo(() => {
    return <Time />;
}, [count]);

关于依赖状态列表为空数组或者 undefined 的不同表现如同 useEffect。

把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算。

当你把引用对象传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。因为对象引用未发生改变。

useContext

context 是在外部 create,内部 use 的 state,它和全局变量的区别在于,数据的改变会触发依赖该数据组件的 rerender。而 useContext hooks 可以帮助我们简化 context 的使用。

如果不使用 useContext

// 1. 使用 createContext 创建上下文
const UserContext = new createContext();

// 2. 创建 Provider
const UserProvider = props => {
  let [username, handleChangeUsername] = useState('');
  return (
    <UserContext.Provider value={{ username, handleChangeUsername }}>
      {props.children}
    </UserContext.Provider>
  );
};

// 3. 创建 Consumer
const UserConsumer = UserContext.Consumer;

// 4. 使用 Consumer 包裹组件
const Pannel = () => (
  <UserConsumer>
    {({ username, handleChangeUsername }) => (
      <div>
        <div>user: {username}</div>
        <input onChange={e => handleChangeUsername(e.target.value)} />
      </div>
    )}
  </UserConsumer>
);

使用 useContext hooks

// 1. 使用 createContext 创建上下文
const UserContext = new createContext();

// 2. 创建 Provider
const UserProvider = props => {
  let [username, handleChangeUsername] = useState('');
  return (
    <UserContext.Provider value={{ username, handleChangeUsername }}>
      {props.children}
    </UserContext.Provider>
  );
};

const Pannel = () => {
  const { username, handleChangeUsername } = useContext(UserContext); // 3. 使用 Context
  return (
    <div>
      <div>user: {username}</div>
      <input onChange={e => handleChangeUsername(e.target.value)} />
    </div>
  );
};

useRef

useRef 返回一个可变的 ref 对象,其 .current 属性初始化为传递的参数( initialValue )。返回的对象将持续整个组件的生命周期。

A ref plays the same role as an instance field.

useRef 和 DOM refs 有点类似,但 useRef 是一个更通用的概念,它就是一个你可以放置一些东西的盒子。它可以很方便地保存任何可变值,其类似于在 class 中使用实例字段的方式。

获取 input 元素

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // `current` points to the mounted text input element
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

注意 useRef() 并不仅仅可以用来当作获取 ref 使用,使用 useRef 产生的 ref 的 current 属性是可变的,这意味着你可以用它来保存一个任意值。

useImperativeHandle

需要配合 forwardRef 使用,用于将 ref 暴露给父组件

useLayoutEffect

其函数签名与 useEffect 相同,但它会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。

尽可能使用标准的 useEffect 以避免阻塞视觉更新。

useLayoutEffect 与 componentDidMount、componentDidUpdate 的调用阶段是一样的。但是,我们推荐你一开始先用 useEffect,只有当它出问题的时再尝试使用 useLayoutEffect。

useDebugValue

为自定义 Hooks 在 React DevTools 展示一个标注。

自定义 Hook

可以通过自定义的 Hook 将组件中类似的状态逻辑抽取出来。

自定义 Hook 非常简单,我们只需要定义一个函数,并且把相应需要的状态和 effect 封装进去,同时,Hook 之间也是可以相互引用的。使用 use 开头命名自定义 Hook,这样可以方便 eslint 进行检查。

举几个常见的例子

日志打点

const useLogger = (componentName, ...params) => {
  useDidMount(() => {
    console.log(`${componentName}初始化`, ...params);
  });
  useUnMount(() => {
    console.log(`${componentName}卸载`, ...params);
  })
  useDidUpdate(() => {
    console.log(`${componentName}更新`, ...params);
  });
};

修改 title:根据不同的页面名称修改页面title:

function useTitle(title) {
  useEffect(
    () => {
      document.title = title;
      return () => (document.title = "主页");
    },
    [title]
  );
}

双向绑定

function useBind(init) {
  let [value, setValue] = useState(init);
  let onChange = useCallback(function(event) {
    setValue(event.currentTarget.value);
  }, []);
  return {
    value,
    onChange
  };
}

生命周期 Hooks

const useDidMount = fn => useEffect(() => fn && fn(), []);
const useDidUpdate = (fn, conditions) => {
  const didMoutRef = useRef(false);
  useEffect(() => {
    if (!didMoutRef.current) {
      didMoutRef.current = true;
      return;
    }
    // Cleanup effects when fn returns a function
    return fn && fn();
  }, conditions);
};
const useWillUnmount = fn => useEffect(() => () => fn && fn(), []);

解锁更多高级使用

// useHover
const useHover = () => {
  const [hovered, set] = useState(false);
  return {
    hovered,
    bind: {
      onMouseEnter: () => set(true),
      onMouseLeave: () => set(false),
    },
  };
};
function Hover() {
  const { hovered, bind } = useHover();
  return (
    <div>
      <div {...bind}>
        hovered:
        {String(hovered)}
      </div>
    </div>
  );
}
// useField
const useField = (initial) => {
  const [value, set] = useState(initial);

  return {
    value,
    set,
    reset: () => set(initial),
    bind: {
      value,
      onChange: e => set(e.target.value),
    },
  };
}
function Input {
  const { value, bind } = useField('Type Here...');

  return (
    <div>
      input text:
      {value}
      <input type="text" {...bind} />
    </div>
  );
}

Hooks 注意事项与解决问题

使用范围:只能在 React 函数式组件或自定义 Hook 中使用 Hook。

声明约束:不要在循环,条件或嵌套函数中调用 Hook,这一点和 Hook 的工作原理有关。

设计 Hooks 主要是解决 ClassComponent 的几个问题:

  • 很难复用逻辑(只能用HOC,或者render props),会导致组件树层级很深
  • 会产生巨大的组件(指很多代码必须写在类里面)
  • 类组件很难理解,比如方法需要bind,this指向不明确
  • 面向生命周期编程问题
    • 相互关联且需要对照修改的代码被进行了拆分
    • 完全不相关的代码却在同一个方法中组合在一起

Hooks 原理

相信使用过 useState 之后对于 React 在一次重新渲染的时候如何获取之前更新过的 state 呢?

我们需要了解到一个 fiber 的概念,每个节点都会有一个对应的Fiber对象,他的数据解构如下:

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Instance
  this.tag = tag;
  this.key = key;
  this.elementType = null;  // 就是ReactElement的`$$typeof`
  this.type = null;         // 就是ReactElement的type
  this.stateNode = null;

  // Fiber
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;

  this.ref = null;

  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.firstContextDependency = null;

  // ...others
}

需要注意的是 this.memoizedState,这个 key 就是用来存储在上次渲染过程中最终获得的节点的 state 的,每次执行 render 方法之前,React 会计算出当前组件最新的 state 然后赋值给 class 的实例,再调用 render

执行 functionalComponent 的时候,在第一次执行到 useState 的时候,他会对应 Fiber 对象上的memoizedState

这个属性原来设计来是用来存储 ClassComponentstate 的,因为在 ClassComponentstate 是一整个对象,所以可以和 memoizedState 一一对应。

但在 Hooks 中,React 并不知道我们会调用几次 useState,所以在保存 state 这件事情上,React 想出了一个比较有意思的方案,那就是调用 useState 后设置在 memoizedState 上的对象还存在一个 next 属性,类似与链表结构,next 指向的是下一次 useState 对应的 Hook 对象。

就是因为是以这种方式进行 state 的存储,所以 useState(包括其他的Hooks)都必须在 FunctionalComponent 的根作用域中声明,也就是不能在 if 或者循环中声明

扩展:状态逻辑复用的发展

React 状态逻辑复用通常有三种方案:mixin、Hoc、Hook,一句话总结下来就是:mixin 已被抛弃,HOC 正当壮年,Hook 初露锋芒。

mixin

Mixin(混入)是一种通过扩展收集功能的方式,它本质上是将一个对象的属性拷贝到另一个对象上面去,不过你可以拷贝任意多个对象的任意个方法到一个新对象上去,这是继承所不能实现的。它的出现主要就是为了解决代码复用问题。

很多开源库提供了 Mixin 的实现,如 Underscore 的 _.extend 方法、JQuery 的 extend 方法。

HOC

高阶组件本质是高阶函数,只不过他场景特定了,该函数接受一个组件作为参数,并返回一个新的组件。

HOC 组件实现的两种方式:属性代理和反向继承

属性代理:函数返回一个我们自己定义的组件,然后在render中返回要包裹的组件,这样我们就可以代理所有传入的props,并且决定如何渲染,实际上 ,这种方式生成的高阶组件就是原组件的父组件

function proxyHOC(WrappedComponent) {
  return class extends Component {
    render() {
      return <WrappedComponent {...this.props} />;
    }
  }
}

反向继承:返回一个组件,继承原组件,在 render 中调用原组件的 render。由于继承了原组件,能通过this访问到原组件的生命周期、props、state、render等,相比属性代理它能操作更多的属性。

function inheritHOC(WrappedComponent) {
  return class extends WrappedComponent {
    render() {
      return super.render();
    }
  }
}

HOC 能实现的常见功能

  • 组合渲染
  • 条件渲染
  • 操作 props
  • 获取 refs
  • 状态管理
  • 操作 state(反向继承)

如果组件同时用到多个 HOC,会导致代码难以阅读,我们可以创建一个 compose helper 函数

const compose = (...fns) => fns.reduce((f, g) => (...args) => g(f(...args)));
compose(logger,visible,style)(Input);

HOC 的实际应用

  • 日志打点:使用高阶组件(HOC)解决横切关注点
  • 可用、权限控制
  • 双向绑定
  • 表单校验

Redux connect

connect 函数就是高阶函数的思想,通过用法,我们大致可以实现简单 connect 原型,但这里不多书,下次起个单独的文章讲述关于 reduxreact-redux 的简单实现。

function connect(mapStateToProps, mapDispatchToProps) {
    const stateProps = mapStateToProps(store);
    const dispatchProps = mapDispatchToProps(dispatch)
    return function(WrapperComponent) {
        return function(props) {
            return <WrapperComponent {...props} {...stateProps} {...dispatchProps}/>
        }
    }
}

HOC 注意事项

使用高阶组件的注意事项

  • 静态属性拷贝:hoist-non-react-statics 自动拷贝所有静态属性
  • refs 传递
  • 不要在 render 方法内创建高阶组件:作者从 diff 算法的角度说明为啥会有性能问题,在极少数情况下,你需要动态调用 HOC。你可以在组件的生命周期方法或其构造函数中进行调用。
  • 不要改变原始组件
  • 透传不相关的 props
  • 约定-displayName:官方推荐使用 HOCName(WrappedComponentName)

关于 refs 传递需要多说两句,使用高阶组件后,获取到的 ref 实际上是最外层的容器组件,而非原组件,但是很多情况下我们需要用到原组件的 ref。我们需要用一个回调函数来完成 ref 的传递。

function hoc(WrappedComponent) {
  return class extends Component {
    getWrappedRef = () => this.wrappedRef;
    render() {
      return <WrappedComponent ref={ref => { this.wrappedRef = ref }} {...this.props} />;
    }
  }
}
@hoc
class Input extends Component {
  render() { return <input></input> }
}
class App extends Component {
  render() {
    return (
      <Input ref={ref => { this.inpitRef = ref.getWrappedRef() }} ></Input>
    );
  }
}

React 16.3 版本提供了一个 forwardRef API 来帮助我们进行 refs 传递,这样我们在高阶组件上获取的 ref 就是原组件的 ref 了,而不需要再手动传递

function hoc(WrappedComponent) {
  class HOC extends Component {
    render() {
      const { forwardedRef, ...props } = this.props;
      return <WrappedComponent ref={forwardedRef} {...props} />;
    }
  }
  return React.forwardRef((props, ref) => {
    return <HOC forwardedRef={ref} {...props} />;
  });
}

您可能已经注意到 HOC 与容器组件模式之间有相似之处。容器组件担任分离将高层和低层关注的责任,由容器管理订阅和状态,并将 prop 传递给处理渲染 UI。HOC 使用容器作为其实现的一部分,你可以将 HOC 视为参数化容器组件。

Render Props

renderProps 就是一种将 render 方法作为 props 传递到子组件的方案,相比 HOC 的方案,renderProps 可以保护原有的组件层次结构。在我看来,同时提供了花式渲染的更多可能。

最常见例子

class Mouse extends React.Component {
  static propTypes = {
    render: PropTypes.func.isRequired
  }

  state = { x: 0, y: 0 };

  handleMouseMove = (event) => {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }

  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
        {this.props.render(this.state)}
      </div>
    );
  }
}

function App() {
  return (
    <div style={{ height: '100%' }}>
      <Mouse render={({ x, y }) => (
          // render prop 给了我们所需要的 state 来渲染我们想要的
          <h1>The mouse position is ({x}, {y})</h1>
        )}/>
    </div>
  );
}

useHooks

这里有一个网址,里面有一些你可能会用到的 Hooks,比如

  • useEventListener
  • useWhyDidYouUpdate
  • useMedia
  • useLockBodyScroll
  • useDebounce
  • usePrevious

参考



留言