推荐学习指数:⭐️⭐

1. 前言

本篇针对使用过 Svelte 4 的同学,讲解 Svelte 4 中存在的问题,以及使用符文带来的好处。如果初学就是 Svelte 5,本篇简单了解即可。

2. Svelte 4 中的问题

2.1. 问题 1:状态与派生状态不一致

这是一段 Svelte 4 中的代码:

<script>
  let count = 0;
  $: double = count * 2

  function increment() {
    count += 1
  }
</script>

<button on:click={increment}>Click</button>

{count} * 2 = {double}

浏览器效果如下:

看起来很正常,但其实有一个小问题。

我们在 increment() 函数中打印一下 count 和 double 的值:

<script>
  let count = 0;
  $: double = count * 2

  function increment() {
    count += 1
+   console.log({count, double})
  }
</script>

<button on:click={increment}>Click</button>

{count} * 2 = {double}

浏览器效果如下:

当第一次点击的时候,count 的值是 1,double 的值却是 0。第二次点击的时候,count 的值是 2,double 的值却是 2。你会发现,count 和 double 的值并不同步。

这是为什么呢?

这是因为 Svelte 为了避免重复计算,会确保没有其他值更改的时候,才会重新计算 double,所以它会拖到快要进行 DOM 更新时再重新计算,所以 double 变更的时机要晚于 console.log 执行的时机。

如果你想要获取同步的值,你可以这样写:

<script>
  let count = 0;
  $: double = count * 2

  function increment() {
    count += 1
+   requestAnimationFrame(() => {
+      console.log({count, double})
+   })
  }
</script>

<button on:click={increment}>Click</button>

{count} * 2 = {double}

浏览器效果如下:

2.2. 问题 2:依赖追踪只发生在编译时

第二个问题是依赖追踪只发生在编译时。还是上节这个例子,我们用 doubleCount 函数优化下响应式声明:

<script>
  let count = 0;
  $: double = doubleCount()

  function doubleCount() {
    return count * 2
  }

  function increment() {
    count += 1
  }
</script>

<button on:click={increment}>Click</button>

{count} * 2 = {double}

如果这样写,double 是不会响应式更新的。浏览器效果如下:

这是因为编译器无法看到依赖关系,所以 double 并不会响应式更新。如果你想要更新,你需要这样做:

<script>
  let count = 0;
-   $: double = doubleCount()
+   $: double = doubleCount(count)

  +function doubleCount() {
    return count * 2
  }

  function increment() {
    count += 1
  }
</script>

<button on:click={increment}>Click</button>

{count} * 2 = {double}

通过在参数中传入 count,让 Svelte 在编译的时候知道 double 依赖了 count,才会让 double 在 count 变化的时候重新计算。

2.3. 问题 3:学习成本高

还是这个计数器:

<script>
  let count = 0;

  $: double = count * 2

  function increment() {
    count += 1
  }
</script>

<button on:click={increment}>Click</button>

{count} * 2 = {double}

假设有很多计数器,我们创建一个函数将代码复用:

<script>
  function createCounter() {
    let count = 0;

    $: double = count * 2

    function increment() {
      count += 1
    }
  }
</script>

<button on:click={increment}>Click</button>

{count} * 2 = {double}

因为 let 没有声明在顶层,所以响应式会失效,$: double 也变成了普通的语法,没有什么作用。

为了让 count 值变得响应式,我们需要借助 Store:

<script>
  import { writable } from "svelte/store";

  function createCounter() {
    const { subscribe, update } = writable(0)
    function increment() {
      update((count) => count+1)
    }
    return {
      subscribe,
      increment
    }
  }

  const counter = createCounter()
</script>

<button on:click={counter.increment}>Click</button>

{$counter}

浏览器效果如下:

这样做本身没有什么问题,但是对于初学者而言,仅仅是为了解决一个普通的代码复用问题,却要去用复杂的 Store,还要理解 Store contract 以及掌握 $xxx 这种自动订阅语法。这会提高初学者的理解和学习成本。

2.4. 问题 4:重复计算

这是一个 Bindings 章节用到的例子:

<script>
  let todos = [
    { done: false, text: 'finish Svelte tutorial' },
    { done: false, text: 'build an app' },
    { done: false, text: 'world domination' }
  ];

  function remaining(todos) {
  console.log('recalculating')
  return todos.filter((t) => !t.done).length;
  }
</script>

<ul class="todos">
  {#each todos as todo}
  <li class:done={todo.done}>
    <input
      type="checkbox"
      bind:checked={todo.done}
      />

      <input
        type="text"
        placeholder="What needs to be done?"
        bind:value={todo.text}
        />
      </li>

  {/each}
</ul>

<p>未完成:{remaining(todos)}</p>

浏览器效果如下:

你会发现,当我们修改任务的完成状态时,remaining(todos) 重新计算,这是没有问题的,但当我们修改任务的内容时,也会导致 remaining(todos) 重新计算,但这是没有必要的。如果列表很长,这会造成性能问题。难道就不能避免这些不必要的计算吗?

3. Runes

Runes 解决了这些问题!

Runes,中文译文“符文”,一个听起来很帅,但又会让人感到有些莫名其妙的名词。想想游戏中的符文作用,往往是有着特殊作用的一个字母或者标记:

比如在巫师 3 中,通过给武器嵌入符文石,可以增强武器力量,比如嵌入火焰符文会额外造成火焰伤害。

Svelte 5 中的符文也差不多,也是通过一些字符实现特殊作用,只不过游戏中的符文千奇百怪,影响的是角色属性,Svelte 中的符文是 $开头的字符,影响的是 Svelte 编译器。

简单来说,符文就是影响 Svelte 编译器的符号。其实 Svelte 4 使用的 let、=、export、$: 就是一种“符文”,但 Svelte 5 新增的符文将使用函数语法替代它们并实现更多的功能。

当使用 Svelte 的时候,符文就是语言的一部分,无须导入(会编译真的可以为所欲为呀!)。

3.1. $state

首先介绍的是状态符文,这是 Svelte 4 的代码:

<script>
  let count = 0;

  function increment() {
    count += 1;
  }
</script>

<button onclick={increment}>
  clicks: {count}
</button>

这是 Svelte 5 的代码:

<script>
  let count = $state(0);

  function increment() {
    count += 1;
  }
</script>

<button onclick={increment}>
  clicks: {count}
</button>

其实就是改了响应式变量的声明方式,之前是 let count = 0,现在是 let count = $state(0)。猛一看,代码变得繁琐了一点,但使用状态符文会带来很多好处。

就比如响应式将不限制于在组件顶层进行 let 声明,在应用的任何位置都可以(在本篇的“好处 1:通用响应式” 章节我们会举例解释)。而且,$state 也可以用于普通对象和数组:

<script>
  let numbers = $state([1, 2, 3]);
</script>

<button onclick={() => numbers.push(numbers.length + 1)}>
  push
</button>

<button onclick={() => numbers.pop()}> pop </button>

<p>
  {numbers.join(' + ') || 0}
  =
  {numbers.reduce((a, b) => a + b, 0)}
</p>

3.2. $derived

之前的响应式声明 $:改为使用 $derived 符文。使用之前:

<script>
  let count = 0;
  $: double = count * 2

  function increment() {
    count += 1
  }
</script>

<button on:click={increment}>Click</button>

{count} * 2 = {double}

使用之后:

<script>
  let count = $state(0);
  let doubled = $derived(count * 2);

  function increment() {
    count += 1;
  }
</script>

<button onclick={increment}>
  clicks: {count}
</button>

<p>{count} * 2 = {doubled}</p>

使用 $derived 的好处在于,doubled 的值始终是新的,这就解决了问题 1:

而且即便你进行重构:

<script>
  let count = $state(0);
  let doubled = $derived(doubleCount());

  function doubleCount() {
    return count * 2;
  }

  function increment() {
    count += 1;
    console.log({count, doubled})
  }
</script>

<button onclick={increment}>
  clicks: {count}
</button>

<p>{count} * 2 = {doubled}</p>

这样也是可以生效的:


使用符文,依赖项将在运行时进行跟踪,这就解决了问题 2。

3.3. 好处 1:通用响应式(Universal reactivity )

使用符文带来的一大好处就是通用响应式,意味着你可以在应用程序的任何地方创建响应状态,而不仅仅是在组件的顶层。比如这是一个普通的计数器:

<script>
  let count = $state(0);
</script>

<button onclick={() => {
  count += 1
}}>
  clicks: {count}
</button>

你可以将逻辑封装到一个函数中,以便它可以在多个地方使用:

<script>
  function createCounter() {
    let count = $state(0);
    return { count }
  }

  let counter = createCounter()
</script>

<button onclick={() => {
  counter.count += 1
}}>
  clicks: {counter.count}
</button>

这样写是不会有效果的,因为响应式并不能跨域函数的边界:

要是想生效,可以借助 getter 和 setter 语法:

<script>
  function createCounter() {
    let count = $state(0);
    return {
      get count() {
        return count
      },
      set count(value) {
        count = value
      }
    }
  }

  let counter = createCounter()
</script>

<button onclick={() => {
  counter.count += 1
}}>
  clicks: {counter.count}
</button>

浏览器效果如下:

尽管我们使用了符文,但我们依然可以把函数封装在 .svelte 组件之外,比如 .svelte.js:

<!-- App.svelte -->
<script>
  import { createCounter } from './counter.svelte.js'

  let counter = createCounter()
</script>

<button onclick={() => {
  counter.count += 1
}}>
  clicks: {counter.count}
</button>

<!-- counter.svelte.js -->
export function createCounter() {
  let count = $state(0);
  return {
    get count() {
      return count
    },
    set count(value) {
      count = value
    }
  }
}

浏览器效果同上

而在 Svelte 4 中,相同的功能我们需要借助 Store 来实现:

<script>
  import { writable } from "svelte/store";
  function createCounter() {
    return writable(0)
  }

  let count = createCounter()
</script>

<button onclick={() => {
  $count += 1
}}>
  clicks: {$count}
</button>

这就是问题 3 反映的问题,原本很简单的功能,我们却需要完全改变代码的编写方式,导入 writeable,理解 Store 相关的 API,知道背后的 subscribe 和 update 功能,还要使用 $store实现自动订阅。这增加了很多需要理解的内容。所以使用符文后,对于初学者而言,学习成本更少,心智负担更小。

3.4. 好处 2:细粒度响应式(Fine-grained reactivity)

使用符文的另一大好处是细粒度的响应式,我们以问题 4 为例,现在我们将原本的代码改为:

<script>
+  let todos = $state([
+    { done: false, text: 'finish Svelte tutorial' },
+    { done: false, text: 'build an app' },
+    { done: false, text: 'world domination' }
+   ]);

-  let todos = [
-    { done: false, text: 'finish Svelte tutorial' },
-    { done: false, text: 'build an app' },
-    { done: false, text: 'world domination' }
-  ];

  function remaining(todos) {
    console.log('recalculating')
    return todos.filter((t) => !t.done).length;
  }
</script>

<ul class="todos">
  {#each todos as todo}
    <li class:done={todo.done}>
      <input
        type="checkbox"
        bind:checked={todo.done}
      />

      <input
        type="text"
        placeholder="What needs to be done?"
        bind:value={todo.text}
      />
    </li>

  {/each}
</ul>

<p>未完成:{remaining(todos)}</p>

浏览器效果如下,仅仅使用 $state 包裹一下 todos,你就会发现不再进行重复计算:

这就是使用符文的好处,将响应式状态的颗粒度进一步细化,只会在需要的时候进行更新。

注:你可能会好奇,这些特点到底是怎么实现的?简单来说,就是借助了 Signals 这一概念,我们会在原理篇中详细介绍。

4. 最后

当然,Svelte5 的符文可不止这两个,本篇主要是为大家讲解符文所解决的问题,以及理解使用符文的必要性。符文改变了 Svelte 项目的开发方式,让你的应用程序更小、更快、代码更容易理解、更容易重构。下篇我们会详细介绍符文的完整用法。