源码分析:react hook 实践

原文连接javascript

前言

本文从 mini React —— Preact 源码的角度,分析 React Hook 各个 API 的优势缺点。 从而理解为何要用 hook,以及如何最佳使用。html

2条规则

为何?

  1. ✅只在最顶层使用 Hook,不要在循环,条件或嵌套函数中调用 Hook;
  2. ✅只在 React 函数中调用 Hook,不要在普通的 JavaScript 函数中调用 Hook。

源码分析

let currentIndex; // 全局索引
let currentComponent; // 当前 hook 所在的组件

function getHookState(index) {
  const hooks =
    currentComponent.__hooks ||
    (currentComponent.__hooks = {_list: [], _pendingEffects: []});

  if (index >= hooks._list.length) {
    hooks._list.push({});
  }
  return hooks._list[index];
}
复制代码
// 获取注册的 hook
const hookState = getHookState(currentIndex++);
复制代码
  • hook 状态都维护在数组结构中,执行 hook api 时,索引 currentIndex + 1 依次存入数组。 当组件 render 以前,会先调用 hook render,重置索引和设置当前组件,hook 注入在 options 内。
options._render = vnode => {
  currentComponent = vnode._component;
  currentIndex = 0;
  // ...
};
复制代码
  • 首先须要知道一点的是,函数组件 在每次 diff 时,整个函数都会从新执行, 而 class组件 只会执行 this.render,所以 hook 在性能上会有些损耗, 考虑到这一点 hook 为那些声明开销很大的数据结构和函数,提供了 useMemouseCallback 优化。java

  • hook 在每次 render 时,取上一次 hook state 时, 若是在循环,条件或嵌套函数不肯定的分支里执行,就有可能取错数据,致使混乱。node

function Todo(props) {
  const [a] = useState(1);
  if(props.flag) {
    const [b] = useState(2);
  }
  const [c] = useState(3);
  // ...
}
复制代码
<Todo flag={true} />
复制代码
  • 此时 a = 1, b = 2, c = 3;
<Todo flag={false} />
复制代码
  • 当条件被改变时,a = 1, c = 2c取错了状态!

第二条嘛,就显而易见了,hook 寄生于 react 组件和生命周期。react

  • Preact hookoptions 对象上声明了 _render -> diffed -> _commit -> unmount 四个钩子, 分别会在对象组件的生命周期前执行,这样侵入性较小。

preact

useState

使用方式

// 声明 hook
const [state, setState] = useState(initialState);
// 更新 state
setState(newState);

// 也能够函数式更新
setState(prevState => { // 能够拿到上一次的 state 值
  // 也可使用 Object.assign
  return {...prevState, ...updatedValues};
});
复制代码
  • 惰性初始 state。若是初始化 state 值开销很大,能够传入函数,初始化只会执行一次。
const [state, setState] = useState(() => {
  const initialState = someExpensiveComputation(props);
  return initialState;
});
复制代码
  • 跳过 state 更新。设置相同的值(Object.is判断),不会触发组件更新。
const [state, setState] = useState(0);
// ...
// 更新 state 不会触发组件从新渲染
setState(0);
setState(0);
复制代码

为何?

  • 坑:依赖 props.state === 1 初始化 hook,为何 props.state === 2 时,hook state 不会变化?
function Component(props) {
  const [state, setState] = useState(props.state);
  // ...
}
复制代码
  • 惰性初始的原理是什么?
  • hook state 变动是怎么驱动组件渲染的,为何说能够当 class state 使用?

源码分析

  • PreactuseState 是使用 useReducer 实现的,便于行文,代码会略加修改。
function useState(initialState) {
  const hookState = getHookState(currentIndex++);
  if (!hookState._component) {
    hookState._component = currentComponent;

    hookState._value = [
      invokeOrReturn(undefined, initialState),

      action => {
        const nextValue = invokeOrReturn(hookState._value[0], action);
        if (hookState._value[0] !== nextValue) {
          hookState._value[0] = nextValue;
          hookState._component.setState({});
        }
      }
    ];
  }

  return hookState._value;
}
复制代码
// 工具函数,用来支持函数式初始和更新
function invokeOrReturn(arg, f) {
  return typeof f === 'function' ? f(arg) : f;
}
复制代码
  • 能够看出 useState 只会在组件首次 render 时初始化一次,之后由返回的函数来更新状态。
  1. 坑:初始化(包括传入的函数)只会执行一次,因此不该该依赖 props 的值来初始化 useState;
  2. 优化:能够利用传入函数来性能优化开销较大的初始化操做。
  • hookState._value[0] !== nextValue 比较新旧值避免没必要要的渲染。
  • 能够看出,更新操做利用了组件实例的 this.setState 函数。这就是为何 hook 能够代替 classthis.state 使用。

