Skip to content

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 的价值是:把“判断”和“操作”分开。先判断,再决定加还是删,逻辑更清楚。

怎么想:先拿元素,再判断类名 ​

思路很简单:

  1. 先拿到要判断的元素,例如当前点击的 A;
  2. 调用 元素.classList.contains('active');
  3. 返回值是布尔值,直接放进 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()。

课件图示 ​

课件第35页:步骤 小兔鲜页面注册 业务②: 表单验证业务 1. 用户名验证(注意封装函数 v

课件第41页:步骤 小兔鲜页面注册 需求④: 下一步提交验证业务 1. 使用 submit 提

课件第48页:案例 小兔鲜首页页面 核心思路:判断本地存储有没有数据(用户名) 如果有就显示

课件第39页:步骤 小兔鲜页面注册 业务③: 已阅读同意业务 1. 注册点击事件,可以 tog

课件第44页:案例 小兔鲜登录页面 业务①: tab切换 关键词: 事件委托,排他思想

课件第45页:案例 小兔鲜登录页面 业务①: tab切换 总结方案 关键词: 事件委托,排他思

课件第46页:案例 小兔鲜登录页面 业务②: 点击登录可以跳转页面 1. 注册表单提交事件,阻

代码示例 ​

代码段一:登录页 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')必须完全一致,且要同源(同一目录下)才能读到。

常见错误 ​

  1. 现象:输入框内容改了但断言不触发;原因:监听 change 却未失焦就判断;修法:测试时先 blur(),或改用 input 事件做即时校验。
  2. 现象:contains 判断类名总是 false;原因:传入 ".active" 或 "active show";修法:传单个类名且不带点,如 classList.contains('active')。
  3. 现象:tab 高亮出现多个或点完消失;原因:先加当前类再移除所有类,排他顺序错;修法:先移除所有/上一个 active,再给当前添加。
  4. 现象:切换后两个下盒子同时显示;原因:只给当前盒子 display:block,未先隐藏全部;修法:循环先把所有面板设为 none,再显示索引对应项。
  5. 现象:表单仍刷新/跳转;原因:submit 回调未首行阻止默认行为,或按钮类型误用;修法:回调开头 e.preventDefault(),并检查按钮 type。
  6. 现象:勾选协议判断失效;原因:把 checked 当方法或读 value;修法:统一读 checkbox.checked 布尔值。
  7. 现象:首页读不到登录用户名;原因:登录页与首页 localStorage 的 key 不一致;修法:统一 key,如都使用 小兔仙-uName。
  8. 现象:退出登录按钮点击无反应;原因:按钮由 innerHTML 动态渲染后仍绑定旧节点;修法:渲染后重新绑定,或用事件委托。
  9. 现象:移动端打开后反复跳转;原因:跳转脚本在目标移动页也执行;修法:加当前端/目标页判断,避免循环跳转。

练习 ​

  1. 完成 tab 上下联动:点击上方链接,上方高亮唯一,下方仅显示对应面板;验收:快速连续点击 5 次无多高亮、无多面板同时显示。
  2. 完成登录提交:空用户名/密码被拦截,未勾选协议被拦截,通过后写入用户名并跳转首页;验收:刷新首页仍能读到刚写入的用户名。
  3. 完成首页渲染函数:有本地数据渲染用户名和“退出登录”,无数据渲染“请登录/免费注册”;验收:手动删除本地存储后刷新,页面恢复未登录结构。
  4. 完成退出登录:点击后删除指定本地存储并重新渲染;验收:控制台对应 key 消失,页面立即恢复默认结构。
  5. 为移动端跳转加保护:仅在 PC 首页执行跳转,移动页不重复跳;验收:移动端模拟刷新后不出现循环跳转。

原视频对照 ​

#视频时长
1阶段案例-登录页面tab栏切换上10:12
2阶段案例-登录页面tab栏切换下07:01
3阶段案例-登录页面提交跳转业务11:11
4阶段案例-首页渲染用户名12:03
5阶段案例-首页退出登录和跳转移动端页面04:28

小结 ​

  1. tab 切换要抓住“事件委托 + 排他”,顺序必须是先清除再设置。
  2. 上下盒子联动优先用 data-id 索引对应,避免靠文本或层级硬猜。
  3. 表单提交场景先阻止默认行为,再做协议勾选、存储和跳转。
  4. 跨页面数据依赖本地存储时,key 必须统一,写入和读取要成对检查。
  5. 动态渲染出的按钮不要提前绑定旧节点,渲染后重绑或使用事件委托。
  6. 退出登录的本质是删除指定存储并重新执行渲染函数。
  7. 移动端跳转必须加条件保护,防止目标页再次执行造成循环。
  8. 调试时优先看控制台中的类名、checked、本地存储 key 和当前索引。