【记录过程】开发一个 TodoList 浏览器插件

lxf2023-05-18 01:14:03

一、前言

最近起了一个用于开发浏览器插件的代码模板(基于 SolidJS + Vite),需要一个立即上手实践的例子,我首先第一想到的是 TodoList 作为 demo,并且最近个人身上的事情实在太多,有点分不清有什么事做,选择此 demo 可谓两全了我的需求。

迫不及待地开始编写这个 TodoList 浏览器插件,直到开发完 popup 页面之后才想起来最好写个文章记录一下这个过程。这个浏览器插件现在的样子如下。

【记录过程】开发一个 TodoList 浏览器插件

我的目标不是只是实现一个 demo 级别的 TodoList,而是从简单做起,将这个 TodoList 不断更新,实现更多实用的功能,更精美(不会 UI 设计),并且记录好这个过程。

二、需求分析

首先,需求分析并非一锤定音,在之后仍会保持更新。

TodoList 最基本的功能有如下:

  • 添加新 Todo
  • 修改 Todo 的名称
  • 标记已完成的 Todo
  • 一键清除所有标记完成的 Todo
  • 本地持久化 TodoList

此外,为了分清当前必须完成的任务,需要加上功能:

  • 标记 Todo 为特殊 Todo
  • 一览无余当前的特殊 Todo

我们有可能会因为把项目放错地方,打算删掉,所以加上功能:

  • 长按某个按钮删除项目(长按是为了防止误触)

三、开发过程

1. 环境搭建

建议使用作者整的一个 demo 级别的 cli 来创建模板。选择模板 Chrome Extension + Vite + Solid + TS 创建,然后根据提示进行。 【记录过程】开发一个 TodoList 浏览器插件

package.json 中将 name 字段改成自己喜欢的名字,比如 TodoList

该模板的使用方法为,运行pnpm dev,然后到浏览器扩展管理中,选择这个目录下的 dist 目录,并加载,如果有这样的画面,说明环境搭建成功了。 【记录过程】开发一个 TodoList 浏览器插件 【记录过程】开发一个 TodoList 浏览器插件

至此,环境已经搭建完毕。

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键/随便点某一项的加号) 【记录过程】开发一个 TodoList 浏览器插件

测试各项功能是否已经成功:

  • 在输入框中输入好后按 Enter 键 / 点击某一项的+
  • 点击一项,标记完成/取消标记。
  • 在输入框中输入好后点击某一项的#
  • 点击@展开;~收起。

至此,我们好像完成了基本的需求的开发,但是有一个点非常需要注意:如若某一项 todo 的状态变了,那么他的状态会影响他的子项,以及他所在的宗亲链上的所有前世代,牵一发而动全身。

那么,如何实现这个需求。其实很简单,相当于做树相关的语法题级别的算法题,基本的思路就是:

  1. 将所变化后的状态下沉感染给所有后代:标记完成就是子任务也完成了,否则就是未完成。
  2. 将所变化后的状态上浮判定给前代直到顶端:前代的所有子代是否完成?是否有子代没完成?
  3. 更新视图。

在这个算法中要维护每一个 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,尝试验证是否已经成功了。 【记录过程】开发一个 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;
}

编写完成,查看效果。

【记录过程】开发一个 TodoList 浏览器插件

我们再来实现:点击查看当前的特殊任务。

展现逻辑:只显示被标记的 Todo。

我们首先得加个这种逻辑的入口(切图加个按钮)。

(后面代码还是直接截图 diff 吧,直接复制太累了x)。

src/store/todolist.ts

【记录过程】开发一个 TodoList 浏览器插件

src/components/todolist.tsx

【记录过程】开发一个 TodoList 浏览器插件

src/components/todolist.css

【记录过程】开发一个 TodoList 浏览器插件

然后,当只显示特殊 Todo以及当前 Todo 是特殊 Todo时,才显示当前这个 Todo

照这个逻辑,添加代码。(顺便还是要改一下样式,就是每个项目的颜色)

src/store/todolist.ts

【记录过程】开发一个 TodoList 浏览器插件

src/components/todolist.tsx

【记录过程】开发一个 TodoList 浏览器插件

新加一个:在渲染子项目的时候修改item.show条件为:(item.show || onlyShowStar())

【记录过程】开发一个 TodoList 浏览器插件

【记录过程】开发一个 TodoList 浏览器插件

查看效果。

【记录过程】开发一个 TodoList 浏览器插件

【记录过程】开发一个 TodoList 浏览器插件

很完美。

删除功能的实现很简单,如下。

src/components/todolist.tsx

【记录过程】开发一个 TodoList 浏览器插件

【记录过程】开发一个 TodoList 浏览器插件

src/store/todolist.ts

【记录过程】开发一个 TodoList 浏览器插件

本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!