useEffect

使用方式

  • 例如,经常使用根据 query 参数,首次加载组件只发一次请求内容。
function Component(props) {
  const [state, setState] = useState({});
  
  useEffect(() => {
    ajax.then(data => setState(data));
  }, []); // 依赖项
  // ...
}
复制代码
  • useState 有说到,props 初始 state 有坑,能够用 useEffect 实现。
function Component(props) {
  const [state, setState] = useState(props.state);
  
  useEffect(() => {
    setState(props.state);
  }, [props.state]); // props.state 变更赋值给 state
  // ...
}
复制代码
  • 清除反作用,例如监听改变浏览器窗口大小,以后清除反作用
function WindowWidth(props) {
  const [width, setWidth] = useState(0);

  function onResize() {
    setWidth(window.innerWidth);
  }
  // 只执行一次反作用,组件 unmount 时会被清除
  useEffect(() => {
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  return <div>Window width: {width}</div>;
}
复制代码
  • 注意:在 useEffect 在使用 state 时最好把它做为依赖,否则容易产生 bug
function Component() {
  const [a, setA] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => console.log(a), 100);
    return () => clearInterval(timer)
  }, []);
  return <button onClick={() => setA(a+1)}>{a}</button>
}
复制代码

当你点击按钮 a+=1 时,此时 console.log 依旧打印 0。 这是由于 useEffect 的反作用只会在组件首次加载时入 _pendingEffects 数组,造成闭包。git

修改以下:github

function Component() {
  const [a, setA] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => console.log(a), 100);
    return () => clearInterval(timer)
- }, []);
+ }, [a]);
  return <button onClick={() => setA(a+1)}>{a}</button>
}
复制代码

这段代码在 React 里运行,输出会随点击按钮而变化,而在 preact 中,以前定时器未被清除, 说明有 bug。-_-||ajax

为何?

  • useEffect 解决了什么问题

通常发送数据请求 componentDidMount 中,以后 componentWillUnmount 在相关清理。 这就致使相互无关的逻辑夹杂在 componentDidMount,而对应的扫尾工做却分配在 componentWillUnmount 中。小程序

有了 useEffect ,你能够把相互独立的逻辑写在不一样的 useEffect 中,他人担忧维护时,也不用担忧其余代码块里还有清理代码。api

  • 在组件函数体内执行反作用(改变 DOM、添加订阅、设置定时器、记录日志等)是不被容许的?

每次 diff 函数组件会被当作class 组件的 this.render 函数相似使用, 总体会被执行,在主体里操做反作用是致命的。

  • useEffect 的机制?

源码分析

function useEffect(callback, args) {
  const state = getHookState(currentIndex++);
  if (argsChanged(state._args, args)) {
    state._value = callback;
    state._args = args;

    currentComponent.__hooks._pendingEffects.push(state);
  }
}
复制代码
  • 工具函数,依赖项为 undefined 或依赖项数组中一个值变更,则 true
function argsChanged(oldArgs, newArgs) {
  return !oldArgs || newArgs.some((arg, index) => arg !== oldArgs[index]);
}
复制代码
  • 能够看出反作用的回调函数会在 _pendingEffects 数组中维护,代码有两处执行
options._render = vnode => {
  currentComponent = vnode._component;
  currentIndex = 0;

  if (currentComponent.__hooks) { // 这里为何要清理了再执行!!!
    currentComponent.__hooks._pendingEffects.forEach(invokeCleanup);
    currentComponent.__hooks._pendingEffects.forEach(invokeEffect);
    currentComponent.__hooks._pendingEffects = [];
  }
};
复制代码
function invokeCleanup(hook) {
  if (hook._cleanup) hook._cleanup();
}

function invokeEffect(hook) {
  const result = hook._value(); // 若是反作用函数有返回函数的,会被当成清理函数保存。
  if (typeof result === 'function') hook._cleanup = result;
}
复制代码
options.diffed = vnode => {
  const c = vnode._component;
  if (!c) return;

  const hooks = c.__hooks;
  if (hooks) {
    if (hooks._pendingEffects.length) {
      afterPaint(afterPaintEffects.push(c));
    }
  }
};
复制代码
function afterPaint(newQueueLength) {
  if (newQueueLength === 1 || prevRaf !== options.requestAnimationFrame) {
    prevRaf = options.requestAnimationFrame;
    (prevRaf || afterNextFrame)(flushAfterPaintEffects);
  }
}
复制代码
function flushAfterPaintEffects() {
  afterPaintEffects.some(component => {
    if (component._parentDom) {
      try {
        component.__hooks._pendingEffects.forEach(invokeCleanup);
        component.__hooks._pendingEffects.forEach(invokeEffect);
        component.__hooks._pendingEffects = [];
      } catch (e) {
        options._catchError(e, component._vnode);
        return true;
      }
    }
  });
  afterPaintEffects = [];
}
复制代码
  • 我很怀疑,options._render 的代码是从 flushAfterPaintEffects 不假思索的拷过去。 致使上面讲到的一个 bug

  • afterPaint 利用 requestAnimationFramesetTimeout 来达到如下目的

