初学者指南:记忆法
本文最初发布于malikbrowne.com。
上周,我浏览了不同的文章,寻找关于 React v16.3 中新生命周期方法的指导。我偶然发现了这篇文章,它讨论了许多开发人员可能使用的getDerivedStateFromProps
错误方法。
如果你不熟悉 React,该方法只是允许组件根据其 props 的变化更新其内部状态。但是,文章建议不要这样做,而我在代码中经常这样做:
使用getDerivedStateFromProps
或componentWillReceiveProps
确保组件仅在输入发生变化时执行昂贵的重新渲染计算。
然而,一种更简单、更简洁的方法可以通过一种称为记忆化的函数式编程技术来实现。
作为一名对性能感兴趣的成长型程序员,我喜欢接触新的函数式编程技术,它们可以帮助我提升日常代码的运行速度。我曾听其他工程师在不同的算法问题中讨论过记忆化技术。然而,我从未花时间去了解这些炒作究竟是什么——主要是因为它听起来太复杂了。
在这篇文章中,我将解释什么是纯函数,记忆是如何工作的,以及如何在 React 组件中组合它们以使你的代码更高效。
我们先来谈谈纯函数。
什么是纯函数?
根据定义,纯函数是满足以下条件的函数:
- 它是一个函数,如果传入相同的参数,它总是返回相同的结果。
- 该函数不会对您的应用程序产生任何可观察到的副作用,包括:
- 网络请求
- 数据突变
- 记录到文件
- 更改应用程序状态
- 它是一个仅访问您传递给它的数据的函数,使得依赖关系易于定义。
这篇文章中将纯函数比作咖啡研磨机,这可能有助于理解这个想法。
纯粹的功能就像一台咖啡研磨机:咖啡豆放进去,粉末出来,故事结束。
好处
纯函数有几个好处 - 其中两个是:
- 它们可以产生更多的声明性程序来描述不同输入与输出的关系。
- 它们可以提高代码的可测试性,并使代码调试不再那么困难。
然而,值得注意的是,副作用一般来说并不坏- 这意味着我们不必使每个函数都变得纯净。
纯函数示例
假设我们有一个递归函数,它返回一个数字的阶乘:
const factorial = n => {
if (n === 1) {
return n;
}
return n * factorial(n - 1)
}
// factorial(4)
// 4! === 4 * 3 * 2 * 1 === 24
如果我们传入factorial(4)
,我们的计算就会每次都返回结果 24 。
既然我们现在知道纯函数每次都会返回相同的值,如果我们的函数能够记住(或缓存)结果,岂不是更方便?这样,下次有人想计算时factorial(100)
,我们就可以节省时间和资源,直接给他们已经存储的答案。
朋友们,这就是记忆法。
记忆到底是什么?
根据定义,
记忆化是一种优化技术,主要用于通过存储昂贵的函数调用的结果并在再次出现相同输入时返回缓存的结果来加快程序速度。
通俗地说,这意味着如果你给函数同样的问题,它就会记住问题的答案。为了实现一个简单的记忆化解决方案,我们可以以映射的形式实现某种类型的缓存,然后我们的函数可以引用它。
使用记忆函数后,我们的阶乘解决方案如下所示:
// our original factorial function
const factorial = n => {
if (n === 1) {
return n;
}
return n * factorial(n - 1)
}
// a memoized function used to calculate our factorial
const scopedMemoizedFactorial = () => {
const fakeCache = {};
return (value) => {
if (value in fakeCache) {
// return the value from our fake cache
return fakeCache[value];
}
else {
// calculate our factorial
const result = factorial(value);
fakeCache[value] = result;
return result;
}
}
}
注意事项
scopedMemoizedFactorial
返回一个稍后会被调用的函数。我们可以在 JavaScript 中做到这一点,因为函数是一等对象,这意味着我们可以将它们用作高阶函数并返回另一个函数。fakeCache
由于闭包的实现,它可以记住这些值- 这之所以有效,是因为我们正在使用的函数是纯函数,就像我们之前讨论过的一样。如果它没有返回相同的值,我们的缓存就不会返回正确的输出值!
如果您想看一个通用记忆函数的示例,请查看这个要点,其中展示了 Stoyan Stefanov 的记忆模式JavaScript Patterns
。
在 React 中使用记忆化
为了便于示例说明,我们假设有一个第三方 API,它会返回应用程序中所有用户的 JSON 数据。其数据结构如下所示:
[
{
name: "Malik",
age: 24,
company: "Meetup",
// ...and a bunch of other fields like this
},
// ...and 996 other entries just like this
]
如果您想查看整个数据集的样子,请查看此链接。(感谢JSON 生成器!)
我们的应用程序的要求是创建一个搜索框,它将过滤我们的用户列表并返回所有名称与查询匹配的用户的排序列表。
没有记忆的代码如下所示:
class App extends React.PureComponent{
state = {
searchValue: ""
};
filterList = (list, searchValue) =>
list.filter(member => member.name.toLowerCase().startsWith(searchValue));
sortList = list =>
list.sort((a, b) => {
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
});
handleInputChange = searchValue => {
this.setState({ searchValue, inputChanged: true });
};
render() {
const { searchValue, inputChanged } = this.state;
const filteredMembers = this.filterList(data, searchValue);
const members = this.sortList(filteredMembers);
return (
<div className="App">
<h1>No Memoization Example</h1>
<Search
searchValue={searchValue}
onInputChange={e => this.handleInputChange(e.target.value)}
placeholder="Search for a member"
/>
<div className="members">
{members.map(member => {
return <Member member={member} key={member._id} />;
})}
</div>
</div>
);
}
}
在此处查看实际运行的代码。
该解决方案在大多数情况下都能完美运行,但当数据量较大时,应用程序的速度会变得很慢。
出现这种情况有两个原因:
- 过滤大量数据是一项昂贵的操作
- 应用程序的其他重新渲染将导致该函数再次调用昂贵的操作。
使用助手memoize-one
我们可以轻松地将记忆功能添加到此示例中:
import memoize from 'memoize-one';
class App extends React.PureComponent {
state = {
searchValue: ""
};
filterList = memoize((list, searchValue) =>
list.filter(member => member.name.toLowerCase().startsWith(searchValue))
);
sortList = memoize(list =>
list.sort((a, b) => {
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
})
);
handleInputChange = searchValue => {
this.setState({ searchValue });
};
render() {
const { searchValue } = this.state;
const filteredMembers = this.filterList(data.slice(0, 50), searchValue);
const members = this.sortList(filteredMembers);
return (
<div className="App">
<h1>With Memoization Example</h1>
<Search
searchValue={searchValue}
onInputChange={e => this.handleInputChange(e.target.value)}
placeholder="Search for a member"
/>
<div className="members">
{members.map(member => {
return <Member member={member} key={member._id} />;
})}
</div>
</div>
);
}
}
memoize-one
非常棒,因为它只存储最后一次函数调用的结果,所以您不必担心缓存破坏问题。
性能重要说明
记忆的想法很棒,但请记住记忆的主要好处:存储昂贵的函数调用的结果。
我采用了阶乘解决方案,并使用性能时间线 API来计时我们的函数调用所花费的时间(精确到微秒):
// we use performance.now() to keep track of how long each call takes
const tick = () => performance.now();
const t0 = tick()
optimizedFactorial(5000); // calculated
const t1 = tick();
console.log(`The first call took ${t1 - t0}ms.`);
// The first call took 0.3999999971711077ms.
optimizedFactorial(5000); // cached
const t2 = tick();
console.log(`Our memoized call took ${t2 - t1}ms.`);
// Our memoized call took 2.2000000026309863ms.
optimizedFactorial(4999); // calculated again with different param
const t3 = tick();
console.log(`A call that wasn't stored in our cache took ${t3 - t2}ms.`);
// A call that wasn't stored in our cache took 0.3999999971711077ms
如你所见,在我的电脑上,memoized 调用花费的时间比实际时间长了五倍多才得到相同的结果。这是因为,为了使我们的 memoized 技术发挥作用,计算机需要为新变量分配内存并实例化它,这需要花费大量的时间才能执行计算。
因此,我们可以看到,在此解决方案中使用记忆技术将是一种过早的优化 - 并且会对我们的应用程序的性能产生负面影响。
需要注意的另一件事是,该解决方案不能处理与“破坏”缓存有关的许多麻烦,包括:
- 设置最大年龄或尺寸
- 我们的缓存排除项
这两种痛点都可能导致应用程序内存泄漏,这对调试来说可能是一场噩梦。正因如此,许多工程师倾向于使用已经实现解决方案的记忆助手来处理这些常见问题。其中包括:
关于 React 中的 memoization,这篇React 博客文章介绍了一些主要的限制。由于他们使用了类似的示例,我将在下面分享:
- 大多数情况下,你会希望将记忆函数附加到组件实例上。这可以防止组件的多个实例互相重置彼此的记忆键。
- 您还需要使用具有有限缓存大小的记忆助手,以防止随着时间的推移发生内存泄漏。
props.members
如果每次父组件渲染时都重新创建,本节中展示的所有实现都将失效。但在大多数情况下,这种设置是合适的。
结论
记忆化是一项非常棒的技术,如果使用得当,可以增强你的应用程序。使用更多函数式编程技术可以编写更简单、更可预测的代码,并具有更高的可测试性。
我强烈建议您通过名为memoize-one的包在您的某个应用程序中尝试使用记忆功能。
如果您对本文中的任何概念有任何疑问,请随时在评论中留下问题!
我始终乐于听取开发者社区成员的意见,所以也欢迎在Twitter上联系我。欢迎告诉我你对使用 memoization 提升性能的看法!
下期再见。
文章来源:https://dev.to/milkstarz/a-beginners-guide-memoization-22f0