SvelteJS 笔记(上)

Svelte 官方在线编辑和运行代码的工具:https://svelte.dev/repl/hello-world?version=3.49.0

组件 (Component)

在 Svelte 当中,一个 .svelte 文件就是一个组件。.svelte 文件中可以包含 HTML, CSS 和 JavaScript.

在组件的 HTML 里可以直接引用 JavaScript 当中定义的变量(即组件的状态)。

<script>
  let name = 'world';
  let src = '/tutorial/image.gif';
</script>

<h1>Hello {name}!</h1>
<img src={src} alt='image'/>

当 HTML 标签的属性名跟变量名一样的时候,还可以简写:

<img {src} alt='image'/>

在组件里可以通过 CSS 定义样式:

<p>This is a paragraph.</p>

<style>
  p {
    color: purple;
    font-family: 'Comic Sans MS', cursive;
    font-size: 2em;
  }
</style>

这里的样式只在当前组件范围内有效。

组件是可以嵌套的:

<!-- 文件 App.svelte -->
<script>
  import Nested from './Nested.svelte';
</script>

<p>This is a paragraph.</p>
<Nested/>


<!-- 文件 Nested.svelte -->
<p>This is another paragraph.</p>

<style>
  p {
    color: purple;
    font-family: 'Comic Sans MS', cursive;
    font-size: 2em;
  }
</style>

有时候变量里带有 HTML 标签,默认情况下,它是以字符串形式直接将 HTML 原始内容显示在界面上的。如果你想显示渲染后的效果,可以在访问变量的时候加上 @html.

<script>
  let string = `this string contains some <strong>HTML!!!</strong>`;
</script>

<p>{@html string}</p>

响应性 (Reactivity)

组件的状态变量在变化时,会同时更新界面上所有引用到该变量的地方。

<script>
  let count = 0;

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

<button on:click={incrementCount}>
  Clicked {count} {count === 1 ? 'time' : 'times'}
</button>

可以使用 $: 来声明一个依赖于另一个变量的响应式变量:

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

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

<button on:click={handleClick}>
  Clicked {count} {count === 1 ? 'time' : 'times'}
</button>

<p>{count} doubled is {doubled}</p>

当以上代码中的 count 变化时,doubled 会跟着变。

不仅仅是变量,任意表达式都可以被声明为响应式的:

//单行表达式
$: console.log('the count is ' + count);

//多行表达式
$: {
  console.log('the count is ' + count);
  alert('I SAID THE COUNT IS ' + count);
}

//条件表达式
$: if (count >= 10) {
  alert('count is dangerously high!');
  count = 9;
}

注意,Svelte 的响应性 (reactivity) 是通过赋值操作来触发的。通过数组和对象的方法来改变它们自身的属性不会触发响应性,例如:

<script>
  let numbers = [1, 2, 3, 4];

  function addNumber() {
    numbers.push(numbers.length + 1);
  }

  $: sum = numbers.reduce((t, n) => t + n, 0);
</script>

<p>{numbers.join(' + ')} = {sum}</p>
<button on:click={addNumber}> Add a number </button>

上面的代码通过 Array#push() 来操作数组的内容,因此界面不会跟着变化。

修复这个问题的一个常见方法,就是将该数组重新赋值给自身:

function addNumber() {
  numbers.push(numbers.length + 1);
  numbers = numbers;
}

或者可以用 ES6 的展开语法 (spread syntax):

function addNumber() {
  numbers = [...numbers, numbers.length + 1];
}

数组的其它方法,包括 pop(), shift(), splice() 等,以及对象的方法,如 Map#set(), Set#add() 等,都同样无法触发响应性。

但是对数组、对象自身属性的赋值,又可以触发响应性。例如:

let obj = {name: 'yu'};

function changeName() {
  obj.name = 'yuan';
}

又但是,将响应性的变量赋值给另一个变量时,通过第 2 个变量来改变其属性,将不会触发响应性。

let obj = {name: 'yu'};

