大函数组件时代 之 Hooks海贼团

132 阅读15分钟

大航海时代的来临 - 类组件到函数组件

了解类组件与函数组件的本质差异

许多年后,当老人们提起类组件与函数组件的最大差异时,总不可避免地谈到当年类组件独有的页面状态和生命周期这两个概念,但当Hooks诞生之后,这种落差已经不复存在。

也有人说他们差别在性能不同,但哪个更好?很多此类的基准测试都有缺陷,且性能主要取决于代码的执行内容,而不是编写方式,正常项目中,他们的性能差异可以忽略不计。

又或者:

image

除了表面的写法,某些API不同外,他们之间真的没有本质区别吗?(对你使用霸王色霸气凝视)

当然有🤓,在“心智模型”中:

Function components capture the rendered values.* -- Dan Abramov , React.js*

先来看看一个简单的订阅组件,使用我们熟悉的函数编写:

function SubscribeInFunction(props) {
  const showMessage = () => {
    alert("欢迎订阅" + props.user + "的直播间");
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000); // 模仿请求延迟
  };

  return <button onClick={handleClick}>订阅</button>;
}

它的功能很简单,点击 - 等待 - 通知。

接着,我们把它改造成类组件:

class SubscribeInClass extends React.Component {
    showMessage = () => {
      alert("欢迎订阅" + this.props.user + "的直播间");
    }
    
    handleClick = () => {
      setTimeout(this.showMessage, 3000); // 模仿请求延迟
    };
    
    render(){
      return <button onClick={this.handleClick}>订阅</button>;
    }
}

除了代码行数+2外,似乎没有任何区别?是骡子是马,让我们把它们在沙箱中实际溜溜就知道了。

打开上面的示例沙箱之后:

  1. 让我们先点击属于函数组件的订阅按钮,3s过后,收到了订阅成功通知!

  2. 随后我们再点击类组件订阅,但是这一次,我们在通知来临之前切换直播间。

3s之后,我们还是成功收到了订阅通知,它也提示着我们成功订阅了刚刚切换的直播间....

等等,有什么不对劲的地方?🧐

刚刚我们发送的不是订阅第一个直播间的请求吗,现在咋提示成功订阅了第二个?😮

问题到底发生在哪呢?让我们再仔细看看类组件中的方法:

    showMessage = () => {
      alert("欢迎订阅" + this.props.user + "的直播间");
    }
    
    handleClick = () => {
      setTimeout(this.showMessage, 3000); // 模仿请求延迟
    };

这个函数读取了this,也就是组件实例中的prop,并进行展示。

问题在于,该函数是在3s后执行的,但是那时的组件实例已经今非昔比了,this已改,prop中的直播间自然是最新的。

而在函数组件中却不会有这个问题,至于为何,且待我娓娓道来。😎


现在我们先假设函数组件不存在,看看如何在类中解决这个问题。

首先,我们要恢复未来执行的函数与当前组件状态的联系,哦不,或者直接切断这个联系:

    showMessage = (user) => {
      alert("欢迎订阅" + user + "的直播间");
    }
    
    handleClick = () => {
      const {user} = this.props;
      setTimeout(() => this.showMessage(user), 3000); // 模仿请求延迟
    };

嗒当~,问题解决~,可以下班了。

2年过后,随着业务的持续迭代,这段代码成了:

    handleClick = () => {
      const {
        user,
        name,
        age,
        gender,
        love,
        god,
        king,
        apple,
        orange,
        ... rest
      } = this.props;
      setTimeout(() => this.showMessage(user), 3000); // 模仿请求延迟
      // ...
    };
    
    handleHover = () => {
      const {
        user,
        name,
        age,
        gender,
        love,
        god,
        king,
        apple,
        orange,
        ... rest
      } = this.props;
      setTimeout(() => this.showMessage(user), 3000); // 模仿请求延迟
      // ...
    };

