Svelte JS 学习笔记(一):Hello World
Svelte基础
引言
简介
一个Svelte程序是由一个或多个component组件构成的。组件是一个可复用的、自包含的代码块,其中封装了HTML、CSS和JS,它们互相包含。组件的文件拓展名是.svelte
这个就是.svelte文件了
可以用下面的指令来创建一个svelte项目:
npx sv create my-app
cd my-app
npm run dev总不能老用在线交互式教程吧
第一个组件
添加数据
只有HTML标签的.svelte文件没什么意思,让我们加一点数据进去吧
svelte提供了JS和TS两种演示代码,鉴于我对JS和TS都不懂,因此接下来的学习和演示都会使用JS
<script>
let name = 'Svelte';
</script>
<h1>Hello {name}!</h1>声明一个名为name的字符串变量(由于使用了let,因此name不可变,并且作用域仅限于这一个script块)
然后使用{}包裹变量名name,在HTML模板中渲染这个变量
Svelte默认使用SSR渲染第一个页面
在{}范围内,我们可以调用任意JS函数,随便写JS代码——比如调用字符串变量的方法:
<h1>Hello {name.toUpperCase()}!</h1>元素动态属性(Dynamic Attributes)
{}不只是能包裹一个变量渲染一个文本,还能和HTML元素配合使用控制元素属性——比如控制图片的src属性:
<script>
let src = '/tutorial/image.gif';
</script>
<img src={src}/>
图片渲染出来了,这很好。但如果我们把鼠标悬停到img标签上
编辑器会提醒我们这个img元素缺少一个alt属性(当图片渲染不出来时会渲染alt的值)
注
在开发一个web程序时,我们应当确保页面对所有人群均可用——包括盲人和聋哑人,还有小水管网速的人。无障碍 a11y 不是一件能轻松处理的事,但好在svelte会提醒我们注意编写无障碍页面
在这个演示中,图片就缺少一个alt文本,让我们把它补上:
<img src={src} alt="Never gonna give you up"/>可以结合前面的数据添加和渲染让alt文本“动起来”:
<script>
let src = '/tutorial/image.gif';
let name = 'man';
</script>
<img src={src} alt="A {name} is singing Never gonna give you up"/>记得先定义变量再使用
短属性
有时候元素属性和传递进去的变量,它们的名称不一定一样。对于这种情况,Svelte提供了短属性:
<img {src} alt="A {name} is singing Never gonna give you up"/>
样式
和HTML一样,Svelte也有样式(byd你不本来就是SPA框架吗 )
接下来我们给<p>段落标签加个样式:
<p>This is a paragraph.</p>
<style>
/* Write your CSS here */
p {
color: goldenrod; /*金黄色*/
font-family: 'Comic Sans MS', cursive;
/*第一个是主字体, 备选字体是cursive手写体; ff列表的优先级从左向右依次递减*/
font-size: 2em; /*2倍 默认字体大小*/
}
</style>
重要
<p>的CSS样式只在App.svelte这一个组件里有效,我们不用担心会在其他组件里改到这边的<p>自定义样式——下一节就会演示这个
嵌套组件
把所有代码都写在一个组件 里是不现实的。
这一关的Demo里同目录下多了一个文件Nested.svelte。我们在App.svelte顶部加个:
<script>
import Nested from './Nested.svelte'; // 把Nested.svelte这个组件改名为Nested, 改名效应仅限于当前组件
</script>然后在App.svelte的<p>文本里声明一下同名的Nested标签:
<p>This is another paragraph.</p>
<Nested />Nested不一定要叫Nested,import Opened from './Nested.svelte也能用, 但这里是为了演示Svelte有智能防止组件冲突的特性
<p>This is a paragraph.</p>
<script>
import Nested from "./Nested.svelte"
</script>
<Nested />
<style>
p {
color: goldenrod;
font-family: 'Comic Sans MS', cursive;
font-size: 2em;
}
</style><p>This is another paragraph.</p>
注意到即使是这样,App组件的文本受到App内样式的影响,而从Nested组件导入进来的元素<p>This is another paragraph.</p>却没有被App内样式影响
提示
Svelte的组件名都是大写字母开头,据说是为了和HTML元素区分开来
【标签】禁用HTML转义
一般来说,字符串都会当作纯文本处理,换句话说,不用担心XSS注入:
这里原本的代码用的是反引号包裹字符串字面量
但你也可以无视风险,强行渲染:
<p>{@html string}</p>