componentDidMountcomponentDidUpdate 不一样的是,在浏览器完成布局与绘制以后,传给 useEffect 的函数会延迟调用,不会在函数中执行阻塞浏览器更新屏幕的操做。 (勘误:ReactuseEffect 能达到这效果,Preact 并无实现)

useMemo

使用方式

function Counter () {
  const [count, setCount] = useState(0);
  const [val, setValue] = useState('');
  const expensive = useMemo(() => {
    let sum = 0;
    for (let i = 0; i < count * 100; i++) {
      sum += i;
    }
    return sum
  }, [ count ]); // ✅ 只有 count 变化时,回调函数才会执行

  return (
    <>
      <span>You Clicked {expensive} times</span>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      <input value={val} onChange={event => setValue(event.target.value)} />
    </>
  )
}
复制代码

为何?

  • useMemo 解决了什么问题

上面反复强调了,函数组件体会被反复执行,若是进行大的开销的会吃性能。 因此 react 提供了 useMemo 来缓存函数执行返回结果,useCallback 来缓存函数。

源码分析

function useMemo(factory, args) {
  const state = getHookState(currentIndex++);
  if (argsChanged(state._args, args)) {
    state._args = args;
    state._factory = factory;
    return (state._value = factory());
  }

  return state._value;
}
复制代码
  • 能够看出,只是把传入的函数根据依赖性执行了一遍把结果保存在内部的 hook state 中。

  • 记住,全部的 hook api 都同样,不要在没有传入state 做为依赖项的状况下,在副租用中 使用 state

useCallback

使用方式

const onClick = useCallback(
  () => console.log(a, b),
  [a, b]
);
复制代码

为何?

  • useCallback 解决了什么问题

上面提到了,用来缓存函数的

  • 例如,上面优化监听窗口的例子。
