editflow系列(一):科普React Hooks

分类: 开发笔记

在讲解editflow相关设计之前,先来熟悉一下React Hooks。

React Hooks的函数式思维

官方文档中Hooks的例程非常简单:

import React, { useState } from 'react';

function Example() {
  // 声明一个新的叫做 “count” 的 state 变量
  const [count, setCount] = useState(0);

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

通过useState就可以在函数组件中“使用状态”。“状态”对于函数来说是本应是一个副作用,Hooks则为纯函数带来了使用各式各样的副作用的方案。

什么叫副作用呢?

数学里面的函数,或者纯函数式编程里面的函数,对于确定的输入/参数,函数返回/输出的结果也应当是确定的。

对于React函数式组件而言,props是输入,return的Virutal DOM则是输出。对于确定的props,组件的内容和样式应当是确定不变的。

但是像React组件这种UI的组成部分,往往需要根据一些外部状态,改变其展现的界面内容或样式。也就是说,即使输入的props保持一致,组件的输出结果仍然可能发生变化。

就像上面的例子,组件根本没有props,即没有输入,当然也就可以认为输入是恒定的;但每次组件的按钮被单击,都会使得计数器数值增加1,也就是输出并不恒定——这说明该组件不应是纯函数组件。

组件函数内部使用了一个外部状态count和修改外部状态的方法setCount。他们构成了函数的副作用。

我一般倾向于将状态描述为“函数内部使用的外部状态”,而不是“函数内部的状态”,因为该状态实际上只是被函数所用,二者并没有包含或者拥有关系。

这个状态会因按钮单击而发生变化(也就是组件Example会改变外部状态),同时<p>的值也会随count状态值的改变而改变(外部状态会影响Example的行为)。如此一来,纯函数式组件就做不到了。因为“受外部状态影响”或者“改变外部状态”,这些都是副作用,纯函数不应拥有副作用。

以前,只能使用React的组件类来实现副作用;但现在有了Hooks,函数式组件也同样可以做到。

useState

使用状态,即创建/使用/修改一个外部状态。

函数式组件渲染,相当于执行一次该函数获得返回的Virtual DOM。useState的作用简单概括一下,就是只在第一次渲染(执行函数)时,创建state并赋初值,后续的渲染(执行函数)useState只会返回上次state的值。

这样一来就构成了一个外部的状态,useState与函数的输入无关,仅仅用于初始化或获取状态的值。

当然,useState返回的第二个参数setState用于改变状态,允许函数拥有改变外部状态的能力。

乍一听好像函数拥有了状态了,似乎不再纯粹。但实际上还是前文提到的,函数本身并无状态。这里仅仅是注入了外部的一个状态,相当于一种输入;同时函数会根据输入,不仅仅输出Virtual DOM,还会“输出一些副作用”,并不产生副作用。简单点说,就是函数调用setState方法并不会真的改变相应state的值,而是对外输出一个“改变的意向/效果”。React调度器知道了它有这个意向,才会真正实现对应的效果——改变状态值。

所有的Hooks其实都是这样,并不会直接产生副作用,而是使得函数有能力对外输出产生副作用的意向。
不过,我为了表述方便,下面就不再咬文嚼字特意区分这个概念了。毕竟已经了解,就不必过于纠结字眼,能够方便理解其应用才是后文应该关心的。

useEffect

使用副作用,即,使得函数在某些情况下可以触发副作用(引起外部环境改变)。

其实按我理解,当你使用useState返回的setState函数时,同样也是触发了副作用。不过那个副作用,是改变了外部的React状态。

这里useEffect可以做到的是,改变任意外部环境的状态(而不仅限于React的state变量),比如修改浏览器的标题、发起IO(如网络请求)等。

另外,useEffect并非由程序主动触发,而是根据依赖变量值的变化触发。默认情况是每次渲染(执行函数)都会触发,但提供了依赖变量后,副作用仅在依赖变量的值发生改变时触发。

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

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

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

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

上例中,修改document.title是一个副作用,它仅会在count改变时触发。指定依赖变量可以避免不必要的副作用触发,提高性能。

简单一点理解,useEffect就是声明一些事情随着一些变量的改变而发生。

useEffect的第一个参数即副作用函数,它还可以拥有一个返回值,返回值的类型也是一个函数。这个函数将被用于清理副作用。

每次副作用被触发时,React会首先调用上次副作用返回的清理函数以清理上次的副作用。这在使用时钟、发起网络请求等场景中十分有用。

例如:

useEffect(() => {
    const timer = setTimeout(() => { 
        console.log("timeout"); 
    }, 1000);
    return () => clearTimeout(timer);
});

useContext

有时候我们需要一个状态或者全局的数据同时被多个子组件甚至子孙组件获得或改变,除了把状态或数据当作props一层层传下去,还有什么办法吗?

那就是使用useContext了!

const MyCtx = React.createContext({ val: 1 });

export default function App() {
  return (
    <MyCtx.Provider value={{ val: 2 }}>
      <Example />
      <Example />
      <Example />
    </MyCtx.Provider>
  );
}

function Example() {
  const { val } = useContext(MyCtx);
  return <p>{val}</p>;
}

可以看到上例中,三个Example都没有从props中接受上层组件的信息,而是直接通过useContext获取。 这样可以大大减少传递变量的代码量,让代码更加简洁清晰。

再举一个共享状态的例子:

const MyCtx = React.createContext({ val: 1 });

export default function App() {
  const [val, setVal] = useState(1);

  return (
    <MyCtx.Provider value={[val, setVal]}>
      <Example />
      <Example />
      <Example />
    </MyCtx.Provider>
  );
}

function Example() {
  const [val, setVal] = useContext(MyCtx);
  return (
    <div>
      <p>{val}</p>
      <button onClick={() => setVal(i => i + 1)}>add</button>
    </div>
  );
}

useContext

点击任意一个Example组件的按钮,都会调用MyCtx这个ContextsetVal,也就是上层AppsetVal,也就会改变上层Appval状态,从而导致所有Example组件的<p>的值发生改变。

可见,useContext是共享状态的利器!

useReducer

这个东西和useState是类似的,只不过setState这种直接替换整个state的函数,被reducer函数所取代。

reducer函数形如(state, action) => newState,接受旧状态和一个表示变换操作的action,返回新状态。主要的不同就在于action,也就是说reducer可以支持更多样的改变状态的逻辑。

这个东西配合useContext基本上可以取代Redux库的功能。

useReducer很好理解,所以我就不多说了,直接搬运官网的例子:

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>
    </>
  );
}