function changeName() {
  let foo = obj;
  foo.name = 'yuan';
}

总之一句话:想要触发响应性,被更新的变量必须直接出现在赋值表达式的左边。

属性 (Props)

嵌套在另一个组件内部的组件,需要从外部组件获取数据时,可以使用 props(properties 的简写)。具体的方法是:在内部组件里 export 一个变量用于接收数据,再在外部组件使用内部组件的标签上加入同名属性。

<!-- 文件 App.svelte -->
<script>
  import Nested from './Nested.svelte';
</script>
<Nested answer={42}/>

<!-- 文件 Nested.svelte -->
<script>
  export let answer;
</script>

<p>The answer is {answer}</p>

Props 可以设置默认值:

<!-- 文件 App.svelte -->
<script>
  import Nested from './Nested.svelte';
</script>
<Nested answer={42}/>
<Nested />

<!-- 文件 Nested.svelte -->
<script>
  export let answer = 'a mystery';
</script>

<p>The answer is {answer}</p>

如果有一个对象,它的每个字段对应着组件里的每一个属性,可以直接将这个对象用解构赋值的方式传递给组件:

<script>
  import Info from './Info.svelte';

  const pkg = {
    name: 'svelte',
    version: 3,
    speed: 'blazing',
    website: 'https://svelte.dev'
  };
</script>

<Info {...pkg}/>
<!-- Info name={pkg.name} version={pkg.version} speed={pkg.speed} website={pkg.website}/ -->

另外,可以在嵌套组件内部使用 $$props 来访问传入的所有属性。这种方式在即使没有 export 变量的情况下仍然可以访问传进来的属性。不过这是 Svelte 不建议使用的方法,因为 Svelte 无法对其进行优化。

逻辑

if 条件的写法

<script>
  let x = 7;
</script>

