Svelte快速上手系列二:逻辑、事件、生命周期

1,449 阅读10分钟

逻辑控制

原生HTML没有逻辑判断,比如条件判断和循环,但是svelte里提供了这些

if判断

如果需要根据某些特定条件渲染某些html,可以用if结构包裹起来

<script>
	let user = { loggedIn: false };

	function toggle() {
		user.loggedIn = !user.loggedIn;
	}
</script>

{#if user.loggedIn}
	<button on:click={toggle}>
	    退出登录
	</button>
{/if}

{#if !user.loggedIn}
	<button on:click={toggle}>
		登录
	</button>
{/if}

上面使用if判断,根据用户的登录状态来显示不同的html结构

注意if结构的使用方式

{#if 条件}
    
    html结构

{/if}

if-else判断

除了if判断,svelte还提供了else判断,用于简化多个if判断的情况

我们可以把上面的代码改造一下

<script>
	let user = { loggedIn: false };

	function toggle() {
		user.loggedIn = !user.loggedIn;
	}
</script>

{#if user.loggedIn}
	<button on:click={toggle}>
	    退出登录
	</button>
{:else}
	<button on:click={toggle}>
		登录
	</button>
{/if}

注意else语句的使用方式,是一个单标签,而且是在if标签里面用

{#if 条件}
    html结构
{:else}    
    html结构
{/if}

else if判断

更多种条件的判断可用else if语句

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

{#if x > 10}
	<p>{x} 比10大</p>
{:else if 5 > x}
	<p>{x} x小于5</p>
{:else}
	<p>{x}在5~10之间</p>
{/if}

else if的使用方式和else一样,同样包裹在if结构里面

Each循环语句

svelte里面也提供了循环语句,Each,更像是vue的v-for和react的map的结合体

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

<h1>油管上有名的猫猫们</h1>

<ul>
	{#each cats as cat}
		<li><a target="_blank" href="https://www.youtube.com/watch?v={cat.id}">
			{cat.name}
		</a></li>
	{/each}

</ul>

注意each语句的使用方式,和if很相似,

{#each 集合  as  个体}
    
    html

{/each}

此外,each语句还有第二个参数 i

{#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}

这里和vue的v-for对比一下, i的作用相当于index,作为下标

v-for="(item, index) in array"

其实svelte里面提供了解构的用法, 将集合中单个元素的属性解构出来作为第一个参数

{#each cats as { id, name }, i}
	<li><a target="_blank" href="https://www.youtube.com/watch?v={id}">
		{i + 1}: {name}
	</a></li>
{/each}

Each循环语句的Key

each有个弊端,默认情况下,当我们修改each的值时,它会在each块的末尾添加或者修改,并且更新任何已经更改的值

看下面这个例子,这里使用了Each循环,渲染了一列表情

GIF 2021-7-15 13-35-58.gif

我们可以看到,点击“删除第一个”的按钮,它删除了第一个Emoji组件,但是删除了最后一个DOM节点,然后更新其余DOM节点的名称,但不更新表情符号

其具体逻辑如下

  • 在子组件中定义好html结构
  • 子组件根据传值显示对应的表情内容
  • 在父组件里进行修改操作

子组件emojis.svelte

<script>
	const emojis = {
        apple: "🍎",
        banana: "🍌",
        carrot: "🥕",
        doughnut: "🍩",
        egg: "🥚"
	}

	// name的值根据prop传值的改变而变化
	export let name;

	// ...但是表情符号的变量在组件初始化时就给定了
	const emoji = emojis[name];
</script>

<p>
    <span> { name } 的表情是 { 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>

父组件App.svelte

<script>
  import Emoji from "./children/Emojis.svelte"
  let emojis = [
            { id: 1, name: 'apple' },
            { id: 2, name: 'banana' },
            { id: 3, name: 'carrot' },
            { id: 4, name: 'doughnut' },
            { id: 5, name: 'egg' },
    ];

  function handleClick() {
        emojis = emojis.slice(1);
 }

</script>

<div>
  <button on:click={handleClick}>
    删除第一个
  </button>
  
  {#each emojis as item}
    <Emoji name={item.name}/>
  {/each}
</div>

<style>
  div{
      box-shadow: 0 0 10px 2px rgba(1, 1, 1, .2);
      margin: 20px;
      padding: 10px;
    }

    button:hover{
      background-color: aqua;
    }  
  </style>

但是我们需要的是移除第一个表情组件和它的DOM节点,而不影响其它的

所以,我们在使用Each的时候要为每个each块指定一个唯一标识“key”

所以,对each循环做一下修改

{#each emojis as item (item.id)}
    <Emoji name={item.name}/>
  {/each}

上面item.id充当了key,它告诉svelte当组件更新的时候该去改变哪个具体的DOM节点

我们可以使用任何类型的变量充当key,甚至是item本身,但最好用数字或者字符串会更安全

然后,我们想要的效果就出来了

GIF 2021-7-16 14-16-32.gif

Event事件

事件监听和行内事件

正如我们之前用的那样,在svelte里,可以用on:指令来监听DOM事件

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

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

<div on:mousemove={handleMousemove}>
	鼠标的实时位置是 {m.x} x {m.y}
</div>

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

当然,也像原生js样。svelte也支持行内的事件

<div on:mousemove="{e => m = { x: e.clientX, y: e.clientY }}">
	鼠标的实时位置是 {m.x} x {m.y}
</div>

在某些框架中,您可能会看到出于性能原因避免使用内联事件处理程序的建议,尤其是在循环内部。 该建议不适用于 Svelte — 编译器将始终做正确的事情,无论您选择哪种形式。

事件修饰符

DOM事件有其特定的修饰符来约束其自身的行为,例如,once修饰符,只会执行一次事件

<script>
    function handleClick() {
            alert('我只会弹出一次')
    }
</script>

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

以下是可用的事件修饰符:

  • preventDefault — 阻止默认事件.
  • stopPropagation — 阻止事件冒泡
  • passive — 提升鼠标滚轮事件以及触摸事件的性能
  • nonpassive — 显式的设置passive为false
  • capture — 在事件捕获阶段触发事件处理函数
  • once —让事件只触发一次
  • self — 只有事件源是元素本身的时候才触发

如果需要多个修饰符,我们可以链式调用

on:click|once|capture={...}.

组件事件

子组件可以派发事件到父组件,不过必须先创建一个事件派发器,这是svelte的内置函数,可以直接引入使用

新建一个子组件Inner.svelte

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

	const dispatch = createEventDispatcher();

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

<button on:click={sayHello}>
  点我说你好
</button>

image.png

在父组件App.svelte中接收子组件派发的meaaage事件

<script>
	import Inner from './children/Inner.svelte';

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

<Inner on:message={handleMessage}/>

点击按钮触发的效果如下:

image.png

这里要注意几点:

  • 子组件的dispatch(eventName, {})函数的第一个参数就是向父组件派发的事件名称,第二个参数是派发的数据
  • 父组件监听时的事件名称要和子组件派发的一致
  • createEventDispatcher 必须在组件第一次实例化时调用——你不能稍后在内部执行它,例如 一个 setTimeout 回调或者promise中。
  • 如果父组件不接受派发的事件,子组件依然可以派发成功,但不会有任何反应

组件嵌套的事件转发

与 DOM 事件不同,组件事件不会冒泡。 如果要监听某个深度嵌套组件上的事件,中间组件必须转发该事件

在下面的案例中,我们把Inner组件包裹在新的Outer.svelte组件中

这样,Inner组件的事件先派发给Outer,Outer再派发给App

所以我们要在Outer组件中再使用createEventDispatcher创建一个派发器,将来自Inner组件的事件派发给App

Outer.svelte

<script>
	import Inner from './Inner.svelte';
	import { createEventDispatcher } from 'svelte';

	const dispatch = createEventDispatcher();

	function forward(event) {
		dispatch('message', event.detail);
	}
</script>

<Inner on:message={forward}/>

但这样,当事件很多的时候,代码就比较冗余

不过svelte给我们提供了简写

没有值的事件监听意味着派发所有的事件

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

<Inner on:message/>

上面的代码也就是说:监听Inner派发来的事件的时候不给处理函数,就会直接派发来自Inner的所有事件

所以我们在App.svelte里就可以拿到从Inner组件里面派发的事件了

App.svelte

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

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

<Outer on:message={handleMessage}/>

原生DOM事件转发

事件转发也适用于 DOM 事件。

我们希望在我们的 CustomButton组件 上获得点击通知——为此,我们只需要在 CustomButton.svelte 中的 元素上转发点击事件

CustomButton.svelte

<button on:click>
    点我
</button>

App.svelte

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

    function handleClick() {
            alert('按钮被点了');
    }
</script>

<CustomButton on:click={handleClick}/>

组件生命周期

每个组件都有一个生命周期,从创建时开始,到销毁时结束。

我们可以在关键的声明周期函数中去处理我们特定的逻辑

onMount

最常使用的是 onMount,它在组件第一次渲染到 DOM 之后运行。

下面这个例子,我们在onMount时来加载一些网络图片

生命周期函数是需要单独引入的

<script>
	import { onMount } from 'svelte';
	let photos = [];
	onMount(async () => {
		const res = await fetch(`https://jsonplaceholder.typicode.com/photos?_limit=20`);
		photos = await res.json();
	});
</script>

生命周期函数必须在组件初始化时调用,以便回调绑定到组件实例——而不是(比如)在 setTimeout 中。

如果 onMount 回调返回一个函数,则该函数将在组件销毁时调用。

推荐将网络数据请求的行为放在onMount中,这样减少在ODM加载完成后还出现数据未加载的情况

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

  let photos = [];

  onMount(async () => {
    const res = await fetch(`https://jsonplaceholder.typicode.com/photos?_limit=20`);
    photos = await res.json();
  });
</script>


<div class="photos">
  <h1>Photo album</h1>
	{#each photos as photo}
		<figure>
			<img src={photo.thumbnailUrl} alt={photo.title}>
			<figcaption>{photo.title}</figcaption>
		</figure>
	{:else}
		<!-- this block renders when photos.length === 0 -->
		<p>loading...</p>
	{/each}
</div>


<style>
div{
  box-shadow: 0 0 10px 2px rgba(1, 1, 1, .2);
  margin: 20px;
  padding: 10px;
}
</style>

onDestory

要在组件销毁时运行代码,请使用 onDestroy。

例如,我们可以在组件初始化时添加一个 setInterval 函数,并在它不再相关时将其清理干净。 这样做可以防止内存泄漏。

虽然在组件初始化期间调用生命周期函数很重要,但从哪里调用它们并不重要。

因此,如果我们愿意,我们可以将处理组件销毁时的逻辑抽象为 utils.js 中的辅助函数...

新建utils.js,这里在组件销毁时我们清理掉定时器

import { onDestroy } from 'svelte';

export function onInterval(callback, milliseconds) {
	const interval = setInterval(callback, milliseconds);
	onDestroy(() => {
		clearInterval(interval);
		console.log("定时器已经清理,组件销毁")
	});
}

Timer.svelte中引入

<script>
	import { onInterval } from './utils.js';
	export let callback;
	export let interval = 1000;

	onInterval(callback, interval);
</script>

<p>
	组件每{interval} 秒执行一次回调函数
</p>

<style>
	p { 
		border: 1px solid blue;
		padding: 5px;
	}
</style>

在App.svelte中使用Timer.svelte

我们使用一个按钮来控制定时器的打开和关闭,进而控制Timer组件的挂载与销毁

这样可以监控onDestory生命周期对定时器的清除操作

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

	let open = true;
	let seconds = 0;

	const toggle = () => (open = !open);
	const handleTick = () => (seconds += 1);
</script>

<div>
	<button on:click={toggle}>{open ? '关闭定时器' : '打开定时器'} </button>
	<p>
		定时器组件已经运行了
		{seconds} 次
	</p>
	{#if open}
            <Timer callback={handleTick} />
        {:else}  
          <p>组件销毁</p>  
	{/if}
</div>

打开和关闭计时器几次,并确保计数器保持滴答作响并且 CPU 负载增加。 这是由于内存泄漏,因为之前的计时器没有被删除。 在解决示例之前不要忘记刷新页面。

GIF 2021-7-16 8-58-00.gif

通常,在onDestory周期中,我们可以做以下操作

  • 清除定时器、计时器等任务
  • 销毁一些图表
  • 清除还在执行的动画
  • 清除占用内存较大的变量

beforeUpdate 和 afterUpdate

beforeUpdate函数在DOM更新前就会立即执行,而afterUpdate则恰好相反,在DOM更新后执行,用于DOM和数据同步后才处理的逻辑

通常,这两个函数用于强制执行无法用状态改变而驱动的事情,例如更新元素滚动的位置

这里我们使用了一个聊天机器人的案例,根据输入的内容,控制聊天窗口的滚动

聊天机器人引用了第三方的库 ElizaBot,具体用法可以参考其文档

执行npm install elizaBot即可安装该库

这里用它作为一个自动回复的机器人,来和我们聊天

新建一个Chat.svelte

<script>
  import Eliza from 'elizabot';
  import { beforeUpdate, afterUpdate } from 'svelte';

 let div;
 let autoscroll;

 beforeUpdate(() => {
	autoscroll = div && (div.offsetHeight + div.scrollTop) > (div.scrollHeight - 20);
   console.log("滚动条件具备")
 });

 afterUpdate(() => {
	if (autoscroll) div.scrollTo(0, div.scrollHeight);
    console.log("滚动完毕")
 });


 const eliza = new Eliza();

 let comments = [
	{ author: 'eliza', text: eliza.getInitial() }
 ];


  //处理聊天内容输入
  function handleKeydown(event) {
	if (event.key === 'Enter') {
		const text = event.target.value;
		if (!text) return;

		comments = comments.concat({
			author: 'user',
			text
		});

		event.target.value = '';

		const reply = eliza.transform(text);

		setTimeout(() => {
			comments = comments.concat({
				author: 'eliza',
				text: '...',
				placeholder: true
			});

			setTimeout(() => {
				comments = comments.filter(comment => !comment.placeholder).concat({
					author: 'eliza',
					text: reply
				});
			}, 500 + Math.random() * 500);
		}, 200 + Math.random() * 200);
	}
	}
</script>

<div class="wrap">
  <div class="chat">
    <h1>Eliza</h1>
  
    <div class="scrollable" bind:this={div}>
      {#each comments as comment}
        <article class={comment.author}>
          <span>{comment.text}</span>
        </article>
      {/each}
    </div>
  
    <input on:keydown={handleKeydown}>
  </div>
</div>


<style>

  .wrap{
    box-shadow: 0 0 10px 2px rgba(1, 1, 1, .2);
    margin: 20px;
    padding: 10px;
  }
	.chat {
		display: flex;
		flex-direction: column;
		height: 100%;
		max-width: 320px;
	}

	.scrollable {
		flex: 1 1 auto;
		border-top: 1px solid #eee;
		margin: 0 0 0.5em 0;
		overflow-y: auto;
	}

	article {
		margin: 0.5em 0;
	}

	.user {
		text-align: right;
	}

	span {
		padding: 0.5em 1em;
		display: inline-block;
	}

	.eliza span {
		background-color: #eee;
		border-radius: 1em 1em 1em 0;
	}

	.user span {
		background-color: #0074D9;
		color: white;
		border-radius: 1em 1em 0 1em;
		word-break: break-all;
	}
</style>

上面的代码可以直接拷贝运行,不必在意里面具体写的什么,我们只需要看看beforeUpdate 和 afterUpdate函数触发的时机和效果

 beforeUpdate(() => {
	autoscroll = div && (div.offsetHeight + div.scrollTop) > (div.scrollHeight - 20);
   console.log("滚动条件具备")
 });

 afterUpdate(() => {
	if (autoscroll) div.scrollTo(0, div.scrollHeight);
    console.log("滚动完毕")
 });

代码有几个特殊的地方,在后面会讲到

 <div class="scrollable" bind:this={div}>
      
 </div>

上面的bind:this={div},可以理解为将该真实的DOM元素绑定到一个变量上(参考vue和react的ref)

当我们输入内容回车时。机器人先回复“...”,随后再回复我们的问题

GIF 2021-7-16 9-45-22.gif

然后可以看到控制台输出的内容

聊天内容增加时。div就会自动往上滚动,这个时候就会触发我们的生命周期函数

image.png

第一组是组件挂载时触发的

第二组是我们发送消息,机器人回复...触发的

第三组是机器人回复我们问题后触发的

tick

tick 函数与其他生命周期函数不同,您可以随时调用它,而不仅仅是在组件第一次初始化时调用。

它返回一个Promise,一旦任何挂起的状态更改应用于 DOM(或立即,如果没有挂起的状态更改),它就会解决

当您在 Svelte 中更新组件状态时,它不会立即更新 DOM。

相反,它会等到下一个微任务来查看是否有任何其他需要应用的更改,包括其他组件中的更改。

这样做可以避免不必要的工作,并允许浏览器更有效地进行批处理。

详解js的宏任务与微任务(event loop) www.jianshu.com/p/53e8597df…

js 宏任务和微任务 www.cnblogs.com/wangziye/p/…

新建一个Tick.svelte

<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));

		this.selectionStart = selectionStart;
		this.selectionEnd = selectionEnd;
	}
</script>

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

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

上面我们选择一段文本,然后按下Tab键

可以看到文本被清除,鼠标光标自动跳转的文本的最后面

no-tick.gif

然而这不是我们想要的效果,我们希望按下Tab键后选中的文本不被清除,同时文本变成大写,光标位置也不发生变化

我们可以通过导入tick来解决这个问题...

在selectionStart和selectionEnd赋值前,调用tick函数

import { tick } from 'svelte';


await tick();
this.selectionStart = selectionStart;
this.selectionEnd = selectionEnd;

然后我们再选中按下Tab键,就可以看到想要的效果了

tick.gif

上面代码有个特别的地方

const { selectionStart, selectionEnd, value } = this;

注意这里的this,不是我们理解的那个this,而是textarea这个DOM元素本身