☰
纯前端实现注册登录:从表单到localStorage的完整指南
2026/10/9 10:41:45 网站建设 项目流程

简介:这是一份面向前端初学者与Web开发学习者的HTML注册登录示例,聚焦用户交互与前端表单校验,特别适合用来理解正则匹配、控件绑定与页面跳转等基础知识。资源共4个文件,包含2个HTML页面和2张JPG背景图片,压缩包仅559KB,轻量易用,可离线打开运行,也能直接作为课堂练习或实验模板。功能上实现了用户信息填写、单选/多选操作、下拉框选择,用户名与密码均通过正则表达式进行校验,任何栏目未通过校验或空白都无法注册、提交;注册成功后先进入成功提示页,随后自动跳转回注册界面,便于循环演示。代码覆盖了文本输入、下拉列表、选项按钮、事件绑定与表单提交拦截等典型环节,配合提供的背景图片,页面效果直观,适合初学者对照拆解,快速搭出可复用的前端注册登录原型。目前已有8690人学习/下载,对于需要现成示例来练习HTML表单与校验逻辑的开发者来说,实用价值较高。

1. HTML 实现用户注册登录:一个表单,两条核心链路

做过前端的人十有八九被问过一句话:“先帮我搞个注册登录,样式不难。”这需求听起来简单,但真正落地的核心不在页面上那几个输入框,而在于两条链路:注册的写入链路和登录的校验链路。用纯 HTML 配合原生 JavaScript,再借助 localStorage 与 sessionStorage,完全可以交付一个能跑、能演示、能继续迭代的注册登录 demo。本文的目标读者就是三类人:刚开始学 html+css+js 基础语法的新手、要交课设或毕设演示页的学生、以及需要在项目前期快速出一版可点流程的从业者。先把一个小结论放在前面:纯前端做注册登录的价值在于跑通流程,别把它当成生产级鉴权方案。

2. 搭建注册与登录表单:字段设计、前端校验与提交拦截

2.1 注册字段怎么定:账号、密码、确认密码的默认取舍

一个“简单”的注册页,我一般只放三个核心字段:用户名、密码、确认密码。很多初学者喜欢加上邮箱、手机号、性别、头像,但在纯前端记忆的模型下,那些字段只会增加校验复杂度和存储体积。邮箱和手机号属于“找回密码”的基础设施,纯前端没有邮件服务和短信网关,加了反而没法闭环。

用户名建议限制为 2~12 个字符,密码至少 6 位、最多 20 位——这两个上限不是为了刁难用户,而是避免后期在 localStorage 里塞进超大字符串。确认密码字段可以不要,但对新人友好,也能在提交前提前拦掉大部分手误,所以保留是最稳妥的选择。

2.2 表单结构与内置校验:一个 html 表单的后端在哪

先看注册页的表单结构。这里我刻意没有引入任何框架,方便你直接复制到本地跑。

<form id="registerForm" autocomplete="on"> <label for="username">用户名</label> <input type="text" id="username" name="username" required minlength="2" maxlength="12" placeholder="2-12 个字符" autocomplete="username"> <label for="password">密码</label> <input type="password" id="password" name="password" required minlength="6" maxlength="20" placeholder="至少 6 位" autocomplete="new-password"> <label for="confirm">确认密码</label> <input type="password" id="confirm" name="confirm" required minlength="6" maxlength="20" autocomplete="new-password"> <button type="submit">注册</button> </form>

这段 html 表单的写法有讲究。required、minlength、maxlength是浏览器原生校验属性,不满足条件时压根不会触发 submit 事件,能省掉一部分手写判断。autocomplete属性容易被忽略:用户名填username,密码填new-password,这样浏览器密码管理器不会把“注册密码”误当成“登录旧密码”去自动填充,减少现场的玄学问题。

2.3 提交拦截与校验逻辑:为什么必须用 submit 事件

表单按钮如果写成type="button",意味着你没有走浏览器原生的提交通道,后面所有 Enter 键触发、表单语义全会乱掉。正确的做法是让按钮保持type="submit",然后在 JavaScript 里监听 submit 事件并e.preventDefault()。

const form = document.getElementById('registerForm'); form.addEventListener('submit', function (e) { e.preventDefault(); const username = document.getElementById('username').value.trim(); const password = document.getElementById('password').value; const confirm = document.getElementById('confirm').value; if (username.length < 2) { alert('用户名至少 2 个字符'); return; } if (password.length < 6) { alert('密码至少 6 位'); return; } if (password !== confirm) { alert('两次输入的密码不一致'); return; } saveUser(username, password); });

