1. 实验14-16到底在练什么:从“能写页面”到“能写应用”
如果你的Web前端开发技术课程已经上到实验14,我基本可以断定:你已经熬过了最枯燥的HTML标签、CSS盒子模型和JavaScript基础语法,马上就要进入这门课真正值钱的部分了。多数学校把前13个实验安排成“地基工程”——写一个静态网页、调一个浮动布局、用JS计算一个斐波那契数列、操作一下DOM节点。这些实验做完了,你的感受往往是“我会写了,但这些东西凑在一起能干嘛?”
实验14到16,就是为了回答这个“能干嘛”而存在的。这三组实验和前面的最大区别在于两个字:联动。页面上的按钮开始真正触发数据变化,数据变化之后界面会自动更新,刷新浏览器之后用户的操作还能被记住。简单说,你要从一个“能画出页面的人”,变成“能做出应用的人”。
先说清楚这三组实验在整个课程体系里的定位。前端开发的核心能力可以拆成三块:结构(HTML)、表现(CSS)、行为(JavaScript)。如果你前面的实验已经能解决结构和表现,那么14-16几乎全部压在“行为”这一块上,而且是行为里最难的三类:异步请求、数据持久化、综合状态管理。具体来说:
- 实验14通常围绕“前端怎么和后端要数据”展开,核心是AJAX、Fetch、JSON解析,以及渲染数据时的DOM操作。
- 实验15一般考“数据存哪儿”,核心是localStorage、sessionStorage、Cookie这三兄弟的选型、读写和边界问题。
- 实验16基本就是终极考核,把事件绑定、DOM渲染、异步交互、本地存储串成一个完整的小应用,比如待办清单、留言板、成绩管理系统。
我见过太多同学在实验13之前顺风顺水,到了14-16突然卡住,卡的原因其实不是知识点难,而是习惯了“写完就完了”的做题思维,面对多环节协作时不知道先从哪下手。这篇文章我就按这套逻辑,把三组实验的常见做法、必踩的坑、以及我当时是怎么一步步调通的,全部拆给你看。
2. 实验14:异步数据交互——AJAX与Fetch的实操拆解
2.1 题目通常长什么样?核心考点在哪里
实验14的题目版本繁多,但骨架高度相似。最典型的一种是:
利用AJAX技术从服务器接口获取一组学生成绩数据(JSON格式),并将其渲染到页面表格中;要求页面加载时显示“数据加载中”提示,加载失败时显示错误信息,同时需要提供一个“刷新数据”按钮允许用户重新请求。
这种题看起来只有一句话,实际上藏着四个考点:异步请求是否写对、JSON数据是否被正确解析、渲染表格时是否用DOM操作而不是拼接字符串、有没有处理加载状态和异常情况。多数人丢分就丢在最后两项。老师让你写“加载中”提示,不是为了让你多打几行字,而是在考察你是否理解异步操作的基本体验——用户在等待期间必须有反馈,请求失败时必须有兜底,这是真实项目里最基本的要求。
我见过很多同学直接用原生XHR(XMLHttpRequest)写一大坨回调,也能跑通,但在2025年的课程里,我更建议你直接上Fetch,理由下面细说。
2.2 从XMLHttpRequest到Fetch,到底该用哪个
先看一段原生的XHR写法,很多教材还在教这个:
function loadData() { const xhr = new XMLHttpRequest(); xhr.open('GET', 'https://api.example.com/students', true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onreadystatechange = function () { if (xhr.readyState === 4) { if (xhr.status === 200) { const data = JSON.parse(xhr.responseText); renderTable(data); } else { showError('请求失败:' + xhr.status); } } }; xhr.send(); }这段代码没有任何语法问题,但它的问题在于可读性差、嵌套多了之后难以维护。同样的功能用Fetch写,清爽得多:
async function loadData() { const loadingEl = document.getElementById('loading'); const errorEl = document.getElementById('error'); loadingEl.style.display = 'block'; errorEl.style.display = 'none'; try { const response = await fetch('https://api.example.com/students'); if (!response.ok) { throw new Error('HTTP状态码异常:' + response.status); } const data = await response.json(); renderTable(data); } catch (err) { errorEl.textContent = '数据加载失败:' + err.message; errorEl.style.display = 'block'; } finally { loadingEl.style.display = 'none'; } }两者的核心区别在于:XHR要求你手动判断readyState和status两层状态,而Fetch用Promise把“网络请求”和“数据解析”拆成了两步,再用async/await把代码拍扁。我的建议是:如果课程没有强制要求XHR,直接用Fetch。现在的浏览器兼容性已经完全不是问题了,而且写出来的代码更接近工业界实际做法。
注意:Fetch有个新手必踩的坑——它只有在网络连接完全失败(比如断网、域名解析失败)时才会reject,HTTP 404、500这类状态码它照样resolve。所以你必须在拿到response之后手动检查
response.ok。我见过不止一个同学漏掉这一步,接口返回404,页面却显示“请求成功”。
2.3 解析JSON和渲染表格时的关键细节
解析JSON看起来是JSON.parse或者response.json()一句话的事,但数据结构和你的渲染逻辑必须匹配。比如接口返回的是这种包裹对象:
{ "code": 0, "message": "success", "data": [ { "id": 1, "name": "张三", "score": 88 }, { "id": 2, "name": "李四", "score": 75 } ] }前端代码就不能默认data直接是数组,要先判断result.code === 0再做渲染。这在实际项目里叫“和后端约定数据格式”,考的就是你有没有这个意识。
渲染表格的时候,新手最常见的做法是拼HTML字符串:
let html = ''; for (const item of list) { html += '<tr><td>' + item.name + '</td><td>' + item.score + '</td></tr>'; } document.getElementById('tbody').innerHTML = html;这样能跑,但有两个隐患。第一,如果item.name里含HTML标签(比如用户提交了<img onerror="alert(1)">),会被浏览器当成标签解析,这就是XSS注入的雏形;第二,innerHTML每次赋值都会触发浏览器重新解析,频繁大列表操作时性能不佳。我建议用DOM方法构建节点:
function renderTable(list) { const tbody = document.getElementById('tbody'); tbody.innerHTML = ''; // 先清空旧数据 const fragment = document.createDocumentFragment(); list.forEach(item => { const tr = document.createElement('tr'); const tdId = document.createElement('td'); tdId.textContent = item.id; const tdName = document.createElement('td'); tdName.textContent = item.name; const tdScore = document.createElement('td'); tdScore.textContent = item.score; tr.appendChild(tdId); tr.appendChild(tdName); tr.appendChild(tdScore); fragment.appendChild(tr); }); tbody.appendChild(fragment); }用textContent赋值会自动转义HTML字符,从根上避免了XSS问题;用DocumentFragment组装多个节点只触发一次回流,性能也好得多。这个习惯如果实验14就养成,实验16的综合项目会受益很大。
2.4 实验14常见报错排查速查表
我整理了实验14里最高频的几个报错场景,直接对照排查:
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
| 控制台报跨域CORS错误 | 请求的接口域名与页面域名不一致,且服务器未开启CORS | 改用老师提供的本地JSON文件或配置了CORS的代理接口 |
| 页面一直显示“加载中” | 请求挂起或finally逻辑缺失 | 检查接口地址是否能直接访问;确认finally中关闭loading |
渲染出来是[object Object] | 对数组元素直接做了字符串拼接 | 用JSON.stringify调试,确认数据结构后按字段取值 |
| 点击刷新按钮页面报错 | 按钮click事件绑定时,函数被立即执行 | 检查是否写了btn.onclick = loadData(),应该写btn.onclick = loadData |
| 数据出现但表格错位 | 渲染前没有清空tbody旧数据 | 每次渲染前执行tbody.innerHTML = '' |
这里第4个问题我多说一句。btn.onclick = loadData()和btn.onclick = loadData的区别是:前者把loadData的返回值赋给onclick,按钮点击时什么都不发生;后者才是把函数本身交给按钮,点一下执行一次。这个细节几乎每届学生都会有人栽,写的时候多看一眼。
3. 实验15:本地存储实战——localStorage、sessionStorage与前端状态保持
3.1 三种存储方案,到底该选谁
如果说实验14解决的是“程序运行时怎么拿数据”,实验15解决的就是“浏览器关了再打开,数据还在不在”。前端本地存储有三兄弟:Cookie、localStorage、sessionStorage。先看一张对比表:
| 特性 | Cookie | localStorage | sessionStorage |
|---|---|---|---|
| 容量上限 | 约4KB | 约5MB | 约5MB |
| 生命周期 | 由Max-Age/Expires控制 | 永久保存,除非手动清除 | 标签页关闭即清除 |
| 是否随请求发送到服务器 | 是 | 否 | 否 |
| 访问方式 | document.cookie | localStorageAPI | sessionStorageAPI |
| 使用场景 | 会话标识、跨域携带少量数据 | 用户偏好、草稿、购物车 | 临时状态、页面间传值 |
实验15的题目一般会让你做一个“用户偏好设置”或者“表单数据保存”的小功能。比如:用户输入一段文字、勾选一个颜色主题,点击保存后刷新页面,内容还在。这种场景用localStorage是完全正确的,因为数据量小、不需要发给服务器、而且要求持久保存。
3.2 实验15的关键实现:序列化与反序列化
localStorage的API本身只有五个方法:getItem、setItem、removeItem、clear、key。但有个最核心的坑:它只能存字符串。你存一个数字18,取出来也是字符串"18";你存一个对象,直接setItem('user', {name: '张三'}),取出来只会是"[object Object]"。
所以要存对象,必须先JSON.stringify,读取时再JSON.parse。这是实验15的绝对核心,也是评分点。我推荐一个通用的封装写法:
// storage.js —— 一个极简的工具函数集 const storage = { get(key, defaultValue) { try { const raw = localStorage.getItem(key); return raw === null ? defaultValue : JSON.parse(raw); } catch (e) { return defaultValue; // 解析失败时返回默认值,避免页面崩掉 } }, set(key, value) { localStorage.setItem(key, JSON.stringify(value)); }, remove(key) { localStorage.removeItem(key); }, clear() { localStorage.clear(); } };注意get方法里的try...catch:如果某一次存进去的数据格式坏了,或者用户手动在控制台改坏了,JSON.parse会抛异常,整个页面跟着崩。加上这层保护,至少能保证页面正常渲染,最多就是取到默认值——这个处理方式在真实项目里叫“防御性编程”,实验里写出来很加分。
3.3 实验15必看的几个边界问题
第一,容量上限问题。localStorage的5MB看起来大,但如果存的是循环引用的对象或者大量历史记录,很快就满了。存满时setItem会抛QuotaExceededError。我的建议是,在写入前判断一下数据规模,或者每次写入都放在try...catch里,满了之后给出“存储空间已满,请清理后重试”的提示,而不是页面无响应。
第二,不要存敏感信息。这是很多课程不会明说但老师在评分时会看的一点。学生的实验里经常有“记住登录状态”的功能,于是有人把密码直接明文存进localStorage。这是绝对要避免的。localStorage明文数据可以被任何页面脚本读取,没有隐私可言。实验报告里你只需要说明“密码等敏感信息不应存储于localStorage,应存储在服务端session中”,这句话能体现你的工程素养。
第三,页面刷新后的数据恢复时机。实验15会要求“刷新后数据自动回填”,这时候最容易被忽略的是:回填操作必须在DOM结构加载完之后执行。如果你把读取localStorage的代码放在<head>里的脚本中,此时body里的输入框还没生成,document.getElementById('name')拿到的是null,代码直接报错。解决方法是把脚本放到body底部,或者用DOMContentLoaded事件:
document.addEventListener('DOMContentLoaded', () => { const savedNote = storage.get('note', ''); document.getElementById('noteInput').value = savedNote; });3.4 实验15衍生场景:多标签页同步
如果实验15想拿高分,可以主动做一个加分项:多标签页同步。localStorage本身不触发跨页面通知,但浏览器提供了storage事件,当其他标签页修改localStorage时,当前页会收到通知。监听它就能实现“两个标签页同时打开,一边改数据,另一边实时更新”的效果:
window.addEventListener('storage', (e) => { if (e.key === 'themeColor') { document.body.style.backgroundColor = e.newValue; } });这个功能看着高级,代码就几行,放进实验报告里的“扩展功能”部分,比你多写十个console.log都有用。
4. 实验16:综合项目实战——把事件、DOM、异步、存储全部打通
4.1 实验16的项目怎么设计才不容易翻车
实验16通常不再给特别细的步骤,而是给一个完整需求,让你自己实现。常见题目有:待办事项管理、简易留言板、图书借阅管理系统、个人博客后台。不管题目是什么,你需要主动划分出三层结构:数据层、渲染层、交互层。
- 数据层:负责从localStorage读数据、写数据、增删改查。它不关心页面长什么样,只负责维护数据。
- 渲染层:接收数据,生成页面结构。它不关心数据从哪来,只负责把数据显示出来。
- 交互层:监听页面事件,调用数据层和渲染层完成联动。
我见过太多实验16的代码是“一大坨”:所有逻辑都堆在页面的<script>标签里,状态变量散落各处,一个函数里又做数据更新又做DOM操作又做事件绑定。这种代码别人看不懂,你自己调bug的时候也痛苦。分层思想(哪怕只是函数级别的分层)是实验16最值得练习的东西。
4.2 一个可复现的完整例子:带持久化的待办事项应用
下面我给你一个完整的参考实现,功能是:添加待办事项、标记完成、删除事项、刷新后数据不丢。代码不多,但每一层都清晰。
HTML结构:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>实验16:待办事项</title> </head> <body> <div class="app"> <h1>待办事项</h1> <div> <input type="text" id="todoInput" placeholder="输入新的待办事项" /> <button id="addBtn">添加</button> </div> <ul id="todoList"></ul> </div> <script src="app.js"></script> </body> </html>JavaScript(app.js):
// ========= 数据层:所有对localStorage的读写都收口在这里 ========= const store = { TODOS_KEY: 'todos', getAll() { try { const raw = localStorage.getItem(this.TODOS_KEY); const todos = raw ? JSON.parse(raw) : []; return Array.isArray(todos) ? todos : []; } catch (e) { return []; } }, save(todos) { localStorage.setItem(this.TODOS_KEY, JSON.stringify(todos)); } }; // ========= 渲染层:把数据渲染成DOM ========= function render(todos) { const listEl = document.getElementById('todoList'); listEl.innerHTML = ''; if (todos.length === 0) { const li = document.createElement('li'); li.textContent = '暂无待办事项,添加一条吧'; listEl.appendChild(li); return; } const fragment = document.createDocumentFragment(); todos.forEach((todo, index) => { const li = document.createElement('li'); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = todo.done; checkbox.dataset.index = index; const span = document.createElement('span'); span.textContent = todo.text; // 已完成的事项加删除线样式 if (todo.done) { span.style.textDecoration = 'line-through'; span.style.color = '#999'; } const deleteBtn = document.createElement('button'); deleteBtn.textContent = '删除'; deleteBtn.dataset.index = index; li.appendChild(checkbox); li.appendChild(span); li.appendChild(deleteBtn); fragment.appendChild(li); }); listEl.appendChild(fragment); } // ========= 交互层:事件绑定和业务逻辑 ========= let todos = store.getAll(); // 页面加载时从存储中恢复 function addTodo() { const inputEl = document.getElementById('todoInput'); const text = inputEl.value.trim(); if (!text) { alert('请输入待办内容'); return; } todos.push({ text, done: false }); store.save(todos); render(todos); inputEl.value = ''; } function toggleTodo(index) { todos[index].done = !todos[index].done; store.save(todos); render(todos); } function deleteTodo(index) { todos.splice(index, 1); store.save(todos); render(todos); } // 事件绑定 document.getElementById('addBtn').addEventListener('click', addTodo); document.getElementById('todoInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') { addTodo(); } }); // 用事件委托统一处理checkbox和删除按钮 document.getElementById('todoList').addEventListener('click', (e) => { const target = e.target; if (target.type === 'checkbox') { toggleTodo(Number(target.dataset.index)); } else if (target.textContent === '删除') { deleteTodo(Number(target.dataset.index)); } }); // 初始渲染 render(todos);拆开看,这个实现里有几个点值得你在实验报告里专门写一段说明:
第一,为什么用事件委托?如果给每个动态生成的checkbox和按钮都单独绑定事件,每次render()都要重新绑定一次,容易出现重复绑定或者绑定丢失。事件委托把监听器统一挂在父容器todoList上,利用事件冒泡机制,不管子节点是新是旧,点击都能被统一处理。这就是真实项目中常用的做法。
第二,为什么用dataset.index而不是直接把todo对象存在事件里?因为todos数组会变(增删改),而下标索引在每次渲染时都是最新对齐的。虽然简单,但可以避免很多闭包陷阱。
第三,为什么每次操作后都立即store.save(todos)?很多人习惯等到所有操作结束再统一保存,结果一旦中间出错,用户数据全丢。在状态变更的源头就地保存,是最不容易出问题的策略。这也呼应了实验15学的localStorage,实验16的核心就是真实地把实验15的内容用起来。
4.3 联调时最容易忽略的细节
实验16综合了太多知识点,联调阶段常见的坑也是五花八门。我列几个我实际教学和改作业时反复看到的:
刷新按钮/提交按钮触发了表单提交。如果页面里有个<form>,按钮在表单内部且没有设置type="button",点击后浏览器会执行表单提交、刷新页面。这是实验16第一杀手。解决方法:要么给按钮加type="button",要么在submit事件里e.preventDefault()。
初值读取时机不对。拿上面待办事项来说,let todos = store.getAll()必须放在render(todos)之前。如果你把读取放在事件绑定之后,虽然通常也能跑,但一旦渲染函数内部有依赖,顺序错了就白屏。代码规范上我建议:数据初始化 → 渲染 → 绑定事件,这个顺序不要乱。
修改数组后忘记重新渲染。todos.push(...)只是改了内存里的数组,页面上的DOM不会自动更新。很多同学会纠结“为什么我加了数据页面没反应”,这就是原因——你在交互层改了数据,但没有调用render(todos)把新状态同步到页面。以后你学Vue、React,会知道这是“单向数据流”的问题,但现在用原生JS,请记住:每次数据变化,手动执行一次渲染。
5. 关于这三组实验,我最想对你说的话
写完这三组实验,你在前端这条路上就算是真正入门了。回头看你可能会发现,实验14让你懂得了“页面可以向别人要数据”,实验15让你懂得了“页面可以记住自己的状态”,实验16让你把它们拧成了一台能自己运转的小机器。这个转变,比你会背十个API都重要。
最后给你三个我自己的经验:
第一,实验报告别只贴代码,要写“为什么”。我改作业的时候,最烦看到一份报告全是代码、没有任何说明。哪怕你只写一句“这里用事件委托是为了避免重复绑定”,在老师眼里也是独立思考的体现。你踩过的每一个坑,都应该写进报告的“遇到的问题”部分,这不是暴露缺点,这是展示成长过程。
第二,把浏览器开发者工具当成你的第二个屏幕。实验14调试接口看Network面板,实验15看Application面板里的Local Storage,实验16看Console报错和Sources断点。这三组实验如果你能不靠alert弹数据调试,你就已经领先大部分同学了。按F12打开开发者工具,Network、Console、Application这三个面板,是前端调试的三块基石。
第三,别怕把代码推倒重来。做实验16的时候,我见过太多同学在一个错误的方向上越走越远,代码补丁叠补丁,最后自己都看不懂。如果你发现代码已经乱到没法维护,大胆从头开始,花一小时重构,比耗一下午在一堆烂代码里找bug划算得多。写代码这行,删掉重来从来不是浪费时间,它是成本最低的纠错方式。
这三组实验做完,去试试给自己写一个真正想用的小工具吧——记账本、学习打卡、电影收藏夹,都行。当你写的代码真的能帮你解决一个生活里的小问题,你才真正感受到前端的乐趣。