Svelte 反应性陷阱 + 解决方案(如果您在生产中使用 Svelte,则应该阅读此内容)
Svelte是一个很棒的框架,我的团队使用它构建生产环境应用已经一年多了,取得了巨大的成功,提高了效率,也享受到了其中的乐趣。它的核心特性之一是响应式,这可是头等公民,使用起来非常简单,并且允许编写一些你能想象到的最具表现力、最具声明性的代码:当某个条件满足或相关内容发生变化时,无论原因或方式如何,都会运行相应的代码片段。它简直太棒了,而且非常漂亮。编译器的魔法。
当你只是简单尝试一下时,它似乎运行起来毫无阻力,但随着你的应用变得越来越复杂,要求越来越高,你可能会遇到各种令人费解、未记录的行为,这些行为非常难以调试。
希望这篇短文能帮助你缓解一些困惑,让你回到正轨。
在我们开始之前,有两个免责声明:
- 以下所有示例均为人为设计。请勿发表诸如“你本可以用其他方式实现该示例以避免此问题”之类的评论。我知道。我向你保证,我们在实际代码库中遇到过所有这些问题,而且当 Svelte 代码库相当庞大且复杂时,这些情况和误解确实可能会出现。
- 以下提出的任何见解均不属于我的功劳。这些见解是我与团队成员以及 Svelte 社区的一些成员共同努力解决这些问题的结果。
陷阱 #1:隐式依赖很危险
这是一个经典的例子。假设你写了下面的代码:
<script>
let a = 4;
let b = 9;
let sum;
function sendSumToServer() {
console.log("sending", sum);
}
$: {
sum = a + b;
sendSumToServer();
}
</script>
<label>a: <input type="number" bind:value={a}></label>
<label>b: <input type="number" bind:value={b}></label>
<p>{sum}</p>
一切正常(点击上方或此处的REPL 链接),但在代码审查时,系统会提示你提取一个函数来计算和,以提升可读性或其他原因。
你照做了,结果如下:
<script>
let a = 4;
let b = 9;
let sum;
function calcSum() {
sum = a + b;
}
function sendSumToServer() {
console.log("sending", sum);
}
$: {
calcSum();
sendSumToServer();
}
</script>
<label>a: <input type="number" bind:value={a}></label>
<label>b: <input type="number" bind:value={b}></label>
<p>{sum}</p>
审阅者很高兴,但哦不,代码不再工作了。更新a
或b
不更新总和,也不会向服务器报告。为什么?
嗯,反应块没有意识到这一点,a
并且b
存在依赖关系。你能怪它吗?我想不能,但当你有一个很大的反应块,里面有多个隐式的、可能很微妙的依赖关系,而你恰好重构了其中一个,那么这对你来说没什么帮助。
情况可能会变得更糟……
一旦自动依赖项识别机制遗漏了某个依赖项,它就无法按照预期顺序(也就是依赖关系图)运行反应式代码块。相反,它会从上到下运行它们。
这段代码能够产生预期的输出,因为 Svelte 会跟踪依赖项,但这个版本却没有,因为像我们之前看到的那样,存在隐藏的依赖项,并且反应式代码块是按顺序运行的。问题是,如果你碰巧有同样的“坏代码”,但顺序不同,就像这样,它仍然会产生正确的结果,就像一颗等待被踩到的地雷。
这会带来巨大的影响。你可能有一段“坏代码”,碰巧所有反应式代码都按“正确”的顺序运行,但如果你把一个代码块复制粘贴到文件中的其他位置(例如在重构时),突然间一切都崩溃了,而你却不知道原因。
值得重申的是,这些例子中的问题可能显而易见,但如果一个响应式代码块包含大量隐式依赖,而它仅仅丢失了其中的一个,问题就会变得不那么明显。
事实上,当一个响应式代码块包含隐式依赖时,理解这些依赖的唯一方法是仔细阅读它的全部内容(即使它很长而且分支很多)。
这使得隐式依赖在生产环境中非常糟糕。
解决方案 A - 具有显式参数列表的函数:
从反应块调用函数或重构时,仅使用将所有依赖项明确作为参数的函数,以便反应块“看到”传入的参数并“理解”当它们发生变化时该块需要重新运行 - 就像这样。
<script>
let a = 4;
let b = 9;
let sum;
function calcSum(a,b) {
sum = a + b;
}
function sendSumToServer(sum) {
console.log("sending", sum);
}
$: {
calcSum(a,b);
sendSumToServer(sum);
}
</script>
<label>a: <input type="number" bind:value={a}></label>
<label>b: <input type="number" bind:value={b}></label>
<p>{sum}</p>
我几乎可以听到一些函数式程序员读者说“呃”,但在大多数情况下,我仍然会选择解决方案 B(如下),因为即使你的函数更纯粹,你也需要阅读整个反应块来了解依赖关系是什么。
解决方案 B-明确:
将所有依赖项明确地写在代码块的顶部。我通常使用一个if
包含所有依赖项的语句,就像这样:
<script>
let a = 4;
let b = 9;
let sum;
function calcSum() {
sum = a + b;
}
function sendSumToServer() {
console.log("sending", sum);
}
$: if (!isNaN(a) && !isNaN(b)) {
calcSum();
sendSumToServer();
}
</script>
<label>a: <input type="number" bind:value={a}></label>
<label>b: <input type="number" bind:value={b}></label>
<p>{sum}</p>
我并不是说在计算两个数之和时应该这样写代码。我想说的是,通常情况下,在代码块顶部加上这样的条件语句可以使代码块更具可读性,并且不易重构。这确实需要一些规范(不要遗漏任何依赖项),但从经验来看,在编写或修改代码时,做到正确并不难。
陷阱 #2:基于原始触发器和基于对象的触发器行为不同
这并非 Svelte 独有,但在我看来,Svelte 让它不那么明显。
考虑一下这个
<script>
let isForRealz = false;
let isForRealzObj = {value: false};
function makeTrue() {
isForRealz = true;
isForRealzObj.value = true;
}
$: if (isForRealz) console.log(Date.now(), "isForRealz became true");
$: if (isForRealzObj.value) console.log(Date.now(), "isForRealzObj became true");
</script>
<p>
click the button multiple times, why does the second console keep firing?
</p>
<h4>isForRealz: {isForRealz && isForRealzObj.value}</h4>
<button on:click={makeTrue}>click and watch the console</button>
如果你一边观察控制台一边持续点击按钮,你会注意到该if
语句对于原语和对象的行为有所不同。哪种行为更正确?我想这取决于你的用例,但如果你从一个重构到另一个,那就准备好迎接惊喜吧。
对于原语,它按值比较,只要值没有改变就不会再次运行。
对于对象,你可能会认为每次都是一个新对象,而 Svelte 只是按引用比较,但这似乎并不适用于这里,因为当我们使用赋值时,isForRealzObj.value = true;
我们不是创建一个新对象,而是更新现有对象,引用保持不变。
解决方案:
好吧,记住这一点,小心一点。如果你意识到了这一点,其实并不难发现。如果你使用的是对象,并且不想每次都运行代码块,你需要记住自己先与旧值进行比较,如果没有变化就不要运行你的逻辑。
陷阱 #3:邪恶的微任务(嗯,有时候……)
好了,到目前为止,我们只是热身。这个有多种形式。我将演示两种最常见的。您会看到,Svelte 批量处理一些操作(即反应式块和 DOM 更新),并将它们安排在更新队列的末尾 - 想想 requestAnimationFrame 或 setTimeout(0)。这称为 或micro-task
。tick
当您遇到它时特别令人费解的一件事是,异步会完全改变事物的行为方式,因为它会超出微任务的边界。因此,在同步/异步操作之间切换会对代码的行为产生各种影响。您可能会遇到以前不可能出现的无限循环(从同步切换到异步时),或者遇到完全或部分停止触发的反应式块(从异步切换到同步时)。让我们看一些示例,其中 Svelte 管理微任务的方式可能导致潜在的意外行为。
3.1:缺失状态
这里的名字改了多少次?
<script>
let name = "Sarah";
let countChanges = 0;
$: {
console.log("I run whenever the name changes!", name);
countChanges++;
}
name = "John";
name = "Another name that will be ignored?";
console.log("the name was indeed", name)
name = "Rose";
</script>
<h1>Hello {name}!</h1>
<p>
I think that name has changed {countChanges} times
</p>
Svelte 认为答案是 1,但实际上答案是 3。
正如我上面所说,响应式块仅在微任务结束时运行,并且仅“看到”当时存在的最后一个状态。从这个意义上讲,它实际上并不名副其实地“响应式”,因为它并非每次发生变化时都会触发(换句话说,它不会像你直观地预期的那样,由其依赖项上的“set”操作同步触发)。
3.1 的解决方案:
当你需要实时追踪所有状态变化而不遗漏任何变化时,可以使用store。store会实时更新,不会跳过任何状态。你可以在 store 的set
函数中拦截这些变化,也可以通过直接订阅它(通过store.subscribe
)。以下是针对上述示例的操作方法
3.2 - 无需递归
有时你会想要一个反应式代码块,它会不断修改自身依赖项的值,直到“稳定”为止,换句话说,就是经典的递归。为了清晰起见,这里有一个略显牵强的例子,这样你就能明白这会带来哪些问题:
<script>
let isSmallerThan10 = true;
let count = {a:1};
$: if (count.a) {
if (count.a < 10) {
console.error("smaller", count.a);
// this should trigger this reactive block again and enter the "else" but it doesn't
count = {a: 11};
} else {
console.error("larger", count.a);
isSmallerThan10 = false;
}
}
</script>
<p>
count is {count.a}
</p>
<p>
isSmallerThan10 is {isSmallerThan10}
</p>
无论count
是原始类型还是对象,else
反应块的一部分都不会运行,并且isSmallerThan10
会不同步,并且是静默的(它显示true
事件,尽管计数是 11,而它应该是false
)。
发生这种情况是因为每个反应块每个 tick 最多只能运行一次。
当我们从异步存储切换到乐观更新存储时,这个特定问题困扰着我的团队,它以各种微妙的方式导致应用程序中断,让我们完全困惑。请注意,当您有多个反应块在循环中相互更新依赖关系时,也会发生这种情况。
这种行为有时可以被视为一种功能,它可以保护您免于无限循环,就像这里一样,甚至可以防止应用程序进入不良状态,就像这个由 Rich Harris 提供的示例一样。
3.2 的解决方案:强制异步来拯救
为了让响应式代码块能够顺利运行,你必须在代码中巧妙地放置tick()
的调用。 一个非常有用的模式(不是我想出来的,也不能居功自傲)是
$: tick().then(() => {
//your code here
});
这是使用此技巧的示例的修复版本isSmallerThan10
。
概括
根据我团队的经验,我向大家展示了 Svelte 中最常见的与响应式相关的陷阱,以及一些规避它们的方法。
在我看来,所有框架和工具(至少是我迄今为止使用过的那些)似乎都难以创建“无陷阱”的响应式实现。
相比我迄今为止尝试过的其他所有工具,我仍然更喜欢 Svelte 的响应式风格,并希望其中一些问题能够在不久的将来得到解决,或者至少能够得到更完善的文档。
我想,在使用任何工具编写生产级应用程序时,为了保持井然有序,人们不可避免地必须非常详细地了解该工具的内部工作原理,Svelte 也不例外。
感谢您的阅读,祝您构建愉快!
如果您在应用程序中遇到了这些陷阱中的任何一个,或者我未提到的任何其他陷阱,请在评论中分享。