你一个我一个,代码变得无比冗长和难以维护,类似的,将showMessage的定义放入handleClick中,也会造成如回调地狱一般的困境,削弱了扁平管理的字节风格。

但我们似乎可以将它们都放进一个统一的函数里,在该函数中只进行一次参数解构,即可将每一个组件实例与其中的回调函数绑定,并保证绑定的状态永远不会变化!

虽然可以创建的一个全新的包装函数,但是我们优先活水,不如直接放入现有的render函数中:

class SubscribeInClass extends React.Component {
  render() {
    // 口头挖路!状态暂停⏸️
    const props = this.props;

    // 在每一次render中,享受固定的上下文
    const showMessage = () => {
      alert("欢迎订阅" + props.user + "的直播间");
    };

    const handleClick = () => {
      setTimeout(showMessage, 3000); // 模仿请求延迟
    };

    return <button onClick={handleClick}>订阅</button>;
  }
}

总结:当我们完全依赖JS的闭包,这个问题就迎刃而解了😘

“这揭示了关于用户界面本质的有趣观察。如果我们说,UI 在概念上是当前页面状态的函数,那么事件处理程序就是渲染结果的一部分——就像视觉输出一样。我们的事件处理程序“属于”特定的渲染,带有特定的 props 和状态。”

到了这里,恭喜你发明了函数组件的前身,至此我们不再需要一层类的包装,正式迈向大函数组件时代!

image


无法忽视的海贼团 - 熠熠生辉的 Hooks

一鲸落万物生,函数组件衍生的hooks们

上一节中,我们介绍了类组件和函数组件的本质差异,一句话就是函数组件捕获了当前组件的状态。

差异之外,类组件有的能力也一个也不能少,比如状态管理this.state成为了我们耳熟能详的useState,它也遵循着捕获的原则,在实例中解析当前的组件状态,确保回调使用正确的参数。

const [state, setState] = useState(initialState || createInitialState) // Hooks海贼团大当家,耳熟能详,果木果木诺~

但如果我们就是想让现在的回调获取未来最新的状态呢?

在类中我们可以通过读取this实例来很快实现,因为this是可变的,但它在函数组件中已经光荣退休,于是我们需要一个在实例更迭中仍保持不变的角色:

image

我想这就是Hooks海贼团招募Ref的原因之一,它是一个可变值,由组件的所有实例共享。

const ref = useRef(initialValue) // Hooks海贼团top3

除了使用它操纵DOM外,还可以借助它的眼睛预知未来:

function MessageThread() {
  const [message, setMessage] = useState('');
  const latestMessage = useRef('');
 
  const handleSendClick = () => {
    setTimeout(() => alert('当前消息是:' + latestMessage.current), 3000);
  };
 
  const handleMessageChange = (e) => {
    setMessage(e.target.value);
    latestMessage.current = e.target.value; // **跟在大哥State后面**
  };
}

React 会保存 ref 初始值,并在后续的渲染中忽略它。但当创建ref时使用了昂贵的方法,每次重新渲染时仍然会调用它,因此我们可以手动优化一下:

function Video() {
  // new VideoPlayer() 的结果只会在首次渲染时使用
  // 但是依然在每次渲染时都在调用
  const playerRef = useRef(new VideoPlayer()); 
  // ...

// 手动优化
function Video() {
  const playerRef = useRef(null);
  // 只执行一次
  if (playerRef.current === null) {
    playerRef.current = new VideoPlayer();
  }
  // ...

然而,ref是需要我们手动更新的,Context not Control,我们需要它知道自己什么时候该更新,为此Hooks海贼团还需要一个如闹钟一样自律的角色:

image

useEffect(setup, dependencies?)

Effect帮助ref自动更新,只需要它安静地躺在自己的臂弯之中。

function MessageThread() {
  const [message, setMessage] = useState('');
 
  const latestMessage = useRef('');
  useEffect(() => {
    latestMessage.current = message; // 自动
  }, [message]);
 
  const showMessage = () => {
    alert('当前消息是:' + latestMessage.current);
  };
}

此外,在Effect中进行赋值还可以保证只有在DOM完全更新后ref的值才会改变,防止阻碍可中断渲染功能,如 Time Slicing 和 Suspense。

Effect还能帮你完成许多自动化任务,不过,记得及时清除上一次残留的副作用:

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

  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount(count + 1);
    }, 1000);

    return () => clearInterval(intervalId); // 避免count发生鬼畜行为
  }, [count]); 

  return <div>Count: {count}</div>;
}

