Svelte 是没有对应的状态库的,因为它内置了状态管理,它被称为 store
。
当期望脱离组件的层级(父-子)关系且能够在任意位置都能访问某个状态(变量)时,状态管理仍然是非常有用的一个特性。
总的来说,Svelte 的状态管理更为简单直接 —— 我对这种简单的热爱毫不掩饰,它起码不会使用起来要绕晕脑袋(可能我的理解能力比较低),使得我的代码在别人眼里看来很“高级”。
1、可写状态(Writable stores)
并非所有的状态都属于在组件层次的结构内。某些时候,有些状态需要被多个毫不相干的组件或普通的 JavaScript 模块访问。
在 Svelte 中,我们通过store来实现。
本节的示例会比较复杂,它由一个主(父)组件加上三个子组件组成,之所以分成3个子组件,其目的是为了展示出 store 的特性。
Svelte 将状态划分为两种,一种可读可写
,一种只读
,都用可读可写
(可以读取,也可以修改)虽然省事,不过允许或者说强制状态是只读的,可以防止状态被意外修改。
只读状态会在第3节详尽介绍,本节仅关注可写状态。
要创建一个可读且可写的状态十分简单,例如我们要创建一个数字型的可写状态 count
,Svelte 提供 writable
函数来创建:
stores.js
import { writable } from 'svelte/store';
export const count = writable(0);
我们可以将创建状态的代码,放到单独的文件 store.js
中,以便其他需要用到 count 状态的组件可以引入使用。
接下来我们计划要实现的功能是这样:
可写状态示例
非常简单的例子,点击 +
按钮,count
加 1
,-
按钮,count
减 1
,reset
按钮重置 count
为 0
。
首先我们刻意将 +
、-
、reset
三个按钮分别写三个组件,这三个按钮在 App.svelte 中是并列出现的,不是父子关系:
Incrementer.svelte (递增)
<script>
import { count } from './stores.js';
function increment() {
// TODO 递增 count 的值,需要用到 count 当前值,应该使用 update
}
</script>
<button on:click={increment}>+</button>
Decrementer.svelte (递减)
<script>
import { count } from './stores.js';
function decrement() {
// TODO 递减 count 的值,需要用到 count 当前值,应该使用 update
}
</script>
<button on:click={decrement}>-</button>
Reset.svelte (重置)
<script>
import { count } from './stores.js';
function reset() {
// TODO 重置 count 的值为 0(无需知道当前 count 的值,可以使用 set。
}
</script>
<button on:click={reset}>reset</button>
最后,我们将三个组件汇聚 App.svelte:
App.svelte
<script>
import { count } from './stores.js';
import Incrementer from './Incrementer.svelte';
import Decrementer from './Decrementer.svelte';
import Reset from './Reset.svelte';
let count_value;
const unsubscribe = count.subscribe(value => {
count_value = value;
});
</script>
<h1>count 当前的值是:{count_value}</h1>
<Incrementer/>
<Decrementer/>
<Reset/>
所谓 store(也即状态),只不过是具有 subscribe
方法的对象,它允许当 store 的值改变时自动通知对此感兴趣的相关组件或程序。在 App.svelte
中,count
便是一个 store,我们在 count.subscribe
的回调中设置 count_value
的值。
点击 stores.js
选项卡看看 count
的定义,可见它是一个 writable store(可写状态),这表示除了 subscribe
方法外,它还具有 set
和 update
方法。
现在转到 Incrementer.svelte
组件,我们可以关联 +
按钮:
function increment() {
count.update(n => n + 1);
}
现在再点击 +
按钮应该会更新 count 了。同理,在 Decrementer.svelte
中实现递减。
最后,在 Reset.svelte
里实现 reset
:
function reset() {
count.set(0);
}
当我们需要知道count
当前值的时候,应该使用update
,它会将当前值传递到回调函数供你使用;如果无需知道,则使用set
。
2、自动订阅(Auto-subscriptions)
上一个例子中,程序虽然可以这么写,不过存在一个不易察觉的错误:unsubscribe
函数没有机会被调用。如果该组件会被多次实例化和销毁,这将导致 内存泄露。
解决之道,应该使用 onDestroy
这个生命周期 Hook。
<script>
import { onDestroy } from 'svelte';
import { count } from './stores.js';
import Incrementer from './Incrementer.svelte';
import Decrementer from './Decrementer.svelte';
import Reset from './Reset.svelte';
let count_value;
const unsubscribe = count.subscribe(value => {
count_value = value;
});
onDestroy(unsubscribe);
</script>
<h1>count 当前的值是:{count_value}</h1>
不过事情又开始变得有点呆板重复了。
特别是当你的组件 subscribe 了很多的 store 的时候。Svelte 给出一个绝佳的替代方案,你可以在 store 名称前面加上$
前缀来引用这个 store 的值:
<script>
import { count } from './stores.js';
import Incrementer from './Incrementer.svelte';
import Decrementer from './Decrementer.svelte';
import Reset from './Reset.svelte';
</script>
<h1>count 当前的值是:{$count}</h1>
自动订阅仅适用于在组件的顶层范围声明(或者导入的JS文件中)的 stroe 变量。
在标记中使用 $count
不会有任何限制,你也可以在 <script>
的任何位置使用它,例如在事件处理程序或者响应式声明中。
Svelte 假定所有以$
开头的任何标识符都表示引用某个 store 值,而$
实际上是一个保留字符,Svelte 会禁止你使用$
作为你声明的变量的前缀。
3、只读状态(Readable stores)
并非所有 store 允许所有人可写的。例如,你可能有一个 store 表示鼠标位置或者用户地理位置,允许 ‘外部’ 来修改这个值是没有意义的。对于这种情况,我们可以用只读store。
本节我们要制作一个数字钟,显示当前的时间,先看看主程序的代码:
App.svelte
<script>
import { time } from './stores.js';
const formatter = new Intl.DateTimeFormat('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
</script>
<h1>The time is {formatter.format($time)}</h1>
App.svelte 引用了状态文件 stores.js
,因此需要编写这份文件:
stores.js
import { readable } from 'svelte/store';
export const time = readable(new Date(), function start(set) {
// 在此处实现
return function stop() { };
});
readable
的第一个参数代表初始值,如果还没有具体的初始值,可以先设为 null
或 undefined
。
第二个参数 start
是一个函数,该函数接受一个 set
回调用于设置值,并且返回一个 stop
函数。
当 store 被第一个订阅者读取时,就会调用 start
函数(然后在 start
函数中使用 set
提供一个最终的值;
最后一个订阅者退订时,调用 stop
函数。
stores.js
export const time = readable(new Date(), function start(set) {
const interval = setInterval(() => set(new Date()), 1000);
return function stop() { clearInterval(interval); };
});
4、状态继承(Derived stores)
你可以调用derived
来创建一个新的 store,它将继承自某一个或多个其他的 store。
我们沿用上一节的数字钟例子,稍作添加:
App.svelte
<script>
import { time } from './stores.js';
const formatter = new Intl.DateTimeFormat('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
</script>
<h1>北京时间:{formatter.format($time)}</h1>
<p>页面已打开 {$elapsed} 秒</p>
stores.js
import { readable, derived } from 'svelte/store';
export const time = readable(new Date(), function start(set) {
const interval = setInterval(() => {set(new Date()), 1000);
return function stop() { clearInterval(interval); };
});
const start = new Date();
export const elapsed = derived(
time,
$time => {} // 稍候在此处填写供订阅的值
);
我们可以创建一个继承自time
的 store 来记录页面打开的时间:
export const elapsed = derived(
time,
$time => Math.round(($time - start) / 1000)
);
可从多个 store 派生为一个 store,并显式地
set
一个值而不是返回它(这对于异步派生的值很有用处)。有关更多信息,请查阅
API 参考。
5、自定义状态(Custom stores)
我们第一节《可写状态》中的例子,创建了3个子组件来实现 +1
、-1
及 reset
功能。
当然这只是为了演示而写,实际的开发中我们不用真的创建3个子组件,我们会用一个 store 来封装相关的业务:
import { writable } from 'svelte/store';
function createCount() {
const { subscribe, set, update } = writable(0);
return {
subscribe,
increment: () => {},
decrement: () => {},
reset: () => {}
};
}
export const count = createCount();
App.svelte
<script>
import { count } from './stores.js';
</script>
<h1>count 的当前值是:{$count}</h1>
<button on:click={count.increment}>+</button>
<button on:click={count.decrement}>-</button>
<button on:click={count.reset}>reset</button>
只要一个对象正确地实现了 subscribe
方法,它即是一个 store。除了之外,怎样都行。因此,使用特定领域的逻辑来创建 store 非常容易。
例如,我们前面例子中的 count
store 可以将 increment
、decrement
和 reset
方法包含进来,并避免暴露 set
和 update
两个方法:
function createCount() {
const { subscribe, set, update } = writable(0);
return {
subscribe,
increment: () => update(n => n + 1),
decrement: () => update(n => n - 1),
reset: () => set(0)
};
}
6、状态绑定(Store bindings)
我们假设有一个 store 叫 name
,此外再创建一个 greeting
的 store 来继承 name
,然后两者都导出:
stores.js
import { writable, derived } from 'svelte/store';
export const name = writable('world');
export const greeting = derived(
name,
$name => `Hello ${$name}!`
);
App.svelte
<script>
import { name, greeting } from './stores.js';
</script>
<h1>{$greeting}</h1>
<input value={$name}>
如果 store 是可写的,即它具有 set
方法,则可以绑定到其值,就像绑定到本地组件的状态是一样的。
在这个例子中,我们有一个可写的 store name
和一个派生 store greeting
,尝试修改 <input>
元素:
<input bind:value={$name}>
现在修改 input 的 value 会自动更新 name
及其所有相关的依赖项。
我们还可以直接为组件内部的 store 值进行赋值。添加一个 <button>
元素:
<button on:click="{() => $name += '!'}">
Add exclamation mark!
</button>
$name += '!'
赋值是等效于 name.set($name + '!')
的。
7、与 RxJS 结合
如前面所述,要获得 store
的值,是通过 store.subscribe
方法去订阅,store 产生的值会自动推送给订阅者,这点与 RxJS 如出一辙,实际上,你可以将 Svelte 的 store
看作是 RxJS 的 Observable
。
这样做不是没有原因的,其一是为了更容易与类似 rxjs 这样的库相结合,共同发挥作用;其二是认同 RxJS 这套观察者-订阅的理念且值得借鉴。
下面我们尝试演示 RxJS 相结合的例子:
<script>
import { fromEvent } from 'rxjs'
import { map } from 'rxjs/operators'
let position = fromEvent(document, 'mousemove')
.pipe(map(e => `鼠标位置: ${e.clientX},${e.clientY}`))
</script>
{$position}
上述例子可见,由 RxJS
产生的 Observable
,竟然可以无缝当成 store
来用。
总结
我们通常将状态写到单独的 JS 文件中,例如每个状态一份 JS 文件。
可写状态 writable
应该是我们最常用的状态,只读状态 readable
是在防止被意外修改的情况下使用。
要读取状态的值,使用状态的 subscribe
方法,之所以取这个名字,显然有意为之,例如它兼容 rxjs
的写法,可以轻松与 rxjs 结合使用。
当然,你绝不会放弃使用被称为自动订阅(第2节)的速写形式:使用 $
符号,有了这个速写形式,可以精简很多状态管理相关的代码,简直爽翻。
自定义状态让你有机会封装一个状态,使其更贴近业务,以及使用起来更为便捷。
Svelte 的状态支持继承,目的是在于可以快速从一个已存在的状态中将其复用。