一、前言
最近起了一个用于开发浏览器插件的代码模板(基于 SolidJS + Vite),需要一个立即上手实践的例子,我首先第一想到的是 TodoList 作为 demo,并且最近个人身上的事情实在太多,有点分不清有什么事做,选择此 demo 可谓两全了我的需求。
迫不及待地开始编写这个 TodoList 浏览器插件,直到开发完 popup 页面之后才想起来最好写个文章记录一下这个过程。这个浏览器插件现在的样子如下。
我的目标不是只是实现一个 demo 级别的 TodoList,而是从简单做起,将这个 TodoList 不断更新,实现更多实用的功能,更精美(不会 UI 设计),并且记录好这个过程。
二、需求分析
首先,需求分析并非一锤定音,在之后仍会保持更新。
TodoList 最基本的功能有如下:
- 添加新 Todo
- 修改 Todo 的名称
- 标记已完成的 Todo
- 一键清除所有标记完成的 Todo
- 本地持久化 TodoList
此外,为了分清当前必须完成的任务,需要加上功能:
- 标记 Todo 为特殊 Todo
- 一览无余当前的特殊 Todo
我们有可能会因为把项目放错地方,打算删掉,所以加上功能:
- 长按某个按钮删除项目(长按是为了防止误触)
三、开发过程
1. 环境搭建
建议使用作者整的一个 demo 级别的 cli 来创建模板。选择模板 Chrome Extension + Vite + Solid + TS
创建,然后根据提示进行。
在 package.json
中将 name
字段改成自己喜欢的名字,比如 TodoList
。
该模板的使用方法为,运行pnpm dev
,然后到浏览器扩展管理中,选择这个目录下的 dist 目录,并加载,如果有这样的画面,说明环境搭建成功了。
至此,环境已经搭建完毕。
2. TodoList 的基本功能
我们首先要考虑一个问题,就是 TodoList 是很有可能复用的(比如以后开发新标签页的时候会用上),因此我们希望 TodoList 是在顶层上实现的,在 Vue / React 上来说就是 TodoList 要存储在 Pinia / Redux 等全局状态管理库上。
SolidJS 的设计无需依赖第三方库,实现全局状态管理是很容易的,我们可以先开始实现这个 TodoList 的全局状态管理。
创建文件src/store/todolist.ts
,首先设计基本的数据结构。
// src/store/todolist.ts
export type ITodoItem = {
title: string;
done: boolean;
children?: ITodoItem[];
};
function newTodo(title: string): ITodoItem {
return {
title,
done: false,
};
}
const [todoList, setTodoList] = createSignal<ITodoItem[]>([]);
然后实现上述的所有基本功能。
本地持久化:如果 todoList 更新一次就持久化一次,popup 关闭后触发的事件尚未找出。
通过 createEffect
这个 SolidJS 的 API 来监听依赖项并触发 effect。todoList 的初始化值也要从本地中获取。
// 形似 React useState hook 可以写外面,结合了 React 和 Vue 的好处
const [todoList, setTodoList] = createSignal<ITodoItem[]>(
JSON.parse(localStorage.getItem('todo-list') || '[]')
);
export { todoList };
createEffect(() => {
localStorage.setItem('todo-list', JSON.stringify(todoList()));
});
添加功能
...
export function addTodo(todo: ITodoItem, title: string) {
(todo.children || (todo.children = [])).push(newTodo(title));
setTodoList([...todoList()]);
}
标记完成功能
export function toggleDone(todo: ITodoItem) {
todo.done = !todo.done;
setTodoList([...todoList()]);
}
修改名称功能
export function resetTodoTitle(item: ITodoItem, title: string) {
item.title = title;
setTodoList([...todoList()]);
}
清除已完成功能
export function clearDoneTodo() {
setTodoList(dfs(todoList()));
function dfs(todoList: ITodoItem[]) {
const res = todoList.filter((t) => !t.done);
res.forEach((t) => t.children && (t.children = dfs(t.children)));
return res;
}
}
这里基本所有方法都加上了 setTodoList([...todoList()])
,这是因为要创建新的数组对象来触发更新,可以类比到 React 触发更新的方式。这种方法的弊端就是触发更新后会重新渲染整个 TodoList,比较耗费性能,但 TodoList 的实际应用场景上毋需考虑性能问题,基数不会太大。
功能写完了,我们需要展现出来,那我们就先简单设计组件切个图。
TodoList 是一个用于添加 Todo 的搜索框、用于清除已完成 Todo 的按钮、以及树型组件 TodoListItem 组成。
先考虑子项如何设计,也就是TodoListItem。希望能看懂以下的设计,主要的难点只有如何递归渲染。
同时,为了能够实现展开与收起子项的功能,我们还需要给ITodoItem
加上一个属性show: boolean
,以及用来更新展开的方法(点击展开/收起)。
// src/store/todolist.ts
export type ITodoItem = {
title: string;
done: boolean;
show: boolean;
children?: ITodoItem[];
};
function newTodo(title: string): ITodoItem {
return {
title,
done: false,
show: true
};
}
// src/components/todolist.tsx
import style from './todolist.module.css';
const [todoTitle, setTodoTitle] = createSignal('');
export function TodoList() {
return (
<div class={'font-mono flex flex-col items-center ' + style}>
<h1 class='text-4xl font-serif'>TodoList</h1>
<div class='w-full flex items-center gap-3'>
<input
class='flex-grow h-full'
onChange={(e) => setTodoTitle(e.currentTarget.value)}
value={todoTitle()}
onKeyUp={(e) => {
e.key === 'Enter' && toAddTodo(undefined);
}}
type='text'
/>
<button class='flex-none' onClick={toClearTodo}>
清除
</button>
</div>
<div class='w-full max-h-[20rem] overflow-auto mt-2'>
{todoList().map((c, i) => (
<TodoListItem item={c} prefix='' index={i} />
))}
</div>
</div>
);
}
const colors = ['fed', 'cba', '987', '654'];
function TodoListItem(props: {
item: ITodoItem;
prefix: string;
index: number;
}) {
const { item, prefix, index } = props;
const { children } = item;
const id = `todo-${prefix}${index}`;
const color = `#${colors[(prefix.length >> 1) % colors.length]}`;
return (
<>
<div
class='w-full h-5 flex items-center gap-3 pl-2 pr-3 box-border'
style={{
'background-color': color
}}
>
<input
class='hidden'
id={id}
onChange={() => toToggleDone(item)}
checked={item.done}
type='checkbox'
/>
<label for={id} class='w-[12rem] overflow-hidden whitespace-nowrap'>
{prefix + index}-{item.title}
</label>
<a onClick={() => toAddTodo(item)} href='javascript:'>
+
</a>
<a onClick={() => toResetTodoTitle(item)} href='javascript:'>
#
</a>
{children && children.length && (
<a onClick={() => toToggleShow(item)} href='javascript:'>
{item.show ? '~' : '@'}
</a>
)}
</div>
{item.show &&
children &&
children.map((c, i) => (
<TodoListItem prefix={`${prefix}${index}.`} index={i} item={c} />
))}
</>
);
}
function toAddTodo(todo?: ITodoItem) {
if (!todoTitle()) return;
addTodo(todo, todoTitle());
setTodoTitle('');
}
function toClearTodo() {
clearDoneTodo();
}
function toToggleDone(item: ITodoItem) {
toggleDone(item);
}
function toResetTodoTitle(item: ITodoItem) {
if (!todoTitle()) {
return;
}
resetTodoTitle(item, todoTitle());
setTodoTitle('');
}
function toToggleShow(item: ITodoItem) {
toggleShow(item);
}
/** src/components/todolist.module.css */
a {
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
input:checked+label {
text-decoration: line-through;
}
完成效果如图。(输入框随便乱打 + Enter键/随便点某一项的加号)
测试各项功能是否已经成功:
- 在输入框中输入好后按 Enter 键 / 点击某一项的
+
。 - 点击一项,标记完成/取消标记。
- 在输入框中输入好后点击某一项的
#
。 - 点击
@
展开;~
收起。
至此,我们好像完成了基本的需求的开发,但是有一个点非常需要注意:如若某一项 todo 的状态变了,那么他的状态会影响他的子项,以及他所在的宗亲链上的所有前世代,牵一发而动全身。
那么,如何实现这个需求。其实很简单,相当于做树相关的语法题级别的算法题,基本的思路就是:
- 将所变化后的状态下沉感染给所有后代:标记完成就是子任务也完成了,否则就是未完成。
- 将所变化后的状态上浮判定给前代直到顶端:前代的所有子代是否完成?是否有子代没完成?
- 更新视图。
在这个算法中要维护每一个 todo 的前代是谁,但不能直接在 ITodoItem 添加这个属性,因为这样会导致 JSON.stringify()
出现循环引用而出错。我们需要使用一个 Map<ITodoItem, ITodoItem>
来维护,更新todoList
的时候清空并重新分析整个树的父子关系。
具体实现如下
// src/store/todolist.ts
const parentMap = new Map<ITodoItem, ITodoItem>();
createEffect(() => {
parentMap.clear();
todoList().forEach((c) => dfs(c));
function dfs(todo: ITodoItem) {
const { children } = todo;
children &&
children.forEach((c) => {
parentMap.set(c, todo);
dfs(c);
});
}
});
export function toggleDone(todo: ITodoItem) {
todo.done = !todo.done;
down(todo);
bubble(todo);
setTodoList([...todoList()]);
}
function down(todo: ITodoItem) {
const { children, done } = todo;
children &&
children.forEach((c) => {
c.done = done;
down(c);
});
}
function bubble(todo: ITodoItem) {
if (!parentMap.has(todo)) {
return;
}
const p = parentMap.get(todo)!;
if (p.done !== todo.done) {
if (todo.done) {
p.done = !p.children!.some((c) => !c.done);
} else {
p.done = false;
}
bubble(p);
}
}
现在,可以再次打开你的 TodoList,尝试验证是否已经成功了。
3. TodoList 特殊标记
当任务过多时间太少,分析事情是否必要是否紧急是非常重要的,因为可以帮助我们想清楚现阶段优先完成的任务。我们这个部分就开始编写这样的功能。
首先实现一个标记为特殊任务的功能。(顺便把项目上的按钮的样式微调一下)
基本思路:给ITodoItem
加上一个属性star
代表是否为特殊任务,给定一个按钮用来标记,普通时为*
,特殊时为$
。对于其他项目没有其他影响(包括子代和前代)。
// src/store/todolist.ts
export type ITodoItem = {
title: string;
done: boolean;
show: boolean;
star: boolean; // ++
children?: ITodoItem[];
};
function newTodo(title: string): ITodoItem {
return {
title,
done: false,
show: true,
star: false, // ++
};
}
export function toggleStar(item: ITodoItem) {
item.star = !item.star;
setTodoList([...todoList()]);
}
// src/components/todolist.tsx
function TodoListItem(...) {
return (
<>
<div
style={{
'background-color': color,
color: item.star ? '#f00' : '#222',
}}
>
<a onClick={() => toToggleStar(item)} href='javascript:'>
{item.star ? '$' : '*'}
</a>
{(children && children.length && (
<a onClick={() => toToggleShow(item)} href='javascript:'>
{item.show ? '~' : '@'}
</a>
)) || <a href='javascript:'>-</a>}
</div>
...
</>
);
}
function toToggleStar(item: ITodoItem) {
toggleStar(item);
}
/** src/components/todolist.module.css */
a {
text-decoration: none;
color: inherit;
}
编写完成,查看效果。
我们再来实现:点击查看当前的特殊任务。
展现逻辑:只显示被标记的 Todo。
我们首先得加个这种逻辑的入口(切图加个按钮)。
(后面代码还是直接截图 diff 吧,直接复制太累了x)。
src/store/todolist.ts
src/components/todolist.tsx
src/components/todolist.css
然后,当只显示特殊 Todo以及当前 Todo 是特殊 Todo时,才显示当前这个 Todo。
照这个逻辑,添加代码。(顺便还是要改一下样式,就是每个项目的颜色)
src/store/todolist.ts
src/components/todolist.tsx
新加一个:在渲染子项目的时候修改item.show
条件为:(item.show || onlyShowStar())
查看效果。
很完美。
删除功能的实现很简单,如下。
src/components/todolist.tsx
src/store/todolist.ts