Svelte 实践组件开发流程

1,242 阅读9分钟

前言

业务代码编写,尤其是开发一些业务组件时,有一定的规律可循,所以本文将将利用一个 TodoListdemo 介绍笔者总结的组件开发的业务流程。

此外,近来看到很多框架类介绍文章都有提到 Svelte 这个新贵框架,其独特的编译时让其具备可以在任何框架,甚至浏览器原生 js 中直接使用的能力,所以本文将用这个 Svelte 实践组件开发的流程,顺带学习一下这个框架。

开发流程

设计数据模型

开发业务之前,通常要先思考这业务需要哪些数据,这个阶段通常是我们所谓的前、后端定义接口阶段,如果仅需要前端侧,那么也需要从入口处定义好数据结构。

虽然大多数时候是后端同学主导这个阶段,但我觉得前端也应该积极参与这个过程中去,因为接口数据结构的改变通常会导致很多返工

设计数据模型可以使用一些辅助措施。非常建议在项目中引入 TypeScript,如果没有引入 TS,那么我们可以用简单的一段 TS 注释记录设计的数据模型结构,当然如果不会 TS 也不打紧,用一些数据模型设计工具比如 Star UML、Visio 帮助梳理也可以,更或者我们直接可以用在线文档或草稿纸上写画。

笔者一般都是在草稿纸上列出来所有可能的属性,然后考虑怎么用 TS 编写接口、类型定义。

设计数据结构也需要针对业务场景进行考虑。

  • 基础的比如 loading、隐藏与否、禁用与否,我们使用 boolean 值。
  • 单纯展示数据场景,使用数字、字符串等。
  • 表单项,Input 对应数字或者字符串,Select 单选对应字符串,多选字符串数组,CheckBox 对应 boolean 值。
  • 实现一个列表组件,那么最好使用数组结构,有时候列表需要保持唯一,我们可以考虑使用 Set,再如有时候列表比较长,而且需要经常根据数组元素值查询数组所在位置那么可以用 Map 或者 Set 的数据结构,或者单独维护一个数组元素到索引的对象。
  • 如果要实现一个表单组件或者配置页面,我们可以用一个对象表示表单或者配置,每个表单或者配置项又可以根据上述是否是列表或者具体表单项,进行再次嵌套设计,直至可以用这个数据模型结构描述整个业务。

在本场景中,我们需要实现一个 TODOList,其主要有包含两部分状态数据:

其一也是最重要的列表数据。其定义如下:

let todoList: ITodoItem[] = [];

export interface ITodoItem {
    readonly id: string;
    // 标题
    readonly title: string;
    // 备注
    readonly note?: string;
    // 完成状态
    readonly status: CompleteStatus;
    // 消耗时间
    readonly time?: string;
}
// 定义每个条目的完成情况
export enum CompleteStatus {
    Todo,
    Done,
}

其二是检索的状态,如果我们只需要一个检索表单项,我们只需要一个字符串状态:

let filterText: string = ''

如果需要多个检索条件的话,比如我们需要加入一个按完成状态筛选的条件,那么就可以用对象表示这个检索表单,定义如下:

export interface ITodosFilter {
    readonly filterText: string;
    readonly status: CompleteStatus;
}

组件结构规划

按照交互稿或者需求细节,我们对组件结构的结构进行划分,对于本文的 TodoList,划分为如下几部分:

image.png

  • header:TodoList 的标题和搜索表单
  • body:TodoList 的列表,以及检索后的内容
  • item:每个 Todo 项

因此,本文主要需要实现外层的 App 容器组件及包含的 TodoListHeaderTodoListBodyTodoListItem 一共四个组件。

当然如果每个 Todo 项的结构不是太复杂,可以和 TodoListBody 放在一起实现。本文为方便起见,只实现了 TodoListHeaderTodoListBody 两个组件。