svelte的免责声明,重点在最后一行:无视风险,强行XSS
如果要渲染的文本是用户可控的,svelte建议你自己手动再转义一次
响应式
状态
Svelte的核心是其强大的响应式系统,它能使DOM与我们的程序状态保持同步
<script>
let count = 0;
function increment() {
// TODO implement
}
</script>
<button onclick={increment}>
Clicked {count}
{count === 1 ? 'time' : 'times'}
</button>使用$state(...)包裹用来声明count变量的值0:
<script>
let count = $state(0);
function increment() {
// TODO implement
count++;
}
</script>
<button onclick={increment}>
Clicked {count}
{count === 1 ? 'time' : 'times'}
</button>这将使count变成一个响应式变量
Svelte管这叫符文。符文告诉Svelte,我这个count不应该是一个普普通通的变量。符文和函数都有一对圆括号,但符文不是函数,它是Svelte语言的一部分
深状态
在上一节中,我们看到状态响应自重新赋值,但它也可以响应突变——这种我们称之为深响应
<script>
let numbers = [1, 2, 3, 4];
function addNumber() {
// TODO implement
}
</script>
<p>{numbers.join(' + ')} = ...</p>
<button onclick={addNumber}>
Add a number
</button>一样地,使用符文改造numbers数组:
<script>
let numbers = $state([1, 2, 3, 4]);
function addNumber() {
// TODO implement
numbers[numbers.length] = numbers.length + 1;
}
</script>
<p>{numbers.join(' + ')} = ...</p>
<button onclick={addNumber}>
Add a number
</button>
每点一次按钮都会多一个数字
我们也可以让addNumber的写法更优雅:
function addNumber() {
// TODO implement
numbers.push(numbers.length + 1);
}提示
深响应是基于代理实现的,而对代理值的修改不会影响原值
导出状态
我们经常要从一个状态导出 另一个状态。对于这种情况,Svelte提供了$derive符文:
这一次我们给上一次Demo加个总和计算变量total:
let total = $derived(numbers.reduce((t, n) => t+n, 0));补习一下.reduce方法:
array.reduce(callback(accumulator, currentValue, currentIndex, array), initialValue)参数说明:
callback: 执行每个数组元素的回调函数accumulator: 累积值(上一次调用回调函数的返回值)currentValue: 当前处理的数组元素currentIndex(可选): 当前元素的索引array(可选): 调用 reduce 的数组本身
initialValue(可选): 初始值,作为第一次调用 callback 函数时 accumulator 的值
使用示例
- 计算数组元素总和
const numbers = [1, 2, 3, 4, 5];
const sum = numbers.reduce((acc, curr) => acc + curr, 0);
console.log(sum); // 输出: 15- 计算数组元素乘积
const numbers = [1, 2, 3, 4];
const product = numbers.reduce((acc, curr) => acc * curr, 1);
console.log(product); // 输出: 24- 统计元素出现次数
const fruits = ['apple', 'banana', 'apple', 'orange', 'banana', 'apple'];
const count = fruits.reduce((acc, fruit) => {
acc[fruit] = (acc[fruit] || 0) + 1;
return acc;
}, {});
console.log(count); // 输出: { apple: 3, banana: 2, orange: 1 }- 数组去重
const numbers = [1, 2, 2, 3, 3, 4];
const unique = numbers.reduce((acc, curr) => {
if (!acc.includes(curr)) {
acc.push(curr);
}
return acc;
}, []);
console.log(unique); // 输出: [1, 2, 3, 4]$derived中的语句将在依赖的状态(这里是numbers状态)更新时重新执行
监视状态
当状态更新时,追踪状态变化总是有用的——但是这不意味着你要用console.log打印Svelte的状态,不然就会
这是因为numbers是一个响应式代理
现在我们来看看如何调试Svelte的状态吧——
$state.snapshot(...):获取状态快照

$inspect:自动调试
$inspect相当于snapshot + console.log
自动获取POJO快照,自动调试
还可以用.with方法自定义调试所用的打印函数,比如:
$inspect(numbers).with(console.trace);
更新(effects)
截至目前,我们一直在用状态演示何为响应式。但状态不是响应式的全部——状态只在有东西发生变化时他才会响应变化,其他时候都只是一个blingbling的变量
响应所体现的东西,我们称之为作用 effect 。我们已经见识过作用 了——Svelte以我们的名义创建的东西,用来在状态改变时更新DOM树——但我们也可以用$effect符文自定义作用
提示
大多数时候我们都不应该自定义作用 。$effect可以用来灵机一动,但它绝不是应该频繁使用的东西。当然,如果你能很好地处理事件处理器中的副作用的话,那也不是不行
现在对于下面的代码:
<script>
let elapsed = $state(0);
let interval = $state(1000);
</script>
<button onclick={() => interval /= 2}>speed up</button>
<button onclick={() => interval *= 2}>slow down</button>
<p>elapsed: {elapsed}</p>我们想要实现一个setInterval函数来持续追踪组件挂载时长。创建一个$effect:
$effect(() => {
setInterval(() => {
elapsed += 1;
}, interval);
});
然后点击speed up按钮,我们会发现时间流逝 越来越快——这是因为每次点击这个按钮,interval的值都会减小一半,更新间隔越来越小。不过,slow down按钮却不起作用——这是因为我们没有在作用 更新时清除旧的定时器
我们可以给$effect定义一个return函数来清除旧的计时器:
$effect(() => {
const id = setInterval(() => {
elapsed += 1;
}, interval);
return () => {
clearInterval(id);
}
});回收函数会在interval改变之后 effect函数重新执行之前被调用;组件被销毁时也会调用回收函数
提示
SSR模式下effects不会启用
全局响应(Universal Reactivity)
在之前的Demo中,我们会在(svelte文件)组件内使用符文来添加响应式逻辑,但我们也可以不在sv组件内使用符文,比如当我们需要共享一些全局状态时

