很多人学完 JavaScript 基础语法之后都会卡在同一个地方变量、分支、循环、函数都认识了但一打开编辑器面对一个空荡荡的页面就是不知道从哪儿开始写代码。你正在看的这篇“js学习二”就是为这个阶段准备的。它不打算把var、if、for再讲一遍而是要带你跨过那条从“看得懂”到“写得出”的沟用一套待办事项页面把第二阶段真正需要掌握的知识点串起来数组高阶方法、模板字符串、解构赋值、Promise、async/await、DOM 操作、本地存储。学完这一轮你至少能独立写出一个能交互、能保存数据的真实页面。适合刚啃完基础语法想动手做项目的同学也适合学了一遍但发现自己只会跟着教程敲代码、脱离教程就断片的人。回顾我自己的学习路径其实第二阶段才是最容易放弃的阶段。语法糖太多概念太抽象再加上异步编程和 DOM 操作掺在一起很多人学到这里就开始怀疑自己是不是不适合写代码。这篇文章就是想帮你把这一程尽量走得顺一点我会直接告诉你哪些东西必须弄懂、哪些东西可以先跳过以及我在实际项目里踩过的那些坑。1. 整体设计为什么第二阶段要围绕小项目展开1.1 第二阶段真正的“知识地图”如果第一阶段是学说话那第二阶段就是学写短文。你需要的不再是零散的单词而是把这些单词组织成有意义句子的能力。具体到 JavaScript 里第二阶段的知识点可以分成四个维度语法层面模板字符串、解构赋值、扩展运算符、箭头函数、let/const与作用域。数据层面数组的map、filter、find、reduce对象的拷贝与合并JSON 序列化。异步层面回调函数、Promise、async/await、事件循环的基础认知。页面层面DOM 查询、事件监听、动态渲染、本地存储。这四个维度不是并列的而是一层层往上叠的。语法是地基数据处理是日常活异步是绕不过去的坎页面操作是最终目的。很多教程喜欢把每个点单独拎出来讲结果就是你学完map会做题但不知道自己为什么要用它。所以我更推荐用项目把这张地图走一遍。1.2 为什么选“待办事项”这个项目选待办事项是因为它小但五脏俱全。一个最简单的待办事项页面至少包含这些功能输入新事项、渲染事项列表、删除事项、筛选已完成和未完成。这些东西背后对应的是事件监听、数组增删、遍历渲染、条件判断。再加一个localStorage持久化就覆盖了“数据从哪来、怎么改、怎么存、怎么再显示”的完整链路。有人可能会说待办事项太老套了能不能换个新奇的。我的看法是学习项目最重要的是教学密度不是外观。你做一个视频卡片列表、做一个记账本核心逻辑和待办事项几乎一模一样都是“数据 渲染 交互”。与其纠结项目够不够酷不如先把这条通用链路跑通。等你写过一个完整的待办事项再去做其他页面就会发现80% 的代码逻辑是可以平移的。2. 核心 API 与语法细节写项目时真正高频的语法2.1 数组高阶方法代码从“怎么遍历”变成“要什么数据”初学者最常见的习惯是拿到一个数组就for循环循环里再塞if一个函数写完十行起步逻辑还容易绕晕。第二阶段你要做的第一个改变就是把“怎么写循环”的思维切换成“我要得到什么结果”。假设我们有一个购物清单const items [ { name: 苹果, price: 5, done: true }, { name: 牛奶, price: 12, done: false }, { name: 面包, price: 15, done: true }, ];如果我只想要还没买的东西用for循环写是这样的const undone []; for (let i 0; i items.length; i) { if (!items[i].done) { undone.push(items[i]); } }用filter写const undone items.filter(item !item.done);同样的效果代码短了一半而且一眼就能看出意图过滤出未完成的项。filter的规则很简单回调函数返回true这一项就保留返回false就丢掉。再看map。它的作用是“数组里每一项都会进过一个加工然后组成一个新的数组”。想拿到所有商品名字的数组const names items.map(item item.name); // [苹果, 牛奶, 面包]在处理待办事项的文案、拼列表结构的时候map是绝对的主力。你可能会看到有人把map和forEach放在一起比较。区别是forEach不产生新数组只负责“做一遍事情”比如把每一项打印出来而map会返回一个新数组。新手容易犯的错就是在forEach里写return然后困惑为什么拿不到值因为forEach根本不会把返回值收集起来。find则用来找“第一个符合条件的项”。比如我根据 ID 找到一条待办事项const target items.find(item item.id 2);它和filter的区别在于find找到第一个就停返回的是那一个对象filter会一直遍历完整个数组返回一个数组。以后做删除功能时find通常配合splice使用或者直接用filter生成一个没有目标项的新数组。reduce我先不多铺开因为它对新手不够友好。但你可以先记住它的用途把整个数组“累积”成一个值比如求和const total items.reduce((sum, item) sum item.price, 0); // 32在写项目初期map、filter、find这三个已经能覆盖绝大多数场景。reduce可以等你对回调函数很熟练了再回头看它不会跑。2.2 模板字符串与解构赋值让代码读起来像人话模板字符串就是反引号字符串最大的价值是结束了那个用拼接字符串的黑暗时代。以前写列表项// 拼接字符串 li item.name - item.price 元/li用模板字符串li${item.name} - ${item.price}元/li看起来只是少打几个引号但当你需要拼接的变量变多、甚至要拼一个完整 HTML 结构时模板字符串的可读性优势就非常明显了。它不是那种“最好知道一下”的语法而是“不知道会很痛苦”的语法。解构赋值解决的是取值的问题。以前要从对象里取三个字段要写三行const name item.name; const price item.price; const done item.done;解构一行搞定const { name, price, done } item;数组也能解构比如交换两个变量的值不用再建临时变量let a 1; let b 2; [a, b] [b, a];解构和模板字符串没有高深的理论它们的作用就是减少重复让代码更短、更清晰。很多人写了几年代码回看刚学时的写法都会觉得旧代码像裹脚布主要就是这两样带来的改变。2.3 箭头函数与 this 的边界箭头函数最直观的好处是短。items.map(item item.name)比常规写法省了function、return这些词。但更重要的是它没有自己的this箭头函数里的this指向定义它时所在的作用域。这件事既是方便也是陷阱。方便在事件处理和定时器里你可以直接访问到外层对象的属性。陷阱在于如果你指望通过bind或者apply改变箭头函数的this那是做不到的。举一个例子const counter { count: 0, start() { setInterval(() { this.count; console.log(this.count); }, 1000); }, };这里的this指向counter因为箭头函数在定义时就“捕获”了外层this。如果换成普通函数这里的this就是window访问this.count会得到undefined。很多同学在写定时器、事件回调的时候遇到this不对的问题大概率就是在这个地方没拎清楚。我的建议是先记住箭头函数“没有自己的 this”这句话用的时候多留个心眼。3. 异步编程第二阶段最抽象的拦路虎3.1 先理解为什么需要 PromiseJavaScript 的异步场景到处都有请求接口、读取本地数据、定时器、动画。你写代码的时候没办法让程序停下来等数据返回只能告诉它“等数据回来了再处理”。这个“等数据回来了再处理”就是回调函数。但回调函数嵌套多了以后代码会变得非常难看形成了所谓的“回调地狱”// 伪代码 getData(function (a) { getMoreData(a, function (b) { getMoreData(b, function (c) { console.log(c); }); }); });Promise 不是要通过复杂的概念消灭回调而是提供一种更规范、更扁平的写法。简单理解Promise 是一个表示“未来结果”的对象。它有三种状态pending等待中、fulfilled已成功、rejected已失败。状态一旦从等待变为成功或失败就不会再改变。用 Promise 改写上面的结构getData() .then(a getMoreData(a)) .then(b getMoreData(b)) .then(c console.log(c)) .catch(err console.error(err));代码变成了一条链逻辑顺序从上到下一目了然。3.2 做一个模拟异步加载的例子我们用代码演示一下。假设我们要从一组数据里随机返回一条文案但为了模拟网络延迟我们加了一个定时器function fetchRandomItem() { return new Promise((resolve, reject) { setTimeout(() { const items [写文章, 读代码, 整理笔记]; const randomIndex Math.floor(Math.random() * items.length); resolve(items[randomIndex]); }, 1000); }); }用的时候fetchRandomItem() .then(item { console.log(今天要做 item); }) .catch(err { console.log(出错了, err); });注意new Promise的回调里有两个参数resolve和reject。成功的时候调用resolve(value)失败的时候调用reject(error)。resolve传出去的值会进入.then的第一个参数回调reject传出去的错误会进入.catch。如果 Promise 执行过程中抛出异常也会被.catch捕获这一点在实战中非常有用。3.3 async/await用同步的写法去写异步async/await是更进一步的语法糖。await会暂停函数内的代码直到它后面的 Promise 出结果。但有个前提await只能在async函数里使用。async function showItem() { try { const item await fetchRandomItem(); console.log(今天要做 item); } catch (err) { console.log(出错了, err); } }用async/await再配合try/catch代码读起来就跟同步代码一样了没有回调嵌套也没有.then链。在实际项目里我大部分时候都用async/await只有在需要多个互不依赖的 Promise 并行执行时才会专门用Promise.all。有一个细节提醒一下await后面如果跟的不是 Promise而是一个普通值JavaScript 会直接把这个值返回相当于帮你做了一次“包装拓展”。所以写await someValue是不报错的但没什么意义除非你确定那个值可能是 Promise。异步编程是初学者最容易卡住的地方。你说难吧它其实就是“等”和“拿”两个动作说不难吧this、作用域、执行顺序交织在一起真的很绕。我自己的经验是不要一开始就纠结事件循环的底层细节先会用new Promise包一层模拟请求会用async/await拿到结果等你写的异步代码多了再反过头看事件循环一切就自然通了。4. 从脚本到页面DOM 操作的完整实战4.1 获取元素与事件监听让页面开始响应前面讲了很多语法和逻辑层面的东西但它们最终都得落到页面上。页面上的每个按钮、输入框、列表在 JavaScript 里都对应一个 DOM 节点。你操作 DOM页面就会变化。获取元素最常见的方式是document.getElementById和document.querySelector。它们各有优势const input document.getElementById(todo-input); const list document.querySelector(.todo-list); const addBtn document.querySelector(#add-btn);querySelector的能力更强能像 CSS 选择器一样精确定位元素但性能上不如getElementById。在待办事项这种小页面里用哪个都行我更推荐querySelector因为它的规则统一后期维护方便。绑定事件监听addBtn.addEventListener(click, function () { const value input.value.trim(); if (!value) return; // 空内容直接忽略 addTodo(value); input.value ; });这里有一个关键点input.value必须在事件触发时去取不能放在事件绑定之前取。很多新手会这样写// 错误示范 const value input.value.trim(); addBtn.addEventListener(click, function () { addTodo(value); });页面加载的时候用户还没输入任何内容value就已经被读出来了后面点击按钮使用的全是空字符串。这个错误很隐蔽因为它没有任何报错你就是发现点了按钮没反应。控制台打印一下value就能看出来问题难的是你要意识到“取值时机”这件事。4.2 渲染列表数据和页面之间的桥梁渲染的本质就是把数组变成 HTML 字符串再放进页面的某个容器里。我用一个函数来完成这件事function render() { list.innerHTML todos.map(item { const cls item.done ? done : ; return li class${cls}>list.addEventListener(click, function (e) { if (!e.target.classList.contains(del-btn)) return; const id Number(e.target.closest(li).dataset.id); todos todos.filter(todo todo.id ! id); render(); });当用户点击删除按钮时事件会冒泡到list在list的监听函数里判断点击的目标是否是.del-btn。如果是就通过closest(li)找到对应的列表项再通过>function save() { localStorage.setItem(my-todos, JSON.stringify(todos)); } function load() { const saved localStorage.getItem(my-todos); return saved ? JSON.parse(saved) : []; }一个常见的坑是用户第一次打开页面时localStorage里还没有数据getItem返回的是null直接JSON.parse(null)会得到null而不是数组。如果你不做保护后面所有针对数组的操作都会报错。所以我在load函数里做了判断有保存的数据才解析没有就返回空数组。在对待办事项做增删改的时候每次操作完都要记得调用save()不然页面显示正常一刷新就回到了旧状态。这里我推荐一个做法所有修改todos的逻辑集中在一个地方处理然后在每个修改动作末尾统一save()和render()。不要满屏幕乱调否则排查问题的时候你会找不到数据到底是在哪一步被改坏的。5. 常见问题与排查思路这些坑我替你踩过了这一节我整理几个初学者在写这种项目时最容易碰到的报错和现象每条都附上排除思路。5.1document.getElementById获取到null原因脚本放在 HTML 里需要操作的按钮、输入框之前执行了。浏览器从上往下解析页面如果 JavaScript 写在head里此时下面的 DOM 还没被解析出来自然拿不到。解决把script放到/body之前DOM 解析完再执行 JS。或者把逻辑包在DOMContentLoaded事件里。也可以用defer属性延迟脚本执行。5.2 数据改动后页面没有刷新这是最高频的问题。比如todos.push(newTodo)之后页面上没出现新条目。原因很可能只是因为你忘了调用render()。记住一点JavaScript 操作数组后页面不会自动更新必须手动把最新的数据渲染到 DOM 上。我把“数据 → 渲染”看作一条流水线每次改完数据都要问自己一句渲染函数调用了吗5.3filter之后数组没有变两种情况。第一种是没接住返回值写了todos.filter(...)就以为todos变了但filter不会修改原数组它返回的是新数组必须赋值回去todos todos.filter(...)。第二种是数组是用const声明的赋值回去会报错。解决方案是把todos用let声明或者干脆把新的数组赋值给一个新变量再使用。5.4 点击删除按钮结果整条列表都没了常见原因是innerHTML渲染出的按钮没有绑定事件然后你把事件绑在了按钮上刷新后按钮重新生成事件丢失。或者是在事件委托里判断条件写得不对导致所有点击都走到了删除逻辑。排查时先打印一下e.target看看点击的时候到底点到了什么再逐步检查条件判断。5.5async/await报错偏向语法解析如果你在普通函数里写await控制台会报错因为await只能在async函数里用。解决方法在函数前面加上async关键字。另外async函数返回的是一个 Promise如果你在async函数外面直接拿返回值会拿到 Promise 而不是你想要的数据。// 错误示范 const result showItem(); // 这是一个 Promise不是结果 // 正确做法 async function init() { await showItem(); }5.6map结果里存的不是列表而是一堆逗号如果你不小心把map返回的数组直接放进了innerHTML页面会显示成“a,b,c”。因为数组会被强制转成字符串元素之间用逗号隔开。记得先.join()把数组变成干净的字符串。5.7 控制台看不到任何报错但页面就是不动这个可以说最棘手。我的建议是先检查事件是否绑定成功再检查数据有没有改变最后检查渲染是否执行。三个环节里总有一个断了。在浏览器控制台里打断点或者加console.log一步一步查。很多新手不爱打日志喜欢盯着代码空想这是效率最低的调试方式。你在addEventListener里、在render函数的开头、在.filter之后各打一行日志报错的定位往往几十秒就能完成。5.8 页面初始化时旧数据和新数据混在一起这通常是因为你没有在初始化时先load()再render()。正确顺序是页面加载 → 从localStorage读出数据 → 赋给todos→ 调用渲染。如果你初始化时直接用了一个空数组那刷新后读出来的旧数据自然就没了。写在后面一点学习上的体会如果让我给这个阶段的学习者一个建议那就是不要光看一定要动手把这个待办事项项目从头到尾写出来。写不出来就去参考别人的代码理解每一行的作用然后合上参考自己再写一遍。我见过很多同学刷完教程式视频后觉得自己全会了一旦关掉视频连一个按钮点击都写不出来。差别就在于有没有亲手把逻辑从头构造一遍。写的过程里报错是家常便饭。别慌也别急着复制代码。先看报错信息猜一下它出现在哪个环节然后打日志去验证。调试得多了你才是真的在进步。等这个项目能流畅地“新增、删除、刷新还在”之后你可以试着加一个编辑功能或者加一个按时间排序的筛选让它变成你独有的版本。带着自己的问题去写代码比跟着任何教程写十遍都有用。