Svelte 反应性陷阱 + 解决方案(如果您在生产中使用 Svelte,则应该阅读此内容)

2025-06-11

Svelte 反应性陷阱 + 解决方案(如果您在生产中使用 Svelte,则应该阅读此内容)

Svelte是一个很棒的框架,我的团队使用它构建生产环境应用已经一年多了,取得了巨大的成功,提高了效率,也享受到了其中的乐趣。它的核心特性之一是响应式,这可是头等公民,使用起来非常简单,并且允许编写一些你能想象到的最具表现力、最具声明性的代码:当某个条件满足或相关内容发生变化时,无论原因或方式如何,都会运行相应的代码片段。它简直太棒了,而且非常漂亮。编译器的魔法。

当你只是简单尝试一下时,它似乎运行起来毫无阻力,但随着你的应用变得越来越复杂,要求越来越高,你可能会遇到各种令人费解、未记录的行为,这些行为非常难以调试。
希望这篇短文能帮助你缓解一些困惑,让你回到正轨。

在我们开始之前,有两个免责声明:

  1. 以下所有示例均为人为设计。请勿发表诸如“你本可以用其他方式实现该示例以避免此问题”之类的评论。我知道。我向你保证,我们在实际代码库中遇到过所有这些问题,而且当 Svelte 代码库相当庞大且复杂时,这些情况和误解确实可能会出现。
  2. 以下提出的任何见解均不属于我的功劳。这些见解是我与团队成员以及 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>
Enter fullscreen mode Exit fullscreen mode

一切正常(点击上方或此处的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>

Enter fullscreen mode Exit fullscreen mode

审阅者很高兴,但哦不,代码不再工作了。更新ab不更新总和,也不会向服务器报告。为什么?
嗯,反应块没有意识到这一点,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>
Enter fullscreen mode Exit fullscreen mode

我几乎可以听到一些函数式程序员读者说“呃”,但在大多数情况下,我仍然会选择解决方案 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>
Enter fullscreen mode Exit fullscreen mode

我并不是说在计算两个数之和时应该这样写代码。我想说的是,通常情况下,在代码块顶部加上这样的条件语句可以使代码块更具可读性,并且不易重构。这确实需要一些规范(不要遗漏任何依赖项),但从经验来看,在编写或修改代码时,做到正确并不难。

陷阱 #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>
Enter fullscreen mode Exit fullscreen mode

如果你一边观察控制台一边持续点击按钮,你会注意到该if语句对于原语和对象的行为有所不同。哪种行为更正确?我想这取决于你的用例,但如果你从一个重构到另一个,那就准备好迎接惊喜吧。
对于原语,它按值比较,只要值没有改变就不会再次运行。

对于对象,你可能会认为每次都是一个新对象,而 Svelte 只是按引用比较,但这似乎并不适用于这里,因为当我们使用赋值时,isForRealzObj.value = true;我们不是创建一个新对象,而是更新现有对象,引用保持不变。

解决方案:

好吧,记住这一点,小心一点。如果你意识到了这一点,其实并不难发现。如果你使用的是对象,并且不想每次都运行代码块,你需要记住自己先与旧值进行比较,如果没有变化就不要运行你的逻辑。

陷阱 #3:邪恶的微任务(嗯,有时候……)

好了,到目前为止,我们只是热身。这个有多种形式。我将演示两种最常见的。您会看到,Svelte 批量处理一些操作(即反应式块和 DOM 更新),并将它们安排在更新队列的末尾 - 想想 requestAnimationFrame 或 setTimeout(0)。这称为 或micro-tasktick当您遇到它时特别令人费解的一件事是,异步会完全改变事物的行为方式,因为它会超出微任务的边界。因此,在同步/异步操作之间切换会对代码的行为产生各种影响。您可能会遇到以前不可能出现的无限循环(从同步切换到异步时),或者遇到完全或部分停止触发的反应式块(从异步切换到同步时)。让我们看一些示例,其中 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>
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

无论count是原始类型还是对象,else反应块的一部分都不会运行,并且isSmallerThan10会不同步,并且是静默的(它显示true事件,尽管计数是 11,而它应该是false)。
发生这种情况是因为每个反应块每个 tick 最多只能运行一次
当我们从异步存储切换到乐观更新存储时,这个特定问题困扰着我的团队,它以各种微妙的方式导致应用程序中断,让我们完全困惑。请注意,当您有多个反应块在循环中相互更新依赖关系时,也会发生这种情况。

这种行为有时可以被视为一种功能,它可以保护您免于无限循环,就像这里一样,甚至可以防止应用程序进入不良状态,就像这个由 Rich Harris 提供的示例一样。

3.2 的解决方案:强制异步来拯救

为了让响应式代码块能够顺利运行,你必须在代码中巧妙地放置tick()
的调用。 一个非常有用的模式(不是我想出来的,也不能居功自傲)是

$: tick().then(() => {
  //your code here
});
Enter fullscreen mode Exit fullscreen mode

这是使用此技巧的示例的修复版本isSmallerThan10

概括

根据我团队的经验,我向大家展示了 Svelte 中最常见的与响应式相关的陷阱,以及一些规避它们的方法。

在我看来,所有框架和工具(至少是我迄今为止使用过的那些)似乎都难以创建“无陷阱”的响应式实现。

相比我迄今为止尝试过的其他所有工具,我仍然更喜欢 Svelte 的响应式风格,并希望其中一些问题能够在不久的将来得到解决,或者至少能够得到更完善的文档。

我想,在使用任何工具编写生产级应用程序时,为了保持井然有序,人们不可避免地必须非常详细地了解该工具的内部工作原理,Svelte 也不例外。

感谢您的阅读,祝您构建愉快!

如果您在应用程序中遇到了这些陷阱中的任何一个,或者我未提到的任何其他陷阱,请在评论中分享。

继续阅读 https://dev.to/isaachagoel/svelte-reactivity-gotchas-solutions-if-you-re-using-svelte-in-production-you-should-read-this-3oj3
PREV
使用 Docker 设置 Node 的分步指南 了解如何在 Docker 容器内设置 Node JS
NEXT
开发者营销:非传统指南