热搜:前端 nest neovim nvim

Hook 革命!浅谈 React 新 Hook 的未来与思想

lxf2023-05-26 01:27:21

在可在控制流中调用!React 新 hook 尝鲜中,我们对 React 新 Hook use 的设计理念和限制进行了深入分析,并提供了一个可能的实现来帮助读者更好地理解这一概念。

然而,这个 Hook 的雄心远不止于此。根据相关提案,我们有充分理由推断,这个全新的 Hook 将引发一场颠覆性的 Hook 变革。

未来展望

useContext -> use(Context)

许多读者反馈称,尽管这个 Hook 确实可以在控制流中调用,但将其命名为 use 似乎并不合适。既然其入参是 Promise,为何不直接称其为 usePromise 呢?

然而,将 Promise 作为入参仅仅是这个 Hook 宏伟蓝图的第一步。根据相关提案和计划,这个 Hook 将接受被称为“usable”的类型作为入参。

具体来说,什么是“usable”尚无明确定义,似乎还需要进行更多的思考和讨论。

然而,可以确定的是,第一个官方实现的“usable”数据结构是 React Context。原先的 useContext(Context) 调用可以等价地替换为 use(Context)

显然,这样做的好处是能在控制流中调用。至于缺点,如果组件本身编写得不够规范,不够“纯”,依赖 useContext 进行重渲染,而不是像纯函数那样仅读取并展示数据,那么组件的逻辑和流程可能会受到影响。

消费外部资源的问题

然而,仅仅作为一个可以在控制流中调用的 useContext 仍无法满足 use 的远大抱负。在 React 团队的设想中,所有外部资源,包括非 React 资源,都可以通过 use 被使用。而这才是 React 新 hook 的重点。首先我们来回顾下现在我们是如何消费外部资源的。

在此,以 rxjs 中的 BehaviorSubject 为例,这是一个非常典型的非 React 资源。下面,我们将通过讲述 useBehaviorSubject 设计演变过程来进行阐述。

一个简单的 useBehaviorSubject 示例如下:

function useBehaviorSubject(subject) {
  const [, setState] = useState(0)
  
  useEffect(() => {
    const subscription = subject.subscribe(() => {
      // force rerender
      setState(x => x + 1);
    });
    return () => subscription.unsubscribe()
  }, [subject])
  
  // return the value of this subject.
  return subject.getValue()
}

这样的问题在于,每次为 subject 发送一个新值时,都会导致重新渲染,即使这个新值和旧值完全相同。对于遵循函数式编程的组件来说,这无疑会导致许多不必要的重绘。

为了减少无效重绘,一种可选的做法是通过 useState 将值存储在 fiber 结构上。这样,在每次执行 setState 时,通过 React 自带的差异比较(diff)机制,可以避免许多无效的重新渲染。

function useBehaviorSubject(subject) {
  const [state, setState] = useState(subject.getValue())
  
  useEffect(() => {
    const subscription = subject.subscribe(() => {
      setState(subject.getValue())
    })
    return () => subscription.unsubscribe();
  }, [subject])
  
  // or return state.
  return subject.getValue()
}

在上述代码示例中,如果新旧两值相同,React 将不会触发组件的重新渲染,从而实现较好的性能。

然而,我们刚刚的讨论是基于普通模式,即传统模式(legacy mode)。在并发模式(concurrent mode)中,使用 useEffect 订阅外部资源可能会导致“tearing”问题。为了解决这个问题,我们只能在外部资源更新时临时关闭时间分片(time-slicing)功能,并同步地更新组件。这也解释了为什么 useSyncExternalStore 中存在 sync 关键字。

function useBehaviorSubject(subject) {
  return useSyncExternalStore(subject.subscribe, subject.getValue);
}

由于这样的调用会导致时间分片被临时关闭,因此,如果有一系列外部资源像火车式地进行连续更新,这将阻塞 React 组件的重绘,甚至可能导致页面卡顿。这实际上又让我们回到了并发模式原本想要解决的问题,即外部资源的存在使得解决方案又回到了原点。

另一个缺陷是,state 在 fiber 上存储了一份数据。如果使用像 immer 这样的不可变库,或是新的 ES 提案中的 Record 或 Tuple,每次更改都会生成一份新的对象。如果代码编写得不够好,那么这种方法可能会导致相对于第一种方法使用更多的内存。因此,这是一种以空间换取时间的解决方案。

Hook 革命!浅谈 React 新 Hook 的未来与思想

从 React 源码中,我们可以清晰地看到 useSyncExternalStore 和 useState 一样,将值写到了 fiber 上。

虽然许多算法都包含这样的思想,但如果能够进行优化,无疑可以节省一定的内存。

