做前端的人应该都有过这种体验:学了一堆JS基础语法,if/else、for循环、function都会写,但真拿到一个页面需求,还是不知道从哪里下手。教材里的例子永远是console.log打印结果,面试题永远在问数组去重,等到自己写项目时却连数据怎么渲染到页面上都要搜半天。这个系列我打算专门解决这个“断层”——用真实能跑的小案例,把基础语法和实际场景串起来。第二篇我选的题目是一个带筛选和统计功能的待办事项面板,它涵盖了JS里最常用的数组方法、字符串处理、事件绑定、DOM渲染和本地存储,几乎能把这些基础点一次性串成一条线。
这个案例做下来之后,你会发现其实前端开发没有那么多玄学:map不是背概念用的,filter不是做算法题用的,它们本来就是页面开发里最自然的数据流操作。我会直接给出完整可复现的代码,解析每一步的设计思路,顺便讲几个我在实际项目中踩过的坑。适合刚学完JS基础、准备走前端开发方向的读者,也适合那些会写语法但还不熟悉“业务逻辑怎么落进真实页面”的初学者。
1. 为什么第二讲,我把目标锁定在“数据状态管理”上
1.1 从静态页面到动态数据流
很多初学者的第一反应是:做一个待办事项面板是不是太简单了?不就是ul里面塞li吗?但把需求稍微提高一点,情况就完全不一样了。
需求如下:页面上有一个输入框和“添加”按钮;点击添加后,事项出现在列表中;每个事项有“已完成”和“未完成”两种状态;支持筛选(全部/进行中/已完成);顶部有一个统计条显示总数、未完成数、已完成数;刷新浏览器后数据不丢失。
这个需求本质上是前端开发中最基础也最核心的“数据流问题”:数据变了,页面要跟着变;用户在页面上操作,数据要跟着变。这两句话循环往复,就是大部分前端应用的真实运行方式。JS基础阶段最喜欢考的for循环、数组遍历、字符串拼接,恰恰就是支撑这个数据流的底层工具。
1.2 案例的技术选型:原生JS,不引框架
这个案例我刻意不用Vue、React,也不引入jQuery。原因很简单:框架帮你隐藏了太多细节。在Vue里改个数据,视图自动更新,你不需要知道背后的DOM操作是怎么做的;但如果你一开始就不理解这个更新过程,后面框架出现诡异问题时你连排查方向都没有。
用原生JS写这个案例,你至少能搞清楚三件事:一是“数据和DOM之间的映射关系”,也就是一份数据要变成什么样的HTML结构;二是“事件如何反哺数据”,用户点击之后状态怎么改、数组怎么变;三是“数据变化后如何重新渲染”,最朴素的render函数长什么样。这三件事理解透了,再去看框架的响应式原理、虚拟DOM,都会轻松很多。
1.3 案例最终效果预览
我们先约定一下最终的样子:
- 页面顶部一个输入框,输入内容后按回车或点按钮,生成一条待办事项。
- 事项列表按时间倒序排列(新添加的在最上面)。
- 每条事项左侧一个复选框,勾选表示已完成,文字带删除线。
- 列表下方三个筛选按钮:全部、进行中、已完成。
- 统计区显示:“共X条,未完成Y条,已完成Z条”。
- 数据用
localStorage持久化,刷新后依然存在。 - 底部有一个“清空已完成”按钮,一键删除所有已完成事项。
这个规模最适合练手:代码量不大,但每个模块都有实际用途。接下来我会从页面骨架开始,一步步搭起来,每一步都说明为什么要这么写。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先把“手术台”搭好:HTML骨架与CSS样式设计
2.1 页面结构设计要点
HTML结构不复杂,但有几个细节值得注意。我用了一个section作为主容器,内部划分成三个区域:header(输入区)、main(列表区)、footer(底部操作区)。这种语义化结构对后续JS选择和后期维护都很友好。
html复制<section class="todo-app">
<header class="app-header">
<h1>待办事项</h1>
<div class="input-row">
<input type="text" id="todoInput" placeholder="输入新事项,按回车添加" autocomplete="off" />
<button id="addBtn">添加</button>
</div>
</header>
<main class="app-main">
<ul id="todoList" class="todo-list"></ul>
<div class="empty-tips" id="emptyTips">暂无待办事项</div>
</main>
<footer class="app-footer">
<div class="filter-group">
<button class="filter-btn active" data-filter="all">全部</button>
<button class="filter-btn" data-filter="active">进行中</button>
<button class="filter-btn" data-filter="completed">已完成</button>
</div>
<div class="stats" id="stats"></div>
<button class="clear-completed" id="clearCompleted">清空已完成</button>
</footer>
</section>
结构上的三个设计点:
- 列表区域用
ul id="todoList",后续JS渲染时往这里面塞li。 - 筛选按钮用
data-filter属性标注筛选类型,后面事件委托时可以直接通过dataset.filter拿值,不用给三个按钮分别写事件。 - 空状态提示独立放一个div,默认显示,有数据时隐藏。这个小东西很多初学会忽略,但实际产品里“空状态”是必须处理的。
2.2 样式设计:怎么体现“已完成”和“筛选状态”
CSS部分不用写太花哨,但有两个细节必须交代清楚。
已完成项的视觉反馈,我用了text-decoration: line-through加一个opacity: 0.6。删除线是待办场景里最通用的“已完成”符号,这一点没有什么可替代性。同时把复选框和文字用一个label包起来,这样点击文字也能切换勾选状态。
css复制.todo-item {
display: flex;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid #eee;
gap: 10px;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
opacity: 0.6;
}
筛选按钮的选中态,我通过给按钮动态添加active类来控制。这个类控制背景色和文字颜色:
css复制.filter-btn {
border: 1px solid #ddd;
background: #fff;
padding: 6px 14px;
border-radius: 16px;
cursor: pointer;
}
.filter-btn.active {
background: #1a73e8;
color: #fff;
border-color: #1a73e8;
}
这里有个很关键的动作:筛选状态本身也是“页面上的状态”。我用一个全局变量currentFilter记录当前筛选类型,每次切换筛选时,不仅要给按钮换active类,还要重新渲染列表。这两件事是联动的,漏掉任何一个都会导致按钮高亮了但列表没变化。
2.3 为什么不直接在HTML里写onclick
很多老教程的习惯是在按钮上写<button onclick="addTodo()">,简单直接,新手也容易理解。但我不建议这么做。原因有两个:
一是全局污染。onclick绑定的函数会被挂到window对象上,如果页面还有其他脚本,同名函数会互相覆盖,排查起来非常头疼。二是无法重复绑定。同一个元素如果用addEventListener多次绑定,可以挂多个处理函数;但onclick是属性,后赋值的会覆盖之前的。
这个案例里我统一用addEventListener,不仅是为了“规范”,更是为后面的“事件委托”做铺垫。等做到筛选按钮批量事件绑定时,你就能体会到不写onclick的好处。
3. 状态管理是核心:数组三大方法在这首次“合体”
3.1 把页面“状态”抽象成一个数组
整个案例的数据核心是一个todos数组。数组里每一项就是一个待办事项对象:
javascript复制{
id: Date.now() + Math.random(), // 唯一标识
text: '学习JS数组方法',
completed: false,
createdAt: Date.now()
}
id用时间戳加随机数的组合,尽量保证唯一性。实际开发中更好的是用crypto.randomUUID(),不过考虑到兼容性,这个案例用时间戳加随机数就够了。
把状态收敛到一个数组里有非常大的好处:任何对数据的修改,都成了“对数组进行增删改”,而页面渲染永远只依赖todos数组本身。换句话说,只要数组是对的,页面就是对的。这就是“数据驱动视图”思想的雏形。
3.2 render函数:用map把数组变成HTML字符串
当我需要把数组内容渲染到页面时,最自然的工具就是map。它把每一项数据映射成一段HTML字符串,然后join('')合并成一个大字符串,一次性赋值给ul的innerHTML。
javascript复制function render() {
const filtered = filterTodos();
const emptyTips = document.getElementById('emptyTips');
const todoList = document.getElementById('todoList');
if (filtered.length === 0) {
emptyTips.style.display = 'block';
todoList.innerHTML = '';
} else {
emptyTips.style.display = 'none';
todoList.innerHTML = filtered.map(function(todo) {
return `
<li class="todo-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
<input type="checkbox" class="todo-checkbox" ${todo.completed ? 'checked' : ''} />
<span class="todo-text">${escapeHtml(todo.text)}</span>
<button class="delete-btn" data-action="delete">删除</button>
</li>
`;
}).join('');
}
updateStats();
}
map在这里做的事,用最朴素的写法相当于:
javascript复制var arr = [];
for (var i = 0; i < filtered.length; i++) {
arr.push('...html片段...');
}
var html = arr.join('');
map的语义更聚焦:它明确表达了“一组数据转换为一组同样长度的新值”。在JS基础面试题里,map和forEach的区别经常被问到,这个案例就是最好的记忆场景:forEach只负责循环遍历,不产生新数组;map则要求你返回一个新值,相当于做了一次“映射”。
3.3 filter的作用:三种筛选态的实现
筛选的需求很简单:点击“全部”显示所有数据,点击“进行中”只显示completed === false的数据,点击“已完成”只显示completed === true的数据。这就是filter的经典用法:
javascript复制function filterTodos() {
if (currentFilter === 'active') {
return todos.filter(function(todo) {
return !todo.completed;
});
}
if (currentFilter === 'completed') {
return todos.filter(function(todo) {
return todo.completed;
});
}
return todos;
}
filter返回一个新数组,不会修改原数组。这个特点很重要:筛选不应该改变数据本身,它只是数据的“投影”。如果你在筛选时不小心用todos = todos.filter(...),那等于把被筛掉的数据永久删除了——这是新手容易犯的非常隐蔽的错误。
3.4 用reduce统计,顺便把forEach也练了
统计区的“共X条,未完成Y条,已完成Z条”,本质上是对数组的归约操作。我用reduce实现:
javascript复制function updateStats() {
const total = todos.length;
const completed = todos.reduce(function(count, todo) {
return todo.completed ? count + 1 : count;
}, 0);
const active = total - completed;
document.getElementById('stats').textContent =
'共 ' + total + ' 条,未完成 ' + active + ' 条,已完成 ' + completed + ' 条';
}
reduce在这里的语义是:遍历数组每一项,把“已完成的数量”这个累加器一点点推进。第一个参数是上一次回调的返回值,第二个参数是当前遍历的元素,第三个参数(0)是初始值。面试时如果被问reduce能做什么,除了求和还能做很多事,比如把数组按某种属性分组,但基础场景里“计数归约”是最直观的理解方式。
顺带练一下forEach的场景:给每个todo的文本节点本身插入一个删除按钮时,forEach可以做一次性事件绑定。但后面我会讲到,大量事件绑定最好用事件委托,所以forEach在这个案例里最合理的用途是“清空已完成”时遍历删除:
javascript复制function clearCompleted() {
todos = todos.filter(function(todo) {
return !todo.completed;
});
save();
render();
}
这里用的是filter而不是forEach+splice,原因是后者很容易在遍历过程中因为索引偏移而出错。用filter生成一个“排除已完成”的新数组,再把新数组赋给todos,干净利落。这是我在实际项目中比较推荐的做法——不要一边遍历一边修改原数组,要用“返回新数组”的方式替代。
4. 字符串与DOM细节:URL校验、包含判断、三级联动里藏着的JS基本功
4.1 URL有效性验证:别看简单,坑在字符串边界
搜索热词里有一个“js验证url有效性”,这个需求在很多前端表单里都会出现。它和数组方法关系不大,但非常考验字符串基本功。很多人的第一版代码是:
javascript复制function isValidUrl(url) {
return url.indexOf('http') === 0;
}
这样写的问题显而易见:http开头未必就是有效URL(比如httpabc也能通过);https、带路径、带查询参数的URL会被误判。稍微好一点的做法是用URL构造函数:
javascript复制function isValidUrl(url) {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch (e) {
return false;
}
}
new URL会解析字符串,如果字符串格式非法,会直接抛异常,所以用try/catch包住。校验协议是http或https,可以过滤掉javascript:、file:这类危险协议。这个函数在后续如果要扩展“添加待办时允许填链接”的需求时就能直接用上。
4.2 字符串包含判断:includes、indexOf与第三个参数
热词里有个高频问题是“js判断字符串是否包含”,这个在本案例里也有对应场景:比如用户在输入框里输入的内容,和已有的待办事项完全重复时,提示“该事项已存在”。这里最直接的工具就是includes:
javascript复制function isDuplicate(text) {
const trimmed = text.trim().toLowerCase();
return todos.some(function(todo) {
return todo.text.trim().toLowerCase() === trimmed;
});
}
严格说这里用的是some,不是includes,因为todos是对象数组,不能直接includes。但“判断字符串是否包含”的includes技巧体现在另一个地方:当用户输入的关键词需要模糊匹配时,比如在列表顶部加一个搜索框,判断todo.text.includes(keyword)就能实现按关键词过滤。
要注意的是,includes区分大小写。所以做模糊搜索时,最好先把两边都转成小写再比较:
javascript复制const keyword = searchInput.value.trim().toLowerCase();
const filtered = todos.filter(function(todo) {
return todo.text.toLowerCase().includes(keyword);
});
这个案例中我把搜索框去掉了,避免代码量过大,但思路是完全一样的。
4.3 三级联动里的数据映射思想
“js三级联动”是一个经典的前端基础案例,一般用于省市区、分类筛选等场景。它本质上也是“数据驱动视图”的体现:选一级,决定二级的候选列表;选二级,决定三级的候选列表。
在待办案例里,虽然没有直接做三级联动,但“筛选状态”的设计思想是类似的:all/active/completed三个状态,决定了列表显示哪一个子集。这已经是“状态决定数据、数据决定视图”的雏形。如果把数据源换成省市区:
javascript复制const areas = {
'北京市': ['朝阳区', '海淀区'],
'上海市': ['浦东新区', '徐汇区']
};
function updateSecondLevel(province) {
const cities = areas[province] || [];
// 用 map 生成 <option>
}
这里的关键是把“联动”理解为“根据上一个选择,重新计算当前可选项”,而不是为每个选项单独写死事件。这种思想在真实项目中非常有用,比如电商筛选器的多级分类。
5. 事件委托:一个监听器管所有列表按钮
5.1 为什么要事件委托
问题场景:列表里的每一项都有一个“删除”按钮,复选框切换状态也需要监听。如果我用forEach给每一个按钮单独绑定事件,在新增一条事项后也要重新绑定一次。更麻烦的是,每次render()都重新innerHTML,旧的监听器会丢失,新生成的元素如果没有重新绑定事件,点击就完全没反应。
事件委托的思路完全不同:我不给具体的li、button绑定事件,而是在它们的父容器ul上只绑定一次事件。因为事件冒泡机制,点击任何一个子元素,事件都会冒泡到ul上,我只要在ul的事件处理函数里判断“我到底点到的是谁”就可以了。
5.2 代码实现:一句话判断操作类型
javascript复制document.getElementById('todoList').addEventListener('click', function(e) {
const target = e.target;
// 点击删除按钮
if (target.classList.contains('delete-btn')) {
const li = target.closest('li');
const id = Number(li.dataset.id);
deleteTodo(id);
return;
}
// 点击复选框
if (target.classList.contains('todo-checkbox')) {
const li = target.closest('li');
const id = Number(li.dataset.id);
toggleTodo(id);
return;
}
});
closest('li')是从当前元素向上找到最近的li,然后通过li.dataset.id拿到这条事项的唯一标识。之前渲染时我在li上放了data-id="${todo.id}",就是为了这里能精准定位数据。
使用事件委托后,不管列表怎么增删,只需要一个监听器。这不仅省内存,还省掉了“重新绑定事件”的麻烦。这个模式在实际项目中非常普遍,尤其是列表型页面。
5.3 数据操作函数:增删改查的“改”
删除和切换完成状态这两个操作,都是“先改数据,再重新渲染”,这是整个案例最核心的思维模式。
javascript复制function deleteTodo(id) {
todos = todos.filter(function(todo) {
return todo.id !== id;
});
save();
render();
}
function toggleTodo(id) {
todos = todos.map(function(todo) {
if (todo.id === id) {
return { ...todo, completed: !todo.completed };
}
return todo;
});
save();
render();
}
toggleTodo里用了扩展运算符...todo,把原有字段展开后只覆盖completed字段,生成一个新对象。这样既保证了对象不可变性(不直接修改原对象),又避免了手写{id: todo.id, text: todo.text, completed: !todo.completed}的啰嗦。面试题里“js怎么用扩展运算符把一个数组里面的值都添加到另外一个数组”就是同款技能:
javascript复制const arr1 = [1, 2];
const arr2 = [3, 4];
const merged = [...arr1, ...arr2]; // [1, 2, 3, 4]
在对象上,...的用法也类似,它把对象的所有可枚举属性复制到新对象上,后面的属性会覆盖前面的同名属性。
6. 本地存储、模块拆分与进阶方向:从案例走向工程化
6.1 localStorage持久化:把数据存进浏览器
刷新后数据不丢失,本质上就需要本地存储。localStorage是浏览器提供的键值对存储接口,以字符串形式保存数据。常用API就三个:setItem、getItem、removeItem。
javascript复制const STORAGE_KEY = 'todo-app-data';
function load() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
todos = JSON.parse(stored);
} catch (e) {
todos = [];
}
}
}
function save() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
有几个细节要注意:
JSON.stringify把数组转成字符串才能存。JSON.parse把字符串转回数组。如果存储的内容损坏(比如被用户手动改过),JSON.parse会抛异常,所以必须包try/catch。- 存储数据的体积有限制(一般5MB左右),这个案例足够用了。
- 如果之后要做更复杂的缓存,可以考虑
IndexedDB,但那是另一个话题。
加载时机放在页面初始化时:
javascript复制load();
render();
6.2 模块拆分:现在不拆,后面也会拆
随着案例功能越来越多(添加、删除、筛选、统计、存储、校验),把所有函数都写在<script>标签里会显得杂乱。为了贴近真实项目习惯,可以把代码按功能分成几个“块”:
data.js:负责todos数组的初始化、存取(load/save)。render.js:负责渲染列表和统计信息。actions.js:负责业务操作(addTodo/deleteTodo/toggleTodo/clearCompleted)。main.js:负责初始化与事件绑定。
在原生JS阶段,最简单的方式是直接在HTML里依次引入多个<script>标签,注意顺序:
html复制<script src="data.js"></script>
<script src="render.js"></script>
<script src="actions.js"></script>
<script src="main.js"></script>
因为data.js里的load/save会被后面的函数使用,所以必须先加载。这种方式虽然还不是真正的模块化(没有export/import),但能让初学者理解“职责分离”。等到引入构建工具后,再把这些改成ES Module就是顺水推舟的事。
6.3 进阶方向:Worker上传大文件与WeakMap缓存
搜索热词里有“前端使用worker上传大文件”和“js中的weakmap”,这俩其实是和基础案例配套的进阶话题。
Worker解决的是“JS单线程阻塞”问题。如果上传大文件时在主线程里做文件切片、计算MD5,页面会因为长时间的同步计算而卡死。把计算任务放进Worker,主线程只负责调度和显示进度,用户界面就能保持流畅。这和本案例的核心思想一脉相承:所有可能耗时的任务都不应该阻塞UI渲染。
WeakMap和Map的区别是:WeakMap的键只能是对象,且是弱引用,不会阻止垃圾回收。在本案例里,如果我想给某个todo对象缓存一个“计算好的搜索索引”,但这个对象已经从todos里被删除了,WeakMap里的键会被自动回收,不会造成内存泄漏。这种场景在真实项目里比较常见,比如给DOM元素挂临时数据。
我会建议先把本案例吃透,再去碰Worker和WeakMap。基础案例的价值在于把“数组方法+字符串+事件+存储”这四大板块打通,后面学任何框架都比别人快。
7. 三个月后回头看:我踩过的坑和给你的避坑建议
7.1 第一坑:渲染函数里用了map但不join()
我第一次写这个案例时,render里map完直接赋给innerHTML,结果页面上出现了“逗号”。原因很明确:map返回的是数组,数组转字符串时默认用逗号分隔。解决方式就是join(''),或者用reduce拼接字符串。这是新手最容易忽略的细节。
7.2 第二坑:新增事项后没有重新渲染
有相当长时间,我写addTodo时只往todos数组里push了数据,以为数组变了页面就会自己变。结果自然是页面没反应。原生JS里没有“响应式”概念,每次数据变动后必须手动调用render()。这个“手动”的动作如果漏掉,数据流就断了。
这个坑在熟悉框架后会被隐藏,但排查性能问题时你会发现,框架里最底层的做法无非就是“把数据和DOM绑在一起,并自动触发更新”。
7.3 第三坑:用innerHTML拼接用户输入的内容存在注入风险
如果用户输入的待办事项是<img src=x onerror=alert(1)>,直接插入innerHTML会执行这段脚本。虽然本地小项目影响不大,但这是前端安全里最基本的XSS问题。解决方式是在渲染前做HTML转义:
javascript复制function escapeHtml(text) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(text));
return div.innerHTML;
}
利用textContent会自动转义的特殊字符的特性,把输入内容转为安全的HTML字符串。这是我在后面项目里特别留意的习惯。
7.4 给初学者的建议:怎么用这个案例训练自己
如果你正在刷前端面试题,可以把这份代码当成“题干”,然后自己回答这么几个问题:map和forEach的区别是什么?filter会不会修改原数组?事件委托的原理是什么?localStorage存数字会变成什么类型?
如果想加深理解,建议做三个小改动:第一,把“全部/进行中/已完成”改成“高/中/低优先级”的筛选;第二,加一个搜索框,用includes做模糊匹配;第三,把render函数改成用DocumentFragment操作DOM而不是直接拼innerHTML,然后对比一下性能差异。
我个人在实际操作中的体会是:初学者总喜欢追求“更多的新知识”,今天摸一下Vue,明天碰一下React,但真正能沉淀下来的反而是一个个亲手跑通的完整案例。这个待办面板的成本大概两个小时,做完之后再去翻那些前端面试题,你会发现很多题都不再是概念背诵,而是变成了“我写代码时本来就遇到过的逻辑”。把基础案例做扎实,远比你收藏一百个高级知识点更值钱。