在这一节的Demo中,计数器组件是从./Counter.svelte中导入过来的(并被重命名为Counter);而在./Counter.svelte中,计数器按钮本身是从./shared.js中导入的,组件在这里只是加了个点击事件监听器
<script>
import { counter } from './shared.js';
</script>
<button onclick={() => counter.count += 1}>
clicks: {counter.count}
</button>// shared.js
export const counter = {
count: 0
};你可能会想直接给counter的右值套个$state把它变成响应式的,但问题是……
读得懂英文的人都会知道该怎么解决这个问题——直接把文件后缀名重命名成.svelte.js即可
记得去Counter.svelte里修改import语句
现在点击任何一个按钮都会更新所有按钮的计数值——毕竟它们只是对同一个组件的多次引用
提示
You cannot export a $state declaration from a module if the declaration is reassigned (rather than just mutated), because the importers would have no way to know about it.
属性 Props
声明属性
截至目前,我们一直在处理组件内 的状态——也就是说,状态只在一个特定组件内可用。但在业务开发中,我们常常要把数据从一个组件传递到它的子组件。要做到这一点,我们需要声明属性 (简称props)。Svelte提供了$props符文来声明一个属性。


在本题的Demo中,<p>文本的变量绑定定义来自Nested.svelte,而影响页面渲染的是App.svelte,Nested所编写的响应式逻辑在App组件不起作用
把answer的值改成$props()后,Nested组件所编写的响应式逻辑在App主组件中就可以用了
默认值
<script>
let { answer = 'a mystery' } = $props();
</script>
<p>The answer is {answer}</p>这样设置一个默认值
在answer变量没有被显式赋值时,Svelte会转而渲染answer的默认值:
展开属性
在这节的Demo中,我们忘了把pkg.name传递给引用过来的页面组件

