Riot.js TodoMVC 实战解析:用微型 UI 库与页面内编译器构建 Todo 应用
【免费下载链接】todomvcHelping you select a JavaScript framework - Todo apps for React.js, Angular, Vue and many more项目地址: https://gitcode.com/gh_mirrors/to/todomvc
Riot.js 是一款 React 风格的极简用户界面微库(micro-library),它反对当下流行的样板代码(boilerplate)与不必要的复杂度,主张用小巧而强大的 API 与简洁的语法完成界面开发。本仓库的 examples/riotjs 是 TodoMVC 官方应用集中的 Riot.js 实现,本文将以该示例为主线,深入讲解 Riot.js 标签(tag)的声明式写法、页面内编译(on-page compile)的加载流程、父子标签通信、路由与 localStorage 持久化,帮助你掌握用 Riot.js 2.x 快速搭建一个完整可运行的 Todo 应用的完整思路与具体写法。
示例定位:小而灵活的 Riot.js
Riot.js 的核心设计理念是"反对样板代码与不必要的复杂度"(详见 examples/riotjs/readme.md),团队认为一个客户端库应该拥有小而强大的 API 和简洁的语法。正因为 Riot 体积小、足够灵活,它提供了多种使用方式:
- 使用编译器(compiler)编译标签文件;
- 使用 Browserify 等工具打包 JavaScript;
- 直接在页面引入微型 Riot 编译器,把标签文件"飞行编译"(compile tags on the fly)。
本示例采用的是第三种方式:在页面上使用 Riot 编译器动态编译标签文件js/todo.html,无需任何构建步骤,打开浏览器即可运行,这恰好是 Riot "去复杂度"理念的直观体现。
需要说明的是,本示例严格遵循 TodoMVC 的 编码风格规范(Tab 缩进、单引号、分号、严格模式等),而 Riot 官方社区另有自己推荐的编码风格(见 Riot 仓库的 CONTRIBUTING),两者存在差异,读者在阅读源码时可留意这一点。
目录与依赖一览
examples/riotjs/ ├── index.html # 页面入口,按顺序加载依赖与标签 ├── package.json # npm 依赖声明 ├── readme.md # 官方示例说明文档 └── js/ ├── todo.html # Riot 标签定义(<todo> 与 <todoitem>),含模板与逻辑 ├── app.js # 应用启动:挂载 <todo> 标签并注入初始数据 └── store.js # localStorage 封装,负责数据读写依赖声明见 examples/riotjs/package.json:
{ "private": true, "dependencies": { "todomvc-app-css": "^2.0.0", "todomvc-common": "^1.0.1", "riot": "^2.2.4" } }其中riot@^2.2.4是本例使用的 Riot.js 2.x 版本,todomvc-app-css与todomvc-common提供 TodoMVC 标准外观与公共脚本(base.css、base.js)。安装依赖后在 examples/riotjs 目录下执行npm install即可获得本地运行所需文件。
页面加载与"飞行编译"流程
先看入口 examples/riotjs/index.html:
<!doctype html> <html lang="en">/*global riot, todoStorage */ (function () { 'use strict'; riot.mount('todo', { data: todoStorage.fetch() }); }());riot.mount('todo', opts)会把页面中的<todo>节点替换为编译后的标签实例,并将第二个参数{ data: todoStorage.fetch() }作为opts传入——todoStorage.fetch()从 localStorage 读出的历史数据就这样注入到了标签内部(在todo.html中以opts.data接收)。
核心标签<todo>:数据、筛选与事件
标签定义集中在 examples/riotjs/js/todo.html,一个文件里声明了两个标签:外层<todo>与内层<todoitem>。Riot 标签的特点是"模板 + 逻辑"写在同一份标签内:HTML 结构使用花括号{ }绑定表达式,<script>块内则是标签的方法与生命周期逻辑。
<todo>标签的模板部分:
<todo> <section class="todoapp"> <header class="header"> <h1>todos</h1> <input class="new-todo" autofocus autocomplete="off" placeholder="What needs to be done?" onkeyup={ addTodo }> </header> <section class="main" show={ todos.length }> <input id="toggle-all" class="toggle-all" type="checkbox" checked={ allDone } onclick={ toggleAll }> <label for="toggle-all">Mark all as complete</label> <ul class="todo-list"> <li riot-tag="todoitem" class="todo { completed: t.completed, editing: t.editing }" each={ t, i in filteredTodos() } todo={ t } parentview={ parent }></li> </ul> </section> <footer class="footer" show={ todos.length }> <span class="todo-count"> <strong>{ remaining }</strong> { remaining === 1 ? 'item' : 'items' } left </span> <ul class="filters"> <li><a class={ selected: activeFilter=='all' } href="#/all">All</a></li> <li><a class={ selected: activeFilter=='active' } href="#/active">Active</a></li> <li><a class={ selected: activeFilter=='completed' } href="#/completed">Completed</a></li> </ul> <button class="clear-completed" onclick={ removeCompleted } show={ todos.length > remaining }> Clear completed</button> </footer> </section> ...几个值得展开的 Riot 2.x 语法点:
show={ todos.length }:Riot 的条件显示指令。当列表为空时,show表达式为 0(falsy),main与footer区域整体隐藏;each={ t, i in filteredTodos() }:循环指令。遍历filteredTodos()的返回值,当前项绑定为t,索引为i;todos数组为空时each不渲染任何<li>;riot-tag="todoitem":在<li>上声明内嵌的子标签,并把当前 todo(todo={ t })与父标签实例(parentview={ parent })传给子标签——这是 Riot 2.x 中父子标签通信的关键写法;class={ completed: t.completed, editing: t.editing }:条件 class 绑定,根据completed/editing布尔值动态切换completed、editing两个 class,配合 TodoMVC 样式实现"勾选完成"与"双击编辑"的视觉状态;onkeyup={ addTodo }、onclick={ toggleAll }:Riot 的事件绑定,直接指向标签内部方法;{ remaining === 1 ? 'item' : 'items' }:模板内支持 JavaScript 表达式,实现单复数文案切换。
标签内的逻辑部分如下(省略了模板中重复的footer.info部分):
<script> 'use strict'; var ENTER_KEY = 13; var self = this; self.todos = opts.data || []; riot.route.exec(function(base, filter) { self.activeFilter = filter || 'all'; }); self.on('update', function() { self.remaining = self.todos.filter(function(t) { return !t.completed; }).length; self.allDone = self.remaining === 0; self.saveTodos(); }); saveTodos() { todoStorage.save(self.todos); }; filteredTodos() { if (self.activeFilter === 'active') { return self.todos.filter(function(t) { return !t.completed; }); } else if (self.activeFilter === 'completed') { return self.todos.filter(function(t) { return t.completed; }); } else { return self.todos; } }; addTodo(e) { if (e.which === ENTER_KEY) { var value = e.target.value && e.target.value.trim(); if (!value) { return; } self.todos.push({ title: value, completed: false }); e.target.value = ''; } }; removeTodo(todo) { self.todos.some(function (t) { if (todo === t) { self.todos.splice(self.todos.indexOf(t), 1); } }); }; toggleAll(e) { self.todos.forEach(function (t) { t.completed = e.target.checked; }); return true; }; removeCompleted() { self.todos = self.todos.filter(function(t) { return !t.completed; }); }; riot.route(function(base, filter) { self.activeFilter = filter; self.update(); }); </script>这段逻辑体现了 Riot 2.x 的几个核心机制:
数据模型:self.todos = opts.data || []把挂载时传入的历史数据存入标签实例;self即当前标签实例,模板表达式{ remaining }、{ activeFilter }等都会读取self上的同名属性。
更新钩子(update 事件):self.on('update', ...)注册了标签每次更新前的回调——Riot 在重新渲染模板前会自动触发update事件。这里统一计算派生状态remaining(未完成数)与allDone(是否全部完成),并调用saveTodos()把最新数据写回 localStorage,实现"每次界面变化自动持久化",无需手动同步。
路由(riot.route):riot.route是 Riot 内置的 hash 路由。文件里注册了两次回调:先通过riot.route.exec(function(base, filter) { ... })在初始化时读取当前 hash(如#/all),设置activeFilter;再通过riot.route(function(base, filter) { ... })监听 hash 变化,更新activeFilter并调用self.update()触发重渲染。模板中三个过滤器链接href="#/all"、#/active、#/completed就是路由的触发点,配合class={ selected: activeFilter=='all' }高亮当前筛选。
事件方法:addTodo在输入框按回车(ENTER_KEY即 13)时读取并 trim 输入,非空则 push 一条{ title, completed: false }记录并清空输入框;toggleAll根据全选框状态批量设置每条 todo 的completed;removeCompleted过滤掉已完成项;removeTodo通过splice从数组中移除指定项。这些方法在模板中通过onclick、onkeyup等事件属性直接绑定。
子标签<todoitem>:单项编辑与父子通信
列表项由子标签<todoitem>渲染,它演示了 Riot 父子标签的典型协作模式:
<todoitem> <div class="view"> <input class="toggle" type="checkbox" checked={ opts.todo.completed } onclick={ toggleTodo }> <label ondblclick={ editTodo }>{ opts.todo.title }</label> <button class="destroy" onclick={ removeTodo }></button> </div> <input name="todoeditbox" class="edit" type="text" onblur={ doneEdit } onkeyup={ editKeyUp }> <script> 'use strict'; var ENTER_KEY = 13; var ESC_KEY = 27; var self = this; opts.todo.editing = false; toggleTodo() { opts.todo.completed = !opts.todo.completed; opts.parentview.saveTodos(); return true; }; editTodo() { opts.todo.editing = true; self.todoeditbox.value = opts.todo.title; }; removeTodo() { opts.parentview.removeTodo(opts.todo); }; doneEdit() { if (!opts.todo.editing) { return; } opts.todo.editing = false; var enteredText = self.todoeditbox.value && self.todoeditbox.value.trim(); if (enteredText) { opts.todo.title = enteredText; opts.parentview.saveTodos(); } else { self.removeTodo(); } }; editKeyUp(e) { if (e.which === ENTER_KEY) { self.doneEdit(); } else if (e.which === ESC_KEY) { self.todoeditbox.value = opts.todo.title; self.doneEdit(); } }; self.on('update', function() { if (opts.todo.editing) { opts.parentview.update(); self.todoeditbox.focus(); } }); </script> </todoitem>关键机制拆解:
opts传递:父标签通过<li riot-tag="todoitem" ... todo={ t } parentview={ parent }>传入两个opts——opts.todo指向当前这条 todo 数据对象,opts.parentview指向父标签实例。由于opts.todo是对象引用,子标签对它的修改(如opts.todo.completed = !opts.todo.completed)会直接反映到父标签的todos数组中,这是本示例不依赖事件冒泡而实现数据同步的关键;- 勾选与删除:
toggleTodo翻转completed后调用opts.parentview.saveTodos()持久化;removeTodo直接调用父标签的removeTodo(opts.todo)方法完成移除; - 双击编辑:
ondblclick={ editTodo }进入编辑态(editing = true),并把当前标题填入输入框;输入框name="todoeditbox"使 Riot 自动把它挂到self.todoeditbox上; - 回车/失焦提交、Esc 取消:
editKeyUp中ENTER_KEY(13)提交编辑,ESC_KEY(27)恢复原标题后提交;doneEdit会先校验editing状态防止误触发,输入非空则更新title并保存,输入为空则删除该条 todo; - 自动聚焦:
self.on('update', ...)中,若当前处于编辑态,则让父标签同步更新并调用todoeditbox.focus(),保证双击后输入框立即获得焦点。
数据持久化:localStorage 封装
examples/riotjs/js/store.js 是全部持久化逻辑,只有 15 行:
(function (exports) { 'use strict'; var STORAGE_KEY = 'todos-riotjs'; exports.todoStorage = { fetch: function () { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); }, save: function (todos) { localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); } }; })(window);- 存储键为
todos-riotjs,通过exports参数把todoStorage挂到window全局(IIFE 写法,避免污染作用域,符合 TodoMVC 编码规范中的严格模式使用方式); fetch()读取并JSON.parse,无数据时返回[];save()将数组序列化后写回;- 与
todo.html的调用关系是:app.js挂载时用todoStorage.fetch()注入初始数据;标签每次update时由saveTodos()调用todoStorage.save(self.todos),从而实现"改一处、存一次、刷新不丢"。
如何本地运行与测试
本示例无需构建,安装依赖后即可直接访问。仓库根目录提供了统一的静态服务与测试链路:
- 在 examples/riotjs 下执行
npm install,安装riot、todomvc-app-css、todomvc-common; - 启动仓库根目录的静态服务器:
npm run server(对应根目录 package.json 中"server": "node tests/server.js",由 tests/server.js 基于 Express 提供express.static服务),然后浏览器打开http://localhost:8000/examples/riotjs/(端口依据根目录 package.json 中start-server-and-test server http://localhost:8000的约定为 8000); - 若要跑完整 TodoMVC 验收测试,可在仓库根目录执行
npm test(启动服务器 + Cypress 端到端测试)或npm run test:all(服务器 + 全量示例检查),Cypress 配置见 cypress.config.js。
由于本示例采用"页面内编译",本地调试无需 webpack、browserify 等构建链——修改js/todo.html后刷新浏览器即可生效,这正是 readme 强调的 Riot 灵活性在实际工程中的直接收益。
小结
通过 examples/riotjs 这一 TodoMVC 实现,可以看到 Riot.js 2.x 的核心能力全景:标签(tag)把模板与逻辑收敛于一个文件;each/show/class等指令完成声明式渲染;opts对象引用实现父子标签间的直接数据协作;riot.route提供内置 hash 路由;update事件钩子把派生状态计算与自动持久化统一起来。整个应用没有框架层样板代码,一个标签文件、一个启动脚本、一个存储封装,就完成了 TodoMVC 的全部交互要求——这恰恰呼应了 readme 开篇对 Riot 的定位:小 API、简洁语法、拒绝不必要的复杂度。
【免费下载链接】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),仅供参考