likes
comments
collection
share

useEffectEvent是如何权衡React的闭包和性能问题

作者站长头像
站长
· 阅读数 55

目标

  • 分享 React 中常见的闭包场景以及如何应对
  • 分享 react 在权衡闭包和性能问题中的实践

背景

现在我们需要实现一个简易的表单组件,在这个表单组件中包含一个 input 输入框以及一个重型子组件,为了避免因重型子组件进行不必要的重渲染而带来的性能问题,开发者一般会使用 React.memo 将其包裹,如下所示:

const HeavyComponentMemo = React.memo(HeavyComponent);

const Form = () => {
  const [value, setValue] = useState();

  return (
    <>
      <input
        type="text"
        value={value}
        onChange={(e) => setValue(e.target.value)}
      />
      <HeavyComponentMemo />
    </>
  );
};

接下来需要为 HeavyComponent 组件传递两个参数,一个是 title,一个是回调函数 onClick ,当点击组件内的完成按钮时会触发 onCLick 事件并提交该表单的数据:

const HeavyComponentMemo = React.memo(HeavyComponent);

const Form = () => {
  const [value, setValue] = useState();

  const onClick = () => {
     // 在这里提交数据
    console.log(value);
  };

  return (
    <>
      <input
        type="text"
        value={value}
        onChange={(e) => setValue(e.target.value)}
      />
      <HeavyComponentMemo
        title="Welcome to the form"
        onClick={onClick}
      />
    </>
  );
};

现在问题来了,因为 React.memo 采用的是浅比较(Object.is)来判断 props 是否变化,如果发生变化就会重新执行被包裹的组件,即缓存失效,在我们的表单组件中,每次触发 Form 组件更新 onClick 函数就会被重新创建,进而导致 HeavyComponentMemo 需要重新从渲染。为了避免组件的重新渲染,此时需要使用 React.useCallback 包裹 onClick 事件。

const onClick = useCallback (() => {   
  // 在这里提交数据
  console.log(value);
} , []) ;

在 React 中使用 React.useCallback 时需要为其添加依赖项,因此如果想在 onClick 内部提交表单数据,就必须为其声明依赖项:

const onClick = useCallback(() => {
  // 在这里提交数据
  console.log(value);
}, [value]);

但此时又会陷入另一个困境,每当输入的值发生改变,onClick 函数的缓存就会失效,进而导致 HeavyComponentMemo 组件重新渲染,因此对 HeavyComponentMemo 组件的优化是失效的。

因此只能选找其他的解决方案,好在 React.memo 给开发者提供了第二个参数——比较函数,它允许开发者自定义 props 的比较规则,如果该函数返回 true,那么 React 就知道 props 没有变化。组件就不会重新渲染,这里我们只需要关心 title 的变化,如下所示:

const HeavyComponentMemo = React.memo(
  HeavyComponent,
  (before, after) => {
    return before.title === after.title;
  },
);

const Form = () => {
  const [value, setValue] = useState();

  const onClick = () => {
    // submit our form data here
    console.log(value);
  };

  return (
    <>
      <input
        type="text"
        value={value}
        onChange={(e) => setValue(e.target.value)}
      />
      <HeavyComponentMemo
        title="Welcome to the form"
        onClick={onClick}
      />
    </>
  );
};

此时当输入内容发生变化时,HeavyComponentMemo 组件不会发生重渲染,优化目的是达到了,但是此时还是会存在一个问题,如果此时我们在输入框中输入一些内容然后点击完成按钮,在 onClick 中打印的却是 undefined ,如果我们在 onClick 外打印,此时输出的是正确的值:

// 总是打印正确的值
console.log(value);

const onClick = () => {
  // 总是打印undefined
  console.log(value);
};

点击查看完整的示例

这其实就是 React 中的闭包问题,在如今的函数式编程中,我们其实一直在创建闭包,只是没有意识到,在函数式组件中创建的所有方法都是闭包:

const Component = () => {
  const onClick = () => {
    // 闭包
  };

  return <button onClick={onClick} />;
};

与此同时 useEffect 、useCallback 钩子中的所有内容也都是一个闭包,在这些钩子中可以访问组件中声明的状态,传入的 props 以及局部变量:

const Component = () => {
  const [state, setState] = useState();

  const onClick = useCallback(() => {
    // 闭包
    console.log(state);
  });

  useEffect(() => {
    // 闭包
    console.log(state);
  });
};

React 是如何解决闭包问题?

虽然闭包一直是许多开发者比较头痛的问题,但是在 React 中编写程序时却不需要开发者深入理解闭包的概念,这是为什么呢?

答案是 React 帮开发者自动处理了,下面通过一个简单的例子来模拟一下 React.useCallback 是如何解决闭包问题的。

const something = (value) => {
  const inside = () => {
    console.log(value);
  };

  return inside;
};

something 中定义了一个 inside 函数,在 inside 函数使用了 value 值,最终 something 函数将 inside 函数返回,但是这样每次调用 something 函数内部的 inside 函数就会重新创建,下面对 inside 进行缓存处理:

const cache = {};

const something = (value) => {
  if (!cache.current) {
    cache.current = () => {
      console.log(value);
    };
  }

  return cache.current;
};

现在,something只需返回已保存的值,而不是每次都重新创建该函数,但是当我们尝试调用 something 函数时会出现一个奇怪的事情:

const first = something('first');
const second = something('second');
const third = something('third');

first(); // logs "first"
second(); // logs "first"
third(); // logs "first"

虽然我们调用 something 函数时传入不同的参数,但是最终打印的始终是第一个值,这是因为我们第一次调用时闭包就已经产生了,此时 inside 里 value 值就是 first,当我们下次再调用 something 时,我们不是创建一个新的函数,而是使用之前创建的函数。

useEffectEvent是如何权衡React的闭包和性能问题

为了修复这个问题,就需要每次在传入的值发生变化时重新创建 inside 函数及其闭包:

const cache = {};
let prevValue;

const something = (value) => {
  // 检查传入的值是否变化
  if (!cache.current || value !== prevValue) {
    cache.current = () => {
      console.log(value);
    };
  }

  prevValue = value;
  return cache.current;
};

我们将传入的值保存在变量中,以便我们可以将下一个值与前一个值进行比较。如果变量发生了变化,则更新 cache.current,此时效果就和预期的一样:

const first = something('first');
const anotherFirst = something('first');
const second = something('second');

first(); // logs "first"
second(); // logs "second"
console.log(first === anotherFirst); // will be true

点击查看完整的例子

上面就是模拟的 React.useCallback 实现,他通过比较前后的依赖项来及时更新保存的函数,从而解决闭包问题,其他比如 React.memo,useEffect 也是相似的原理:

const Component = () => {
  const [state, setState] = useState();

  const onClick = useCallback(() => {
    console.log(state);

    // 需要添加依赖项
  }, [state]);
};

如何权衡闭包和性能问题?

上面我们只介绍了 React 是如何自动处理闭包问题的,但是针对我们文章开头出现的问题还是有些力不从心,那么该如何去权衡这两个问题呢?这个问题细化之后主要有两点:

  • 保证 onClick 函数的地址不变
  • 每次调用 onClick 函数时始终能获取到最新的值

下面将会介绍社区提供的两种比较认可的 hack 方案。

方案一

使用 ahooks 提供的 useMemoizedFn 方法,该方法可以完全替代 useCallback,它的使用方式如下:

const HeavyComponentMemo = React.memo(HeavyComponent);

const Form = () => {
  const [value, setValue] = useState();

  const onClick = useMemoizedFn(() => {
     // 在这里提交数据
    console.log(value);
  });

  return (
    <>
      <input
        type="text"
        value={value}
        onChange={(e) => setValue(e.target.value)}
      />
      <HeavyComponentMemo
        title="Welcome to the form"
        onClick={onClick}
      />
    </>
  );
};