综合来看,React 在与外部资源交互时面临以下挑战:

  • 存在无效渲染

  • 必须临时关闭并发模式

  • 以空间换取时间

为了解决这些问题,React 团队可能需要进一步优化现有的解决方案,以提高与外部资源交互的效率和性能。

使用 use 消费外部资源

本文的主角 use 将有望一劳永逸地解决这三个长期存在的问题,同时还保留了它的传统特性——在控制流中调用。

针对无效渲染,我们需要改变思路。在许多复杂的组件中,无效渲染往往是由于不得不在顶层调用 Hook 监听外部资源,但实际上并未在某些控制流中使用这些资源。如果能将 Hook 放置到真正需要这些资源的控制流中调用,就可以优化这些无效渲染。

另外,正如上一篇文章所提到的,React 团队更倾向于将缓存这个问题留给用户层处理,而不是让 React 擦屁股。因此,在发送新值之前,只需判断新旧值是否相等,就可以避免在 fiber 上存储旧值,从而在节省空间的同时避免无效的强制渲染,实现 "小孩子才做选择,我都要" 的境界。进一步讲,不在 fiber 上存值还带来了诸多优势,这些优势相信无需赘述。

最后,关于并发模式,我们追求的是更精细的调度,即资源级或原子级的调度。实际上,道理很简单:只需将资源本身作为入参传递,当资源更新时,将与该资源相关的所有更新作为一个批次进行处理。这样就避免了由于 useSyncExternalStore 粗粒度更新导致的并发失效问题。

function useBehaviorSubject(subject) {
  return use(subject, (callback) => {
    let subscription;
    subscription = subject.subscribe(() => {
      callback();
      subscription?.unsubscribe()
    })
  }, subject.getValue)
}

因此,使用 use 获取外部资源的例子如上所述,与 useSyncExternalStore 非常相似,但有两点不同:第一是 subject 会作为第一个参数传入,当然另一种可能性是手动将其转换成所需的 “usable” 类型;第二是在值不能挂在 fiber 上的同时,effect 也不能挂在 fiber 上,因此需要在每次更新后手动执行 unsubscribe

当然,上述例子是通过在社区的只言片语中拼凑出来的,该想法还处于非常早期的阶段,未来很可能会继续改进。

思想及其优势

当然,我花这么大篇幅并不是想说 React 团队画了个大饼,而是为深入讨论思想铺路,因为只有全新软件工程思想的推出和应用才能引发新一轮的进化。

从时间角度来说,软件工程认为,无非就四种解决方案:

  • 减少复杂度,压缩执行时间。从业务角度,这和具体的业务息息相关,React 很难给出什么具体的方案。从算法角度,通常要以空间换取时间。React 能做的,就是在优化执行时间的同时,避免使用额外空间。

  • 时间切片,这点就是 React 的并发模式,能改进的就是使时间切片更加精准,并且减少不得不关闭时间切片的情况,上文也做了讨论。

  • 预载。在上一篇文章有讨论,React 希望通过 use 的推出来鼓励请求的预载。

  • 懒计算。use 这个新 Hook 的核心就在于此,把 Hook 的调用挪到真正需要数据的 block 中。换言之,就是精准注册重新渲染的可能性。

懒计算是软件工程的通用术语,在这里,我们换成“懒订阅”来更加精准地描述取值时再进行订阅的行为。通过懒订阅,我们可以减少不必要的订阅操作,从而提高应用性能。这种思想在 React 的新 Hook 中得到了体现,有望引发一场 Hook 革命,带来更优化的应用程序。

Demo

虽然这个 Hook 还没有上线,但是我们已经可以充分利用这个 Hook 的核心思想了,那就是懒订阅。虽然目前的 Hook 都不能放到控制流中,但是我们可以绕个弯子,当真正读取数据的时候,再去监听数据源,从而注册渲染可能性,从而避免因为控制流程的存在,Hook 的返回值没有被消费,从而造成无效重绘的情况。

这个优化后的 useBehaviorSubject 针对懒订阅的思想进行了实现。当 getValue 被调用时,才会触发数据源的订阅。同时,在每次重新渲染时,都会执行取消订阅操作。

function useBehaviorSubject(subject) {
  const subscriptionRef = useRef(null);
  
  const [value, setValue] = useState(subject.getValue())
  
  if(subscriptionRef.current) {
    // unsubscribe in each rerender.
    // because rerender may go into blocks without actually getting value
    subscriptionRef.current.unsubscribe()
  }
  
  useEffect(() => {
    // make sure unsubscribe when unmount
    return () => subscriptionRef.current?.unsubscribe()
  }, [])
  
  return function getValue() {
    if(!subscriptionRef.current) {
      // only subscribe after getting value
      subscriptionRef.current = subject.subscribe(() => {
        setValue(subject.getValue())
      })
    } 
    
    return subject.getValue()
  }
}