但是上述代码总会在每次渲染时清除和重建一个定时器,是不是有点浪费了?为此我们可以将 c => c + 1 状态更新器传递给 setCount,为Effect减负😊:

useEffect(() => {
    const intervalId = setInterval(() => {
      setCount(c => c + 1); // ✅ 传递一个 state 更新器
    }, 1000);
    return () => clearInterval(intervalId);
  }, []); // ✅ 现在 count 不是一个依赖项

上述展示的Effect任务都是渲染后立即执行的,更准确一点地说,React会在浏览器屏幕绘制完毕之后再执行Effect,但是如果这个任务涉及到页面的更新呢,再加上一点延迟,在屏幕绘制之后再执行可能会导致内容闪烁,因此,我们需要给Effect配置一个副手。

image

useLayoutEffect(setup, dependencies?)

layoutEffect专门负责在DOM绘制之前执行任务。

回忆一下antd的tooltip,它有一个特殊的功能,即当默认位置没有足够的空间时,他会自动选择另一个有空余的显示方向,想做到这一点,我们就需要知道气泡实际渲染时的高度。

要获取这个值,我们需要等到Tooltip组件被添加到DOM树之后,即在Effect的componentDidMount生命周期中。

现在让我们用Effect先试一下:

将鼠标hover到预览中的button上,看看发生了什么?

codesandbox.io/s/layouteff…

页面绘制了两次!因为你可以看到tooltip位置的偏移。

将 tooltip.js 文件中的 layoutEffect注释打开,注释掉Effect,看看变化~

页面只渲染了一次!如我们最开始所期望的结果一般。

话虽如此,useLayoutEffect 如果被过度使用(如使用不稳定的延迟阻塞渲染),会使你的应用程序变得不可预测,慎重使唤它哦。


刚刚我们讲到Effect可以在依赖变化时自动执行内部任务,忘了提了,依赖也可以是除prop和state之外的任何在组件内部声明的函数或变量哦。

看看下面的模拟直播间切换组件:

function LiveRoom() {
  const [roomId, setRoomId] = useState('');

  // 根据需求可能随时更改的配置
  function createOptions() {
    return {
      serverUrl: 'https://localhost:8080',
      roomId: roomId
    };
  }

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]); 
  // ...
  }

如果任由 randomCreateOptions 作为依赖,那可想而知,每次渲染导致的函数重新创建都会触发Effect执行,直播间会不断地重连。🫣

我们也可以简单地把依赖项移到Effect内部:

  useEffect(() => {
    // 根据需求可能随时更改的配置
   function createOptions() {
     return {
       serverUrl: 'https://localhost:8080',
       roomId: roomId
     };
   }
  
    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); 

但这显然违背了我们坦诚清晰的原则,且如果createOptions还会用在其它地方,这样的臃肿代码是过不了CR的哦。

为此,我想你也早就猜到我要提到的那位大人:

image

const cachedFn = useCallback(fn, dependencies)

它是团队里的厚实大哥,想在转瞬即变的时代洪流中保持初心?把你的灵魂献祭给它吧。

function LiveRoom() {
  const [roomId, setRoomId] = useState('');

  const createOptions = useCallback(() => {
    return {
      serverUrl: 'https://localhost:8080',
      roomId: roomId
    };
  }, [roomId]); // ✅ 仅当 roomId 更改时更改

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]); // ✅ 仅当 createOptions 更改时更改
  // ...

