前言
业务代码编写,尤其是开发一些业务组件时,有一定的规律可循,所以本文将将利用一个 TodoList
的 demo
介绍笔者总结的组件开发的业务流程。
此外,近来看到很多框架类介绍文章都有提到 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,划分为如下几部分:
- header:TodoList 的标题和搜索表单
- body:TodoList 的列表,以及检索后的内容
- item:每个 Todo 项
因此,本文主要需要实现外层的 App
容器组件及包含的 TodoListHeader
、TodoListBody
、TodoListItem
一共四个组件。
当然如果每个 Todo 项的结构不是太复杂,可以和
TodoListBody
放在一起实现。本文为方便起见,只实现了TodoListHeader
和TodoListBody
两个组件。
确认好组件结构后,可以初步考虑状态数据的存放位置,虽然不同业务千差万别,但是对于 MVC 思想的框架来讲,万变不离其宗,只需要牢记如下两个核心原则:
- 组件数据都是自上而下传递。
- 状态在哪个组件,操作状态的方法就在哪个组件。
对于本文的 TodoList,直观来看,TodoListHeader 需要用 filterText
来描述检索,TodoListBody 需要用 todoList
数组来描述列表,那么我们简单地按照这样分配就可以了吗?
显然没有那么简单,TodoListHeader 中输入框键盘 enter 事件触发时,需要添加一个 ITodoItem
的条目,也就是需要操作 todoList
的状态数据,根据第 2 条准则,那么这个 todoList
的状态数据应该放在外层的 App
下,而且其相关的操作方法也要放在 App
下。
另一方面,为了实现检索效果,TodoListBody
的渲染结果同样需要 filterText
的状态,按照第 1 条准则,由于组件数据都是自上而下传递的,那么filterText
也需要放在 App
下,否则 TodoListBody
将会访问不到。
业务代码编写
搞清楚上述内容,就可以着手业务代码的编写,因为此时你已经对整个组件及组件的状态规划料了熟于心,所以组件代码开发起来就不困难了。
在 App 组件下,我们需要管理上述的检索项 filterText
、 TodoList
以及操作它们的方法,所以可以编写如下代码:
<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>
自此,我们便实现了整个应用,最终效果如图:
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 中也有计算属性,官方称之为反应性,有如下几种写法:
- 行内
<div class:done="{item.status === CompleteStatus.Done}">
</div>
如上,我们可以使用 item.status === CompleteStatus.Done
表达式的结果作为渲染的条件。
但是这种方式适合比较简单的逻辑判断。
- 计算函数
<script lang="ts">
function getStatus(item: ITodoItem){
return item.status === CompleteStatus.Done;
}
</script>
<input
checked={getStatus(item)}
>
如上,我们可以在 script
中定义一个函数,然后就可以在 DOM 中使用。
这种方式就比较适合在逻辑比较复杂的场景下,做条件渲染,但是需要注意的是计算函数应该尽可能保证纯函数形式,需要的状态数据通过参数传入,里边不能直接依赖状态数据,否则会出现错误。
$:
声明反应性
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 动态样式设置
- class:xx 形式设置:
<div class="content" class:done="{item.status === CompleteStatus.Done}">
</div>
而且注意,可以这样这样设置多个 class:xx,后边的不会覆盖前面的 class,而是叠加效果。
如上,如果 item.status === CompleteStatus.Done
为 true
,那么最终 div
的 class 是 content done
。
- 也可以采用和
React
一样的计算属性方式 d
<script>
import classnames from 'classnames';
const getClassString = (item: ITodoItem) => classnames("content", {
done: item.status === CompleteStatus.Done
})
</script>
<div class="getClassString(item)">
</div>
待探究地方
- Svelte bind={this} 可以实现类似 React、vue 中 ref 功能。
- 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
的模式下,状态的位置需要考虑使用该状态的组件,数据自上而下流动的特点决定了 TodoList
、filterText
只能放在 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 UI、Carbon,但是例如 antd 已经提到没有精力再去开发这套框架的组件库 ,所以 Svelte 的生态还是远不及 React、Vue 的。
我觉得对于 Svelte 编译时
的特点挺值得关注的,可以考虑用来帮助团队快速开发一套通用组件库,能够让不同框架下的业务端使用,例如 web-components-with-svelte、www.cnblogs.com/powertoolst… 都提到用 Svelte 开发跨框架的组件。
编写匆忙,如有疏漏欢迎指正。