于是页面没有渲染出来pkg.name对应的值
我们当然可以手动把name={pkg.name}加进去,但显然pkg中的字段名和子组件中预留的占位符一致
<!-- PackageInfo.svelte -->
<script>
let { name, version, description, website } = $props();
</script>
<p>
The <code>{name}</code> package is {description}. Download version {version} from
<a href="https://www.npmjs.com/package/{name}">npm</a> and <a href={website}>learn more here</a>
</p>因此,我们可以在App组件里使用{...variable}直接展开复合变量:
提示
相应地,在PackageInfo.svelte中,我们也可以使用...把键值对展开到props中:
let {name, ...stuff} = $props();也可以直接跳过键值对析构:
let stuff = $props();这样的话,我们还可以基于对象路径来访问属性中的值:
console.log(stuff.name, stuff.version);逻辑结构
If-Else结构
If块
HTML没有逻辑表达式,比如条件分支啊循环啊这些,HTML都没有,但Svelte有(byd 你一个SPA框架能没有这些? )
在下面的Demo中:
<script>
let count = $state(0);
function increment() {
count += 1;
}
</script>
<button onclick={increment}>
Clicked {count}
{count === 1 ? 'time' : 'times'}
</button>我们需要使用if块控制当count值达到一个标准时渲染一个文本:
<script>
let count = $state(0);
function increment() {
count += 1;
}
</script>
<button onclick={increment}>
Clicked {count}
{count === 1 ? 'time' : 'times'}
</button>
{#if count > 10}
<p>{count} is greater than 10</p>
{/if}
Else块
有if必有else。使用{:else}
{#...}是块的起始{/...}结束一个块{:...}延续一个块
恭喜!你已经学完Svelte给HTML添加的大部分语法了!
Else-If块
{:else if statement}
循环结构
Each块
使用each块来遍历列表:
{#each 列表 as 当前元素, 当前索引}
{/each}改造前:
<script>
const colors = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'];
let selected = $state(colors[0]);
</script>
<h1 style="color: {selected}">Pick a colour</h1>
<div>
<button
style="background: red"
aria-label="red"
aria-current={selected === 'red'}
onclick={() => selected = 'red'}
></button>
<button
style="background: orange"
aria-label="orange"
aria-current={selected === 'orange'}
onclick={() => selected = 'orange'}
></button>
<button
style="background: yellow"
aria-label="yellow"
aria-current={selected === 'yellow'}
onclick={() => selected = 'yellow'}
></button>
<!-- TODO add the rest of the colours -->
<button></button>
<button></button>
<button></button>
<button></button>
</div>
<style>
h1 {
font-size: 2rem;
font-weight: 700;
transition: color 0.2s;
}
div {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-gap: 5px;
max-width: 400px;
}
button {
aspect-ratio: 1;
border-radius: 50%;
background: var(--color, #fff);
transform: translate(-2px,-2px);
filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.2));
transition: all 0.1s;
color: black;
font-weight: 700;
font-size: 2rem;
}
button[aria-current="true"] {
transform: none;
filter: none;
box-shadow: inset 3px 3px 4px rgba(0,0,0,0.2);
}
</style>改造后:
<script>
const colors = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'];
let selected = $state(colors[0]);
</script>
<h1 style="color: {selected}">Pick a colour</h1>
<div>
{#each colors as color}
<button
style="background: {color}"
aria-label="{color}"
aria-current={selected === color}
onclick={() => selected = color}
></button>
{/each}
</div>
<style>
h1 {
font-size: 2rem;
font-weight: 700;
transition: color 0.2s;
}
div {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-gap: 5px;
max-width: 400px;
}
button {
aspect-ratio: 1;
border-radius: 50%;
background: var(--color, #fff);
transform: translate(-2px,-2px);
filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.2));
transition: all 0.1s;
color: black;
font-weight: 700;
font-size: 2rem;
}
button[aria-current="true"] {
transform: none;
filter: none;
box-shadow: inset 3px 3px 4px rgba(0,0,0,0.2);
}
</style>
each as的第二个值是遍历出来的元素的索引
带键的each块
一般来说,在each遍历列表或键值对的时候修改遍历对象,如果对象的长度发生变化,那么浏览器会在each块的末尾增加或移除DOM结点。并且结果常常不如人愿
这么说确实很绕口,直接看演示吧
可以看到键值对更新只更新了App组件里的,没有同步更新emoji列表
解决方案是给遍历出来的元素标记 一下:
一个不正确的翻译
我们可以使用任意对象作为键,毕竟Svelte内部使用的是Map——换句话说我们可以直接拿thing本身作为标记 (thing)。使用一个字符串或一个数字肯定要安全一些,但是这会意味着标识符不具备引用相等性,比如更新来自API服务器的数据时
一个更贴切的翻译
"你可以用整个对象 (thing) 当作循环的 Key,因为 Svelte 内部是用 Map 来存的(Map 的键可以是对象)。
但是,最好还是用字符串或数字 (thing.id) 当 Key。
因为当你从 API 拉取新数据时,虽然 ID 没变,但新生成的对象在内存里是一个新的实例(引用变了)。如果你用对象当 Key,Svelte 会以为数据全换了,导致页面重绘;用 ID 当 Key 就能避免这个问题。"
Await块
使用{#awaut}异步等待一个耗时不定的函数(promises)并根据不同情况渲染页面:
只有最近一次promise会起效,所以不用担心状态竞争的问题
如果你自信你的异步函数不会报错,你可以把{:catch}块给去了;如果你打算在函数有响应后再渲染文本,还可以这么做:
{#await promise then number}
<p>you rolled a {number}! </p>
{/await}事件
DOM事件
使用on<name>函数监听任何DOM事件
<script>
let m = $state({ x: 0, y: 0 });
function onpointermove(event) {
m.x = event.clientX;
m.y = event.clientY;
}
</script>
<div onpointermove={onpointermove}>
The pointer is at {Math.round(m.x)} x {Math.round(m.y)}
</div>
<style>
div {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
padding: 1rem;
}
</style>之前演示动态属性时提到过当变量名与img src属性同名时,可以隐式绑定属性,在这里也一样,onpointermove函数正好与onpointermove事件同名,于是:
<div {onpointermove}>
The pointer is at {Math.round(m.x)} x {Math.round(m.y)}
</div>内联处理器(Inline Handler)
可以直接把语句塞进{}里,毕竟{}可不只是放变量的地方,它是可以执行JS语句的
// function onpointermove
</script>
<div onpointermove={(event) => {
m.x = event.clientX;
m.y = event.clientY;
}}>
The pointer is at {Math.round(m.x)} x {Math.round(m.y)}
</div>捕获(Capturing)
一般来说,事件处理器的事件是会冒泡的(也即事件上浮机制):
通过在on<name>函数名称加上capture可以捕获事件:
当带捕获的处理器和不带捕获的处理器共处一个事件时,带捕获的处理器会先执行。在上面的演示中,先弹出来的是div的alert框
组件事件
可以在props里传递事件处理器
在Stepper.svelte中声明事件处理器:
<script>
let {increment, decrement} = $props();
</script>
<button onclick={decrement}>-1</button>
<button onclick={increment}>+1</button>在App.svelte中给increment和decrement处理器传值
<script>
import Stepper from './Stepper.svelte';
let value = $state(0);
</script>
<p>The current value is {value}</p>
<Stepper
increment={() => value++}
decrement={() => value--}
/>展开事件
...也可以用来展开事件
这题Demo已经做好前戏了,我们只需要去./BigRedButton.svelte的<button>标签那边展开事件
双向绑定
文本输入绑定
总的来说,Svelte中的数据流是自上而下 的——父组件可以给子组件设置props,而组件可以给元素设置响应式属性,但反过来就不行
有时候确实应该打破规矩。以这个组件中的input标签为例,我们可以给input元素加个oninput监听器,自动把name的值赋给event.target.value
<script>
let name = $state('world');
</script>
<input value={name} />
<h1>Hello {name}!</h1>但这样未免有点样板化 了,当我们尝试让它适配其他表单元素时表现还会更糟
我们应该使用bind:value指令——这样不仅name的值会影响input元素中的name占位符,input元素中的name也会反过来更新状态name
整型输入绑定
DOM里的输入值都是字符串,这对于type="number"和type="range"滑动条来说并不友好——这意味着我们要在使用它的值之前进行显式类型转换
不过没事,我们有bind:value,而Svelte会自动帮我们擦屁股
单选框输入绑定
<label>
<input type="checkbox" bind:checked={yes} />
</label>下拉列表输入 (Selected Bindings)
<script>
let questions = [
{
id: 1,
text: `Where did you go to school?`
},
{
id: 2,
text: `What is your mother's name?`
},
{
id: 3,
text: `What is another personal fact that an attacker could easily find with Google?`
}
];
let selected = $state();
let answer = $state('');
function handleSubmit(e) {
e.preventDefault();
alert(
`answered question ${selected.id} (${selected.text}) with "${answer}"`
);
}
</script>
<h2>Insecurity questions</h2>
<form onsubmit={handleSubmit}>
<select
bind:value={selected} // 绑这里
onchange={() => (answer = '')}
>
{#each questions as question}
<option value={question}>
{question.text}
</option>
{/each}
</select>
<input bind:value={answer} />
<button disabled={!answer} type="submit">
Submit
</button>
</form>
<p>
selected question {selected
? selected.id
: '[waiting...]'}
</p>
提示
本题Demo没有给selected预设默认值,那么绑定输入时默认会将其设置为列表的第一个元素。不过还是得注意一下——在绑定完成初始化前,selected都会是undefined,所以我们不能闭着眼睛在HTML模板里引用selected,模板里的selected.id就是用来预防这种情况的:
<script>
...
function handleSubmit(e) {
e.preventDefault();
alert(
`answered question ${selected.id} (${selected.text}) with "${answer}"`
);
}
</script>
...
<p>
selected question {selected
? selected.id
: '[waiting...]'}
</p>组输入绑定
<label>
<input
type="radio"
name="scoops"
value={number}
// 绑name的名称
bind:group={scoops}
/>
{number} {number === 1 ? 'scoop' : 'scoops'}
</label><script>
let scoops = $state(1);
let flavours = $state([]);
const formatter = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' });
</script>
<h2>Size</h2>
{#each [1, 2, 3] as number}
<label>
<input
type="radio"
name="scoops"
value={number}
// 绑name的名称
bind:group={scoops}
/>
{number} {number === 1 ? 'scoop' : 'scoops'}
</label>
{/each}
<h2>Flavours</h2>
{#each ['cookies and cream', 'mint choc chip', 'raspberry ripple'] as flavour}
<label>
<input
type="checkbox"
name="flavours"
value={flavour}
// 绑name的名称
bind:group={flavours}
/>
{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 {formatter.format(flavours)}
</p>
{/if}
下拉列表的多选项绑定 (Select Multiple)
<script>
let scoops = $state(1);
let flavours = $state([]);
const formatter = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' });
</script>
<h2>Size</h2>
{#each [1, 2, 3] as number}
<label>
<input
type="radio"
name="scoops"
value={number}
bind:group={scoops}
/>
{number} {number === 1 ? 'scoop' : 'scoops'}
</label>
{/each}
<h2>Flavours</h2>
<!-- 用select multiple替换掉这里
{#each ['cookies and cream', 'mint choc chip', 'raspberry ripple'] as flavour}
<label>
<input
type="checkbox"
name="flavours"
value={flavour}
bind:group={flavours}
/>
{flavour}
</label>
{/each}
-->
<select multiple bind:value={flavours}>
{#each ['cookies and cream', 'mint choc chip', 'raspberry ripple'] as flavour}
<option>{flavour}</option>
{/each}
</select>
{#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 {formatter.format(flavours)}
</p>
{/if}提示
摁住Ctrl键可以多选选项
多行文本框输入绑定 (Textarea Inputs)
<textarea bind:value={value}></textarea>因为本题Demo中,用来存放输入的变量value与textarea的value属性同名,所以这里可以用简写:
<textarea bind:value></textarea>顺便认识一下marked函数在这里的作用:
类与样式
类属性
本题Demo(未改造)
<script>
let flipped = $state(false);
</script>
<div class="container">
Flip the card
<button
class="card"
onclick={() => flipped = !flipped}
>
<div class="front">
<span class="symbol">♠</span>
</div>
<div class="back">
<div class="pattern"></div>
</div>
</button>
</div>
<style>
.container {
display: flex;
flex-direction: column;
gap: 1em;
height: 100%;
align-items: center;
justify-content: center;
perspective: 100vh;
}
.card {
position: relative;
aspect-ratio: 2.5 / 3.5;
font-size: min(1vh, 0.25rem);
height: 80em;
background: var(--bg-1);
border-radius: 2em;
transform: rotateY(180deg);
transition: transform 0.4s;
transform-style: preserve-3d;
padding: 0;
user-select: none;
cursor: pointer;
}
.card.flipped {
transform: rotateY(0);
}
.front, .back {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
backface-visibility: hidden;
border-radius: 2em;
border: 1px solid var(--fg-2);
box-sizing: border-box;
padding: 2em;
}
.front {
background: url(./svelte-logo.svg) no-repeat 5em 5em, url(./svelte-logo.svg) no-repeat calc(100% - 5em) calc(100% - 5em);
background-size: 8em 8em, 8em 8em;
}
.back {
transform: rotateY(180deg);
}
.symbol {
font-size: 30em;
color: var(--fg-1);
}
.pattern {
width: 100%;
height: 100%;
background-color: var(--bg-2);
/* pattern from https://projects.verou.me/css3patterns/#marrakesh */
background-image:
radial-gradient(var(--bg-3) 0.9em, transparent 1em),
repeating-radial-gradient(var(--bg-3) 0, var(--bg-3) 0.4em, transparent 0.5em, transparent 2em, var(--bg-3) 2.1em, var(--bg-3) 2.5em, transparent 2.6em, transparent 5em);
background-size: 3em 3em, 9em 9em;
background-position: 0 0;
border-radius: 1em;
}
</style>和其他属性一样,我们也可以给类指定一个JS属性。这里,我们会给card元素(动态地)加一个flipped类:
<button
class="card {flipped ? 'flipped' : ''}"
onclick={() => flipped = !flipped}
>不过我们还可以做得更好,基于特定条件添加或移除一个类在UI开发中就是一个很常见的模式——而Svelte也允许我们给class传入一个对象或一个数组,传入的参数之后会使用clsx转换为字符串
<button
class={["card", {flipped}]}
onclick={() => flipped = !flipped}
>clsx的一些转换示例
import clsx from 'clsx';
// or
import { clsx } from 'clsx';
// Strings (variadic)
clsx('foo', true && 'bar', 'baz');
//=> 'foo bar baz'
// Objects
clsx({ foo:true, bar:false, baz:isTrue() });
//=> 'foo baz'
// Objects (variadic)
clsx({ foo:true }, { bar:false }, null, { '--foobar':'hello' });
//=> 'foo --foobar'
// Arrays
clsx(['foo', 0, false, 'bar']);
//=> 'foo bar'
// Arrays (variadic)
clsx(['foo'], ['', 0, false, 'bar'], [['baz', [['hello'], 'there']]]);
//=> 'foo bar baz hello there'
// Kitchen sink (with nesting)
clsx('foo', [1 && 'bar', { baz:false, bat:null }, ['hello', ['world']]], 'cya');
//=> 'foo bar hello world cya'样式助记符 (The Style Directive)
改造前
<script>
let flipped = $state(false);
</script>
<div class="container">
Flip the card
<button
class={["card", { flipped }]}
onclick={() => flipped = !flipped}
>
<div class="front">
<span class="symbol">♠</span>
</div>
<div class="back">
<div class="pattern"></div>
</div>
</button>
</div>
<style>
.container {
display: flex;
flex-direction: column;
gap: 1em;
height: 100%;
align-items: center;
justify-content: center;
perspective: 100vh;
}
.card {
position: relative;
aspect-ratio: 2.5 / 3.5;
font-size: min(1vh, 0.25rem);
height: 80em;
background: var(--bg-1);
border-radius: 2em;
transform: rotateY(180deg);
transition: transform 0.4s;
transform-style: preserve-3d;
padding: 0;
user-select: none;
cursor: pointer;
}
.card.flipped {
transform: rotateY(0);
}
.front, .back {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
backface-visibility: hidden;
border-radius: 2em;
border: 1px solid var(--fg-2);
box-sizing: border-box;
padding: 2em;
}
.front {
background: url(./svelte-logo.svg) no-repeat 5em 5em, url(./svelte-logo.svg) no-repeat calc(100% - 5em) calc(100% - 5em);
background-size: 8em 8em, 8em 8em;
}
.back {
transform: rotateY(180deg);
}
.symbol {
font-size: 30em;
color: var(--fg-1);
}
.pattern {
width: 100%;
height: 100%;
background-color: var(--bg-2);
/* pattern from https://projects.verou.me/css3patterns/#marrakesh */
background-image:
radial-gradient(var(--bg-3) 0.9em, transparent 1em),
repeating-radial-gradient(var(--bg-3) 0, var(--bg-3) 0.4em, transparent 0.5em, transparent 2em, var(--bg-3) 2.1em, var(--bg-3) 2.5em, transparent 2.6em, transparent 5em);
background-size: 3em 3em, 9em 9em;
background-position: 0 0;
border-radius: 1em;
}
</style>和class一样,我们也可以编写自己的内敛style,毕竟Svelte本质上就是HTML Plus Ultra Pro Max:
<button
class={["card", { flipped }]}
style={["transform", {flipped}]}
onclick={() => flipped = !flipped}
>把本题的样式写全了是下面这样的,Svelte想告诉你,可以使用style:指令展开太宽的样式
<button
class={["card", { flipped }]}
style="transform: {flipped ? 'rotateY(0)' : ''}"
style:--bg-1="palegoldenrod"
style:--bg-2="black"
style:--bg-3="goldenrod"
onclick={() => flipped = !flipped}
>组件样式 (Component Styles)
有时候,我们需要让样式影响到子组件内的元素,比如,我们想让这里的框框变成红的、绿的、蓝的
我们可以在App组件里手动写上:globalCSS修改符,无差别影响子组件中任何使用到这个样式的元素:
<style>
.boxes :global(.box:nth-chind(1)) {
background-color: red;
}
.boxes :global(.box:nth-chind(2)) {
background-color: green;
}
.boxes :global(.box:nth-chind(3)) {
background-color: blue;
}
</style>但最好还是不要这么做。原因显而易见,而且这样也太暴力了——下游子组件的任何改动都可能影响selector
大多数情况下,组件都应该自行决定它们可以被外部的哪些样式影响。:global应该是紧急出口,而不是平A
来到Box组件里,把background-color属性改成自定义CSS变量:
<style>
.box {
width: 5em;
height: 5em;
border-radius: 0.5em;
margin: 0 0 1em 0;
background-color: var(--color, #ddd);
}
</style>这样,任何父元素(比如<div class="boxes">)都可以设置--color的值,但也可以像下面这样单独设置组件的--color值:
<div class="boxes">
<Box --color="red"/>
<Box --color="green"/>
<Box --color="blue"/>
</div>
提示
This feature works by wrapping each component in an element with
display: contents, where needed, and applying the custom properties to it. If you inspect the elements, you’ll see markup like this:
> <svelte-css-wrapper style="display: contents; --color: red;">
> <!-- contents -->
> </svelte-css-wrapper>Because of
display: contentsthis won’t affect your layout, but the extra element can affect selectors like.parent > .child.
动作 (Actions)
use助记符 (Use Directive)
Actions本质上是一个元素作用域内的函数,可以用来:
- 和第三方库交互
- 懒加载图像
- 代码提示
- 添加自定义事件处理器
在本节程序中,我们可以在<canvas>乱涂乱画,并在菜单内通过鼠标左键或Tab键 选择画笔的颜色和口径
但当你打开菜单,在颜色选项卡之间选择心仪的颜色时,你会发现 焦点并没有被锁定 在模态框内部(我不清楚这句话到底是什么意思,问了AI才知道原来说的是无障碍访问 的问题)
哈基米的回答,仅供参考
为什么这代码“看起来没问题”但实际有问题?
您觉得代码没问题,是因为您可能主要通过鼠标点击来测试。代码里确实做了遮罩层(modal-background),鼠标点不到底下的内容。
但是,请尝试以下操作(这正是教程让你做的测试):
点击页面上的 Menu 按钮打开弹窗。
不要用鼠标,把手放在键盘的 Tab 键上。
按 Tab 键,焦点会选中第一个颜色按钮。
继续按 Tab,焦点会一个个走过颜色按钮。
再按 Tab,焦点会选中滑块(Range input)。
关键点来了:再按一次 Tab。
预期的“正确”行为(Focus Trapped): 焦点应该回到弹窗内的第一个颜色按钮上,形成一个闭环。用户不关闭弹窗,就永远出不去。
现在的“错误”行为(Focus Not Trapped): 焦点会跳出弹窗,选中弹窗背后的那个 show-menu (Close/Menu) 按钮,甚至跳到浏览器的地址栏去。
为什么要锁定焦点(Focus Trap)?
对于模态框(Modal)这种 UI 组件,有一条 web 开发的铁律:当模态框打开时,用户不应该能与模态框之外的任何东西进行交互。
视觉上: 你用了
backdrop-filter: blur(20px)和全屏 div 挡住了后面,鼠标点不到。键盘上: HTML DOM 结构中,底下的按钮依然存在。如果不加干预,浏览器的默认行为是按 DOM 顺序移动焦点。如果不锁定焦点,键盘用户可能会在看不见的情况下,误触底下的按钮,或者迷失方向。
要解决这个问题,我们需要从actions.svelte.js中导入trapFocus函数:
// actions.svelte.js
export function trapFocus(node) {
const previous = document.activeElement;
function focusable() {
return Array.from(node.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'));
}
function handleKeydown(event) {
if (event.key !== 'Tab') return;
const current = document.activeElement;
const elements = focusable();
const first = elements.at(0);
const last = elements.at(-1)
if (event.shiftKey && current === first) {
last.focus();
event.preventDefault();
}
if (!event.shiftKey && current === last) {
first.focus();
event.preventDefault();
}
}
$effect(() => {
focusable()[0]?.focus();
// TODO finish writing the action
});
}<!-- App.svelte -->
<script>
import Canvas from './Canvas.svelte';
import {
trapFocus
} from './actions.svelte.js';
const colors = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet', 'white', 'black'];
let selected = $state(colors[0]);
let size = $state(10);
let showMenu = $state(true);
</script>
<div class="container">
<Canvas color={selected} size={size} />
{#if showMenu}
<div
role="presentation"
class="modal-background"
onclick={(event) => {
if (event.target === event.currentTarget) {
showMenu = false;
}
}}
onkeydown={(e) => {
if (e.key === 'Escape') {
showMenu = false;
}
}}
>
<div class="menu" use:trapFocus> // 挂载到这里
<div class="colors">
{#each colors as color}
<button
class="color"
aria-label={color}
aria-current={selected === color}
style="--color: {color}"
onclick={() => {
selected = color;
}}
></button>
{/each}
</div>
<label>
small
<input type="range" bind:value={size} min="1" max="50" />
large
</label>
</div>
</div>
{/if}
<div class="controls">
<button class="show-menu" onclick={() => showMenu = !showMenu}>
{showMenu ? 'close' : 'menu'}
</button>
</div>
</div>
<style>
.container {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.controls {
position: absolute;
left: 0;
top: 0;
padding: 1em;
}
.show-menu {
width: 5em;
}
.modal-background {
position: fixed;
display: flex;
justify-content: center;
align-items: center;
left: 0;
top: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(20px);
}
.menu {
position: relative;
background: var(--bg-2);
width: calc(100% - 2em);
max-width: 28em;
padding: 1em 1em 0.5em 1em;
border-radius: 1em;
box-sizing: border-box;
user-select: none;
}
.colors {
display: grid;
align-items: center;
grid-template-columns: repeat(9, 1fr);
grid-gap: 0.5em;
}
.color {
aspect-ratio: 1;
border-radius: 50%;
background: var(--color, #fff);
transform: none;
filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.2));
transition: all 0.1s;
}
.color[aria-current="true"] {
transform: translate(1px, 1px);
filter: none;
box-shadow: inset 3px 3px 4px rgba(0,0,0,0.2);
}
.menu label {
display: flex;
width: 100%;
margin: 1em 0 0 0;
}
.menu input {
flex: 1;
}
</style>
我不是故意的
彻底炸了
回归主线,去actions.svelte.js那边补好代码:
export function trapFocus(node) {
const previous = document.activeElement;
function focusable() {
return Array.from(node.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'));
}
function handleKeydown(event) {
if (event.key !== 'Tab') return;
const current = document.activeElement;
const elements = focusable();
const first = elements.at(0);
const last = elements.at(-1)
if (event.shiftKey && current === first) {
last.focus();
event.preventDefault();
}
if (!event.shiftKey && current === last) {
first.focus();
event.preventDefault();
}
}
$effect(() => {
focusable()[0]?.focus();
// TODO finish writing the action
node.addEventListener('keydown', handleKeydown); // 新加的
// 这个也是要加的, 当组件被回收时用来回收监听器并重置焦点
return () => {
node.removeEventListener('keydown', handleKeydown);
previous?.focus();
}
});
}
现在就可以用Tab键来回选择颜色选项卡了
增加参数 Adding Parameters
动作 action可以携带参数,比如过渡 transitions和动画 animation ,在动作被调用后,动作的参数会伴随组件的一生
在本节中,Hover me按钮已经挂载了来自tippy.js的tooltip样式,但当我们把光标悬停在按钮上时,按钮组件渲染的tooltip样式并不包含文本
<script>
import tippy from 'tippy.js';
let content = $state('Hello!');
function tooltip(node) {
$effect(() => {
const tooltip = tippy(node);
return tooltip.destroy;
});
}
</script>
<input bind:value={content} />
<button use:tooltip>
Hover me
</button>
<style>
:global {
[data-tippy-root] {
--bg: #666;
background-color: var(--bg);
color: white;
border-radius: 0.2rem;
padding: 0.2rem 0.6rem;
filter: drop-shadow(1px 1px 3px rgb(0 0 0 / 0.1));
* {
transition: none;
}
}
[data-tippy-root]::before {
--size: 0.4rem;
content: '';
position: absolute;
left: calc(50% - var(--size));
top: calc(-2 * var(--size) + 1px);
border: var(--size) solid transparent;
border-bottom-color: var(--bg);
}
}
</style>要解决这个问题,我们要给封装tooltip的函数添加一个参数,让我们可以把一个函数传递给tippy库:
提示
这里应该传递函数,而非配置本身,因为配置发生改变时不会触发tooltip函数的重新执行
然后到button那里给tooltip函数加个传参:
过渡 (Transitions)
Svelte原版提供这几种过渡动画:
import {
blur,
crossfade,
draw,
fade,
fly,
scale,
slide
} from 'svelte/transition';过渡动画助记符 (Transition Directives)
现在只有单选框是有动画响应的。而Svelte的Transitions(过渡动画)是作为“一等公民”内置在核心库里的
我们可以从svelte/transition里导入内置的过渡动画,比如fade,然后用transition:给<p>元素也加上淡入淡出动画
所以……我不知道区别在哪……
这个过渡样式好像只对单选框有效
原来是用错了……
AI的回答,仅供参考
transition:fade 本质上就是:
一个数学公式:
opacity = t。一个编译器行为:Svelte 把这个公式算成了 CSS Keyframes。
一个性能优化:因为它变成了 CSS 动画,所以不占用 JS 主线程。
这对 Wails 开发的意义: 因为它是跑在 CSS/GPU 上的,所以即使你的 Go 后端正在疯狂处理数据,把 CPU 占满了,前端的这个“淡入淡出”动画依然会非常丝滑,不会掉帧。这就是 Svelte 适合做桌面应用的原因之一。
过渡动画参数 (Adding Parameters)
过渡动画其实也是一个函数,可以接受参数。下面用fly过渡动画进行演示

来去自如 (In and Out):在一个元素上应用不同的In动画和Out动画
比方说,还是那个<p>,这次使用fade作为淡入过渡,用fly作为淡出过渡:
<script>
import { fade, fly } from 'svelte/transition';
let visible = $state(true);
</script>
<label>
<input type="checkbox" bind:checked={visible} />
visible
</label>
{#if visible}
<p in:fly={{ y: 200, duration: 2000 }} out:fade>
Flies in and out
</p>
{/if}