React 18 如何支持 Strict Effects

本文来源于翻译文章 How to support strict effects

概述

如果你还没有阅读上一篇关于 StrictMode 变化的文章,可以点击这里查看

首先,让我们看一个组件代码示例:

function ExampleComponent(props) {
  useEffect(() => {
    // Effect setup code...

    return () => {
      // Effect cleanup code...
    };
  }, []);

  useLayoutEffect(() => {
    // Layout effect setup code...

    return () => {
      // Layout effect cleanup code...
    };
  }, []);

  // Render stuff...
}
复制代码

这个组件在 mount 和 unmount 时会执行一些 effect,通常这些 effect 只被执行一次(在组件挂载之后),清除函数也只被执行一次(在组件卸载之后)。在 Strict Effects 模式,会触发以下流程:

  • React 渲染组件
  • React 挂载组件
    • 执行 layout effect 启动逻辑代码
    • 执行 effect 启动逻辑代码
  • React 模拟组件被隐藏或卸载
    • 执行 layout effect 清除逻辑代码
    • 执行 effect 清除逻辑代码
  • React 模拟组件重新显示或挂载
    • 执行 layout effect 启动逻辑代码
    • 执行 effect 启动逻辑代码

只要一个 effect 在其自身之后进行清理(必要时返回一个清除函数),这通常不会导致问题。大多数 effect 至少有一个依赖项。因此,它们已经能够适应重新挂载多次,并且可以不需要做任何更改。

不过为了多次挂载(卸载)之后组件正常运行,如果 effect 只在挂载时才运行,那么可能需要对 effect 做一些更改。最有可能因为挂载多次而受到影响,导致需要修改 effect 的情况分为以下两类:

  • 在卸载时需要执行清理操作的 effect
  • 只执行一次的 effect(挂载时或依赖变化时执行)

Effect 清理操作是对称的

无论是添加监听事件还是某些命令式 API,一般来说,如果 effect 返回了一个清除函数,那么它应该与启动函数是对应的。当前,大部分组件都是使用下面的示例模式:

// A Ref (or Memo) is used to init and cache some imperative API.
const ref = useRef(null);
  if (ref.current === null) {
  ref.current = new SomeImperativeThing();
}

// Note this could be useLayoutEffect too; same pattern.
useEffect(() => {
  const someImperativeThing = ref.current;
  return () => {
    // And an unmount effect (or layout effect) is used to destroy it.
    someImperativeThing.destroy();
  };
}, []);
复制代码

如果上面的组件被卸载并重新挂载,那么命令式的 API (这里是指 ref)很可能会被破坏(毕竟,它在第一次卸载后就被销毁了)。要解决这个问题,我们需要在再次挂载时(重新)初始化 ref

// Don't use a Ref to initialize SomeImperativeThing!

useEffect(() => {
  // Initialize an imperative API inside of the same effect that destroys it.
  // This way it will be recreated if the component gets remounted.
  const someImperativeThing = new SomeImperativeThing();

  return () => {
    someImperativeThing.destroy();
  };
}, []);
复制代码

有时,其他函数(如事件处理)也需要以命令的形式进行交互。在这种情况下,可以使用 ref 来共享该值。

// Use a Ref to hold the value, but initialize it in an effect.
const ref = useRef(null);

useEffect(() => {
  // Initialize an imperative API inside of the same effect that destroys it.
  // This way it will be recreated if the component gets remounted.
  const someImperativeThing = ref.current = new SomeImperativeThing();

  return () => {
    someImperativeThing.destroy();
  };
}, []);

const handeThing = (event) => {
  const someImperativeThing = ref.current;
  // Now we can call methods on the imperative API...
};
复制代码

在有些场景中可能需要与其他组件共享命令式 API。在这种情况下,可以使用惰性初始化函数暴露出相关的 API。

// This ref holds the imperative thing.
// It should only be referenced by the current component.
const ref = useRef(null);

// This lazy init function ref can be shared with other components,
// although it should only be called from an effect or an event handler.
// It should not be called during render.
const getterRef = useRef(() => {
  if (ref.current === null) {
    ref.current = new SomeImperativeThing();
  }
  return ref.current;
});