{#if x > 10}
  <p>{x} is greater than 10</p>
{:else if 5 > x}
  <p>{x} is less than 5</p>
{:else}
  <p>{x} is between 5 and 10</p>
{/if}

each 遍历

<script>
  let cats = [
    { id: 'J---aiyznGQ', name: 'Keyboard Cat' },
    { id: 'z_AbfPXTKms', name: 'Maru' },
    { id: 'OUtn3pvWmpg', name: 'Henri The Existential Cat' }
  ];
</script>

<h1>The Famous Cats of YouTube</h1>
<ul>
  {#each cats as cat, i}
    <li><a target="_blank" href="https://www.youtube.com/watch?v={cat.id}">
      {i + 1}: {cat.name}
    </a></li>
  {/each}
</ul>

keyed each block

试运行下面的代码:

<!-- 文件 App.svelte -->
<script>
  import Thing from './Thing.svelte';

  let things = [
    { id: 1, name: 'apple' },
    { id: 2, name: 'banana' },
    { id: 3, name: 'carrot' },
    { id: 4, name: 'doughnut' },
    { id: 5, name: 'egg' },
  ];

  function handleClick() {
    things = things.slice(1);
  }
</script>

<button on:click={handleClick}>Remove first thing</button>

{#each things as thing}
  <Thing name={thing.name}/>
{/each}

<!-- 文件 Thing.svelte -->
<script>
  const emojis = {
    apple: "🍎",
    banana: "🍌",
    carrot: "🥕",
    doughnut: "🍩",
    egg: "🥚"
  }
  
  export let name;
  const emoji = emojis[name];
</script>

<p><span>The emoji for { name } is { emoji }</span></p>

<style>
  p { margin: 0.8em 0; }
  span {
    display: inline-block;
    padding: 0.2em 1em 0.3em;
    text-align: center;
    border-radius: 0.2em;
    background-color: #FFDFD3;
  }
</style>

点击一次按钮,会发现 apple 那一行文字没有了,但是苹果的 emoji 仍然存在,而最后一个鸡蛋的 emoji 却消失了。这是因为 App.svelte 里的 things 在删除第一个元素之后,Svelte 认为第 1 到第 4 个元素发生了变化,就跟着修改了前面 4 个 <Thing> 的内容,但 Thing.svelte 里的 emoji 变量没有跟着变化,仍然保留着之前的值,所以看到的效果就是 banana 对应的 emoji 变成了一个苹果。同时因为最后一个 <Thing> 标签没有对应的 things 元素,就被删除了。

当然在 Thing.svelte 里可以使用 $: emoji = emojis[name] 来使 emoji 跟着 name 而变化,但这个例子要说明的问题在于:things 被删除的是第一个元素,而 <Thing> 被删除的是最后一个元素。所以在本例中,正确的解法是为每个 each block 创建一个键,告诉 Svelte 根据什么来绑定数组和 DOM 元素。

{#each things as thing (thing.id)}
  <Thing name={thing.name}/>
{/each}

await blocks

Svelte 的组件还可以直接在模板上根据一个 Promise 的状态进行渲染不同的内容:

{#await promise}
  <p>...waiting</p>
{:then number}
  <p>The number is {number}</p>
{:catch error}
  <p style="color: red">{error.message}</p>
{/await}

如果没有必要处理 Promise 的异常,可以把 catch 块省略掉。同时如果你不打算在 Promise 执行完之前显示任何内容,也可以把第一个块省略掉:

{#await promise then number}
  <p>the number is {number}</p>
{/await}

事件处理

<script>
  let m = { x: 0, y: 0 };

  function handleMousemove(event) {
    m.x = event.clientX;
    m.y = event.clientY;
  }
</script>

<div on:mousemove={handleMousemove}>
  The mouse position is {m.x} x {m.y}
</div>

<style>
  div { width: 100%; height: 100%; }
</style>

可以把事件处理器内联 (inline event handler):

<script>
  let m = { x: 0, y: 0 };
</script>

<div on:mousemove="{e => m = { x: e.clientX, y: e.clientY }}">
  The mouse position is {m.x} x {m.y}
</div>

<style>
  div { width: 100%; height: 100%; }
</style>

在其它框架中,可能会看到“不要使用内联事件处理“的建议,因为那会引起性能上的问题。但这个问题在 Svelte 中不存在。

另外,在事件处理器的内部,this 指向 DOM 元素本身。

<script>
  function handleInput() {
    console.log(this.value)
  }
</script>
<input on:input={handleInput}/>

事件修饰符 (Event modifiers)

在 Svelte 中可以用事件修饰符来修饰事件处理器,比如 once 修饰符可以让某个事件只被触发一次:

<script>
  function handleClick() {
    alert('no more alerts')
  }
</script>

<button on:click|once={handleClick}>
  Click me
</button>

可用的修饰符有:

  • preventDefault: 调用 event.preventDefault() 阻止控件触发事件的默认处理行为;
  • stopPropagation: 调用 event.stopPropagation() 防止事件冒泡和事件捕获;
  • passive: 提高了触摸/滚轮事件的滚动性能(Svelte 会在安全的地方自动添加它);
  • nonpassive: 显式地设置 passive: false;
  • capture: 在捕获阶段触发事件处理,而非冒泡阶段;
  • once: 执行过一次之后移除该事件处理器;
  • self: 仅在 event.target 是自身时触发事件处理;
  • trusted: 仅在 event.isTrusted 为 true 时(例如用户主动触发时)触发事件处理

这些修饰符可以同时串联起来使用:on:click|once|capture={...}

组件事件 (Component events)

一个组件自身可以处理许多事件,例如鼠标的移动、点击、文字的输入等。如果这些事件的处理逻辑由组件自身实现,那么将代码写在组件内部即可。如果部分事件的处理逻辑需要外部来实现,那么就需要为组件编写自定义的事件,外部其它组件在使用该组件时,通过 on:自定义事件 的指令 (directive) 来处理事件。

<!-- 文件 App.svelte -->
<script>
  import Inner from './Inner.svelte';

  function handleMessage(event) {
    alert(event.detail.text);
  }
</script>

<Inner on:message={handleMessage}/>

<!-- 文件 Inner.svelte -->
<script>
  import { createEventDispatcher } from 'svelte';

  const dispatch = createEventDispatcher();

  function sayHello() {
    dispatch('message', {
      text: 'Hello!'
    });
  }
</script>

<button on:click={sayHello}>
  Click to say hello
</button>

注意这里的 createEventDispatcher() 必须在组件初次实例化时就被调用,不能放在诸如 setTimeout() 之类的回调函数里。

事件转发

与 DOM 事件不同,组件的自定义事件没有冒泡机制,因此当一个组件想要监听深度嵌套在其内部的组件时,需要中间的组件为事件作转发。

事件转发的实现很简单,只要在被转发的组件上添加不带值的同名指令即可:

<!-- 文件 App.svelte -->
<script>
  import Outer from './Outer.svelte';

  function handleMessage(event) {
    alert(event.detail.text);
  }
</script>

<Outer on:message={handleMessage}/>

<!-- 文件 Inner.svelte 同上一个例子,保持不变 -->

<!-- 文件 Outer.svelte -->
<script>
  import Inner from './Inner.svelte';
</script>

<Inner on:message/>

对于 DOM 事件的转发,也可以用相同的方法。

数据绑定

在组件内部的一个文本框里,可以通过 on:input 事件来实现数据双向绑定:

<script>
  let name = 'woda';
</script>

<input value={name} on:input={(e) => name = e.target.value}>
<h1>Hello {name}!</h1>

但有个更简单的方法:将 <input>value 属性改为 bind:value 指令:

<script>
  let name = 'woda';
</script>

<input bind:value={name}/>
<h1>Hello {name}!</h1>

如果变量名与属性名相同的话,还可以简写为:<input bind:value/>.

Group inputs

遇到 radio button 和 checkbox 可以使用 bind:group 指令。前者使用一个简单的变量来绑定,后者需要跟一个数组作绑定。

<script>
  let scoops = 1;
  let flavours = ['Mint choc chip'];

  let menu = [
    'Cookies and cream',
    'Mint choc chip',
    'Raspberry ripple'
  ];

  function join(flavours) {
    if (flavours.length === 1) return flavours[0];
    return `${flavours.slice(0, -1).join(', ')} and ${flavours[flavours.length - 1]}`;
  }
</script>

<h2>Size</h2>

<label>
  <input type=radio bind:group={scoops} name="scoops" value={1}>
  One scoop
</label>

<label>
  <input type=radio bind:group={scoops} name="scoops" value={2}>
  Two scoops
</label>

<label>
  <input type=radio bind:group={scoops} name="scoops" value={3}>
  Three scoops
</label>

<h2>Flavours</h2>

{#each menu as flavour}
  <label>
    <input type=checkbox bind:group={flavours} name="flavours" value={flavour}>
    {flavour}
  </label>
{/each}

{#if flavours.length === 0}
  <p>Please select at least one flavour</p>
{:else if flavours.length > scoops}
  <p>Can't order more flavours than scoops!</p>
{:else}
  <p>
    You ordered {scoops} {scoops === 1 ? 'scoop' : 'scoops'}
    of {join(flavours)}
  </p>
{/if}

上面的 checkbox 也可以用 <select multiple> 替代。

Contenteditable bindings

<script>
  let html = '<p>Write some text!</p>';
</script>

<div contenteditable="true" bind:innerHTML={html}></div>
<pre>{html}</pre>

<style>
  [contenteditable] {
    padding: 0.5em;
    border: 1px solid #eee;
    border-radius: 4px;
  }
</style>

This

每个 DOM 元素和组件都可以通过 bind:this 把自身绑定到一个变量上。但由于界面渲染完成之前,被绑定的DOM 元素或组件还不存在,所以目标变量的值会是 undefined. 因此操作该变量的代码应该放到组件的 onMount() 回调函数内。

<canvas
  bind:this={canvas}
  width={32}
  height={32}
></canvas>

<script>
  import { onMount } from 'svelte';

  let canvas;

  onMount(() => {
    const ctx = canvas.getContext('2d');
    //...
  }
</script>

组件绑定

正如变量可以绑定到 DOM 元素上一样,变量也可以绑定到另一个组件上。

<!-- 文件 App.svelte -->
<script>
  import Nested from './Nested.svelte';
  let input 
</script>
<input bind:value={input}/>
<Nested bind:answer={input}/>

<!-- 文件 Nested.svelte -->
<script>
  export let answer = 42;
</script>

<p>The answer is {answer}</p>

如果组件绑定用得太多,会造成数据流很难跟踪,所以请有节制地使用组件绑定。

绑定组件实例

bind:this 同样可以用在组件上:

<!-- 文件 App.svelte -->
<script>
  import InputField from './InputField.svelte';

  let field;
</script>

<InputField bind:this={field}/>
<button on:click={() => field.focus()}>Focus field</button>

<!-- 文件 InputField.svelte -->
<script>
  let input;

  export function focus() {
    input.focus();
  }
</script>

<input bind:this={input} />

生命周期

onMount() 生命周期回调函数会在组件初次渲染之后执行,如果 onMount() 返回了一个函数,该函数会在组件销毁时执行。

为兼容服务器端渲染,最好将 fetch() 请求放在 onMount() 里执行,而不是直接写在 <script> 里边。除了 onDestroy() 回调函数之外,其它的回调函数都不会在服务端渲染期间执行,因此 fetch() 的执行被延迟到了客户端 DOM 渲染之后。

onDestroy() 在组件销毁时执行,常用于执行一些清理操作,例如 setInterval() 的清理。防止内存泄露。

import { onDestroy } from 'svelte';

let counter = 0;
const interval = setInterval(() => counter += 1, 1000);

onDestroy(() => clearInterval(interval));

为了防止忘记清理,可以自定义一个函数:

import { onDestroy } from 'svelte';

export function onInterval(callback, milliseconds) {
  const interval = setInterval(callback, milliseconds);

  onDestroy(() => {
    clearInterval(interval);
  });
}

// 使用
import { onInterval } from './utils.js';

let counter = 0;
onInterval(() => counter += 1, 1000);

beforeUpdate()afterUpdate() 分别在组件的 DOM 更新之前和更新之后执行。

tick() 方法可以在任何时候调用。它会返回一个 Promise, 并且该 Promise 会在 DOM 的 pending 状态变化之后执行。用一个例子来说明:

<script>
  let text = `Select some text and hit the tab key to toggle uppercase`;

  async function handleKeydown(event) {
    if (event.key !== 'Tab') return;

    event.preventDefault();

    const { selectionStart, selectionEnd, value } = this;
    const selection = value.slice(selectionStart, selectionEnd);

    const replacement = /[a-z]/.test(selection)
      ? selection.toUpperCase()
      : selection.toLowerCase();

    text = (
      value.slice(0, selectionStart) +
      replacement +
      value.slice(selectionEnd)
    );

    // 下面的代码没有效果,因为 DOM 还没有更新
    this.selectionStart = selectionStart;
    this.selectionEnd = selectionEnd;
  }
</script>

<style>
  textarea {
    width: 100%;
    height: 200px;
  }
</style>

<textarea value={text} on:keydown={handleKeydown}></textarea>

运行代码之后,选中文本框中一段字符串,按键盘上的 Tab 键,可以看到选中的文本大小写被转换了。但是转换之后,光标移到了文本的末尾,并没有按照代码的意图继续保持刚刚选中的状态。这是因为执行最后两句代码的时候,DOM 还没有更新。而当 DOM 更新之后,最后两句代码已经执行完了。所以这里需要在执行最后两句代码之前,等待 DOM 更新完成,这时候就需要用到 tick().

import { tick } from 'svelte';
//...其它代码...
async function handleKeydown(event) {
  //...其它代码...
  tick();    
  this.selectionStart = selectionStart;
  this.selectionEnd = selectionEnd;
}
2
2