这段代码做的事很简单,但有三个参数和时机值得注意。第一,trim()只用在用户名上,密码不 trim,因为密码里的空格是合法字符,盲目去空格会改变用户原始输入。第二,先做长度判断,再做一致性判断,顺序不要倒过来——如果两个密码都是空字符串,长度校验已经拦住了,不会走到后面的全等判断。第三,e.preventDefault()必须在同步代码开头执行,如果写成异步后再阻止默认行为,页面可能已经刷新了,这是典型的表单翻车点。

2.4 把用户数据写进 localStorage:键名设计与重复账号拦截

用户点注册之后,数据落哪?我通常维护一个统一键名users,整表存储为 JSON 数组。每个用户是一条记录,至少要包含username和password两个字段,后面要加注册时间或盐值再扩展。

function saveUser(username, password) { const users = JSON.parse(localStorage.getItem('users') || '[]'); const exists = users.some((u) => u.username === username); if (exists) { alert('该用户名已被注册'); return; } users.push({ username, password }); localStorage.setItem('users', JSON.stringify(users)); location.href = 'login.html'; }

localStorage.getItem('users') || '[]'是必写的兜底:第一次注册时整个键不存在,返回null,直接传给JSON.parse会报错导致白屏。Array.some()负责查重,这里暴露了纯前端实现的一个局限——注册页和登录页必须同源,否则读到的不是同一份 localStorage。你把它部署到公网可能被绕过,但在本地演示的语境里,这就是最直接可靠的数据持久层。

3. 登录比对与会话状态:sessionStorage 模拟登录态

3.1 登录页数据比对:从 localStorage 读到账号再匹配

登录页和注册页是两个文件,但它们共享浏览器同源下的 localStorage。登录表单结构与注册页几乎一致,只是少一个确认密码字段,按钮文字换成“登录”。真正的差异在提交逻辑:

const loginForm = document.getElementById('loginForm'); loginForm.addEventListener('submit', function (e) { e.preventDefault(); const username = document.getElementById('username').value.trim(); const password = document.getElementById('password').value; const users = JSON.parse(localStorage.getItem('users') || '[]'); const user = users.find((u) => u.username === username); if (!user || user.password !== password) { alert('账号或密码错误'); return; } sessionStorage.setItem('currentUser', JSON.stringify({ username: user.username, loginAt: Date.now() })); location.href = 'index.html'; });

这里用find先按用户名找记录,找不到直接进错误分支。注意我刻意把“用户不存在”和“密码错误”合并成同一条提示,避免通过回包差异遍历出已注册账号,这对纯前端拷进本地时可能显得防御过度,但代码习惯一旦养成,以后接后端接口时不容易漏。

登录成功后的关键动作是把当前用户写入sessionStorage,键名我固定为currentUser。存loginAt时间戳是低成本的做法,方便以后实现“登录超过 N 小时要求重新登录”之类的伪过期机制。

3.2 sessionStorage 与 localStorage 怎么分工:一张表说清楚

很多新手分不清这两个存储 API,经常写混。它们的差异直接决定产品行为:

对比项localStoragesessionStorage
生命周期手动清除才消失标签页关闭即失效
跨标签页同源下多个标签页共享每个标签页独立
典型用途users 用户表,长期保留currentUser 登录态,临时会话
隐私模式可能不可用,写入可能抛异常关闭窗口后清空

一句话记忆:用户表这类“注册过就一直在”的数据放 localStorage,登录状态这类“关页就该没”的数据放 sessionStorage。反过来的话,会出现刷新页面就掉登录,或者关了窗口再打开还显示已登录的诡异现象。

3.3 页面加载时恢复登录态:导航栏与用户区联动

登录成功后跳转到的index.html,在页面加载时要主动读取登录态,再决定导航栏右边显示“登录 / 注册”还是显示用户名和退出按钮。否则每个页面打开都显示未登录,用户会认为登录白做了。

function getCurrentUser() { const raw = sessionStorage.getItem('currentUser'); if (!raw) return null; try { return JSON.parse(raw); } catch (e) { return null; } } const user = getCurrentUser(); const navRight = document.getElementById('navRight'); if (user) { navRight.innerHTML = `欢迎,${user.username} <button id="logoutBtn">退出</button>`; document.getElementById('logoutBtn').addEventListener('click', function () { sessionStorage.removeItem('currentUser'); location.reload(); }); } else { navRight.innerHTML = '<a href="login.html">登录</a> / <a href="register.html">注册</a>'; }

JSON.parse包进try...catch是必要的:用户或旧代码可能往currentUser里写过非法字符串,解析失败时直接当作未登录处理,而不是让整个页面崩溃。退出按钮只是删掉sessionStorage里的键,然后刷新页面,让导航栏重新渲染成未登录状态。整套流程没有网络请求,但交互闭环是完整的。

4. 密码存储别用明文:SHA-256 与盐值的落地写法

