在上一章节,我们学习了Refs的相关知识:什么是Refs,为什么需要Refs,何时用Refs,何时不用Refs。然而,在讨论到重新渲染前后的Refs时,还有一个话题我们要讨论:函数。更准确的说,是闭包以及它们如何影响其他代码。
让我们探讨一下一些有趣的事情和典型的bug,在这个过程中,学习到:
- 什么是闭包,它们如何出现,以及为什么需要它们。
- 什么是过时闭包,以及它们为什么回出现。
- 导致React出现过时闭包的普遍场景,以及如何处理它们。
注意:也许你永远也解决不了React的闭包问题,这个章节可能会使你的头脑爆炸。请确保你有足够的巧克力来支撑你燃烧大脑。
问题
想象一下,你正在实现一个表单,而这个表单有若干输入框。其中一个字段引用了繁重的外部库。你无法访问这个外部库,所以你没法修复这个库的性能问题。但是你又不得不使用这个库,所以你决定使用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有一个字符串属性,和一个onClick回调。这个onClick回调,在用户点击HeavyComponent中的“done”按钮时触发。如果想把你输入的内传递给这个HeavyComponent组件,通过这个onClick回调即可:
const HeavyComponentMemo = React.memo(HeavyComponent); 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}
/>
</>
);
};
现在,你进入了一个两难困境。我们从第五章知道,包裹在React.memo内的组件的属性,需要是原始值,或者是地址没有发生变化的引用类型。否则的话,这个缓存是无效的。所以,我们需要把onClick缓存在useCallback里:
const onClick = useCallback(() => {
// submit data here
}, [])
但是,在使用useCallback时,我需要通过声明依赖数组来确保函数更新:
const onClick = useCallback(() => {
// submit data here
console.log(value)
// adding value to the dependency
}, [value])
然后,又遇到了这个两难困境:即使我们的onClick被缓存了,它依然会在人们输入内容时发生更新。所以,这一性能优化是失败的。
好吧,我们只能使用另一种方法了。React.memo提供了一个比较函数。它允许我们对组件属性的比较进行更颗粒化的操作。通常情况下,React是自动比较前值和后值。如果我们使用了这个函数,React会依照这个函数的返回结果进行比较。如果返回为true,React会知道属性的前后值是一样的,所以不会重新渲染这个组件。感觉上这正是我们需要的。
我们认为只有一个属性的更新,需要重新渲染,就是title,而这逻辑也是很简单的:
const HeavyComponentMemo = React.memo(
HeavyComponent,
(before, afeter) => {
return before.title === after.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}
/>
</>
);
}
这段代码跑起来了!当我们在输入框输入内容时,这个HeavyComponent并没有重新渲染,而且性能也没有被影响。
但是,这个onClick却不可用。如果去控制台看value的值,打印的是undefined
// thos one logs it correctly
console.log(value);
const onClick = () => {
// this is always undefiend
console.log(value);
}
代码示例: advanced-react.com/examples/10…
这背后发生了什么?
这其实是“过时闭包”问题。为了解决这个问题,我首先要研究JavaScript里最令人头疼的问题:
闭包及其工作原理。
JavaScript, 作用域 与 闭包
让我们先从函数和变量开始。我们在JavaScript里声明了一个函数时,到底发生了什么,无论我们是通过函数声明还是箭头函数。
function something() {
//
}
const something = () => {};
通过声明函数,我们生成了一个本地作用域:这个作用域里的代码对内可见,但对外不可见。
const something = () => {
const value = 'text';
};
console.log(value) // not going to work, "value" is local to "something" function
每当我们创建一个函数时,函数都会生成其本地作用域。如果我们在函数内部再生成一个函数,这个函数内的函数也有自己的作用域,其作用域对外层的函数也是不可见的。
const something = () => {
const inside = () => {
const value = 'text';
}
console.log(value) // not going to work, "value" is local to "inside" function
};
但如果我们从相反的视角看作用域的访问,就是一条康庄大道。在最里层的函数,可以访问所有外层声明的变量!
const something = () => {
const value = 'text';
const inside = () => {
// perfectly fine, value is avaliable here
console.log(value);
}
};
这是通过创建所谓的 “闭包” 来实现的。内部的函数会 “闭合” 外部的所有数据。从本质上来说,它就像是对所有 “外部” 数据在某一时刻的快照,这些数据被冻结在那个时刻,并单独存储在内存中。
除了在inside函数内创建value外,我们还可以把value当作参数进行传递,并返回一个inside函数:
const something = (value) => {
const inside = () => {
// perfectly fine, value is avaliable here
console.log(value);
}
return inside;
}
我们可以这样使用:
const first = something('first');
const second = something('second');
first(); // logs "first"
second(); // logs "second"
我们以“first”为参数调用something函数,并将函数运行结果赋值给了一个变量。而这个函数的运行结果是对已经声明的inside函数的引用。如此一来,一个闭包就被生成了。从现在开始,只要first变量一直保有对inside函数的引用,我们传递的“first”就被冻结了,inside函数就一直可以访问“first”。
同样的事情,也在第二次调用时发生了:我们传了另一个值,形成了另一个闭包,而inside返回函数可以永远访问第二次调用时传入的参数:
const something = (value) => {
const r = Math.random();
const inside = () => {
// ...
};
return inside;
};
const first = something('first');
const second = something('second');
first(); // logs random number
second(); // logs another random number
这个过程,好比为一个动态物体拍照:只要你按下快门,当前图景被当作照片记录下来了。而后面拍下的照片,并不会影响原来拍的照片。
在React中,我一直在无意识得创造闭包。每个函数组件内的回调函数,都是一个闭包:
const Component = () => {
const onClick = () => {
// closure!
}
return <buton onClick={onClick}>
}
在useEffect或者useCallback中的内容,也是一个闭包:
const Component = () => {
const onClick = useCallback(() => {
// closure!
});
useEffect(() => {
// closure!
});
};
而闭包里的成员,可以访问状态、属性和组件的本地变量了:
const Component = () => {
const [state, setState] = useState();
const onClick = useCallback(() => {
// perfectly fine
console.log(state);
});
useEffect(() => {
// perfectly fine
console.log(state);
});
};
组件里的每一个函数都是一个闭包,因为组件本身就是一个函数。
过时闭包问题
为什么闭包会令那么多开发者头疼?
这是因为只要对创建闭包的那个函数存在引用,闭包就会一直存在。而一个函数的索引是一个任意赋值的值。让我们头脑风暴一下。下面是一个返回了闭包的代码:
const something = (value) => {
const inside = () => {
console.log(value);
}
return inside;
}
但这个inside函数会在每次something调用时,而重新创建。如果我想与之斗争,并缓存它,该怎么办?下面是代码:
const something = (value) => {
if (!cache.current) {
cache.current = () => {
console.log(value);
]
}
return cache.current;
}
表面上看,这段代码没啥问题。我们不过是生成了一个名为cache的外部变量,然后把我们的inside函数赋值给了cache.current属性。现在,这个函数不会每次调用时都重新生成,我们只返回已经保存的值。
然而,如果我们调用几次这个函数,就会看到一些怪异的事情:
const first = something('first');
const second = something('second');
const third = something('third');
first(); // logs "first"
second(); // logs "first"
third(); // logs "first"
我们用不同的参数调用了something函数,但是打印的值都是第一次的值.
我们刚刚,其实生成了一个“过时闭包”。每一个闭包在其被创建时,就被“冻结”了。当我们第一次调用something函数时,我们生成了一个value值为“first”的闭包。之后,我们把这个闭包存在了一个位于something函数外的对象里。
当我们再次调用something函数时,就没有再次生成一个新的闭包了,只是返回了原来已经生成的闭包。而第一次调用时所生成的闭包就被永远“冻结”在那里了。
为了修复这个bug,我们需要实现每次value变化时,重新生成这个函数和与之对应的闭包。这是代码:
const cache = {};
let preValue;
const something = (value) => {
// check whether the value has chagned
if (!cache.current !! value !== preValue) {
cache.current = () => {
console.log(value);
}
};
// refresh it
preValue = value;
return cache.current;
}
把value缓存起来以进行前后比较。之后,如果value前后不一样,就更新cache.current。
如此一来,这段代码的就可以正常地打日志了。如果我们比较两个传入一样参数的函数,其引用也是一样的:
const first = something('first');
const anotherFirst = something('second');
const second = something('third');
first(); // logs "first"
second(); // logs "second"
console.log(first === anotherFirst); // wii be true