前端完整示例

前端完整示例

HTML、CSS、JS 各学了一篇, 这篇拿一个真项目把三样串起来: 一个待办事项 (Todo) 应用, 支持添加、勾选完成、删除, 刷新页面数据不丢。麻雀虽小, 前端开发的完整套路 (结构、样式、逻辑、存储) 全在里面。写完这个项目, 我才算真的理解三件套是怎么配合的。

一共三个文件, 放同一个文件夹里, 双击 index.html 就能跑, 不需要装任何东西 (这也是纯三件套时代的美好, 工具链篇会讲为什么后来还是需要装一堆东西):

代码块PLAINTEXT · 4 行收起展开
todo-app/
├── index.html    结构
├── style.css     样式
└── script.js     逻辑

1. HTML: 搭结构

代码块HTML · 36 行收起展开
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Todo 待办事项</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="container">
    <h1>我的待办</h1>

    <!-- 输入区: form 包起来是为了回车也能提交 -->
    <form id="todoForm">
      <input
        type="text"
        id="todoInput"
        placeholder="添加新的待办事项..."
        required
      >
      <button type="submit">添加</button>
    </form>

    <!-- 列表区: 现在是空的, 内容全靠 JS 动态生成 -->
    <ul id="todoList"></ul>

    <!-- 统计区: 数字也是 JS 填的 -->
    <div class="stats">
      <span>总计: <strong id="totalCount">0</strong></span>
      <span>已完成: <strong id="doneCount">0</strong></span>
    </div>
  </div>

  <script src="script.js"></script>
</body>
</html>

注意这个 HTML 的特点: 它只有”容器”, 没有内容。<ul> 是空的, 待办列表长什么样完全由 JS 根据数据决定。这和 HTML 篇里写死内容的静态页面已经是两种思路了。required 是浏览器自带的校验, 空内容按提交按钮会被拦下。

2. CSS: 上样式

代码块CSS · 165 行收起展开
/* 全局重置: 清掉浏览器默认边距, 统一盒模型 (CSS 篇讲过 box-sizing 的坑) */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* 整页: 渐变背景, 用 Flex 把卡片钉在正中间 */
body {
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 20px;
}

/* 白色卡片容器 */
.container {
  background: white;
  border-radius: 12px;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
  padding: 30px;
  width: 100%;
  max-width: 500px;
}

h1 {
  text-align: center;
  color: #333;
  margin-bottom: 30px;
  font-size: 28px;
}

/* 输入行: 又是 Flex, 输入框 flex:1 吃掉剩余宽度 */
#todoForm {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

#todoInput {
  flex: 1;
  padding: 12px 16px;
  border: 2px solid #e0e0e0;
  border-radius: 8px;
  font-size: 16px;
  transition: border-color 0.3s;
}

#todoInput:focus {
  outline: none;
  border-color: #667eea;
}

button[type="submit"] {
  padding: 12px 24px;
  background: #667eea;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 16px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.3s;
}

button[type="submit"]:hover {
  background: #5568d3;
}

#todoList {
  list-style: none;   /* 去掉 li 前面的小圆点 */
  margin-bottom: 20px;
}

/* 每一条待办: Flex 横排, 复选框 + 文本 + 删除按钮 */
.todo-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px;
  background: #f8f9fa;
  border-radius: 8px;
  margin-bottom: 10px;
  transition: transform 0.2s;
}

.todo-item:hover {
  transform: translateX(4px);
}

/* 完成状态的样式: JS 只负责加/删 completed 这个 class, 样式效果归 CSS 管 */
.todo-item.completed .todo-text {
  text-decoration: line-through;
  color: #999;
}

.todo-checkbox {
  width: 20px;
  height: 20px;
  cursor: pointer;
}

.todo-text {
  flex: 1;
  color: #333;
  font-size: 16px;
}

.delete-btn {
  padding: 6px 12px;
  background: #ff4757;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 14px;
  cursor: pointer;
  transition: background 0.3s;
}

.delete-btn:hover {
  background: #ee3744;
}

.stats {
  display: flex;
  justify-content: space-around;
  padding-top: 20px;
  border-top: 2px solid #e0e0e0;
  color: #666;
  font-size: 14px;
}

.stats strong {
  color: #667eea;
  font-size: 18px;
}

.empty-state {
  text-align: center;
  color: #999;
  padding: 40px 20px;
  font-size: 16px;
}

/* 窄屏适配: 手机上输入框和按钮改成上下排 */
@media (max-width: 600px) {
  .container {
    padding: 20px;
  }

  h1 {
    font-size: 24px;
  }

  #todoForm {
    flex-direction: column;
  }

  button[type="submit"] {
    width: 100%;
  }
}

CSS 里最值得咂摸的一处是 .todo-item.completed .todo-text: JS 从头到尾没碰过删除线和灰色, 它只负责给元素加减 completed 这个 class, 视觉效果全写在 CSS 里。逻辑和样式各管各的, 这是三件套配合的标准分工。

3. JavaScript: 写逻辑

代码块JAVASCRIPT · 100 行收起展开
// 先把要操作的 DOM 元素都抓到手
const todoForm = document.getElementById('todoForm');
const todoInput = document.getElementById('todoInput');
const todoList = document.getElementById('todoList');
const totalCount = document.getElementById('totalCount');
const doneCount = document.getElementById('doneCount');

