Old-School Front-End / 2026 ・ No.11

古法前端入门

第11讲 登录与页面切换 ── 在每个页面上确认“你是谁?”

发布

以新井 太郎(申请人)登录后的申请列表。页头右侧是名字和退出登录按钮。菜单只有申请列表和仪表盘。本月汇总为申请 5 笔・待审批 79,251 日元・被退回 1 笔。表格最上面是 0012 纽约出差 46,851 日元 审批中
本讲的目标。显示登录者的名字,只列出这个人自己的申请(笔者的演示页面,经由练习用服务器,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月17日。账号是仅限本演示的虚构账号。浏览器边框是为了说明而补画的)

不靠 AI,一行一行亲手写的“古法”前端入门。第11讲是登录和页面切换。
到目前为止,页头上的“新井 太郎(申请人)”只是写在 HTML 里的装饰。
这次做一个登录页面,无论打开申请列表・申请表单・审批页面中的哪一个,都先向服务器确认“你是谁?”。
并且实际试一试在页面上隐藏,和在服务器上拒绝有什么不同。

一

本讲的目标

新井上一讲出现了申请编号!可是前辈,那个申请算是谁提交的呢?

古井现在谁也不是。页头上的“新井 太郎”,还是写在 HTML 里的文字。

新井那我要是改写成“古井 一郎”,岂不是能代替前辈审批……?

古井今天就来做防止这种事的机制。

当前位置:第11讲

1 准备 2 HTML 骨架 3 表单 4 CSS 基础 5 布局 6 Bootstrap 7 JS 基础 8 DOM 与事件 9 输入校验 10 通信 11 登录 12 审批流程 13 统计与打印 14 jQuery 15 收尾 当前位置 准备 HTML CSS JavaScript 实战 收尾
  • 登录页面 ── 用邮箱地址和密码表明身份。输错时不说是哪一个错
  • 每个页面都确认 ── 申请列表・申请表单・审批页面,一打开就先问服务器“你是谁?”。没有登录就去登录页面,登录后回到原来的页面
  • 因人而异 ── 申请列表里只有自己的申请。“审批”菜单只给审批人看,审批的数据服务器也只交给审批人
  • 退出登录 ── 扔掉暗号,回到登录页面

对方是在上一讲的练习用服务器上加了登录和列表功能的版本(内容不是正题)。账号和密码([email protected] / demo-arai 等)是为本演示准备的虚构数据。

二

页面地图

登录页面和 3 个页面

login.html 入口 index.html 申请列表 auth.js form.html 申请表单 auth.js approve.html 审批(仅审批人) auth.js 登录后,回到原来的页面(next) 任何页面,没有登录(401)就去登录页面 页面按文件分开。共同的工作集中在 auth.js 里 页面(HTML)谁都能拿到。要守护的是 /api/… 返回的数据

页面切换用古法来做:每个页面一个 HTML 文件,菜单就是普通的 <a href> 链接。点一下,浏览器就加载下一个页面。当前页面的菜单项加上 aria-current="page",也告诉读屏软件“在这里”。

这里重要的是,页面(HTML 文件)本身谁都能拿到。我用没有登录的浏览器试了一下。

# 从没有登录的浏览器(Chromium 实测)
获取的对象 状态码
/approve.html(页面的 HTML) 200   谁都能拿到
/api/me 401   没有登录
/api/expenses(自己的申请) 401
/api/approvals(待审批) 401

审批页面的 HTML 以 200 就能拿到。里面的表格是空的,数据要从 /api/… 取来填进去。要守护的不是页面,而是服务器返回的数据。本讲的思路全都从这里开始。

三

登录的机制:暗号

密码只发一次,之后用暗号表明身份

浏览器 服务器 POST /api/login 邮箱地址・密码 200 OK Set-Cookie: sid=…; HttpOnly GET /api/me Cookie: sid=… 200 OK {"name": …, "role": …} 把 Cookie 收起来 暗号表 sid → arai 密码只在第一次发送。之后用服务器给的暗号(会话 ID)表明身份 Cookie 由浏览器自动附上发送,不需要用 JavaScript 去附

登录成功后,服务器在回复的头部加上 Set-Cookie,把暗号(会话 ID)交给浏览器保管。浏览器把它作为 Cookie 收好,向同一网站发请求时自动附上。服务器查自己手边的表,就知道“这个暗号是新井”。

Cookie 的标记意思
HttpOnlyJavaScript 读不到(第八节来试)
SameSite=Lax从其他网站发来的 POST 等请求不附上
Path=/发往本网站任何页面的请求都附上
Max-Age=288008 小时后作废(之后要重新登录)

正式环境的 https 网站,还要加上 Secure(只在加密的通信中发送)。练习用服务器是 http,所以没有加。