useEffect(() => {
  // This component doesn't need to (re)create the imperative API.
  // Any code that needs it will do this automatically by calling the getter.

  return () => {
    // It's possible that nothing called the getter function,
    // in which case we don't have to cleanup the imperative code.
    if (ref.current !== null) {
      ref.current.destroy();
      ref.current = null;
    }
  };
}, []);

复制代码

只执行一次的 effect 可以使用 ref

如果 effect 没有清理函数,不需要任何更改即可支持新的特性。让我们来看一个将日志发送到服务器的 effect。

useEffect(() => {
  SomeTrackingAPI.logImpression();
}, []);
复制代码

这个 effect 的作用是在用户浏览了特定的内容时要做一下日志记录。但是,当该内容被隐藏并且再展示给用户时,这个过程要如何处理呢?是否应该再发送一次日志呢?(这个场景与 tabs 标签页的切换类似)如果需要再次发送日志,那么就不需要更改 effect。如果要求只发送一次日志,那么我们应该使用 ref,改动之后的代码如下:

const didLogRef = useRef(false);

useEffect(() => {
  // In this case, whether we are mounting or remounting,
  // we use a ref so that we only log an impression once.
  if (didLogRef.current === false) {
    didLogRef.current = true;

    SomeTrackingAPI.logImpression();
  }
}, []);
复制代码

“auto focus” 然后恢复 focus

这里介绍一种比较有趣的业务场景:当点击一个按钮时会打开一个弹窗,并自动 focus 到弹窗的某个元素上;当关闭弹窗时,要重新 focus 到之前触发弹窗的按钮上 (也就是恢复到打开弹窗之前的 focus 状态)。在下面的代码中,restoreFocus 记录要恢复 focus 状态的元素。

function Modal({ children }) {
  const restoreFocus = React.useRef(null);

  function handleFocus(event) {
    if (restoreFocus.current === null) {
      restoreFocus.current = event.relatedTarget;
    }
  }

  React.useEffect(() => {
    return () => {
      if (restoreFocus.current !== null) {
        restoreFocus.current.focus();
        restoreFocus.current = null;
      }
    };
  }, []);

  return <div onFocus={handleFocus}>{children}</div>;
}
复制代码

在下面的代码中通过按钮控制弹窗的显示和隐藏。正常情况下,打开弹窗后,由于 <input> 设置了 autoFocus 属性会自动聚焦。进而触发了 Modal 中的 handleFocus,并将<button> 元素记录到 restoreFocus

  const [open, setOpen] = React.useState(false);

  return (
    <React.Fragment>
      <button onClick={() => setOpen(!open)}>{open ? "close" : "open"}</button>
      {open && (
        <Modal>
          <input autoFocus />
        </Modal>
      )}
    </React.Fragment>
  );
复制代码

但是,在 Strict Effects 模式下会通过元素的隐藏和展示来模仿卸载和重新挂载这个过程时,所以当卸载时会聚焦到 <button> 元素上,由于重新挂载时元素只是重新显示,这时不会再聚焦到 <input> 。所以最终看到的结果与上面提到的情况完全不同。

可以通过如下方式实现自动聚焦:

  const [open, setOpen] = React.useState(false);
  const target = React.useRef(null);

  React.useEffect(() => {
    target.current.focus();
  }, []);

  return (
    <React.Fragment>
      <button onClick={() => setOpen(!open)}>{open ? "close" : "open"}</button>
      {open && (
        <Modal>
          <input ref={target} />
        </Modal>
      )}
    </React.Fragment>
  );
复制代码

上面的示例并未覆盖所有情形

本文只涵盖了一些最常见的场景,但并不是详尽的列表。我们计划在将来写一些不太常见的案例。同时,如果您不确定 effect 是否应该运行多次,或者 effect 与上面场景不匹配,请咨询我们,我们将提供帮助。


微信搜索 「ikoofe」, 关注公众号 「KooFE前端团队」, 不定期发布前端技术文章。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享