4.1 明文存储为什么是黑匣子风险

如果按第 2 章的写法直接把password明文推进数组,整个系统就是一键可脱库的状态。你可能会说“这不就是个课设吗”,但就算演示用的音箱旁边坐着甲方,他看到你打开 DevTools 的 Application 面板,里面的密码清清楚楚,这个项目在信任层面就已经扣分了。更现实的风险是,用户注册时如果沿用了常用密码,意味着你把用户在其他平台的凭证也一起泄露了。

纯前端没有真正的绝对安全——代码里的算法全部暴露在浏览器端,攻击者完全可以逆向出哈希逻辑去伪造。但我们要的不是“无法破解”,而是“泄露了也不能直接拿明文去撞库”。存 SHA-256 摘要,能让 localStorage 被导出一瞬间,不至于直接交出可读密码。这是成本最低、也最负责任的方案。

4.2 用 window.crypto.subtle 计算 SHA-256

现代浏览器内置了 Web Crypto API,不需要再引第三方库。关键点是crypto.subtle.digest是异步方法,返回值是ArrayBuffer,要转成十六进制字符串才能方便地存进 localStorage。

async function sha256(text) { const data = new TextEncoder().encode(text); const digest = await crypto.subtle.digest('SHA-256', data); return Array.from(new Uint8Array(digest)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); }

TextEncoder是用来把字符串转成 UTF-8 字节序列的,直接用crypto.subtle.digest会报错“DataCloneError”,这是新手最常见的一步卡壳。输出端padStart(2, '0')确保每个字节都补成两位十六进制,比如字节值 15 转出来是'f',补位后才是'0f',否则拼出来的摘要长度不稳定,后续比对必出问题。

4.3 加盐的简单约定:用户名做盐再拼接

单纯 SHA-256 扛不住彩虹表。给密码加一点盐是常识性做法,但纯前端语境里盐也得存下来。我的做法是:不额外生成随机盐字段,直接用“用户名 + 分隔符 + 密码”作为哈希输入。

async function hashPassword(username, password) { return await sha256(username + '|' + password); }

注册时把返回值存进对象:{ username, password: hashed }。登录时重新拼一次,把计算出的摘要和存储的摘要做字符串全等比较。用用户名当盐有个缺点:不同用户如果密码相同,哈希结果仍不同,这个没问题;但同一用户在两个系统用相同用户名和密码时,哈希仍然相同。它只提升了“全表撞库”的门槛,并不是完美的盐值方案。要更严谨,就在用户对象里存一个salt字段,用crypto.getRandomValues生成随机串。不过那会让代码复杂度上一个台阶,简单项目用用户名加盐,性价比已经足够。

4.4 各种存储写法的成本对比

方案存储内容示例导出 localStorage 后的风险可逆性
明文password: "123456"直接泄露完全可逆
自写异或/替换password: "y6z5s5"容易被猜出算法弱加密,可逆
SHA-256password: "a665a459..."可撞库弱密码单向,彩虹表可查
SHA-256 + 盐password: "9f6c..."撞库成本明显上升单向,基本不可查

我在实际项目里从明文迁到哈希后,改动点只有三处:注册写入前、登录比对前、密码字段的长度放宽一点。其余存储和会话逻辑完全不动。这个迁移成本低到你没有理由继续明文存。

5. 注册登录模块高频踩坑清单:5 条翻车现场

5.1 iframe 里的 localStorage 静默失效

现象:把注册登录页放进后台管理系统的 iframe 里,用户填写完点注册,页面没有报错,但跳转登录后账号不存在。

原因:第三方 iframe 若无allow-same-origin,或浏览器开启严格隐私设置,localStorage 的存取会被拦截。更隐蔽的是,localStorage.getItem在隐私模式下可能直接抛SecurityError,而你没做异常捕获,导致后面所有逻辑没有执行。

解决:把所有的 storage 读写包进函数,内部统一try...catch,出错时降级为内存对象。同时给 iframe 标签补上allow-same-origin属性。判断当前环境能不能用 localStorage,不要靠猜,靠执行结果说话。

5.2 刷新之后登录态说丢就丢

现象:用户登录成功进入首页,按 F5 刷新后,页面又变回未登录状态。去 sessionStorage 里看,currentUser这个键还在。

原因:这大概率不是丢失,而是登录状态存进了 localStorage,刷新不丢;然后你在导航栏逻辑里读的是 sessionStorage,两边键名不一致,读到null就渲染成未登录。如果反过来,登录态存进 sessionStorage,打开新标签页时,那个新页面读不到旧标签页的登录态,也会“看起来像丢了一样”。