四

做登录页面

<form id="login-form">
  <div>
    <label for="email">邮箱地址</label>
    <input type="email" id="email" name="email" autocomplete="username" required>
  </div>
  <div>
    <label for="password">密码</label>
    <input type="password" id="password" name="password" autocomplete="current-password" required>
  </div>
  <p id="login-error" role="alert" hidden></p>
  <button type="submit">登录</button>
</form>
  • type="password" ── 用●遮住输入的文字
  • autocomplete ── 加上 username 和 current-password,浏览器和密码管理软件就知道“这是登录栏”,能帮忙保存和自动填写
  • 输入检查交给浏览器 ── 只有 2 个栏,第9讲的 required 气泡提示就够了。只有申请表单那样的长表单才需要下功夫
// 报销系统:登录页面(第11讲)
// 登录成功后,回到原来的页面(next)

const loginForm = document.querySelector('#login-form');
const loginError = document.querySelector('#login-error');

// 按下登录按钮时
loginForm.addEventListener('submit', async (event) => {
  event.preventDefault();
  loginError.hidden = true;
  const button = event.submitter;
  button.disabled = true;
  try {
    const res = await fetch('/api/login', { method: 'POST', body: new FormData(loginForm) });
    if (res.status === 401) { // 故意不说是账号错还是密码错
      loginError.textContent = '邮箱地址或密码不正确。';
      loginError.hidden = false;
      loginForm.elements.password.select(); // 选中密码栏(方便重新输入)
      return;
    }
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const next = new URLSearchParams(location.search).get('next');
    location.replace(safeNext(next, location.origin)); // replace:按“后退”不会回到登录页面
  } catch (error) {
    loginError.textContent = '没能登录。请确认网络状态后再试一次。';
    loginError.hidden = false;
  } finally {
    button.disabled = false;
  }
});

输错时的提示是“邮箱地址或密码不正确”。故意不说是哪一个错。如果说“密码不正确”,就等于告诉外人“这个邮箱地址已经注册”。实际确认过,密码错和邮箱地址错显示的是同一句话(见第九节的表)。

五

返回地址的坑

要打开申请表单却被转到了登录页面的话,希望登录后能回到申请表单。所以在登录页面的 URL 上带上返回地址。

/login.html?next=%2Fform.html

可是,这个 next 谁都能改写。要是有人发来一个写着 ?next=https://evil.example/ 的链接,登录之后就会被带到一个以假乱真的网站(这种坑叫开放重定向)。很想认为“只要以 / 开头就是本网站”,可一试……

URL 的组成部分,以及是不是同一个网站

URL 的组成部分 http://127.0.0.1:3000 origin 哪个网站 /approve.html pathname 网站内的位置 ?tab=1 search 附加条件 浏览器解析 next 的结果(实测) /form.html → origin http://127.0.0.1:3000 //evil.example/x → origin http://evil.example /\evil.example/x → origin http://evil.example 不看字面形状,用 new URL() 解析后再比较 origin
# safeNext 的结果(Chromium 实测。当前页面是 http://127.0.0.1:3000)
next 的值 当作 URL 解析后 返回到
/form.html (同じサイト)/form.html /form.html
/approve.html?tab=1 (同じサイト)/approve.html?tab=1 /approve.html?tab=1
(空) (同じサイト)/index.html /index.html
https://evil.example/ https://evil.example/ /index.html   ← 其他网站
//evil.example/x http://evil.example/x /index.html   ← 其他网站
/\evil.example/x http://evil.example/x /index.html   ← 其他网站
javascript:alert(1) javascript:alert(1) /index.html   ← 其他网站

//evil.example/x 也好,在 / 后面写 \ 的 /\evil.example/x 也好,明明以 / 开头,浏览器却当成了其他网站。所以 safeNext 不看字面形状,而是用 new URL() 像浏览器一样解析,再比较 origin(哪个网站)是否相同。不同就去申请列表。

还有一点:登录后的跳转用的是 location.replace()。

# 登录之后怎样跳转(Chromium 实测)
跳转方式 历史记录数 按“后退”的话
location.assign() 3 登录页面
location.replace() 2 登录页面之前(about:blank)

用 assign(和普通链接一样)的话,历史记录里留着登录页面,按“后退”又会出现登录页面。replace 会替换当前的历史记录,登录页面不会留在历史里。

六

每个页面都问“你是谁?”

每个页面共同的工作集中在 js/auth.js 里。申请列表・申请表单・审批页面一开始都调用 requireLogin()。

// 报销系统:确认登录状态,并显示页头(第11讲)
// 每个页面最先加载。没有登录就转到登录页面

const ROLE = { applicant: '申请人', approver: '审批人' };