除了返回一个显式的 getValue 函数,我们还可以选择返回一个隐式带有 get 方法的对象,以实现类似的效果。有兴趣的读者可以尝试自己实现这种方法,它与上面的例子相似且不复杂,因此这里不再详细展开。

这两种方法之间存在一些细微差别。例如,当作为 props 传递时,getValue 函数不会被调用,因此只有在子组件调用时才触发监听逻辑。而带有 get 方法的 value 在被读取时就会触发监听逻辑。

此外,若采用 Proxy 实现带有 get 方法的 value,在处理数字、字符串等原始类型时可能会面临一些挑战。在这种情况下,我们可能需要将这些原始类型自动包装成对象,以便更好地支持这些类型。

另一方面,直接使用 value 更符合直觉,而且对于提供 value/onChange 的基础组件库有更好的支持。

总之,请根据具体场景和需求权衡并选择合适的实现方法。

节约无效重绘

让我们通过一个简单的例子来了解如何通过懒订阅避免无效重绘,从而提升性能。

在表格或表单业务的底层,通常可能会有一个根据不同 type 进行分发的组件。使用前文提到的 useBehaviorSubject 后,该组件可能如下所示:

function Dispatcher() {
  const getInputValue = useBehaviorSubject(input$)
  switch(type) {
    case 'input':
      return <Input value={getInputValue()} onChange={e => input$.next(e.target.value)} />
    case ...:
       return ...
  }
}

在这个例子中,懒订阅的优势在于仅在实际需要获取输入值时才会订阅 input$。这意味着如果组件的 type 发生变化而不需要输入值时,getInputValue 不会触发订阅,从而避免了无效的重绘。这种方式可以显著提高性能,特别是在具有复杂控制流的组件中。

假设页面上有 1000 个这样的组件,其中 400 个进入了 input 分支,另外 600 个进入了其他分支。那么,当 input$ 发生改变时,只有那 400 个进入 input 分支的会重新渲染,而剩下的那 600 个不会重新渲染。

当然,针对这个问题,将每个 switch case 拆成一个组件,并将取值的 Hook 放到对应的组件中也能达到性能优化的目的。然而,在大部分情况下,这个组件通常要么非常简单,简单到拆分都显得画蛇添足,要么非常复杂,各种业务逻辑耦合在一起,各种数据和逻辑互相依赖,没有办法拆分。所以,在这样的场景下,使用懒订阅无疑是雪中送炭。

无需重绘获取最新值

除了之前讨论的优势之外,我们可以从另一个角度通过一个实际应用场景来进一步阐述这个 Hook 的潜力。

在许多应用中,我们需要实现一个提交按钮,点击后将页面上的表单项发送给后端。如果不充分考虑性能优化,可能会导致每次表单发生变化时,提交按钮都会触发重绘。

使用上述的 Hook 来实现此类需求,可以确保在获取到最新值的同时,不会触发任何不必要的重绘。

function SubmitButton() {
  const getForm = useBehaviorSubject(form$);
  
  // When we submit, request backend with the latest form.
  return <Button onClick={() => {
    submit(getForm())
  }}>Submit</Button>
}

这里,我们详细解释一下这个例子背后的原理。由于懒订阅会在读取值的时候订阅,在重新渲染时取消订阅,因此,无论提交函数是否会引发与 form$ 变更相关的重绘,form$ 都不会被订阅。这意味着,form$ 的变化不会导致组件重绘。换句话说,在这个例子中,SubmitButton 组件不会因为自身状态改变而触发重绘,从而实现了极致的性能优化。

当然,这只是一个用以说明优势的例子,实际上我们可以使用 form$.getValue()直接获取值。然而,在复杂情况下,useBehaviorSubject(form$) 可能仅是一个复杂 Hook 中的一部分,因此在这个复杂 Hook 的调用方可能无法直接获取到form$本身。换句话说,这个例子展示了一种全新的可能性,类似于 redux 文档中使用 redux 实现 todo-list 一样。

总结

通过对新 Hook 的深入讨论,我们可以看到它为 React 组件开发带来的前所未有的可能性和灵活性。懒订阅作为一种新兴的优化方法,将对实际开发产生积极影响。在未来,我们期待 Hook 的更多变革和进步,以满足开发者不断增长的需求。同时,对其他类型的数据源进行懒订阅优化也是一个值得探讨的方向。

参考资料

  • github.com/acdlite/rfc…
  • beta.reactjs.org/reference/r…
  • github.com/reactwg/rea…
  • github.com/tc39/propos…
  • github.com/facebook/re…
  • redux.js.org/introductio…

Hook 革命!浅谈 React 新 Hook 的未来与思想

扫码关注公众号