除了在组件内使用外,一些自定义Hook也可以使用useCallback来优化。

function useRouter() {
  const { dispatch } = useContext(RouterStateContext);

  const navigate = useCallback((url) => {
    dispatch({ type: 'navigate', url });
  }, [dispatch]);

  const goBack = useCallback(() => {
    dispatch({ type: 'back' });
  }, [dispatch]);

  return {
    navigate,
    goBack,
  };
}

把返回的函数默认包裹在Callback中,是对使用者无声的关怀。


Hooks 成员成长史

介绍了这么多Hooks成员,它们是怎么做到如今的独当一面的,向你介绍他们的独门秘技!

以我们的主角state为例:

  1. 在FiberNode,也就是函数组件运行、编译后产生的渲染对象中,会存储组件中使用的Hook,更具体地说是,将他们连成一串,只存储第一个Hook,通过链表的方式进行遍历。

  2. 在Hook内部,最核心的就是三大属性:

    1. memoizedState 因Hook的种类而类型不同,比如State中它是当前值,而在Effect中它是一个数组,存储它的两个参数。

    2. queue主要存在于State中,用于记录和执行,得出每次渲染的最终结果

    3. next就是我们刚刚提到的,将Hook连成链表的指针

  3. 当组件执行到Hook时,它需要进行如下步骤:

    1. 根据当前生命周期来判断是否是第一次执行,以此来选择不同的执行策略:创建还是更新

    2. 创建时发生了什么?

    3. 更新时又是怎么操作的?

      这就得先看看我们刚刚提到的dispatchAction:

      简单得总结,就是你每一次调用 setState ,都会自动把你的更新值/函数,连到该Hook的更新队列里,准备一锅端!

    记录完了一次渲染的所有更新任务,接下来当然是统一执行:```JavaScript function updateReducer(reducer, initialArg, init) { // 获取当前Hook中的更新队列 const {hook:{queue:{pending}}} = updateWorkInProgressHook(); // 获取队列 let baseQueue = pending; // 过河拆桥 pending = null;

    if (baseQueue !== null) { const first = baseQueue; let newState = hook.memoizedState; // 从这里开始改变 let update = first; // 遍历baseQueue,执行里面所有的update对象,更新state的值 do { ... const action = update.action; newState = (typeof action === 'function' ? action(state) : action); update = update.next; } while (update !== null && update !== first); // 更新hook的值 hook.memoizedState = newState; }

    return [hook.memoizedState, queue.dispatch]; // 作为下一个组件实例的 useState 返回值 }

Effect与State的运行过程相似,同样的Effect收集,并将它们串成环,放置更新队列中。

不同的是它的执行过程,也是它的核心逻辑:

  1. 创建时

    Effect的初始化逻辑:

    create即我们传入的第一个参数,其返回值会作为Effect在unmount时的执行任务

  2. 更新时

    组件挂载前:

    组件挂载后:

Hooks 明日之星

这片海域最不缺的就是追逐最强梦想的人

useDeferredValue

在新内容加载期间显示旧内容,延迟更新 UI 的某些部分。

const deferredValue = useDeferredValue(value)

观看一个具体的例子:这是一个渲染严重耗时的搜索组件

codesandbox.io/p/sandbox/u…

  1. 首先在input中输入 c,进行“模拟查询”,你会发现这个组件有点小卡。

  2. 随后连续输入cccccccc,这下直接卡的没边了,最重要的是input都没响应输入?

  3. 将代码中的注释打开,并注释掉第一个SlowList组件,再次执行上述操作。

  4. 你会发现输入框神奇地恢复灵活了,卡顿的列表也丝滑了一些

WHY?🧐 让我们从头开始:

  const [text, setText] = useState("");
  const deferredText = useDeferredValue(text);
  
  <input value={text} onChange={(e) => setText(e.target.value)} />
  <SlowList text={deferredText} />

useDeferredValue接受一个state(a)后,会立即返回该值(deferredText = a),但当该state更新(a->b)导致组件重新渲染时:

  1. react使用最新的state值 b 尝试进行渲染,但在这个过程中deferredText不会变化,仍为a

  2. 当新的渲染准备完成时,页面更新,deferredText的值也随之更新,使用它的SlowList组件会立即更新完成

对于SlowList来说,这似乎没有什么作用,但对input来说却很重要,它的更新不再受SlowList牵绊,可以立即反应用户的输入,整个渲染过程将一分为二。

现在我们知道了DeferredValue的第一个妙用:你可以自由地选择哪个组件接受最新值,哪一个需要延迟渲染。

接着我们再看看对于SlowList真的没用吗?

  1. 正常的组件周期是:输入 -> 等待渲染完成 -> 输入 -> .....

  2. 但DeferredValue进行的渲染是可中断的,这个周期就变成了:输入 -> 开始渲染 -> 渲染完成前输入 -> 渲染中断 -> 执行新的渲染

这样使得输入连续更新下的SlowList可以快速响应新值,提高响应速度。

但是仅仅如此还是不够,延迟的更新无法被用户感知,也会造成疑惑,因此我们再利用渲染分割的原理,给SlowList包一层可以快速响应更新的透明度渐变loading:

<div style={{
  opacity: text !== deferredText ? 0.5 : 1,
  transition: text !== deferredText ? 'opacity 0.2s 0.2s linear' : 'opacity 0s 0s linear'
 }}>
  <SlowList text={deferredText} />
</div>

codesandbox.io/p/sandbox/u…

这样当输入改变,下方的重度渲染组件也能进行响应,并执行更高效的更新~


useOptimistic

无独有偶,在提升系统响应速度方面,当useDeferredValue努力地延迟部分组件渲染,避免整体卡顿时,useOptimistic也在琢磨另一个角度的创新。

想象一个可编辑表单,列表需要根据用户的输入进行请求和刷新,这是常规操作:

  1. 用户输入 -> 发送请求 -> loading -> 数据返回 -> 刷新列表

loading让每一次输入都需要等待上一次编辑完成,但或许我们可以这样:

codesandbox.io/s/useoptimi…

试试快速在input中输入吧:看看列表是怎么响应变化的~

可以发现,列表乐观地提前进行了渲染,但在结果后面表示它还是一个半成品,不过这样不会影响用户进行其他的操作,且当结果返回时,列表也会自动更新成完整的模样。

这都得益于useOptimistic的特性

 const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);

它接受一个状态state,以及加工函数updateFn,其中updateFn在state更新时返回你想要的乐观结果,如 消息(发送中..)

    // 更新函数 updateFn
    (currentState, optimisticValue) => {
      // 使用乐观值
      // 合并并返回新 state
    }

optimisticState就是它的返回,在没有异步操作时,它的值与state无异,但特殊情况下它接受updateFn的返回值

返回的第二个参数addOptimistic就是触发乐观更新的 dispatch 函数,它接受一个额外参数,并将其和state一并放入updateFn中,产出乐观结果。


除了以上介绍的两个新版Hook外,react还提供了诸多其他的实验性Hook。

如可以根据某个表单动作的结果更新 state 的 useActionState,以及提供上次表单提交状态信息的 useFormStatus ,又或者是让你可以订阅外部 store 的 useSyncExternalStore .....

总之,伟大航路的探索,是如此无穷无尽和令人着迷~


End

听完大函数时代和Hooks海贼团的故事,是不是已经手痒难耐、浑身燥热、蓄势待发要去敲1000行react源码了?

吹吹空调凉快凉快吧,还有一堆需求等着你呢~😉


参考文献