主题
2-20阶段案例-登录页tab栏切换
本页对应 第二章WebAPIs,共 5 段视频,约 44 分钟。
学习目标
- 能使用事件委托为登录页 tab 栏注册点击事件,并通过
data-id获取当前点击项索引,完成active类的高亮切换。 - 能运用排他思想控制登录方式内容区显示:先循环隐藏所有
tab-pane,再根据索引将对应内容区设置为display: block。 - 能监听表单
submit事件并阻止默认提交,使用required校验用户名和密码非空,使用复选框checked属性判断是否勾选协议,未勾选时提示并终止提交。 - 能在登录校验通过后将用户名写入
localStorage,并通过location.href跳转到首页。 - 能封装首页用户信息渲染函数,根据
localStorage中是否存在用户名动态渲染“已登录/未登录”两种结构,并在页面加载时调用。 - 能实现首页退出登录与移动端跳转:点击退出登录后删除本地存储并重新渲染页面;使用
navigator判断移动端并跳转对应页面。
知识点
先明确本节笔记的边界
本节笔记只有一句核心内容:给 input 注册 change 事件,值被修改并且失去焦点后触发。笔记没有附图片和原始代码,因此下面不虚构图片;代码用最小示例说明。转写稿主要讲登录页 tab 栏切换、表单提交、首页渲染等阶段案例,并没有专门讲 change 事件。因此这里只依据笔记原句扩写,不把转写稿里的 tab 栏切换硬说成 change 事件讲解;但会借用转写稿中相邻的表单调试习惯,帮助理解“先验证触发时机,再写业务”的过程。转写稿中的“台不栏/他把它”应理解为 tab 栏,“百度存储/本身身处”应理解为本地存储,“requid”应理解为 required,“check 属性”应理解为 checked。
知识点一:change 的触发时机
为什么需要: 表单输入如果每敲一个字符就处理,容易频繁触发逻辑,比如校验、搜索、联动。change 更像“用户完成一次修改”的信号,适合等用户改完并离开输入框后再读取结果。
怎么想: 先记住笔记原句的两个条件:一是“值被修改”,二是“失去焦点”。只失焦但值没变,不触发;只改值但不失焦,也不触发。可以把 change 理解成“提交式”的时机,而不是“实时输入”的时机。
怎么做: 先写一个 input,注册 change,在回调里打印值和触发提示,确认时机:
html
<input id="username" type="text" placeholder="请输入用户名">
<script>
const username = document.querySelector('#username');
username.addEventListener('change', function () {
console.log('change 触发了');
console.log(this.value);
});
</script>调试时先输入内容但不点别处,观察控制台不触发;再点击页面空白处让 input 失焦,观察 change 触发。若值没改就失焦,也不应触发。
易错点: 把 change 当成 input 事件,以为每输入一个字符就触发;或者只改值不离开输入框,就误以为事件失效。转写稿视频3中调试表单提交时,也是先注册 submit、观察 required 提示、再逐步加判断;这种“先看触发,再写业务”的顺序同样适用于 change。
知识点二:给 input 注册 change 事件
为什么需要: 只有把事件注册到具体 input 上,回调才能在用户完成修改时执行。注册对象错了,后面读值、校验都没有意义。
怎么想: 先获取 input,再 addEventListener('change', 回调)。回调里通过 this.value 或 e.target.value 读取当前值。笔记只要求 input,所以不要一上来扩展到复杂表单。
怎么做:
html
<input id="username" type="text">
<script>
const username = document.querySelector('#username');
username.addEventListener('change', function (e) {
// this.value 与 e.target.value 都可读当前值
console.log('当前值:', e.target.value);
});
</script>如果要判断“值被修改”,可以在 change 回调里读取值后做非空判断、长度判断或后续联动。
易错点: 选择器写错导致获取不到元素;在回调外读取 value,拿到的是旧值;把 e.target 写成 e.target.value 之外的对象。转写稿中多次强调获取元素后再注册事件、再判断条件,例如表单提交案例先获取 form 再注册 submit;这个顺序不能颠倒。
知识点三:在 change 回调里读取值并做业务
为什么需要: change 的价值不只是“触发”,而是触发后拿到用户完成修改的值,再决定校验、提示或联动。
怎么想: 回调内部先读值,再判断,再执行。若业务需要实时反馈,应改用 input 事件;若业务需要“改完再处理”,用 change 更稳。
怎么做:
html
<input id="username" type="text" placeholder="请输入用户名">
<p id="tip"></p>
<script>
const username = document.querySelector('#username');
const tip = document.querySelector('#tip');
username.addEventListener('change', function (e) {
const value = e.target.value.trim();
if (value === '') {
tip.textContent = '用户名不能为空';
} else {
tip.textContent = '你输入的是:' + value;
}
});
</script>调试时先输入、失焦,观察提示是否在失焦后更新;再清空、失焦,观察空值分支。
易错点: 在 change 里做“每输入一个字就提示”的实时效果,会与事件时机不符;读取值时忘记 trim() 导致空格被当成有效内容;把 change 和 submit 混用,以为 change 触发就等于表单提交。转写稿视频3中判断复选框是否勾选时,调试打印的是 checked 的 true/false,而不是想当然读 value;这提醒我们:读控件状态要读对应属性,读 input 文本才读 value。
小结: 本节只抓一句话:给 input 注册 change,值被修改并且失去焦点后触发。先验证时机,再注册事件,最后在回调里读值做业务;不要把 change 当成实时 input,也不要编造转写稿没有讲过的 change 案例。
先认识 classList.contains()