// 核心数据: 一个数组, 整个应用唯一的"真相"
// 启动时先从 localStorage 恢复, 没存过就用空数组 (|| 的兜底用法)
let todos = JSON.parse(localStorage.getItem('todos')) || [];

// 一进来先把已有数据画到页面上
renderTodos();

// 表单提交 = 添加待办 (preventDefault 拦住浏览器默认的刷新式提交, JS 篇讲过)
todoForm.addEventListener('submit', (e) => {
  e.preventDefault();
  addTodo();
});

function addTodo() {
  const text = todoInput.value.trim();
  if (text === '') return;

  const todo = {
    id: Date.now(),       // 用时间戳当唯一 id, 小项目够用
    text: text,
    completed: false
  };

  todos.push(todo);       // 改数据
  saveTodos();            // 存起来
  renderTodos();          // 重新画页面

  todoInput.value = '';   // 清空输入框
}

// 勾选/取消勾选: map 出一个新数组, 只改中招的那一项 (展开运算符复制其余字段)
function toggleTodo(id) {
  todos = todos.map(todo =>
    todo.id === id
      ? { ...todo, completed: !todo.completed }
      : todo
  );
  saveTodos();
  renderTodos();
}

// 删除: filter 留下 id 不相等的
function deleteTodo(id) {
  todos = todos.filter(todo => todo.id !== id);
  saveTodos();
  renderTodos();
}

// 渲染: 把 todos 数组原样画成 DOM, 每次都整个重画
function renderTodos() {
  todoList.innerHTML = '';

  if (todos.length === 0) {
    todoList.innerHTML = '<li class="empty-state">还没有待办事项, 添加一个吧!</li>';
    updateStats();
    return;
  }

  todos.forEach(todo => {
    const li = document.createElement('li');
    li.className = `todo-item ${todo.completed ? 'completed' : ''}`;

    li.innerHTML = `
      <input
        type="checkbox"
        class="todo-checkbox"
        ${todo.completed ? 'checked' : ''}
      >
      <span class="todo-text">${todo.text}</span>
      <button class="delete-btn">删除</button>
    `;

    // 给刚造出来的节点绑事件
    li.querySelector('.todo-checkbox')
      .addEventListener('change', () => toggleTodo(todo.id));
    li.querySelector('.delete-btn')
      .addEventListener('click', () => deleteTodo(todo.id));

    todoList.appendChild(li);
  });

  updateStats();
}

function updateStats() {
  totalCount.textContent = todos.length;
  doneCount.textContent = todos.filter(todo => todo.completed).length;
}

// 持久化: 对象数组序列化成 JSON 字符串存进 localStorage
function saveTodos() {
  localStorage.setItem('todos', JSON.stringify(todos));
}

4. 这份代码最重要的思想: 数据驱动

注意所有操作函数 (add/toggle/delete) 的形状完全一样:

代码块PLAINTEXT · 1 行收起展开
改 todos 数组 → saveTodos() 存盘 → renderTodos() 重画

页面上显示什么, 从来由 todos 这个数组说了算, 我从不直接去 DOM 上”改某一行的文字”。想知道应用当前什么状态? 看数组就行。这个”数据是唯一真相, 页面只是数据的投影”的思路, 就是数据驱动视图, 是理解 Vue 的钥匙。

同时也该看到它的笨拙: 每次哪怕只勾选一项, renderTodos 都把整个列表推倒重画, 还要重新绑一遍事件。
列表一大, 又慢又费劲; 而且”改完数据记得调 renderTodos”全靠自觉, 忘一处页面就和数据脱节。
这些体力活, Vue 会全部接管: 我只改数据, 它负责最小代价地更新页面。带着这个痛点去学 Vue, 会有”啊原来是治这个病的”的感觉。

顺带一提 innerHTML 拼模板字符串这个写法: 方便, 但如果 todo.text 里混进 <script> 这类内容会被当 HTML 解析 (JS 篇提过注入风险)。这里是本地小玩具无所谓, 真项目的用户输入不能这么直接拼。

5. 动手扩展

按顺序做, 每个都用”改数据 → 重渲染”的套路:

  • 加一个”清空已完成”按钮 (filter 一把梭)。
  • 加”全部 / 未完成 / 已完成”三档筛选 (渲染前先过滤数组)。
  • 双击文本进入编辑 (给 todo 对象加个 editing 字段试试)。
  • 加优先级字段并按优先级排序显示。

做到第二个就会发现筛选状态本身也是数据, 也得存变量。状态越攒越多, 就越能体会框架的必要性。

6. 出问题怎么查

老三样: console.log(todos) 看数据对不对; Elements 面板看 DOM 生成了没; Application 面板看 localStorage 里存的字符串。系统排查方法在调试篇专门写了, 这里不展开。

7. 下一步

到这里, 纯三件套的路已经走完了。接下来的路线: 调试篇把 F12 用熟, 然后进工具链篇理解 npm 和模块化 (从”双击就能跑”进化到工程化开发), 最后开 Vue 主线, 亲眼看这份 Todo 用 Vue 重写后能省多少代码。

延伸阅读