useMemo, memo, useCallback

useMemo用于记忆值(useMemouse Memorized Value)。

如果不用useMemo,考虑一下下面的程序:

export default function App() {
  const [val, setVal] = useState(1);

  return (
    <div>
      <p>{val}</p>
      <button onClick={() => setVal(i => i + 1)}>add</button>

      {[...Array(100000).keys()].map((_, i) => (
        <p key={i}>p{i}</p>
      ))}
    </div>
  );
}

每次单击按钮,都会使得val发生改变从而导致App重渲染。而每次重渲染,意味着执行App函数,也就意味着100000个成员的数组会被重新构造一次。所以,每次单击按钮,用户都要忍受一次UI的卡顿。

100000个成员的数组用于生成相应数量的<p>节点,节点内容与外部状态无关,因此完全可以将生成的结果使用useMemo记忆下来:

export default function App() {
  const [val, setVal] = useState(1);

  return (
    <div>
      <p>{val}</p>
      <button onclick={() => setVal(i => i + 1)}>add</button>

      {useMemo(() => [...Array(50000).keys()].map((_, i) => (
        <p key={i}>p{i}</p>
      )), [])}
    </div>
  );
}

再次尝试,就会发现每次点击按钮后,UI的响应速度基本如常了。

可以看到这里使用useMemo时,传递的第二个参数是[]。这个参数是记忆值的依赖变量列表,表示记忆值应当跟随它们的变化而更新。由于这里记忆的东西与外界状态、属性均无关联,所以可以直接填空。

空依赖变量列表可以理解成,仅当组件首次渲染时触发记忆。其实前面useEffect也同理,空依赖变量列表的useEffect即仅首次渲染时触发的副作用。

memo类似于useMemo,但是是组件级别的记忆:

export default function App() {
  const [val, setVal] = useState(1);

  return (
    <div>
      <p>{val}</p>
      <button onClick={() => setVal(i => i + 1)}>add</button>

      <Example n={50000} />
    </div>
  );
}

const Example = memo(props =>
  [...Array(props.n).keys()].map((_, i) => <p key={i}>p{i}</p>)
);

可以看到,Example组件是记忆化组件,意味着它仅在props发生改变时进行渲染,因此可以实现与前面useMemo的例子相同的性能优化的效果。

另外,useCallback之所以放在这一小节,是因为它和useMemo是等价的:
useCallback(fn, deps)相当于useMemo(() => fn, deps)

你可能会问,构造一个函数的代价会很大吗,为什么也需要记忆?
其实useCallback记忆函数有另外的更重要的目的,就是使得回调函数不变以避免重渲染。

还是举个例子吧:

export default function App() {
  const [val, setVal] = useState(1);

  return (
    <div>
      <p>{val}</p>
      <button onClick={() => setVal(i => i + 1)}>add</button>

      <Example n={50000} onClick={() => {}} />
    </div>
  );
}

const Example = memo(props => (
  <div>
    <button onClick={props.onClick}>button</button>
    {[...Array(props.n).keys()].map((_, i) => (
      <p key={i}>p{i}</p>
    ))}
  </div>
));

你会发现在点击add按钮时,UI又卡住了!

这是因为当App重渲染时,ExampleonClick属性对应的回调函数() => {}会重新构造一个,虽然实现一致,但并不是上次的那个函数了。

这也就意味着Exampleprops发生了改变,所以Example即使是memo的组件,也仍然要重渲染。

这时候useCallback就派上用场了,我们需要把() => {}记忆下来:

export default function App() {
  const [val, setVal] = useState(1);
  const cb = useCallback(() => {}, []);

  return (
    <div>
      <p>{val}</p>
      <button onClick={() => setVal(i => i + 1)}>add</button>

      <Example n={30000} onClick={cb} />
    </div>
  );
}

const Example = memo(props => (
  <div>
    <button onClick={props.onClick}>button</button>
    {[...Array(props.n).keys()].map((_, i) => (
      <p key={i}>p{i}</p>
    ))}
  </div>
));

终于,一切正常了。

useRef

useRef非常像useState,主要区别于两点:useRef不会触发重渲染,且是同步的。

所以,useRef更像一个纯粹的外部状态,因为它不像useStateReact的渲染机制挂钩,它单纯的用于在组件的生命周期内保存数据。

因此,它可以被类比成组件类的一个成员变量。

正是这种保存数据的特性,使得它可以用于引用DOM节点:

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // `current` 指向已挂载到 DOM 上的文本输入元素
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

最后还剩几个Hooks,并不常用,有兴趣直接看官网的文档吧。

https://zh-hans.reactjs.org/docs/hooks-reference.html

有人说React Hooks的原理基于函数式编程Algebraic Effects的概念,有兴趣可以了解一下。