英文:
Why doesn't Angular signals run my custom 'equal' check when using mutate()?
问题
以下是已翻译的内容:
我在Angular中有一个表示虚拟仪表板的信号。以下是一个简化版本:
type DashboardStats = {
activeUsers: number,
orderCount: number,
revenue: number,
...
}
// 使用lodash中的相等性比较
const dashboard = signal<DashboardStats>(initialState, { equal: isEqual });
假设我每分钟轮询一次我的“orders”服务,需要将`updatedOrderCount`更新到仪表板中。
我有两种改变信号的方法,可以使用`update()`或`mutate()`之一。
dashboard.update(currentValue =>
{
return {
...currentValue,
orderCount: updatedOrderCount
}
})
在运行我提供的`updateFn`之后,将运行lodash的`isEqual`方法,并深度比较所有字段。如果在过去的一分钟内没有新的订单,那么信号将保持不变,不会通知任何消费者(例如`computed`信号或组件模板)。
如果我使用了`mutate()`,它将如下所示:
dashboard.mutate(dashboard =>
{
dashboard.orderCount = updatedOrderCount;
})
首先,这看起来更加可读。我知道我想写哪个。
然而,如文档中所述:
> 对于可写信号,`.mutate()`不会检查相等性,因为它会改变当前值而不生成新的引用。
这会导致每次运行此mutate调用时,信号值都会更改,并导致UI更新或使用它的`computed`信号重新计算。
现在,对于这个简单的虚拟仪表板来说,这显然不是性能问题,但对于更复杂的信号或复杂的`computed`信号链可能会引起大量工作。它还可能使调试变得更加困难。
基本上,Angular正在说mutate表示您知道自己在进行更改。
因此,问题是为什么Angular不能添加一个类似的布尔值来强制运行检查:
mutate(mutatorFn: (value: T) => void, runEqual?: boolean): void;
允许我在mutate之后强制运行`equal`函数,并获得更好的开发人员体验。
英文:
I have a signal in Angular that represents a hypothetical dashboard. Here is an oversimplification:
type DashboardStats = {
activeUsers: number,
orderCount: number,
revenue: number,
...
}
// using equality comparison from lodash
const dashboard = signal<DashboardStats>(initialState, { equal: isEqual } );
Let's say I poll my 'orders' service every minute and need to update updatedOrderCount
into the dashboard.
I've got two ways to change the signal, using either update()
or mutate()
.
dashboard.update(currentValue =>
{
return {
...currentValue,
orderCount: updatedOrderCount
}
})
After running my provided updateFn
, the isEqual
method from lodash will run and does a deep comparison of all the fields. If we haven't had any new orders in the past minute than the signal is unchanged and won't notify any consumers (such as computed
signals or component template).
If I had used mutate()
this is how it would look:
dashboard.mutate(dashboard =>
{
dashboard.orderCount = updatedOrderCount;
})
First of all that looks a LOT more readable. I know which I'd want to write.
However as explained in the docs:
> For writable signals, .mutate() does not check for equality because it mutates the current value without producing a new reference.
This has the unfortunate side effect that every single time I run this mutate call, the signal value will change and will cause the UI to update or computed
signals that use it to recalculate.
Now this clearly isn't a performance issue for this simple hypothetical dashboard, but can cause a lot of churn for more complicated signals or a complex chain of computed
signals. It can also make debugging harder.
Basically Angular is saying mutate means you know you're making a change.
So the question is why can't Angular add something like a boolean to force the check to run:
mutate(mutatorFn: (value: T) => void, runEqual?: boolean): void;
Allow me to force the equal
function to run after a mutate
and get a nicer developer experience.
答案1
得分: 2
答案实际上非常简单!
以下是 mutate
的当前源代码以供参考:
/**
* 调用 `mutator` 函数并假定已对当前值进行了更改。
*/
mutate(mutator: (value: T) => void): void {
if (!this.producerUpdatesAllowed) {
throwInvalidWriteToSignalError();
}
// Mutate bypasses equality checks as it's by definition changing the value.
mutator(this.value);
this.valueVersion++;
this.producerMayHaveChanged();
postSignalSetFn?.();
}
问题在于要运行任何 equal
函数(在任何语言中)都需要两个输入,即 a
和 b
。问题在于先前的值已经永远丢失,因为我们已经在原地更新了对象。
因此,a === b
总是为真,因此不可能在更改前后的值上运行 equal
(mutate 意味着更改现有对象)。
要在纯 JavaScript 中运行相等,唯一的方法是首先对其进行序列化,然后再进行反序列化,但从性能的角度来看,这将是一个糟糕的主意。
修复这个问题的一种方法是仅在我们确切知道有更改需要进行时才运行 mutate 调用。虽然确定这一点应该总是可能的,但可能不太实际或不像这样简单:
if (updatedOrderCount != dashboard().orderCount)
{
dashboard.mutate(dashboard =>
{
dashboard.orderCount = updatedOrderCount;
});
}
另一种方法是使用像 immer 包 以及特别是 produce 函数 这样的东西。以下是我们可以编写的一个辅助函数:
export const mutateSignal = <T>(value: WritableSignal<T>, mutatorFn: (value: Draft<T>) => void) =>
{
// 使用 immer 创建新状态
const newState = produce(value(), (draftState) => {
mutatorFn(draftState);
});
// 注意:如果没有更改,immer 将返回原始对象实例
const hasChanged = value() !== newState;
if (hasChanged)
{
// 如果有新状态,更新信号值
value.set(newState);
}
return hasChanged;
}
Immer 是一个了不起的项目,它允许你检测运行的任意代码是否有任何更改。如果 orderCount
没有更改,下面的代码将不会导致信号更改:
mutateSignal(dashboard, dashboard =>
{
dashboard.orderCount = updatedOrderCount;
});
不过有一些注意事项:
- Immer 返回不可变(冻结)状态。因此,如果使用 Angular 的
signal.mutate()
后再更改对象,将会导致错误。但这是一个好的副作用,你只需一直使用一种方式即可。 - 在信号上运行
set
(通常情况下)时,如果值是对象,信号将始终被标记为已更改。这可能会令人惊讶(特别是如果你习惯使用distinctUntilChanged()
),但在 defaultEquals 中有解释。因此,如果使用这个mutateSignal
immer 函数,必须在定义信号时使用一个equal
函数。 - 你可以使用
(a, b) => a === b
作为你的相等函数,但这仅在你只使用不可变数据更新信号时才有效。
我要强调的是,也许你根本不需要担心这个问题。Signals 和 computed
计算都应该保持简单。但也许你之所以找到这个问题,是因为你得到了一大堆实际上并不是更改的信号。所以希望这些选项能帮助你解决问题
英文:
The answer is actually quite simple!
Here's the current source for mutate
for reference:
/**
* Calls `mutator` on the current value and assumes that it has been mutated.
*/
mutate(mutator: (value: T) => void): void {
if (!this.producerUpdatesAllowed) {
throwInvalidWriteToSignalError();
}
// Mutate bypasses equality checks as it's by definition changing the value.
mutator(this.value);
this.valueVersion++;
this.producerMayHaveChanged();
postSignalSetFn?.();
}
The issue is that to run any equal
function (in any language) you need two inputs, a
and b
. The problem here is that the previous value has already gone forever since we updated the object in place.
So a === b
is always true and therefore it's not possible to run equal
on a before and after value (mutate means change the existing object).
The only way to run an equals with pure Javascript would be to serialize it first and again afterwards and that would be a terrible idea for performance sake.
One way to fix this is to only run the mutate call if we know 100% we have a change to make. While determining that should always be possible, it may not be practical or as simple as this:
if (updatedOrderCount != dashboard().orderCount)
{
dashboard.mutate(dashboard =>
{
dashboard.orderCount = updatedOrderCount;
});
}
The other approach is to use something like the immer package and specifically the produce function. Here's a helper function we could write:
export const mutateSignal = <T>(value: WritableSignal<T>, mutatorFn: (value: Draft<T>) => void) =>
{
// use immer to create new state
const newState = produce(value(), (draftState) => {
mutatorFn(draftState);
});
// note: immer returns the original object instance if there were no changes
const hasChanged = value() !== newState;
if (hasChanged)
{
// update the signal value if we have new state
value.set(newState);
}
return hasChanged;
}
Immer is an amazing project and allows you to detect whether or not the arbitrary code you ran change anything. The following won't result in a change to the signal if orderCount
didn't change.
mutateSignal(dashboard, dashboard =>
{
dashboard.orderCount = updatedOrderCount;
});
There's a few catches though:
- Immer returns immutable (frozen) state. You therefore can't change the object later using Angular's
signal.mutate()
without getting an error. But that's a good side effect - you just have to be consistent which you use. - When running
set
on a signal (in general) if the value is an object the signal will ALWAYS be marked as changed. That may be surprising (especially if you're accustomed to usingdistinctUntilChanged()
, but it's explained in defaultEquals. Therefore you MUST use anequal
function when you define the signal if you're using thismutateSignal
immer function. - You can use
(a, b) => a === b
for your equality function, but that only works if you only ever update the signal with immutable data.
I do want to stress that maybe you don't need to worry about this at all. Signals, and computed
calculations are meant to be simple. But you may have only found this question because you were getting an unmanageable number of signal changes that weren't really changes. So hopefully this helps give you options
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论