确认好组件结构后,可以初步考虑状态数据的存放位置,虽然不同业务千差万别,但是对于 MVC 思想的框架来讲,万变不离其宗,只需要牢记如下两个核心原则:

  1. 组件数据都是自上而下传递
  2. 状态在哪个组件,操作状态的方法就在哪个组件

对于本文的 TodoList,直观来看,TodoListHeader 需要用 filterText 来描述检索,TodoListBody 需要用 todoList 数组来描述列表,那么我们简单地按照这样分配就可以了吗?

显然没有那么简单,TodoListHeader 中输入框键盘 enter 事件触发时,需要添加一个 ITodoItem 的条目,也就是需要操作 todoList 的状态数据,根据第 2 条准则,那么这个 todoList 的状态数据应该放在外层的 App 下,而且其相关的操作方法也要放在 App 下。

另一方面,为了实现检索效果,TodoListBody 的渲染结果同样需要 filterText 的状态,按照第 1 条准则,由于组件数据都是自上而下传递的,那么filterText 也需要放在 App 下,否则 TodoListBody 将会访问不到。

业务代码编写

搞清楚上述内容,就可以着手业务代码的编写,因为此时你已经对整个组件及组件的状态规划料了熟于心,所以组件代码开发起来就不困难了。

在 App 组件下,我们需要管理上述的检索项 filterTextTodoList 以及操作它们的方法,所以可以编写如下代码:

<script lang="ts">
  import TodoListBody from './components/TodoListBody.svelte';
  import TodoListHeader from './components/TodoListHeader.svelte'
  import { idGen } from './lib/id';
  import { CompleteStatus, type IStatusChangeDetail, type ITodoItem } from './model';

  let filterText: string = '';
  let todoList: ITodoItem[] = [];
  
  // enter 添加后清空检索项
  const clearFilterText = () => {
    filterText = '';
  }

  // 响应 TodoListHeader 子组件的检索内容变化事件
  function handleFilteTextChange(event) {
    filterText = event.detail.value;
  }

  // 触发
  function handleEnter() {
    todoList = [{
        title: filterText,
        status: CompleteStatus.Todo,
        id: idGen(),
    }].concat(todoList);
    clearFilterText();
  }
  
  // enter 后将检索的内容作为新的一项 todoItem
  function handleStatusChange(e: CustomEvent<IStatusChangeDetail>) {
    const { item: target, status } = e.detail;
    todoList = todoList.map(i => {
      if (i.id === target.id) {
        return {
          ...i,
          status,
        }
      }
      return i;
    });
  }

  // 计算属性获取检索后的 todoList ,类似 vue 中的 computed
  function getFilteredTodoList(data: ITodoItem[], filterText: string) {
    const res = data.filter(i => {
      return i.title.includes(filterText);
    })
    return res;
  }
</script>

<main class="todo-app">
  <TodoListHeader 
    {filterText}
    on:filterChange={handleFilteTextChange}
    on:enter={handleEnter}
  ></TodoListHeader>
  <TodoListBody
    todoList={getFilteredTodoList(todoList, filterText)}
    on:statusChange={handleStatusChange}
  ></TodoListBody>
</main>

而在子组件中我们需要对一些事件做封装,以方便 App 组件使用,例如我们在 TodoListHeader 中定义了:

  • filterChange 事件,用来触发 filterText 变化
  • enter 事件,用来触发新增一个 ITodoItem 条目

因此在 TodoListHeader 可以编写如下代码:

<script lang="ts">
    import { createEventDispatcher } from 'svelte';
    const dispatch = createEventDispatcher();

    export let filterText: string;

    // 触发变更检索内容
    function handleChange(e) {
        dispatch('filterChange', {
            value: filterText
        });
    }

    // 触发键盘,响应添加
    function handleKeydown(e: KeyboardEvent) {
        if (e.key === 'Enter') {
            dispatch('enter');
        }
    }
</script>

<div>
  <h1>Todo List</h1>
  <input type="text" on:keydown={handleKeydown} value={filterText} on:input={handleChange}>
</div>

