JavaScript 中的弱记忆
由于我感觉自己缺乏理解,所以我决定写这篇文章来解释一些事情的工作原理。这些事情与状态管理和记忆化有某种联系。
我们需要讨论一些非常基础的东西,以及我们可以在此基础上构建的抽象概念。
这次,一切都会水到渠成。这次,你将轻松掌握 JavaScript 中的 memoization 功能。
如今,记忆化在 JS 世界中已是司空见惯,而且非常重要。随着Reselect 5.0 的最新发布,它也变得比以前更容易一些。无需理解所有细微差别,就能更容易地避免破坏脆弱的选择器。
Reselect 5 于 2023 年 12 月发布,但这一突破性解决方案背后的理念却酝酿了相当长一段时间,至少持续了几年
。 我能找到的第一个“现代”实现——weak -memoize——大约在8 年前就创建了。
我有理由相信,延长采用时间是受到我不太出色的沟通技巧和传达“弱记忆”背后原则的挑战的影响,我五年前在Memoization Forget-Me中最初分享了这一原则。
再试一次吧。
让我们从💁♂️简单的事情,到👨🔬极致的事情,走完整个旅程
。 这次我会让你相信。
一级——让它发挥作用
让我们写几行代码来描述“执行一次”操作。最简单的记忆方法
是 什么?
// some place to store results
let storedValue;
let storedArgument;
function memoizedCalculation(arg) {
// is the arg equal to the known one?
if(arg === storedArgument) {
return storedValue;
}
// if not - let's calculate new value
storedValue = functionToMemoize(arg);
storedArgument = arg;
return storedValue;
}
// usage
const value1 = memoizedCalculation(1);
const value2 = memoizedCalculation(2);
const valueN = memoizedCalculation(2); // will be memoized
看起来很熟悉——我认为很多项目在几个地方都有这样的代码。
唯一真正的问题是——这段代码不可复用,而且……🤮全局变量很难控制,尤其是在测试中。我认为下一步显然是让它更健壮一些,并提出一个level 2
解决方案。
第 2 级 - 将其放入盒子中
处理全局变量是一项复杂的工作,那么将它们设置为局部变量是否可以改善这种情况?
我们如何才能使这些代码更加“局部”且更可复用?
答案是“工厂”
// reusable function you can "apply" to any other function
+function memoizeCalculation(functionToMemoize) {
let storedValue;
let storedArgument;
return function memoizedCalculation(arg) {
// is the arg equal to the known one?
if(arg === storedArgument) {
return storedValue;
}
// if not - let's calculate new value
storedValue = functionToMemoize();
storedArgument = arg;
return storedValue;
}
+}
// fist create a memoized function
+const memoizedCalculation = memoizeCalculation(functionToMemoize);
// usage
const value1 = memoizedCalculation(1);
const value2 = memoizedCalculation(2);
const valueN = memoizedCalculation(2); // will be memoized
有趣的部分 -从使用角度来看并没有什么改变,从功能实现角度来看也没有什么改变 - 99%的代码都被保留了下来,我们只是添加了另一个功能。
虽然这个实现大声喊着“我很简单”——但这正是memoize-one 的写法,所以它“足够简单”来处理任何生产用例🚀
这是一个实际可行的解决方案。或许可以添加.clear
一些方法方便测试,但仅此而已。
更重要的是——“最初的重新选择”也是这样进行的,而且这“不是最好的方法”。没人满意。
reselect 5中发生了一些巨大的变化,所以让我们继续前进并尝试找到那个珍贵的变化。
Boss 战——可能只有一个
reselect
以及的问题memoize-one
在于,只有一个局部变量用于存储上次调用的结果。无论如何,它都不可能超过一个,只是这样而已。
为什么是“一”?一切都与所使用的“模式”以及清理先前记忆结果的能力有关。
这就是“缓存”(带有缓存键、存储限制和生命周期)与没有任何“复杂性”的记忆之间的区别。Memoization
Forget-Me详细解释了这些时刻。
🤔 我们如何改进它?也许问题不在于“如何”或“什么”。或许在于“在哪里”?
🤨 让我问你——这个局部变量存储在哪里?
JavaScript 的正确答案是:
💁♂️“它存储在一个函数闭包中”,但没有定义什么是“闭包”
更有帮助的答案是
从技术角度来说stack
,它就是执行上下文——它存储了当前函数参数、所有局部变量以及函数执行完成后返回执行位置的信息。它是内存中保存真实数据的一个区域。
最简单的情况是,调用函数会“扩展”堆栈,而退出函数会“缩小”堆栈。这就是为什么调用过多函数会导致堆栈溢出,因为堆栈无法无限增长。
然而,在 JavaScript 世界中,这并不那么容易,因为我们有闭包,它可以“看到”来自死函数的变量,或者换句话说➡️闭包的生命周期与其父上下文的生命周期没有绑定。因此,JS 中的“调用堆栈”更像是一个“盒子图”,其中不同函数的堆栈相互指向。
💁♂️ 所以每个函数都有一个包含数据的小盒子📦。非常简洁。
这里有一篇文章深入探讨了 v8 执行上下文的细节。
每次调用函数时,它都会使用不同的堆栈、不同的上下文、不同的“盒子”来执行。就像React hooks一样,它们被静态定义在函数中,并神奇地从 React Fiber(execution context
一个特定组件)中获取值。
问问自己——哪里
useState
有价值?应该有一个特别的地方。总有一些东西是特别的,即使在地狱……
👏👏 执行上下文使得能够使用不同的变量运行相同的函数👏👏
你知道——这听起来就像这样,它在 JavaScript 中的工作方式几乎相同——提供上下文。记住——我们不再使用的关键字不仅适用于类——this
适用于所有操作。
myFunction.call(this, arg1);
如果我们使用 js 上下文(this
)而不是 CPU 上下文(stack
)会怎么样?
正在加载 2 级 - 其他地方
首先,我需要指出memoize 的一个实现细节,它考虑了隐藏函数参数()的潜在副作用,this
可能会导致不同的结果
考虑以下代码
class MyClass {
onClick(event) {
return this.process(event, this.state)
}
render() {
return <div onClick={this.onClick}/>
}
}
你可能忘了它是怎么运作的,但简而言之——它不行🤣因为我们this
读取的是共享值,所以会损失一些时间。为了更正实现,需要内联回调
class MyClass {
onClick = (event) => {
return this.process(event, this.state)
}
在这种情况下,“every” 指this.onClick
的是非常独特且非常“自己” onClick
。对我来说,这听起来像是一种记忆。
想知道这个分配“类成员”的操作是如何进行的吗? Typescript Playground可以帮你解决这个问题。不过,为了省去点击的麻烦……
class MyClass {
constructor() {
// just stores stuff in "this".
this.onClick = (event) => {
return this.process(event, this.state);
};
}
}
回顾原始render
方法读数,this.onClick
您可能会注意到值很this
重要。
那么,假设它this
始终存在。那么稍微更新一下我们的记忆函数怎么样?
如果我们将记忆结果变量存储在 context 中,这样我们就可以拥有任意数量的记忆结果了,怎么样?
让我们做一些非常简单的事情(与传统的 React Context 非常相似)。
function memoizeCalculation(functionToMemoize) {
- let storedValue;
- let storedArgument;
return function memoizedCalculation(arg) {
// is the arg equal to the known one?
+ // let's store data not in the "function context"
+ // but the "execution context". 🤷♂️ why not?
+ if(arg === this.storedArgument) {
+ return this.storedValue;
}
// if not - let's calculate new value
+ this.storedValue = functionToMemoize();
+ this.storedArgument = arg;
+ return this.storedValue;
}
}
现在我们能够存储多个结果,因为我们将其存储在不同的上下文中。
myFunction.call(object1, arg1); // will memoize
myFunction.call(object2, arg1); // will memoize SEPARATELY
myFunction.call(object1, arg1); // will reuse memoization
myFunction.call({storedValue:'return this', storedArgument:args1}, arg1); // will BYPASS 🙀 memoization
这样做确实可行,但也会产生与旧版 context 相同的问题——命名冲突。我们在上面最后一行使用了它,这很酷,但请不要在实际代码中这样做。
我们能做得更好吗?当然!一行代码就能创造奇迹!
function memoizeCalculation(functionToMemoize) {
+ // a magic place for our data
+ const storeSymbol = Symbol();
return function memoizedCalculation(arg) {
// is the arg equal to the known one?
+ if(arg === this[storeSymbol]?.storedArgument) {
+ return this[storeSymbol]?.storedValue;
}
// if not - let's calculate new value
+ this[storeSymbol] = {
+ storedValue: functionToMemoize(),
+ storedArgument arg,
+ }
+ return this[storeSymbol]?.storedValue;
}
}
看起来很棒——我们创建了一个“魔法钥匙”,可以安全地存储我们的数据。市面上有很多解决方案都采用了类似的方法。
然而,我仍然不满意,因为我们会穿透对象边界,把信息存储在不该存储的地方。
我们可以做得更好吗?
第三层——更好的地方
🎉 这是弱记忆开始的章节!
在上面的例子中,我们使用this
来存储information
。我们将这些信息直接存储在 中this
,但任务始终是跟踪关系,仅此而已。
information
与……有关this
this[key]=information
this[information]="related
每次遇到这种情况,你都会问自己——有没有更好的原语来处理这个问题?用数据库术语来说,它可能是一个存储关系的单独表,而不是在表上再加一个字段。thisTable
确实,我们可以在这里使用一个原语 -Map
或者为了匹配我们的用例WeakMap
WeakMap
将保存有关记录的信息A->B
,并在 A 不存在时自动删除该记录。
语义上map.set(A,B)
等于A[B]=true
,但不损害A
我们的更新记忆如下
function memoizeCalculation(functionToMemoize) {
+ // a magic store
+ const store = new WeakMap();
return function memoizedCalculation(arg) {
// is the arg equal to the known one?
+ const {storedArgument, storedValue} = store.get(this);
+ if(arg === storedArgument) {
+ return storedValue;
}
// if not - let's calculate new value
+ const storedValue = functionToMemoize();
+ // we do not "write into this", we "associate" data
+ store.set(this, {
+ storedValue: functionToMemoize(),
+ storedArgument arg,
+ });
+ return storedValue;
}
}
this
现在我们将与和相关的信息存储functionToMemoize
在其他地方。
但存在context
只是一种假设,实际上通常不存在。
我们如何才能改变这种方法,使其至少在不依赖this
存在的情况下从给定方法中获得一些好处?
🤷♂️很简单,只需引入另一个变量。
// "master default"
+ let GLOBAL_CONTEXT = {};
function memoizeCalculation(functionToMemoize) {
// a magic store
const store = new WeakMap();
return function memoizedCalculation(arg) {
+ const context = this || GLOBAL_CONTEXT;
// is the arg equal to the known one?
+ const {storedArgument, storedValue} = store.get(context);
我们还可以再进一步,
如何自动清理所有功能以简化测试(并帮助捕获内存泄漏)。
let GLOBAL_CONTEXT = {};
// instantly invalidates all caches
export const RESET_ALL_CACHES = () =>GLOBAL_CONTEXT={};
或者
+ export const GET_CONTEXT = () => userDefinedWay();
// ...
return function memoizedCalculation(arg) {
+ const context = GET_CONTEXT();
所以现在我们实际上已经实现了无处不在的 context
this ,更重要的是,我们可以通过控制 this 来控制暴露给 memoization 的 store版本GLOBAL_CONTEXT
。
在这里,我们甚至可以开始使用异步上下文跟踪来实现每个线程或每个测试的开箱即用的 memoization 分离。
你知道吗...我只会保留它并删除this
我们不再使用的😎
🤑 额外福利:只需更新GLOBAL_CONTEXT
- 所有缓存将失效,所有先前的值将被垃圾回收。太神奇了🪄!
太棒了,上面的代码确实有用,但距离真正有用和像 一样好的东西还差得远reselect v5
。让我们进入下一个阶段!
第四级 - 级联
这里稍微绕个弯子。Reselect5 并不是从零开始开发的,它的灵感来源于React.cache
函数,而函数并没有详尽的文档,很多开发者也并不真正理解它的工作原理。
那么,让我们检查一下代码吧!这里有一个链接。这是代码的第一部分。
export function cache(fn) {
return function () {
// try to read some "shared" variable, like `GET_CONTEXT` in the above
const dispatcher = ReactSharedInternals.A;
if (!dispatcher) {
// If there is no dispatcher, then we treat this as not being cached.
return fn.apply(null, arguments);
}
// and get some "fnMap", so alike to our "store"
const fnMap = dispatcher.getCacheForType(createCacheRoot);
它实际上看起来非常接近我们刚刚编写的代码
- 一些“全存在”变量——
ReactSharedInternals.A
给我们一个指向缓存存储的指针 - 我们将对其进行“读”和“写”
- 同一时间点可能会有多个这样的上下文,这对于并行执行 SSR 非常有价值
React.cache
只是将结果存储在不同位置以进行不同的渲染。
但是,React 的实现中还有另一个与存储“单一结果”相关的重要时刻。例如,React.cache
使用cascade。Reselect v5也做了同样的事情,memoize-weak和kashe也做了同样的事情。
for (let i = 0, l = arguments.length; i < l; i++) {
const arg = arguments[i];
if (
typeof arg === 'function' ||
(typeof arg === 'object' && arg !== null)
) {
// Objects go into a WeakMap
cacheNode.o = objectCache = new WeakMap();
objectCache.set(arg, cacheNode);
} else {
// Primitives go into a regular Map
cacheNode.p = primitiveCache = new Map();
primitiveCache.set(arg, cacheNode);
}
}
在某些实现中,
null
重新路由到一个const NULL={}
对象以允许使用 WeakMap。
它创建一个🌳Tree-Like 结构,其节点为 WeakMap 或 Map,具体取决于参数类型。
从上面考虑一下红黑树,其中黑色节点为Map
,红色节点为WeakMap
我使用 WeakMap 来处理那些无法再次访问的对象,以便将其缓存条目作为垃圾回收。Expandos 在这里并不适用,因为我们也可能对缓存本身进行垃圾回收。
有一些不太好的情况,比如
cachedFn('a', 'b', 'c', 'd', {})
它会被 GC:ed 掉,但指向它的映射路径却不会被获取。更智能的实现是将弱映射放在外部,但必须更巧妙地确保参数索引被保留。我添加了一些测试来确保这一点,以防万一我们遇到这种情况。
在给定的示例中,第一个参数不是弱映射的,因此cascade
不会被垃圾收集,但缓存函数返回的值会被垃圾收集。
- 除非根本没有弱映射对象
- 但是一旦当前渲染结束并且“主符号”被删除,一切都会被清理。
- 所以这不是问题
🏆 这Cascade
就是 Reselect 5 优于所有先前版本的原因。现在它可以存储多个值,因为它不再是一个局部变量,而是一棵树 🏆
升级——额外的能力
只要我们使用 React 作为示例 - 还有一个功能将我们所讨论的内容连接起来 - Taint API
taintObjectReference 可以防止将特定对象实例(如用户对象)传递给客户端组件。
它是如何运作的?就像上面的“关系”一样(链接至来源)
export const TaintRegistryObjects = new WeakMap();
export function taintObjectReference(
message,
object,
) {
// here it goes
TaintRegistryObjects.set(object, message);
}
/// ...
const tainted = TaintRegistryObjects.get(value);
if (tainted !== undefined) {
throwTaintViolation(tainted);
}
WeakMaps 建立了关系并帮助解决了很多事情。
所以这就是
我希望您喜欢这次旅程,并且概念的演变可以帮助您更好地理解事物是如何联系的以及不同的原语如何帮助您。
去运用这些新知识吧!
快速回顾
- 每个程序都需要在某个地方存储变量。通常你无法控制这个时刻,但有时你可以
- 通过控制值的存储位置,你可以创建许多不同的东西——从弱记忆到反应钩子
- 我们有一些库,比如最新一代的 reselect,它赋予你超能力,让你不再担心记忆
- 仍然有一些边缘情况,你可能需要特殊的逻辑,例如 React 在不同的渲染线程之间分离记忆
- kashe提供了一些底层控制,包括支持 nodejs 的AsyncLocalStorage 实现线程分离。不妨一试。
PS:正如我提到的 React、hooks 和 stack -
React.use 和 React.useMemo 的未来hooks
指向一个有趣的讨论,一旦使用“单栈”模型,这个讨论可能会被完全不同地理解,因为fiber
“单栈”模型将开始使用更像闭包的东西,即独立上下文。