本节笔记只记一个核心方法:
js
元素.classList.contains('类名')它的作用是:看看这个元素有没有包含某个类。如果包含,返回 true;如果没有包含,返回 false。注意它只负责“判断”,不会帮你添加类,也不会帮你删除类。
转写稿里做登录页 tab 栏切换时,反复出现 active 类:点谁,谁就加上 active,下面出现绿色下划线;同时要先把上一个 active 移除。这里就能理解“判断是否有类”的用处:在添加或移除之前,先知道当前元素到底有没有这个类,代码会更稳,调试也更清楚。
为什么需要判断是否有类
做 tab 栏切换时,常见流程是“先移除上一个高亮,再给当前点击项添加高亮”。转写稿里也是这么做的:先移除 active,再给当前 A 添加 active。如果每次都不判断,直接 remove 再 add,一般也能跑;但在一些场景里,我们需要知道“它现在是不是已经高亮”,再决定要不要继续操作。比如用户重复点击同一个 tab,如果它已经是 active,就没必要重复添加;调试时也要确认 active 到底加到了哪个元素上。
所以 contains 的价值是:把“判断”和“操作”分开。先判断,再决定加还是删,逻辑更清楚。
怎么想:先拿元素,再判断类名
思路很简单:
- 先拿到要判断的元素,例如当前点击的
A; - 调用
元素.classList.contains('active'); - 返回值是布尔值,直接放进
if里使用。
转写稿里通过事件委托拿到 e.target,再判断 e.target.tagName === 'A',确认点的是链接。这个思路同样适用于 contains:先确认元素,再判断类。
怎么做:基本写法与 tab 场景
基本写法:
js
const isActive = 元素.classList.contains('active');
console.log(isActive); // true 或 false放进 if 里:
js
if (元素.classList.contains('active')) {
// 已经有 active 类
} else {
// 没有 active 类
}结合 tab 栏切换,可以这样理解:
js
const a = e.target;
if (a.tagName === 'A') {
console.log(a.classList.contains('active')); // 调试:当前 A 是否高亮
a.classList.add('active');
}转写稿里的实际做法是“先移除上一个 active,再给当前添加 active”,并没有用 contains 做分支判断。这并不矛盾:contains 是判断工具,add/remove 是操作工具。你可以继续用“先移除后添加”的排他思想,也可以在需要时先用 contains 判断当前状态。
调试时也可以像转写稿里打印 e.target.dataset.id 一样,打印 contains 的结果,确认当前点击项是否已经带有 active。
易错点
第一,类名不要带点。写 contains('active'),不要写 contains('.active'),因为 classList 里存的是类名本身,不带选择器里的点。
第二,contains 返回的是布尔值,不要写成赋值。例如 if (el.classList.contains('active') = true) 是错误写法,直接写 if (el.classList.contains('active')) 即可。
第三,一次只判断一个类名。contains 不是选择器,不能写复杂选择器;要判断多个类,就分别调用。
第四,先确保元素存在。如果 querySelector 没拿到元素,直接调用 classList.contains 会报错。转写稿里也是先获取 tab 父容器,再注册点击事件,这个顺序不能乱。
第五,注意大小写和类名拼写。active 和 Active 不是同一个类名,写错就会一直返回 false。
总结:元素.classList.contains('类名') 是“只读判断”,返回 true/false。在 tab 切换、高亮状态确认、调试类名是否添加成功时都好用;真正添加和移除仍然用 classList.add() 和 classList.remove()。
课件图示