TodoListBody 中定义了:

  • statusChange 事件,用来更新 Todo 条目的状态

所以可以编写如下代码:

<script lang="ts">
    import {
        CompleteStatus, 
        type IStatusChangeDetail, 
        type ITodoItem
    } from '../model';
    interface IEventType {
        statusChange: IStatusChangeDetail
    }
    import { createEventDispatcher } from 'svelte';
    // 构建
    const dispatch = createEventDispatcher<IEventType>();

    // 来自父组件的属性 props
    export let todoList: ITodoItem[];

    // 包裹 handleStatusChange ,加入当前需要修改的 item
    const handleStatusChangeWrapper = (item: ITodoItem) => (e) => {
        if (e.currentTarget.checked) {
            dispatch('statusChange', {
                item,
                status: CompleteStatus.Done,
            });
        } else {
            dispatch('statusChange', {
                item,
                status: CompleteStatus.Todo,
            });
        }

    };
    // 类似 vue compute 的计算属性,或者如下那样直接写在行内
    function getStatus(item: ITodoItem){
        return item.status === CompleteStatus.Done;
    }
</script>

<div>
<ul class="todo-list">
    {#each todoList as item}
        <li class="todo-item">
            <div class="content" class:done="{item.status === CompleteStatus.Done}">
                <input 
                id={item.id}
                type="checkbox"
                on:change={(e) => handleStatusChangeWrapper(item)(e)} 
                checked={getStatus(item)}
                >
                <label for={item.id}>
                    {item.title}
                </label>
            </div>

            <button class="close-btn"></button>
        </li>
    {/each}
</ul>
</div>

自此,我们便实现了整个应用,最终效果如图:

image.png

Svelte 的初学总结

Svelte 的使用和 React、Vue 等 MVX 的框架类似,但是也有一些自己独特的地方,因为本节将使用过程中遇到一些和 React 等框架不一样的地方,做如下总结。

父子组件通信及类型定义

类似 Vue 的自定义事件,Svelte 的父子通信也通过自定义事件 CustomEvent 实现。

父组件-> 子组件,如下在 App 组件中,可以通过 filterText={filterText}传递,当然如果 prop 的名称和变量名一样,可以简写为 {filterText},如下:

// App.svelte 
<TodoListHeader 
    {filterText}
></TodoListHeader>

子组件 -> 父组件,如下在 TodoListHeader 组件中,通过 dispatch 方法向父组件抛出一个自定义事件,然后父组件用 on:xx 接受这个自定义事件:

// 定义自定义事件的类型,方便自定义事件携带数据的接受使用
interface IFilterChangeDetail {
    readonly filterChange: {
        value: string;
    }
}
// TodoListHeader.svelte  子组件中
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher<IFilterChangeDetail>();

export let filterText: string;

function handleChange(e) {
        dispatch('filterChange', {
                value: filterText
        });
}
// App.svelte 父组件中
function handleFilteTextChange(event: CustomEvent<IFilterChangeDetail>) {
    filterText = event.detail.value;
}
  
<TodoListHeader 
    {filterText}
    on:filterChange={handleFilteTextChange}
></TodoListHeader>

计算属性

类似 Vue 中的 compute,Svelte 中也有计算属性,官方称之为反应性,有如下几种写法:

  1. 行内
<div class:done="{item.status === CompleteStatus.Done}">
</div>  

如上,我们可以使用 item.status === CompleteStatus.Done 表达式的结果作为渲染的条件。

但是这种方式适合比较简单的逻辑判断。

  1. 计算函数
<script lang="ts">
function getStatus(item: ITodoItem){
    return item.status === CompleteStatus.Done;
}
</script>
<input 
    checked={getStatus(item)}
>

如上,我们可以在 script 中定义一个函数,然后就可以在 DOM 中使用。

这种方式就比较适合在逻辑比较复杂的场景下,做条件渲染,但是需要注意的是计算函数应该尽可能保证纯函数形式,需要的状态数据通过参数传入,里边不能直接依赖状态数据,否则会出现错误。

  1. $: 声明反应性

Svelte 也提供了可以直接依赖状态的写法,但是需要用到 $: 这个特殊写法:

$: filteredTodoList = todoList.filter(i => {
      return i.title.includes(filterText);
});

<TodoListBody
    todoList={filteredTodoList}
></TodoListBody>

虽然我们的 filteredTodoList 依赖 todoList 状态数据,但是用 $: 声明了其具有反应性,所以可以在 todoList 变化时触发 filteredTodoList 也会发生变化。

此外 $: 还可以声明块、条件判断的反应性,摘自官网 demo:

<script>
	let count = 0;

	$: if (count >= 10) {
		alert(`count is dangerously high!`);
		count = 9;
	}

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

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

class 动态样式设置

  1. class:xx 形式设置:
<div class="content" class:done="{item.status === CompleteStatus.Done}">
</div>

而且注意,可以这样这样设置多个 class:xx,后边的不会覆盖前面的 class,而是叠加效果。 如上,如果 item.status === CompleteStatus.Donetrue,那么最终 div 的 class 是 content done

  1. 也可以采用和 React 一样的计算属性方式 d
<script>
    import classnames from 'classnames';
    
    const getClassString = (item: ITodoItem) => classnames("content", {
        done: item.status === CompleteStatus.Done
    })
</script>
<div class="getClassString(item)">
</div>

待探究地方

  1. Svelte bind={this} 可以实现类似 React、vue 中 ref 功能。
  2. Svelte store 有点类似 Recoil 这样的原子化状态管理方式,它允许创建一个可读、可写然后可以在任何组件订阅使用的状态原子,如下摘自官网
import { writable } from 'svelte/store';

const count = writable(0, () => {
	console.log('got a subscriber');
	return () => console.log('no more subscribers');
});

count.set(1); // does nothing

const unsubscribe = count.subscribe(value => {
	console.log(value);
}); // logs 'got a subscriber', then '1'

unsubscribe(); // logs 'no more subscribers'

总结

组件设计总结

MVC 的模式下,状态的位置需要考虑使用该状态的组件,数据自上而下流动的特点决定了 TodoListfilterText 只能放在 App 下,而不能放在 App 的子组件 TodoListHeader 或者 TodoListBody 下。

然而另外一方面,我们也应该尽可能将状态放在比较基础的组件中,这样可以提高组件的封装性,尽管有时候为了提高封装性,我们甚至要再存储一份状态,比如 TodoListHeader 中我们又创建了一个状态 filterText,而这又会引入一个状态同步的问题,例如:在其他组件比如 App 组件中有一个清空 filterText 的按钮,那么我们就需要同步更新 TodoListHeader 下的状态。

Svelte 的开发体验

上手难度。如果对 React、Vue 比较熟悉的话,使用 Svelte 会比较容易上手,只不过需要注意其中 class 设置、计算属性相关的点,然后一些基本的程序就可以开发了。

Svelte 编译时。Svelte 作为编译时框架,编译完就可以直出 js 代码,所以实际上用 Svelte 写的组件是能框架无关的,看网络上也有很多 Svelte 写跨框架组件的教程,后边可以了解了解。

生态。笔者开发这个 demo 用的 vite-cli 中带的 Svelte+ts 脚手架,随着去年到现在 Vite 热门起来,其相关生态也不断完善起来,比如官方也提供了 sapper 的脚手架,而且当前已经存在了一些 ui 组件库,如:Svelte Material UICarbon,但是例如 antd 已经提到没有精力再去开发这套框架的组件库 ,所以 Svelte 的生态还是远不及 React、Vue 的。

我觉得对于 Svelte 编译时的特点挺值得关注的,可以考虑用来帮助团队快速开发一套通用组件库,能够让不同框架下的业务端使用,例如 web-components-with-sveltewww.cnblogs.com/powertoolst… 都提到用 Svelte 开发跨框架的组件。


编写匆忙,如有疏漏欢迎指正。