useMemoizedFn 只接受一个参数并返回一个函数,这个函数的地址永远不会变,每次调用 onClick 函数时都能拿到最新的 value 值,下面是他的实现原理:

function useMemoizedFn(fn) {
	// 使用ref保存传入的函数,每次执行useMemoizedFn时都会将最新的fn更新给ref
  const fnRef = useRef(fn);
  fnRef.current = useMemo(() => fn, [fn]);
	// why not write `fnRef.current = fn`?
  // https://github.com/alibaba/hooks/issues/728
  // 使用ref保存需要返回的函数,此处使用ref的目的是为了保证返回值的地址不变
  const memoizedFn = useRef();
  if (!memoizedFn.current) {
    memoizedFn.current = function (this, ...args) {
      return fnRef.current.apply(this, args);
    };
  }
  return memoizedFn.current;
}

从实现原理可以看出,useMemoizedFn 其实是借用了 ref 引用不变数据可变的特性,使用 useRef 创建的 ref 是一个对象,它本身(引用)是永远不会变的,但是可以改变 ref 内部存储的数据。

当出现闭包时,闭包会对其包含的所有内容进行冻结,但是却不会使对象变的不可变,多个变量可以指向同一个对象,他们的引用是相同的:

const a = { value: 'one' };
// b是一个不同的变量,但引用的是同一个对象
const b = a;

此时更改 a 变量的 value 属性 b 变量也会同步更改:

a.value = 'two';

console.log(b.value); // will be "two"

方案二

const Form = () => {
  const [value, setValue] = useState();
  const ref = useRef();

  useEffect(() => {
		// 每次组件重新渲染更新ref,保证函数是最新的
    ref.current = () => {
      console.log(value);
    };
  });
	// 使用useCallback保证onClick地址不变
  const onClick = useCallback(() => {
    ref.current?.();
  }, []);

  return (
    <>
      <input
        type="text"
        value={value}
        onChange={(e) => setValue(e.target.value)}
      />
      <HeavyComponentMemo
        title="Welcome closures"
        onClick={onClick}
      />
    </>
  );
};

方案二原理和方案一类似,只是换用 useCallback 来缓存函数,保证 onClick 函数地址不变,但这里有两个注意点:

  • 不能在组件渲染期间对 ref 进行读和写,主要的原因是 react 希望组件是一个纯函数,官方解答
  • ref 可以不作为依赖项,官方解答

react 官方出手

官方为了权衡闭包和性能问题,推出了 useEffectEvent,这个 hook 的使用方式和 useMemoizedFn 一样,但是这个 hook 目前还处于实验性阶段并且 react 团队并不打算发布正式版,原因如下:

useEffectEvent是如何权衡React的闭包和性能问题 大致意思是:

  • 都于使用一个 useEvent(现useEffectEvent)提案来解决闭包性能两个问题没有信心。
  • 担心开发者会使用 useEvent 全面替代 useCallback,对于一些非事件函数,比如用于渲染的函数和或数据处理的函数,都可以使用 useCallback 进行定义,因此这也增加了开发者在 react 中定义函数的成本。
  • 性能优化会被拆分到黄玄正在做的 auto-memoizing compiler 中去解决。

useEffectEvent 在某种意义上来说确实是补齐了 react hooks 在实践中缺失的重要一环,但同时也是实实在在的增加了开发者的理解和使用成本。

总结

本篇文章主要是给大家分享在使用 react hooks 时存在的闭包问题以及 react 为解决闭包问题而带来的新的性能问题,而 useEffectEvent 正是 react 团队为了权衡这两个问题而设计的,它能够同时保持函数引用不变与访问到最新状,但是目前由于各种问题还一直处于试验阶段,总之无论开发者喜不喜欢,现在问题已经有了,解决办法也给了,那就见仁见智了。

转载自:https://juejin.cn/post/7270317297624055864
评论
请登录