代码示例
代码段一:登录页 Tab 栏切换(事件委托 + 排他思想 + data-id)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>登录 - Tab 栏切换</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
li { list-style: none; }
a { text-decoration: none; color: #333; }
.login { width: 380px; margin: 60px auto; }
/* 上盒子 */
.tab-nav { display: flex; border-bottom: 1px solid #e4e4e4; }
.tab-nav li { flex: 1; text-align: center; }
.tab-nav a { display: block; padding: 12px 0; font-size: 16px; position: relative; }
.tab-nav a.active { color: #27ba9b; font-weight: 700; }
.tab-nav a.active::after {
content: ''; position: absolute; left: 0; bottom: -1px;
width: 100%; height: 2px; background: #27ba9b;
}
/* 下盒子 */
.tab-pane { display: none; padding: 20px 0; }
.tab-pane:first-of-type { display: block; }
.tab-pane input { width: 100%; height: 40px; margin-bottom: 12px; padding: 0 10px; border: 1px solid #ddd; }
.tab-pane button { width: 100%; height: 40px; background: #27ba9b; color: #fff; border: 0; cursor: pointer; }
.qrcode { height: 180px; display: flex; align-items: center; justify-content: center; color: #999; }
</style>
</head>
<body>
<div class="login">
<!-- 上盒子:两个 A 链接,用 data-id 记录索引号 -->
<ul class="tab-nav">
<li><a href="javascript:;" class="active" data-id="0">账户登录</a></li>
<li><a href="javascript:;" data-id="1">二维码登录</a></li>
</ul>
<!-- 下盒子:两个 tab-pane,第二个默认隐藏 -->
<div class="tab-pane">
<input type="text" placeholder="请输入用户名">
<input type="password" placeholder="请输入密码">
<button>登录</button>
</div>
<div class="tab-pane">
<div class="qrcode">二维码登录区域</div>
</div>
</div>
<script>
// 1. 获取上盒子的父级 ul
const tabNav = document.querySelector('.tab-nav')
// 2. 事件委托:点击事件注册到 ul 上,点击里面的 A 也能触发
tabNav.addEventListener('click', function (e) {
// 3. 只有点到 A 链接才处理
if (e.target.tagName === 'A') {
// 4. classList.contains() 判断有没有包含某个类,有返回 true,没有返回 false
if (e.target.classList.contains('active')) return
// 5. 排他思想:先把上一个 active 移除
// 等价写法:document.querySelector('.tab-nav .active')
const active = tabNav.querySelector('.active')
if (active) active.classList.remove('active')
// 6. 再给当前点击的 A 添加 active(高亮 + 下划线)
e.target.classList.add('active')
// 7. 拿到当前点击链接的索引号
const index = e.target.dataset.id
// 8. 先让所有下盒子全部隐藏
const tabPanes = document.querySelectorAll('.tab-pane')
for (let i = 0; i < tabPanes.length; i++) {
tabPanes[i].style.display = 'none'
}
// 9. 再让对应索引号的下盒子显示
tabPanes[index].style.display = 'block'
}
})
</script>
</body>
</html>关键行与易错点
e.target.tagName === 'A':tagName返回的是大写,写成'a'判断永远不成立;不判断的话点li空白处也会触发。classList.contains('active'):返回布尔值,本案例用来“已经是当前项就不再重复处理”,和笔记中的用法一致。- 排他顺序必须是先移除、后添加;同理下盒子必须先全部隐藏、再让对应的显示,写反了页面上会同时看到两个盒子。
tabNav.querySelector('.active')只在ul内部查找,比document.querySelector('.tab-nav .active')范围更小;注意类名前的点不能漏。e.target.dataset.id拿到的是字符串'0'/'1',当作数组下标会自动隐式转换,但不要拿它去做===数字比较。- 初始状态下第一个盒子靠 CSS 的
.tab-pane:first-of-type显示,JS 里设置的是行内样式,行内样式优先级更高,所以切换不会失效。
代码段二:登录表单提交(submit + preventDefault + required + checked + 本地存储)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>登录提交</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
.login-form { width: 380px; margin: 60px auto; }
.login-form input[type="text"],
.login-form input[type="password"] {
width: 100%; height: 40px; margin-bottom: 12px; padding: 0 10px; border: 1px solid #ddd;
}
.login-form label { display: block; font-size: 14px; margin-bottom: 12px; }
.login-form button { width: 100%; height: 40px; background: #27ba9b; color: #fff; border: 0; cursor: pointer; }
</style>
</head>
<body>
<form class="login-form">
<!-- required:浏览器原生非空校验,没填就不会提交 -->
<input type="text" name="username" placeholder="请输入用户名" required>
<input type="password" name="password" placeholder="请输入密码" required>
<label><input type="checkbox" class="remember"> 我已阅读并同意用户协议</label>
<button type="submit">登录</button>
</form>
<script>
// 获取表单、复选框、用户名输入框
const form = document.querySelector('form')
const remember = document.querySelector('.remember')
const username = document.querySelector('[name="username"]')
// change 事件:值被修改并且失去焦点后触发
username.addEventListener('change', function () {
console.log('change 触发了,当前用户名:', username.value)
})
// 注册提交事件
form.addEventListener('submit', function (e) {
// 1. 阻止默认的提交行为(本案例要用 location.href 自己跳转)
e.preventDefault()
// 2. 用户名、密码不能为空:由 input 的 required 属性完成校验
// 3. 判断“我同意”是否勾选,checked 返回 true / false
if (!remember.checked) {
alert('请您勾选同意协议')
return // 终止程序,不再往下执行
}
// 4. 记录用户名到本地存储
localStorage.setItem('小兔仙-username', username.value)
// 5. 跳转到首页
location.href = './index.html'
})
</script>
</body>
</html>关键行与易错点
required是 HTML 原生校验:校验不通过时submit事件根本不会触发,浏览器自己弹出“请填写此字段”,所以preventDefault()不会影响它。e.preventDefault()只阻止“表单默认提交(刷新页面)”,不阻止你后面的 JS 代码继续执行。remember.checked拿到的是布尔值,!remember.checked表示“没勾选”;写成remember.checked === false也可以,但别写成remember.value。- 判断不通过必须
return,否则会继续往下跳转。 change与input事件的区别:change是值被修改并且失去焦点才触发,输入过程中不触发。localStorage只能存字符串,取不到的键返回null;location.href的路径要写对,否则跳 404(本示例需同级存在index.html)。
代码段三:首页渲染用户名 + 退出登录 + 移动端自动跳转
js
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>小兔仙首页</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
li { list-style: none; }
a { text-decoration: none; color: #333; }
.nav { display: flex; justify-content: flex-end; padding: 20px 40px; background: #f5f5f5; }
.nav li { margin-left: 20px; }
.nav a { font-size: 14px; }
</style>
</head>
<body>
<!-- li 结构不变,变的是 li 里面的 a -->
<ul class="nav">
<li><a href="./login.html">请先登录</a></li>
<li><a href="./register.html">免费注册</a></li>
</ul>
<script>
// 1. 获取第一个 li,第二个 li 用下一个兄弟节点拿
const firstLi = document.querySelector('.nav li')
const secondLi = firstLi.nextElementSibling
// 2. 封装渲染函数(页面打开要调用一次,退出登录时还要再调用)
function render() {
// 读取本地存储里登录页面存下的用户名
const data = localStorage.getItem('小兔仙-username')
if (data) {
// 有数据:按照已登录结构渲染
firstLi.innerHTML = `<a href="javascript:;"><i class="iconfont icon-user"></i>${data}</a>`
secondLi.innerHTML = `<a href="javascript:;">退出登录</a>`
} else {
// 没有数据:按照未登录结构渲染
firstLi.innerHTML = `<a href="./login.html">请先登录</a>`
secondLi.innerHTML = `<a href="./register.html">免费注册</a>`
}
}
render() // 页面一打开先渲染一次
// 3. 退出登录:事件委托注册到第二个 li 上
secondLi.addEventListener('click', function (e) {
if (e.target.tagName === 'A') {
// 删除本地存储的数据
localStorage.removeItem('小兔仙-username')
// 重新渲染,读不到数据就会复原成默认结构
render()
}
})
// 4. 如果是移动端打开,自动跳到移动端页面
;(function () {
if (navigator.userAgent.match(/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i)) {
location.href = 'https://m.itcast.cn'
}
})()
</script>
</body>
</html>关键行与易错点
- 必须封装
render()函数:页面初次加载要渲染,退出登录后还要重新渲染一次,写成两段代码会重复。 if (data)利用了“本地存储取不到时返回null(假值)”这一特性,不需要额外判断=== null。firstLi.nextElementSibling取的是元素兄弟;写成nextSibling会拿到换行产生的文本节点,导致取不到第二个li。- 有标签结构必须用
innerHTML,用innerText会把<a>当纯文本显示出来。 removeItem()之后一定要render(),只删数据不重新渲染,页面上用户名不会消失。- 立即执行函数前加分号
;(function(){...})(),防止和上一行代码连在一起报错。 navigator.userAgent是 BOM 的navigator对象属性,判断设备时要在页面一开始执行。- 两个页面的存储键名(
'小兔仙-username')必须完全一致,且要同源(同一目录下)才能读到。
常见错误
- 现象:输入框内容改了但断言不触发;原因:监听
change却未失焦就判断;修法:测试时先blur(),或改用input事件做即时校验。 - 现象:
contains判断类名总是false;原因:传入".active"或"active show";修法:传单个类名且不带点,如classList.contains('active')。 - 现象:tab 高亮出现多个或点完消失;原因:先加当前类再移除所有类,排他顺序错;修法:先移除所有/上一个
active,再给当前添加。 - 现象:切换后两个下盒子同时显示;原因:只给当前盒子
display:block,未先隐藏全部;修法:循环先把所有面板设为none,再显示索引对应项。 - 现象:表单仍刷新/跳转;原因:
submit回调未首行阻止默认行为,或按钮类型误用;修法:回调开头e.preventDefault(),并检查按钮type。 - 现象:勾选协议判断失效;原因:把
checked当方法或读value;修法:统一读checkbox.checked布尔值。 - 现象:首页读不到登录用户名;原因:登录页与首页
localStorage的 key 不一致;修法:统一 key,如都使用小兔仙-uName。 - 现象:退出登录按钮点击无反应;原因:按钮由
innerHTML动态渲染后仍绑定旧节点;修法:渲染后重新绑定,或用事件委托。 - 现象:移动端打开后反复跳转;原因:跳转脚本在目标移动页也执行;修法:加当前端/目标页判断,避免循环跳转。
练习
- 完成 tab 上下联动:点击上方链接,上方高亮唯一,下方仅显示对应面板;验收:快速连续点击 5 次无多高亮、无多面板同时显示。
- 完成登录提交:空用户名/密码被拦截,未勾选协议被拦截,通过后写入用户名并跳转首页;验收:刷新首页仍能读到刚写入的用户名。
- 完成首页渲染函数:有本地数据渲染用户名和“退出登录”,无数据渲染“请登录/免费注册”;验收:手动删除本地存储后刷新,页面恢复未登录结构。
- 完成退出登录:点击后删除指定本地存储并重新渲染;验收:控制台对应 key 消失,页面立即恢复默认结构。
- 为移动端跳转加保护:仅在 PC 首页执行跳转,移动页不重复跳;验收:移动端模拟刷新后不出现循环跳转。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 阶段案例-登录页面tab栏切换上 | 10:12 |
| 2 | 阶段案例-登录页面tab栏切换下 | 07:01 |
| 3 | 阶段案例-登录页面提交跳转业务 | 11:11 |
| 4 | 阶段案例-首页渲染用户名 | 12:03 |
| 5 | 阶段案例-首页退出登录和跳转移动端页面 | 04:28 |
小结
- tab 切换要抓住“事件委托 + 排他”,顺序必须是先清除再设置。
- 上下盒子联动优先用
data-id索引对应,避免靠文本或层级硬猜。 - 表单提交场景先阻止默认行为,再做协议勾选、存储和跳转。
- 跨页面数据依赖本地存储时,key 必须统一,写入和读取要成对检查。
- 动态渲染出的按钮不要提前绑定旧节点,渲染后重绑或使用事件委托。
- 退出登录的本质是删除指定存储并重新执行渲染函数。
- 移动端跳转必须加条件保护,防止目标页再次执行造成循环。
- 调试时优先看控制台中的类名、
checked、本地存储 key 和当前索引。