useEffectEvent是如何权衡React的闭包和性能问题
目标
- 分享 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
时,我们不是创建一个新的函数,而是使用之前创建的函数。
为了修复这个问题,就需要每次在传入的值发生变化时重新创建 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 函数地址不变,但这里有两个注意点:
react 官方出手
官方为了权衡闭包和性能问题,推出了 useEffectEvent,这个 hook 的使用方式和 useMemoizedFn 一样,但是这个 hook 目前还处于实验性阶段并且 react 团队并不打算发布正式版,原因如下:
大致意思是:
- 都于使用一个 useEvent(现useEffectEvent)提案来解决闭包和性能两个问题没有信心。
- 担心开发者会使用 useEvent 全面替代 useCallback,对于一些非事件函数,比如用于渲染的函数和或数据处理的函数,都可以使用 useCallback 进行定义,因此这也增加了开发者在 react 中定义函数的成本。
- 性能优化会被拆分到黄玄正在做的 auto-memoizing compiler 中去解决。
useEffectEvent 在某种意义上来说确实是补齐了 react hooks 在实践中缺失的重要一环,但同时也是实实在在的增加了开发者的理解和使用成本。
总结
本篇文章主要是给大家分享在使用 react hooks 时存在的闭包问题以及 react 为解决闭包问题而带来的新的性能问题,而 useEffectEvent 正是 react 团队为了权衡这两个问题而设计的,它能够同时保持函数引用不变与访问到最新状,但是目前由于各种问题还一直处于试验阶段,总之无论开发者喜不喜欢,现在问题已经有了,解决办法也给了,那就见仁见智了。
转载自:https://juejin.cn/post/7270317297624055864