TodoMVC × Lit:用 Web Components 构建 TodoMVC 的完整实现与事件驱动状态管理
【免费下载链接】todomvcHelping you select a JavaScript framework - Todo apps for React.js, Angular, Vue and many more项目地址: https://gitcode.com/gh_mirrors/to/todomvc
本篇以仓库中的 Lit 示例为主体,拆解 TodoMVC 官方 Lit 实现的完整技术脉络:它如何用自定义元素与 Shadow DOM 重构了其他实现中常见的组件树,如何在不引入任何状态管理库的前提下,用"一个继承EventTarget的类 + 自定义 DOM 事件"完成集中式变更、订阅式更新,以及配套的 wireit/Rollup 构建与本地运行流程。读完后你将能够独立复刻这套"事件驱动 + 属性装饰器"的 Web Components 状态管理模式,并理解每个 Todo 功能背后的具体调用链。
一、为什么 Lit 实现的 DOM 与其他实现不同
README 对 Lit 的定位是"一个用于构建快速、轻量 Web Components 的简单库"。与其他框架版 TodoMVC 最大的结构差异在于:它基于 Web Components 技术创建可互操作、可封装的新 HTML 元素。这带来两个直接后果:
- 文档对象模型(DOM)形态不同。页面上出现的是
<todo-app>、<todo-list>、<todo-form>、<todo-item>、<todo-footer>这类自定义元素,各组件的内部 DOM 被隔离在自己的 Shadow Root 中,互不干扰。 - CSS 采用 Shadow DOM 的样式作用域(style scoping)。由于 Shadow DOM 天然隔离样式,README 特别指出 CSS 被拆分成了独立的模块,每个组件只包含自己所需的部分。这一点可以在源码中直接验证:src/lib/todo.css.ts 提供各组件共享的样式基座(导出为
todoStyles),而 todo-app.ts、todo-form.ts 等每个组件再把自己的专属css\`片段叠加进去,例如 [todo-app.ts](https://link.gitcode.com/i/3253abd719d01a014f5f28f53c250d13#L17-L51) 中的static styles = [todoStyles, css`...`],组件级样式(:host布局、标题、.hidden` 等)与全局基座样式分离维护。
二、状态管理:继承 EventTarget 的 Todos 类
这是本实现最有特色的部分。README 明确说明:
- 本实现不使用任何状态管理库,而是把 Todo 数据建模为一个继承
EventTarget的类; - 该类实例被传递给各组件,组件通过监听其
'change'事件感知数据变化; - README 同时指出,Lit 完全可以搭配 Redux、MobX、各种 signals 库等状态管理方案,"使用一个普通类只是最简单的选项之一,是最接近原生(vanilla)的做法";
- 所有变更(mutations)由 app 组件集中发起,其他组件只负责通过事件通知 app"有变更请求"。
对应源码是 src/lib/todos.ts,核心结构如下:
export interface Todo { id: string; text: string; completed: boolean; } export class Todos extends EventTarget { #todos: Array<Todo> = []; #filter: TodoFilter = this.#filterFromUrl(); // 只读视图:all / active / completed / allCompleted // 变更入口:add / delete / update / toggle / toggleAll / clearCompleted // 每次变更后统一触发: #notifyChange() { this.dispatchEvent(new Event("change")); } }结合源码可以补充几个 README 未展开的实现细节:
- 数据与筛选全部私有化:
#todos与#filter是私有字段,外部只能通过只读 getter(all、active、completed、allCompleted)访问;filter的 setter 在写入后同样触发change事件(见 todos.ts)。 - 筛选状态持久化在 URL hash 中:
#filterFromUrl()用正则/#\/(.*)/解析window.location.hash,非法值回退为"all";Todos实例通过connect()/disconnect()注册与移除hashchange监听,因此切换"Active / Completed"过滤器(链接形如#/active)刷新页面后依然生效。 - ID 生成:
add()中的nanoid()是从 nanoID 项目借鉴的 21 位短 ID 生成器(todos.ts),使用Math.random()非安全版本,适合前端 UI 场景。 - 防御式删除:
delete()中findIndex未命中时返回-1,代码利用index >>> 0将负数翻转成极大数,使splice变为无操作(no-op)——这是一个避免负索引误删末位元素的位运算技巧(todos.ts)。
三、集中式变更:todo-app 组件与事件协议
"变更集中在 app 组件"这一架构,落在 src/lib/todo-app.ts 上。它维护唯一的Todos实例,并在构造函数中把五类自定义事件绑定到自己的私有处理器:
@customElement("todo-app") export class TodoApp extends LitElement { @updateOnEvent("change") @state() readonly todoList = new Todos(); constructor() { super(); this.addEventListener(AddTodoEvent.eventName, this.#onAddTodo); this.addEventListener(DeleteTodoEvent.eventName, this.#onDeleteTodo); this.addEventListener(EditTodoEvent.eventName, this.#onEditTodo); this.addEventListener(ToggleAllTodoEvent.eventName, this.#onToggleAll); this.addEventListener(ClearCompletedEvent.eventName, this.#onClearCompleted); } }事件协议定义在 src/lib/events.ts。五个事件类均继承Event,且构造参数统一为{ bubbles: true, composed: true }:
| 事件名 | 事件类 | 负载 | 触发组件 |
|---|---|---|---|
todo-add | AddTodoEvent | text | todo-form(输入新 Todo) |
todo-delete | DeleteTodoEvent | id | todo-item(点删除按钮/编辑后留空) |
todo-edit | EditTodoEvent | edit: TodoEdit | todo-item(勾选完成/提交编辑) |
todo-toggle-all | ToggleAllTodoEvent | 无 | todo-list(全选框) |
clear-completed | ClearCompletedEvent | 无 | todo-footer |
这里有一个 Web Components 的关键细节:composed: true使事件能够穿透 Shadow DOM 边界。子组件渲染在自己的 Shadow Root 内,事件若不带composed,冒泡到宿主元素就会停止、无法到达<todo-app>;声明bubbles: true, composed: true后,事件才能一路冒泡到 app 组件被集中处理。文件末尾的HTMLElementEventMap全局类型扩展(events.ts)则让 TypeScript 对todo-add等事件名提供类型提示。
updateOnEvent:把 change 事件接进 Lit 更新机制
各展示组件如何"订阅"change?答案是一个自研属性装饰器 src/lib/utils.ts:
export const updateOnEvent = (eventName: string) => (target, propertyKey) => { const descriptor = Object.getOwnPropertyDescriptor(target, propertyKey)!; const { get, set } = descriptor; const newDescriptor = { ...descriptor, set(this, v: EventTarget) { const listener = this.__updateOnEventListener ??= () => this.requestUpdate(); const oldValue = get!.call(this); oldValue?.removeEventListener?.(eventName, listener); v?.addEventListener?.(eventName, listener); return set!.call(this, v); }, }; Object.defineProperty(target, propertyKey, newDescriptor); };它劫持属性的 setter:每当todoList属性被赋值为新的EventTarget实例时,自动对旧实例解绑、对新实例绑定change监听,监听器只调用 Lit 的requestUpdate()。这样@updateOnEvent("change") @property({ attribute: false }) todoList?: Todos;(出现在 todo-form.ts、todo-list.ts、todo-footer.ts)就等价于"该属性一变/数据一变,组件自动重渲染"。源码注释也坦诚:若要在其他项目里复用,应使用类型系统强制属性值必须是EventTarget。
另外,TodoApp在connectedCallback中调用this.todoList.connect()、在disconnectedCallback中调用disconnect()(todo-app.ts),把 hashchange 监听的生死与组件挂载周期绑定,避免内存泄漏。
四、各组件的数据流
组件间数据流可以概括为"属性向下、事件向上":app 把Todos实例通过属性(.todoList=${this.todoList})传给子组件(见 todo-app.ts 的render()),子组件交互后 dispatch 事件,app 收到后统一调用Todos的变更方法。
todo-form:渲染一个输入框,change或按 Enter 时若输入非空则dispatchEvent(new AddTodoEvent(value))并清空输入框(todo-form.ts)。它从不直接改动数据。todo-list:用 Lit 的repeat指令按todo.id作为 key 渲染todo-item列表,数据源是this.todoList.filtered()——即受当前 hash 过滤器影响的视图(todo-list.ts);左侧的"Mark all as complete"复选框变化时发出ToggleAllTodoEvent。todo-item:只接收todoId、text、completed三个普通属性,是纯粹的表现组件。勾选时发出EditTodoEvent({ id, completed: !completed });双击进入行内编辑(@state() isEditing为组件私有状态),Enter 通过blur()触发提交、Escape 取消(先把输入框值重置回原文,使随后的 blur 提交变成无操作)、提交时若文本为空则改发DeleteTodoEvent(todo-item.ts)。todo-footer:渲染剩余项计数、All/Active/Completed 三个过滤器链接(href="#/${filter}",与Todos的 hash 路由相呼应)以及"Clear completed"按钮(todo-footer.ts)。列表为空时直接return nothing不渲染。
五、构建与运行
README 给出的运行步骤非常直接,在 examples/lit 目录下执行:
npm cinpm run serve --watch- 浏览器访问
http://localhost:8000/查看 debug 构建,或http://localhost:8000/dist/查看优化构建
如果只想重新构建代码,直接运行npm run build即可;运行npm run serve --watch时构建会在需要时自动触发。
结合 package.json 可以进一步理解这套命令背后的任务图:
- 依赖:运行时仅依赖
lit(^3.3.2);开发侧为rollup、@web/dev-server(即wds命令)、typescript与任务编排器wireit。engines要求 Node>=20.19.0、npm>=10.0.0。 - wireit 任务依赖链(package.json):
serve依赖build,命令是wds,并以"service": true声明为长驻服务进程;dev是serve的轻量版——README 注释写明"与 serve 类似,只是不做较慢的 rollup 构建",它只依赖tsc后启动wds;build依赖五个子任务:tsc(产出index.js、lib/等编译结果)、rollup(产出dist/index.js)、copy-index-html、copy-base-js、copy-base-css(把 index.html 与todomvc-common包中的base.js/base.css拷入dist/,即优化构建页面所引用的公共资源)。
- Rollup 优化管线(rollup.config.mjs):依次使用
@rollup/plugin-typescript(outputToFilesystem: true)、@rollup/plugin-node-resolve(把裸模块说明符解析为相对路径)、rollup-plugin-html-literals(压缩 HTML 模板字符串)和@rollup/plugin-terser(ecma: 2022压缩 JS);入口为src/index.ts,以 ES 模块格式输出到dist/index.js。 - 开发服务器配置(web-dev-server.config.js):
nodeResolve指定exportConditions: ["development", "browser"],让wds在开发模式下解析到 Lit 的 development 导出条件。 - 入口:src/index.ts 仅一行
export * from "./lib/todo-app.js",即整个应用只需注册todo-app自定义元素,其余元素由其内部import链自动注册。
六、小结
examples/lit 示例展示了一种与框架版 TodoMVC 截然不同的技术路径:以 Web Components(自定义元素 + Shadow DOM)为封装单位,以"继承EventTarget的数据类 +change事件"为状态中枢,以"bubbles: true, composed: true的自定义事件"为组件间通信协议,再辅以updateOnEvent属性装饰器把事件订阅无缝接入 Lit 的requestUpdate()更新机制。它验证了 README 的判断——不依赖 Redux/MobX 等状态库,用最接近原生 Web API 的方式即可完成一个完整的 TodoMVC 应用,包括 hash 路由过滤器、行内编辑与集中式数据变更;而 wireit + Rollup + Web Dev Server 的工具链则同时支撑了根路径的调试构建与dist/下的优化构建。
【免费下载链接】todomvcHelping you select a JavaScript framework - Todo apps for React.js, Angular, Vue and many more项目地址: https://gitcode.com/gh_mirrors/to/todomvc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考