/** 确认返回地址是否安全。只允许本网站内部 */
function safeNext(next, origin) {
  try {
    const url = new URL(next || '/index.html', origin); // 没有 next 就去申请列表
    if (url.origin !== origin) return '/index.html';     // 不允许其他网站
    return url.pathname + url.search;
  } catch {
    return '/index.html';
  }
}

/** 询问当前登录的人。没有登录就转到登录页面 */
async function requireLogin() {
  const res = await fetch('/api/me');
  if (res.status === 401) { // 401 = 没有登录
    const next = location.pathname + location.search;
    location.replace(`/login.html?next=${encodeURIComponent(next)}`);
    return new Promise(() => {}); // 在页面切换之前,不让后续代码执行(永远不会兑现的凭证)
  }
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const me = await res.json();
  showUser(me);
  return me;
}

/** 在页头显示名字,隐藏没有权限的菜单 */
function showUser(me) {
  document.querySelector('#user-name').textContent = `${me.name}(${ROLE[me.role]})`;
  for (const el of document.querySelectorAll('[data-role]')) {
    el.hidden = el.dataset.role !== me.role; // 只是隐藏。真正的防守在服务器(第11讲的七)
  }
}

// 退出登录
document.querySelector('#logout')?.addEventListener('click', async () => {
  await fetch('/api/logout', { method: 'POST' });
  location.replace('/login.html');
});
  • 401 就去登录页面 ── /api/me 回复 401(没有登录)时,把当前位置放进 next,转到登录页面
  • 永远不会兑现的凭证 ── new Promise(() => {}) 是一个永远等不到回复的 Promise。返回它的话,调用方 await requireLogin() 后面的代码就不会执行。在页面切换之前,不会用空数据去画表格
  • 名字用 textContent ── 服务器传来的名字,按第8讲的约定用 textContent 显示

申请表单的发送(第10讲)也加上了 401 的处理。即使填到一半登录过期了,也不清空输入的内容,只提示“请重新登录”。

七

隐藏和拒绝是两回事

“审批”菜单只有审批人需要。在菜单的 li 上写好 data-role="approver" 和 hidden,只有审批人登录时 showUser 才去掉 hidden。

并排的两条页头。上面是以新井 太郎(申请人)登录时,菜单是申请列表和仪表盘。下面是古井 一郎(审批人)时,中间多了“审批”
同一个 HTML,菜单随登录的人而变(笔者的演示页面,经由练习用服务器,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月17日。账号是仅限本演示的虚构账号。浏览器边框是为了说明而补画的)

不过,这只是外观。菜单藏起来了,在地址栏输入 approve.html 照样能打开页面(第二节看到过,HTML 谁都能拿到)。所以审批页面的数据给不给,由服务器决定。

服务器的关卡:401 与 403

/api/approvals 待审批列表 已登录? 是 审批人? 是 200 列表 否 401 没有登录 否 403 没有权限 页面这一侧:隐藏菜单 <li data-role="approver" hidden> … 不让申请人看到“审批” 隐藏是为了“不显示按了也没用的按钮”的体贴。输入 URL 照样能打开页面 守护靠服务器。401 就去登录页面,403 就告诉用户“没有权限”
// 报销系统:审批页面(第11讲)
// 只有审批人能用。其他人会收到服务器返回的 403

async function showApprovals() {
  await requireLogin();
  const res = await fetch('/api/approvals');
  if (res.status === 403) { // 403 = 已经登录,但没有权限
    const note = document.querySelector('#forbidden');
    note.textContent = '你没有查看这个页面的权限(只有审批人可以使用)。';
    note.hidden = false;
    return;
  }
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  // …
}

401 是“不知道你是谁”(去登录页面),403 是“知道你是谁,但这件事你不能做”(重新登录也没用,所以直接告诉对方)。数字很像,页面该做的事却不一样。

新井我让 AI “只给审批人显示审批按钮”,它只写了隐藏按钮的代码。

古井它照你说的做了。你说“隐藏”,它就隐藏。你没说“守护”。

八

暗号放在哪里

网上的文章里,也常见把登录凭证(token)放进 localStorage 的写法。我和 HttpOnly 的 Cookie 比较了一下。

# 存放位置的区别(Chromium 实测)
localStorage.setItem('token', …) → 其他脚本也能读到:'secret-123'
登录后的 document.cookie → '' (HttpOnly 的 Cookie,JavaScript 看不到)
之后的 fetch('/api/me') → 200(Cookie 由浏览器自动附上发送)

# 服务器给的 Cookie:sid(36 个字符)・HttpOnly・SameSite=Lax

HttpOnly 的 Cookie 与 localStorage