function WindowWidth(props) {
  const [width, setWidth] = useState(0);

- function onResize() {
- setWidth(window.innerWidth);
- }
  
+ const onResize = useCallback(() => {
+ setWidth(window.innerWidth);
+ }, []);

  useEffect(() => {
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  return <div>Window width: {width}</div>;
}
复制代码

上面说过,没有依赖的时,不使要用 width,但可使用 setWidth, 函数是引用,闭包变量 setWidth 是同一个地址。

源码分析

  • useMemo 的封装
function useCallback(callback, args) {
  return useMemo(() => callback, args);
}
复制代码

useRef

使用方式

  • 举个例子,点击按钮开启 60 秒倒计时,再次点击中止。
function Counter() {
  const [start, setStart] = useState(false);
  const [time, setTime] = useState(60);

  useEffect(() => { // effect 函数,不接受也不返回任何参数
    let interval;
    if (start) {
      interval = setInterval(() => {
        setTime(time - 1); // ❌ time 在 effect 闭包函数里是拿不到准确值的
      }, 1000);
    }
    return () => clearInterval(interval) // clean-up 函数,当前组件被注销时调用
  }, [start]); // 依赖数组,当数组中变量变化时会调用 effect 函数

  return (
    <button onClick={() => setStart(!start)}>{time}</button>
  );
}
复制代码
  • 在前面的分析中,因为闭包的缘由,取到的 time 值不是最新的。 能够用 time 的初始值来传给 useRef,再来驱动 time 的更新。
function Counter() {
  const [start, setStart] = useState(false);
  const [time, setTime] = useState(60);
+ const currentTime = useRef(time); // 生成一个可变引用
	
  useEffect(() => { // effect 函数,不接受也不返回任何参数
    let interval;
    if (start) {
      interval = setInterval(() => {
+ setTime(currentTime.current--) // currentTime.current 是可变的
- setTime(time - 1); // ❌ time 在 effect 闭包函数里是拿不到准确值的
      }, 1000);
    }
    return () => clearInterval(interval) // clean-up 函数,当前组件被注销时调用
  }, [start]); // 依赖数组,当数组中变量变化时会调用 effect 函数

  return (
    <button onClick={() => setStart(!start)}>{time}</button>
  );
}
复制代码
  • useRef 生成一个对象 currentTime = {current: 60}currentTime 对象在组件的整个生命周期内保持不变。

  • 但这样处理有点画蛇添足,setTime 函数式更新不就行了嘛,current 能够用来替代 interval,这样外部也能取消倒计时。

function Counter() {
  const [start, setStart] = useState(false);
  const [time, setTime] = useState(60);
- const currentTime = useRef(time); // 生成一个可变引用
+ const interval = useRef() // interval 能够在这个做用域里任何地方清除和设置
	
  useEffect(() => { // effect 函数,不接受也不返回任何参数
- let interval;
    if (start) {
- interval = setInterval(() => {
+ interval.current = setInterval(() => {
- setTime(currentTime.current--) // currentTime.current 是可变的
+ setTime(t => t - 1) // ✅ 在 setTime 的回调函数参数里能够拿到对应 state 的最新值
      }, 1000);
    }
- return () => clearInterval(interval) // clean-up 函数,当前组件被注销时调用
+ return () => clearInterval(interval.current) // clean-up 函数,当前组件被注销时调用
  }, [start]); // 依赖数组,当数组中变量变化时会调用 effect 函数

  return (
    <button onClick={() => setStart(!start)}>{time}</button>
  );
}
复制代码

这样既能消灭 interval 变量的反复建立,也能让外部可以清理定时器 interval.current

为何?

  • useRef 返回的对象在组件的整个生命周期内保持不变,怎么理解?
  • 为何不能改变返回的对象,而是只能改变对象 current 属性?

源码分析

function useRef(initialValue) {
  return useMemo(() => ({ current: initialValue }), []);
}
复制代码
  • 内部使用了 useMemo 来实现,传入一个生成一个具备 current 属性对象的函数, 空数组依赖,因此在整个生命周期该函数只执行一次。
  • 直接改变 useRef 返回的值,没法改变内部 hookState._value 值,只能经过 改变内部 hookState._value.current 来影响下次的使用。

useLayoutEffect

使用方式

  • useEffect 使用方式相同。

为何?

  • useEffect 区别在哪里?

源码分析

  • useEffect 的回调在 option.diffed 阶段, 使用 requestAnimationFramesetTimeout(callback, 100) 来异步执行,因为做者都认为 this is not such a big deal ,因此代码就不贴了,并且只是有一层 requestAnimationFrame 也达不到下一帧以前执行的效果。

  • useLayoutEffect 的回调在 option._commit 阶段批量同步处理。

  • React 中估计使用了 requestIdleCallbackrequestAnimationFrame 来进行时间分片,以免阻塞视觉更新。

  • 因为 react 本身内部使用了优先级调度,势必会致使某些低优先级会延迟执行,只有你以为优先级很高,在无论阻塞渲染的状况也要同步执行, 那么你能够用 useLayoutEffect

useReducer

使用方式

  • 数字±1与重置
const initialState = 0;
const reducer = (state, action) => {
  switch (action) {
    case 'increment': return state + 1;
    case 'decrement': return state - 1;
    case 'reset': return 0;
    default: throw new Error('Unexpected action');
  }
};

function Counter() {
  const [count, dispatch] = useReducer(reducer, initialState);
  return (
    <div> {count} <button onClick={() => dispatch('increment')}>+1</button> <button onClick={() => dispatch('decrement')}>-1</button> <button onClick={() => dispatch('reset')}>reset</button> </div>
  );
}
复制代码
  • 第二个参数能够一个函数,返回 state 的初始值;
  • 第三个参数能够一个函数,以第二个参数为入参,返回 state 的初始值。

为何?

  • 何时使用 useReducer

state 逻辑较复杂且包含多个子值,下一个 state 依赖于以前的 state。

使用 reducer 最好是个纯函数,集中处理逻辑,修改源头方便追溯,避免逻辑分散各处,也能避免不可预知的地方修改了状态,致使 bug 难追溯。

源码分析

  • 前面说到,useStateuseReducer 实现的。
function useReducer(reducer, initialState, init) {
  const hookState = getHookState(currentIndex++);
  if (!hookState._component) {
    hookState._component = currentComponent;

    hookState._value = [
      !init ? invokeOrReturn(undefined, initialState) : init(initialState),

      action => {
        const nextValue = reducer(hookState._value[0], action);
        if (hookState._value[0] !== nextValue) {
          hookState._value[0] = nextValue;
          hookState._component.setState({});
        }
      }
    ];
  }

  return hookState._value;
}
复制代码
  • 上一次的 statereducer 的第一个参数,dispatch 接受的参数为第二个参数,产生新的 state

useContext

使用方式

  • 例如设置全局主题 theme
// App.js
function App() {
  return <Toolbar theme="dark" />;
}

// Toolbar.js
function Toolbar(props) {
  // 很麻烦,theme 需层层传递全部组件。
  return (
    <div>
      <ThemedButton theme={props.theme} />
    </div>
  );
}

// ThemedButton.js
class ThemedButton extends React.Component {
  render() {
    return <Button theme={this.props.theme} />;
  }
}
复制代码
  • 使用 Context
// context.js
+ const ThemeContext = React.createContext('light');

// App.js
function App() {
- return <Toolbar theme="dark" />;
+ return (
+ <ThemeContext.Provider value="dark">
+ <Toolbar />
+ </ThemeContext.Provider>
   );
}

// Toolbar.js
function Toolbar(props) {
  return (
    <div>
- <ThemedButton theme={props.theme} />
+ <ThemedButton /> // 无需传递
    </div>
  );
}

// ThemedButton.js
class ThemedButton extends React.Component {
+ static contextType = ThemeContext; // 指定 contextType 读取当前的 theme context。
  render() {
- return <Button theme={this.props.theme} />;
+ return <Button theme={this.context} />; // React 会往上找到最近的 theme Provider,theme 值为 “dark”。
  }
}
复制代码
  • 使用 useContext
// context.js
const ThemeContext = React.createContext('light');

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

// Toolbar.js
function Toolbar(props) {
  return (
    <div>
      <ThemedButton /> // 无需传递
    </div>
  );
}

// ThemedButton.js
- class ThemedButton extends React.Component {
- static contextType = ThemeContext; // 指定 contextType 读取当前的 theme context。
- render() {
- return <Button theme={this.context} />; // React 会往上找到最近的 theme Provider,theme 值为 “dark”。
- }
- }
+ function ThemedButton() {
+ const theme = useContext(ThemeContext);
+ 
+ return <Button theme={theme} />;
+ }
复制代码
  • useContext(MyContext) 至关于 class 组件 中的 static contextType = MyContext

  • 当组件上层最近的 <MyContext.Provider> 更新时,该 Hook 会触发重渲染,并使用最新传递给 MyContext provider 的 context value 值。即便祖先使用 React.memo 或 shouldComponentUpdate,也会在组件自己使用 useContext 时从新渲染。

可使用 React.memouseMemo hook性能优化

为何?

  • useContext 怎么拿到 context 的,而后驱动变化?

源码分析

  • 在当前组件上,拿到 context,订阅当前组件,当 context 发生变化时,发布通知。
function useContext(context) {
  const provider = currentComponent.context[context._id];
  if (!provider) return context._defaultValue;
  const state = getHookState(currentIndex++);
  // This is probably not safe to convert to "!"
  if (state._value == null) {
    state._value = true;
    provider.sub(currentComponent);
  }
  return provider.props.value;
}
复制代码

自定义 hook

使用方式

  • 常见的给组件添加防抖功能,例如使用 antd 的 SelectInput 组件,你可能分别对应使用他们来从新组合一个新的组件,把防抖实如今新组件内部。
  • 利用自定义 hook 能够更细粒度的来分离组件与防抖的关系。
// 防抖 hook
function useDebounce() {
  const time = useRef({lastTime: Date.now()});
  return (callback, ms) => {
    time.current.timer && clearTimeout(time.current.timer);
    time.current.timer = setTimeout(() => {
      const now = Date.now();
      console.log(now - time.current.lastTime);
      time.current.lastTime = now;
      callback();
    }, ms);
  }
}
复制代码
function App() {
  const [val, setVal] = useState();
  const inputChange = useDebounce();
  // 能够屡次使用
  // const selectChange = useDebounce();
  
  return (
    <>
      <input onChange={
        ({target: {value}}) => {
          inputChange(() => setVal(value), 500)
        }
      }/>{val}
    </>
  );
}
复制代码

函数组件 hook 与 class 组件的对比

缺点

  1. 性能较差,但也只是浏览器解析JS级别的损耗。

优点

  1. 减小了代码量,相关逻辑更聚合,便于阅读与维护;
  2. 不用理解 classthisclass 目前还只是语法糖,标准还在更改,没有像传统面向对象的多态、多继承的概念,this 理解成本很高;
  3. 纯函数有利于例如 ts 推导类型,等等。

参考

  1. React 文档
  2. Preact 文档
  3. Preact 源码
  4. 使用 React Hooks 重构你的小程序
相关文章
相关标签/搜索