解决:明确约定——登录态只存 sessionStorage,读的时候从同一个键currentUser取。新开标签页的“不同步”是 sessionStorage 的正常行为,不能算 bug;真的要让多个标签页共享登录态,就改用 localStorage,并接受“关浏览器再开仍在线”的代价。

5.3 中文账号和首尾空格引发比对失败

现象:用户在用户名里输入“ 张三 ”(前后有空格),注册成功。登录时输入“张三”,提示账号或密码错误。或者注册时用中文全角空格,登录时用半角空格,怎么都对不上。

原因:注册时没做trim(),或者只在注册时做了、登录时没做,两条链路处理不一致就会造成“隐形双份账号”。中文全角空格和英文半角空格是不同字符,肉眼分不出来,但字符串比较是严格按码点走的。

解决:统一定义一个normalizeUsername函数,把trim()和全角空格替换写在一起:u.username.trim().replace(/\u3000/g, '')。注册、登录、查重、渲染四处的用户名都先经过它,确保进入比对流程的值是干净的。

5.4 哈希比对时类型不一致导致的相等性翻车

现象:用户注册时存的是十六进制字符串,登录时你拿到的ArrayBuffer没经过转换,直接用===比较,结果永远不相等,所有密码都提示错误。

原因:crypto.subtle.digest返回的是ArrayBuffer,转成十六进制字符串是必须的。如果你在某个版本里转成了 Base64,又在另一处用十六进制比较,或者只转了一边,比对一定失败。这种 bug 有点黑匣子,因为页面不报错,就是登不进去。

解决:把转换过程收敛成一个公共函数,比如本章第 4 节的sha256,全项目只允许通过它产出摘要。另外在登录比对前打一条临时console.log,把两边值的类型和一眼前 8 位都打出来,能快速发现是格式问题还是密码本身错误。

5.5 preventDefault 失效导致表单提交后页面刷新

现象:表单填完点提交,页面闪了一下然后刷新,注册的数据没存入 localStorage,反而 URL 上多了一串参数。

原因:最常见的是监听事件写成了click,而不是submit;或者按钮类型是type="submit"但语法上事件绑定在了一个不存在的变量上,浏览器默认提交行为照常执行。还有一个隐蔽原因:JS 文件在表单 HTML 之前被加载,document.getElementById('registerForm')拿到null,后面的addEventListener直接抛错,默认动作没有任何拦截。

解决:把整个<script>放到</body>前,或者用DOMContentLoaded包一层。提交事件一律监听submit而非click,并且在函数第一行写e.preventDefault(),不给默认刷新留机会。调试时看 Network 面板,如果提交动作产生了 document 请求,说明拦截根本没挂上。

6. 进阶一步:把纯前端注册登录封装成可替换的小模块

到这步整个流程已经能跑了,但代码还散在页面里,以后要接后端时会想骂人。常见做法是把用户表和登录态操作收敛成一个独立模块,我习惯命名为auth.js,对外只暴露四个方法:register、login、logout、current。页面层永远只调接口,不直接读写 storage,这样将来替换成 axios 请求时,页面代码一行都不用动。

const AuthStore = { _readUsers() { return JSON.parse(localStorage.getItem('users') || '[]'); }, async register(username, password) { const users = this._readUsers(); if (users.some((u) => u.username === username)) return { ok: false, msg: '用户名已存在' }; users.push({ username, password: await sha256(`${username}|${password}`) }); localStorage.setItem('users', JSON.stringify(users)); return { ok: true }; }, async login(username, password) { const user = this._readUsers().find((u) => u.username === username); if (!user || user.password !== await sha256(`${username}|${password}`)) { return { ok: false, msg: '账号或密码错误' }; } sessionStorage.setItem('currentUser', JSON.stringify({ username: user.username, loginAt: Date.now() })); return { ok: true }; }, logout() { sessionStorage.removeItem('currentUser'); }, current() { const raw = sessionStorage.getItem('currentUser'); return raw ? JSON.parse(raw) : null; } };

验证这套东西是否健壮,我的习惯是开两个浏览器窗口,一个跑注册流程,另一个跑登录流程,专门测会话隔离;再用隐身窗口跑一遍,确认隐私模式下 localStorage 的表现。至于要不要继续往上加“记住我”“修改密码”“找回密码”,我的建议是按需加——纯前端方案每多一个功能就多一层安全隐患,做到“注册、登录、退出、状态恢复”四件事就足够交付了。

最后说一个自己踩过的教训:早期我把用户表存进了 sessionStorage,结果每次刷新浏览器都丢账号,当时以为是代码写错了,搞了大半天才发现是生命周期选错了。后来养成了习惯,凡是“关了页面还要存在”的数据一律进 localStorage,凡是“会话结束就该失效”的状态一律进 sessionStorage,写完就再没出过这毛病。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询