HttpOnly 的 Cookie Set-Cookie: sid=…; HttpOnly JavaScript 读不到 浏览器自动附上发送 有效期由服务器决定 localStorage localStorage.setItem('token', …) 任何脚本都能读到 每次发送都要自己附上 不删除就一直留着 一旦因 XSS(第8讲)运行了别人的脚本,读得到的暗号就会被偷 暗号放在 HttpOnly 的 Cookie 里,不用 JavaScript 去碰

放进 localStorage 的东西,在该页面上运行的任何脚本都能读到。像第8讲的 XSS 那样,别人的脚本只要运行过一次,暗号就会被带走。HttpOnly 的 Cookie 对 JavaScript 不可见,但浏览器照样会自动发送。不让 JavaScript 碰暗号,是古往今来稳妥的做法。

九

动手试试

启动练习用服务器,用 Playwright 实际输入和点击。中途把服务器端的登录全部清掉,也制造了一次“过期”。

3 个场景。1 在登录页面输错密码,显示“邮箱地址或密码不正确。”,密码栏处于选中状态。2 以古井 一郎(审批人)登录,菜单里出现“审批”,列出 3 笔待审批。3 新井 太郎(申请人)打开审批页面的 URL,显示“你没有查看这个页面的权限”
①密码错误,②以审批人登录,③申请人直接输入审批页面的 URL(笔者的演示页面,经由练习用服务器,在云端的 Chromium 中截图,是用 Playwright 实际输入和点击的画面。2026年10月17日。账号是仅限本演示的虚构账号。浏览器边框是为了说明而补画的)
# 实际操作并记录的值(Chromium。服务器是练习用的)
操作 到达的 URL 页头 页面
没登录就打开申请列表 /login.html?next=%2Findex.html ― ―
密码错误 /login.html?next=%2Findex.html ― 邮箱地址或密码不正确。
邮箱地址错误 /login.html?next=%2Findex.html ― 邮箱地址或密码不正确。
以新井登录 /index.html 新井 太郎(申请人) 列表 5 条・待审批 79,251 日元・被退回 1 笔
直接输入审批页面的 URL /approve.html 新井 太郎(申请人) 你没有查看这个页面的权限(只有审批人可以使用)。
(登录过期后)保存草稿 /form.html 新井 太郎(申请人) 登录已过期。请重新登录(输入的内容还留在这个页面上)。
(登录已过期)打开审批页面 /login.html?next=%2Fapprove.html ― ―
以古井登录 /approve.html 古井 一郎(审批人) 待审批 3 条
“退出登录” /login.html ― ―
  • 没登录就打开申请列表,会带着 next=%2Findex.html 转到登录页面。登录后回到了申请列表
  • 新井的列表里只有自己的 5 笔。山田的申请,服务器从一开始就没有给
  • 新井直接输入审批页面的 URL,页面能打开,但数据是 403,并用提示告诉他“没有权限”
  • 以古井登录后出现“审批”菜单,列出待审批的 3 笔(新井 2 笔・山田 1 笔)
POINT

登录的主体是服务器的工作。要做成正式的系统,还需要:不直接保存密码(存哈希值)、多次输错就让对方等待、改用 https 并加上 Secure 等对策。页面的工作是显示是谁在用、引导去不了的地方、用易懂的话传达失败。守护靠服务器,引导靠页面。

结 — 一页总结与下讲预告

  1. 页面按 HTML 文件分开,用链接切换。HTML 谁都能拿到,要守护的是数据。
  2. 登录后,服务器用 HttpOnly 的 Cookie 交出暗号。浏览器自动发送,JavaScript 不去碰。
  3. 每个页面先调用 requireLogin()。401 就带着 next 去登录页面,返回用 location.replace。
  4. 返回地址要用 new URL() 解析后比较 origin。只看“以 / 开头”,会被 // 和 /\ 骗。
  5. 隐藏菜单是体贴,拒绝靠服务器。401 是“不知道你是谁”,403 是“这件事你不能做”。

新井古井前辈的页面上列着我的纽约出差!请批准吧!

古井还没有按钮。下一讲是审批流程。做批准・退回、留言和状态的变化。两个人同时审批同一个申请会怎样,也来试一试。

参考

页面截图是笔者制作的演示页面,经由练习用服务器(Node.js),于2026年10月17日在云端的 Chromium(Playwright)中拍摄。截图环境里没有 PingFang SC,中文以 Noto Sans CJK SC 显示。账号・密码・申请都是练习用服务器的虚构数据。到达的 URL・页头的显示・提示、状态码、返回地址的判断、Cookie 的标记、localStorage 的实验、“后退”的历史记录,都是用 Playwright 操作和运行同一个 Chromium 记录的实际值。各页面的 HTML 已用 Nu Html Checker(vnu)确认没有错误。登场人物均为虚构。

古法前端入门 第11讲 — 登录与页面切换 / 2026.10

← 返回文章列表