前端工程化项目本地存储与登录鉴权:Axios 与 Token 实战

前端工程化项目本地存储与登录鉴权:Axios 与 Token 实战

导读:把页面与真实后端贯通,建立完整的数据流与鉴权链路,是前端从「写界面」走向「做系统」的分水岭。本文系统讲解工程化后台的「数据 + 鉴权 + 文件」三大支柱------Axios 三层封装(实例 / API / 控制器)的分层设计、浏览器三大本地存储(localStorage / sessionStorage / Cookie)的取舍与安全边界、Token 无状态鉴权的完整生命周期、请求/响应拦截器对鉴权与错误的统一收口、事件委托对动态列表的高效处理、FileReader 与 FormData 处理文件上传的浏览器原生能力。每一项都从「为什么这样设计、底层如何运作、对应什么业务场景」三维展开,结合 MDN、Axios、OWASP 等权威规范,适合希望系统掌握「前后端联调与鉴权安全」的中高级前端工程师。

目录


一、Axios 三层封装与分层设计

名词解释:

  • axios.create:基于一组默认配置创建独立的 Axios 实例。
  • API 分层 :把接口调用按业务模块拆到 src/api/*.js,每个文件导出若干函数。
  • 响应数据规约 :前后端约定的统一返回格式(如 { ok: 1, data, msg })。

概念与底层原理:

业务代码直接 axios.post('/api/admin', body) 有三个隐患:重复 /api 前缀、错误处理分散、无法统一加横切逻辑(token、loading)。工程上的标准解法是「三层封装」------实例层定规矩、API 层列清单、控制器层写流程:

【代码注释】三层各司其职:实例层 request/advserver.jsaxios.create 设定 baseURL、timeout、拦截器;API 层 api/<module>.js 把每个后端接口封装成一个业务函数;控制器层 只调业务函数、关心业务流程。任何接口变更(URL 调整、参数加减)只需改 API 层一处,业务代码零侵入。这是关注点分离在数据层的落地 ------每一层只暴露下一层需要的抽象。市面应用:Vue Element Admin、Ant Design Pro 等成熟模板都采用这种 request/api/业务 三层结构。

js 复制代码
// request/advserver.js ------ 实例层
import axios from 'axios';
import toastr from 'toastr';

const advServer = axios.create({ baseURL: '/api', timeout: 5000 });

advServer.interceptors.response.use(
  res => {
    if (res.data.ok !== 1) {              // 后端约定 ok===1 才成功
      toastr.error(res.data.msg);
      return new Promise(() => {});       // 永久 pending,阻塞业务链
    }
    return res.data;
  },
  error => {
    toastr.error('请求错误!');
    return new Promise(() => {});
  }
);

export default advServer;

【代码注释】baseURL: '/api' 让所有调用自动加前缀------业务写 advServer.post('/admin') 实际请求 /api/adminreturn new Promise(() => {}) 是吞错误的关键------错误已被 toastr 提示,业务 .then 不再触发。这种「拦截器消费错误、业务只关心成功」的设计,把错误处理从 N 个调用点收敛到 1 处市面应用:所有规模化项目的 Axios 封装本质都是这套模式。

js 复制代码
// api/admin.js ------ API 层,每个 export 对应一个后端接口
import advServer from '../request/advserver';

export const postAdmin   = body => advServer.post('/admin', body);
export const getAdmin    = ()   => advServer.get('/admin');
export const deleteAdmin = id   => advServer.delete('/admin/' + id);
export const changePassword = body => advServer.patch('/changpwd', body);

【代码注释】每个 export 函数对应一个后端接口,命名与 RESTful 动词(POST/GET/DELETE/PATCH)对应,一眼看清这个业务有哪些操作。业务代码 import { postAdmin } from '../api/admin' 后只调函数名,再也不直接见到 URL。市面应用 :现代项目的 src/api/src/services/ 目录都是这种「业务函数清单」式结构。

权威参考:Axios 拦截器文档:https://axios-http.com/docs/interceptors

【实战要点】

  • 经典应用场景:所有前后端联调项目;微服务架构下尤其有用。
  • 常见坑 :把 axios 直接 import 进业务代码,失去实例的所有规约------坚持只 import API 函数。
  • 性能与最佳实践:拦截器内部不要再发 HTTP 请求避免死循环;timeout 控制在 10s 内。

【本章小结】

文件 职责
实例层 request/advserver.js baseURL / timeout / 拦截器
API 层 api/<module>.js 接口函数集合
控制器层 controllers/*.js 业务流程

记忆口诀:「Instance 立规矩,API 列清单,Controller 写流程」。

【面试考点】

Q1:为什么用 axios.create 而非直接用 axios

A:1)独立配置------同项目可能调多个后端域名,每个一个实例;2)互不污染------给一个实例加拦截器不影响默认 axios 或其他实例;3)测试友好------mock 实例

入门示例 · 三层封装结构模拟

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>Axios 三层封装模拟</title>
<style>body{font-family:monospace;padding:20px;background:#f5f5f5}
pre{background:#fff;border:1px solid #ddd;padding:12px;border-radius:4px}
button{padding:8px 16px;margin:4px;background:#1976d2;color:#fff;border:none;border-radius:4px;cursor:pointer}
button:hover{background:#1565c0}.ok{color:#2e7d32}.err{color:#c62828}</style>
</head>
<body>
<h2>Axios 三层封装结构模拟</h2>
<button onclick="runRequest()">发起请求</button>
<button onclick="runError()">模拟错误</button>
<pre id="log">点击按钮查看三层封装执行流...</pre>
<script>
// ① 实例层:统一 baseURL / timeout / 拦截器
function createInstance(config) {
  const instance = { baseURL: config.baseURL, timeout: config.timeout };

  // 模拟响应拦截器
  instance.interceptors = {
    handlers: [],
    use(onFulfilled, onRejected) { this.handlers.push({ onFulfilled, onRejected }); }
  };

  instance.get = async function(url) {
    log(`[实例层] GET ${this.baseURL}${url},timeout=${this.timeout}ms`);
    // 模拟网络
    await delay(300);
    const ok = !url.includes('error');
    if (!ok) throw new Error('Network Error');
    const res = { data: { ok: 1, data: [{ id: 1, name: '张三' }, { id: 2, name: '李四' }] } };
    // 执行响应拦截器
    let result = res;
    for (const h of instance.interceptors.handlers) result = h.onFulfilled(result);
    return result;
  };
  return instance;
}

// 实例层注册
const advServer = createInstance({ baseURL: '/api', timeout: 5000 });
advServer.interceptors.use(res => {
  log('[拦截器] 响应 ok='+res.data.ok+',解包 data');
  if (res.data.ok !== 1) throw new Error(res.data.msg);
  return res.data;  // 解包:控制器只拿 data
});

// ② API 层:语义化接口函数
const getAdmin = () => advServer.get('/admin');
const getAdminError = () => advServer.get('/error');

// ③ 控制器层:业务流程
async function renderAdminList() {
  log('[控制器] 调用 getAdmin()...');
  const res = await getAdmin();
  log('[控制器] 拿到数据:' + JSON.stringify(res.data));
  document.getElementById('log').innerHTML +=
    '
<span class="ok">渲染列表:' + res.data.map(u => u.name).join(' / ') + '</span>';
}

async function renderError() {
  try {
    log('[控制器] 调用 getAdminError()...');
    await getAdminError();
  } catch(e) {
    log('<span class="err">[拦截器] 捕获错误:' + e.message + '</span>');
  }
}

function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
let logBuf = '';
function log(msg) { logBuf += msg + '
'; document.getElementById('log').innerHTML = logBuf; }

function runRequest() { logBuf=''; log('─── 正常请求流程 ───'); renderAdminList(); }
function runError()   { logBuf=''; log('─── 错误请求流程 ───'); renderError(); }
</script>
</body>
</html>

【代码注释】这个 Demo 忠实还原了三层封装的职责边界:实例层 只负责统一配置与拦截器;API 层 只是语义化的接口函数(getAdmin = () => ...);控制器层 只写业务逻辑(取数据、渲染)。拦截器在「实例层解包响应」,让控制器层直接拿 res.data 而非 res.data.data,减少重复代码。市面应用 :Vue 项目的 src/utils/request.js(实例层)+ src/api/user.js(API 层)+ src/views/User.vue(控制器层)正是这三层结构的主流实践。

实战示例 · 响应状态码统一处理

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>响应拦截器状态码分发</title>
<style>body{font-family:sans-serif;padding:20px}button{padding:8px 16px;margin:4px;border:none;border-radius:4px;cursor:pointer;color:#fff}
.btn-ok{background:#388e3c}.btn-401{background:#f57c00}.btn-403{background:#d32f2f}.btn-500{background:#616161}
#res{margin-top:16px;padding:12px;border-radius:4px;font-family:monospace;background:#f5f5f5;min-height:40px}</style>
</head>
<body>
<h2>响应拦截器按状态码统一分发</h2>
<button class="btn-ok"  onclick="req(200)">200 成功</button>
<button class="btn-401" onclick="req(401)">401 未登录</button>
<button class="btn-403" onclick="req(403)">403 无权限</button>
<button class="btn-500" onclick="req(500)">500 服务器错误</button>
<div id="res">点击按钮模拟不同响应...</div>
<script>
// 模拟 axios 响应拦截器的状态码分发逻辑
function responseInterceptor(statusCode) {
  const actions = {
    200: () => show('✅ 请求成功,数据正常返回', '#e8f5e9'),
    401: () => { show('⚠️ 401 未登录,跳转登录页(模拟)', '#fff3e0'); },
    403: () => show('❌ 403 权限不足,提示用户联系管理员', '#fce4ec'),
    500: () => show('💥 500 服务器异常,提示稍后重试', '#f3e5f5'),
  };
  (actions[statusCode] || (() => show('未知状态码: ' + statusCode, '#f5f5f5')))();
}

function show(msg, bg) {
  const d = document.getElementById('res');
  d.textContent = msg; d.style.background = bg;
}
function req(code) { show('请求中...', '#f5f5f5'); setTimeout(() => responseInterceptor(code), 300); }
</script>
</body>
</html>

【代码注释】响应拦截器的核心价值是「状态码集中处理」------每个接口不再单独写 if (res.status === 401) redirect('/login'),统一在拦截器里根据状态码触发对应动作(跳转、提示、重试)。actions 对象替代 if-else 链,新增状态码只需加一项,符合「开闭原则」。市面应用 :所有企业级 Vue / React 项目的 request.js 里必有这段逻辑;状态码 401 跳登录、403 提示权限不足,是前端工程的基础设施。

比 mock 全局更精准;4)TS 类型------实例上可定义返回类型。


二、列表数据的异步渲染

概念与底层原理:

进入列表页时的标准模式是「先渲染骨架、再异步填数据」------这是 SPA 首屏性能优化的核心:用户立刻看到页面结构(卡片、按钮、表头),数据 200ms 后到达再填表格行,体验远胜「等数据回来再渲染整页」。

js 复制代码
// controllers/admin.js
import { getAdmin } from '../api/admin';
import AdminTableComponent from '../components/AdminTable';
import adminV from '@/views/admin';

const getAdminExec = () => {
  getAdmin().then(res => {
    document.querySelector('#amdinListBox').innerHTML =
      AdminTableComponent({ adminList: res.data });
  });
};

export default (req, res) => {
  res.render(adminV());            // 1. 先渲染骨架
  getAdminExec();                  // 2. 异步取数填充
  document.querySelector('#addAdminBtn').addEventListener('click', addAdminExec);
};

【代码注释】res.render(adminV()) 立刻渲染页面骨架(含表头、添加按钮、空表格容器),getAdminExec() 异步拉数据后用 innerHTML 把表格行填进 #amdinListBox 占位。这种「shell first, data second」是所有现代 SPA 的通用模式 ------首屏感知速度由「骨架渲染」决定,而非「数据返回」。市面应用:所有列表页、表格页都遵循这个模式;进一步可加骨架屏(skeleton)占位提升体验。

ejs 复制代码
<!-- components/AdminTable.ejs ------ 接收数据渲染表格 -->
<table class="table table-bordered">
  <thead><tr><th>用户名</th><th>注册时间</th><th>操作</th></tr></thead>
  <tbody>
    <% data.adminList.forEach(adminItem => { %>
      <tr>
        <td><%= adminItem.adminName %></td>
        <td><%= adminItem.regTime %></td>
        <td>
          <button class="btn btn-danger" data-id="<%= adminItem._id %>">删除</button>
          <button class="btn btn-success">修改密码</button>
        </td>
      </tr>
    <% }) %>
  </tbody>
</table>

【代码注释】<% data.adminList.forEach(...) %> 是 EJS 列表渲染;data-id="<%= adminItem._id %>" 把数据库 ID 挂到删除按钮的 dataset,后续删除时通过 event.target.dataset.id 取出。innerHTML 渲染会丢失之前绑定在子元素上的事件 ------所以删除事件不能绑在每个按钮上,而要用事件委托(下一章)。市面应用 :Vue 的 v-for / React 的 .map() 与之原理相同,只是用响应式数据省掉了 innerHTML 这一步。

【实战要点】

  • 经典应用场景:所有列表页、表格页。
  • 常见坑innerHTML 重渲染破坏已绑事件------用事件委托规避。
  • 性能与最佳实践 :超过 1000 行用虚拟滚动;图片用 loading="lazy" 懒加载。

【本章小结】

步骤 关键
渲染骨架 res.render
异步取数 getAdmin().then
数据填充 innerHTML = Table({list})

记忆口诀:「Shell 立刻,Data 后填」。

【面试考点】

Q1:用 innerHTML 渲染列表的优劣?

A:优:写法简单、一次替换;劣:1)破坏已有事件监听(需事件委托规避);2)无 diff、整段重绘开销大;3)数据未转义易引发 XSS。生产项目用框架的虚拟 DOM 做 diff 渲染,开发期可接受 innerHTML 但需注意事件委托与数据转义。

入门示例 · 异步取数与列表渲染

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>列表异步渲染</title>
<style>body{font-family:sans-serif;padding:20px}table{border-collapse:collapse;width:100%}
th,td{border:1px solid #ddd;padding:8px 12px;text-align:left}th{background:#1976d2;color:#fff}
tr:nth-child(even){background:#f5f5f5}#status{color:#888;margin:8px 0}
button{padding:8px 16px;background:#1976d2;color:#fff;border:none;border-radius:4px;cursor:pointer}</style>
</head>
<body>
<h2>管理员列表</h2>
<button onclick="loadList()">加载列表</button>
<p id="status"></p>
<div id="tableBox"></div>
<script>
// 模拟后端接口(替代真实 axios 请求)
function getAdmin() {
  return new Promise(resolve => {
    setTimeout(() => resolve({
      data: [
        { id: 1, adminName: 'admin01', createTime: '2024-01-10' },
        { id: 2, adminName: 'admin02', createTime: '2024-02-15' },
        { id: 3, adminName: 'admin03', createTime: '2024-03-20' },
      ]
    }), 600);
  });
}

// EJS-like 模板函数(数据 → HTML 字符串)
function AdminTable({ adminList }) {
  if (!adminList.length) return '<p>暂无数据</p>';
  const rows = adminList.map((a, i) =>
    `<tr><td>${i+1}</td><td>${a.adminName}</td><td>${a.createTime}</td>
     <td><button onclick="delAdmin(${a.id})">删除</button></td></tr>`
  ).join('');
  return `<table><thead><tr><th>#</th><th>账号</th><th>创建时间</th><th>操作</th></tr></thead>
          <tbody>${rows}</tbody></table>`;
}

async function loadList() {
  document.getElementById('status').textContent = '加载中...';
  const res = await getAdmin();
  document.getElementById('tableBox').innerHTML = AdminTable({ adminList: res.data });
  document.getElementById('status').textContent = `共 ${res.data.length} 条`;
}

function delAdmin(id) { alert('删除 id=' + id + '(模拟)'); }
</script>
</body>
</html>

【代码注释】loadList 展示了「Shell 立刻、Data 后填」模式:页面骨架已渲染,点击触发 async 函数,await getAdmin() 等待数据到来,再用 AdminTable(data) 生成 HTML 字符串填入 innerHTMLAdminTable 是纯函数(数据进、HTML 出),等价于 EJS 模板被 ejs-loader 编译后的形式。市面应用 :传统 jQuery 项目里这个模式随处可见;现代框架(Vue v-for、React .map)的底层渲染也走「数据 → DOM」这条路,只是自动化了 diff 更新。

实战示例 · 加载状态与错误处理

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>加载状态管理</title>
<style>body{font-family:sans-serif;padding:20px}
.loading{text-align:center;padding:40px;color:#888}.error{color:#c62828;text-align:center;padding:20px}
table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:8px}th{background:#1976d2;color:#fff}
button{padding:6px 12px;border:none;border-radius:4px;cursor:pointer;margin:2px}
.btn-load{background:#1976d2;color:#fff}.btn-err{background:#e53935;color:#fff}</style>
</head>
<body>
<h2>异步列表 · 加载/成功/失败三态</h2>
<button class="btn-load" onclick="load(false)">正常加载</button>
<button class="btn-err"  onclick="load(true)">模拟网络错误</button>
<div id="box"><p style="color:#aaa">点击按钮加载数据</p></div>
<script>
const box = document.getElementById('box');

function setState(type, payload) {
  if (type === 'loading') {
    box.innerHTML = '<div class="loading">⏳ 加载中,请稍候...</div>';
  } else if (type === 'error') {
    box.innerHTML = `<div class="error">❌ ${payload}<br><button class="btn-load" onclick="load(false)">重试</button></div>`;
  } else if (type === 'success') {
    const rows = payload.map(u =>
      `<tr><td>${u.id}</td><td>${u.name}</td><td><span style="color:${u.active?'green':'red'}">${u.active?'启用':'停用'}</span></td></tr>`
    ).join('');
    box.innerHTML = `<table><thead><tr><th>ID</th><th>账号</th><th>状态</th></tr></thead><tbody>${rows}</tbody></table>`;
  }
}

async function load(forceError) {
  setState('loading');
  try {
    await delay(800);
    if (forceError) throw new Error('请求超时,请检查网络');
    setState('success', [
      { id:1, name:'admin01', active:true },
      { id:2, name:'admin02', active:false },
    ]);
  } catch(e) {
    setState('error', e.message);
  }
}

function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
</script>
</body>
</html>

【代码注释】setState(type, payload) 把「加载中 / 成功 / 失败」三种 UI 状态集中管理,每次转态只调一个函数------这是状态机思维。try/catch 包住 await 是异步错误处理的规范写法;加载失败时显示「重试」按钮而非让用户看到空白页,是工程化 UX 的基本要求。市面应用 :React 的 useState(['idle','loading','success','error']) + Redux 的异步 action 都是这套三态模型的正规版本;Vue 3 的 useAsyncState 也封装了同样的逻辑。


三、事件委托与动态 DOM

名词解释:

  • 事件委托(Event Delegation) :把多个子元素的事件统一绑到父容器,靠 event.target 区分实际触发者。
  • dataset :HTML5 的自定义数据属性 API,<el data-id="123">el.dataset.id

概念与底层原理:

列表渲染了 10 行管理员,每行有删除按钮。逐个 addEventListener 有三个问题:要等 DOM 存在、列表 innerHTML 刷新后事件全丢、N 行就要 N 个监听器。事件委托利用事件冒泡 完美解决------把一个监听器绑在父容器,按 event.target 判定真正的点击对象。

js 复制代码
const deleteAdminExec = event => {
  // 判断点击的是否是删除按钮
  if (event.target.classList.contains('btn-danger')) {
    if (confirm('确定删除?')) {
      deleteAdmin(event.target.dataset.id).then(() => {   // 取 data-id 作参数
        getAdminExec();                                    // 重拉列表
      });
    }
  }
};

export default (req, res) => {
  res.render(adminV());
  getAdminExec();
  // 把删除事件绑在父容器,而非每行按钮
  document.querySelector('#amdinListBox').addEventListener('click', deleteAdminExec);
};

【代码注释】子元素的点击会冒泡到所有祖先,事件委托利用这一点------把监听器绑在 #amdinListBox,表格里所有按钮的点击都冒泡到这里,再用 event.target.classList.contains('btn-danger') 判定是不是删除按钮。这解决了「动态生成 DOM 的事件绑定」难题 ------innerHTML 重渲染后,父容器上的监听器依然有效,无需重新绑定。event.target.dataset.id 取出 HTML5 的 data-id市面应用 :jQuery 的 .on('click', '.btn-danger', handler) 就是事件委托的语法糖;所有长列表、动态表格都依赖它。

【实战要点】

  • 经典应用场景:长列表、动态生成 DOM、性能敏感场景。
  • 常见坑event.target(真正被点的最深元素)与 event.currentTarget(监听器所在元素)混淆。
  • 性能与最佳实践 :用 closest('.btn-danger') 替代 classList.contains,能容忍点到按钮内的图标子元素。

【本章小结】

概念 用法
事件委托 绑父容器,靠 target 区分
dataset data-*.dataset.*
事件冒泡 委托的底层机制

记忆口诀:「绑父容器,识 target,data 取参」。

【面试考点】

Q1:事件委托的优缺点?

A:优:1)单监听器代替 N 个,省内存;2)对动态生成的 DOM 也生效(最关键);3)统一管理。缺:1)只能处理冒泡型事件(focus/blur 不冒泡,用 focusin/focusout 替代);2)需精确判定 event.target

Q2:event.targetevent.currentTarget 区别?

A:event.target 是事件最初触发的元素(最深处,可能是按钮里的图标);event.currentTarget 是当前执行监听器所在的元素(这里是父容器)。事件委托里用 event.target 判定实际点击的子元素。

入门示例 · 事件委托删除与高亮

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>事件委托</title>
<style>body{font-family:sans-serif;padding:20px}
ul{list-style:none;padding:0}li{display:flex;align-items:center;justify-content:space-between;
padding:10px 14px;margin:4px 0;background:#f5f5f5;border-radius:4px;border-left:4px solid #1976d2}
li.selected{background:#e3f2fd;border-left-color:#0d47a1}
button{padding:4px 10px;border:none;border-radius:4px;cursor:pointer;font-size:13px}
.btn-del{background:#e53935;color:#fff}.btn-add{background:#388e3c;color:#fff;padding:8px 16px;margin-top:8px}</style>
</head>
<body>
<h2>管理员列表(事件委托)</h2>
<ul id="list"></ul>
<button class="btn-add" onclick="addItem()">+ 添加管理员</button>
<p id="info" style="color:#888"></p>
<script>
let admins = [
  { id: 1, name: 'admin01' },
  { id: 2, name: 'admin02' },
  { id: 3, name: 'admin03' },
];
let counter = 4;

function render() {
  document.getElementById('list').innerHTML = admins.map(a =>
    `<li data-id="${a.id}">
       <span>${a.name}</span>
       <div>
         <button class="btn-del" data-action="delete" data-id="${a.id}">删除</button>
       </div>
     </li>`
  ).join('');
}

// 只在父容器 #list 绑定一个监听器 ------ 事件委托核心
document.getElementById('list').addEventListener('click', e => {
  const action = e.target.dataset.action;
  const id     = Number(e.target.dataset.id);

  if (action === 'delete') {
    admins = admins.filter(a => a.id !== id);
    render();
    info(`删除 id=${id}`);
  } else if (e.target.closest('li')) {
    // 点击行本身 → 高亮
    document.querySelectorAll('li').forEach(li => li.classList.remove('selected'));
    e.target.closest('li').classList.add('selected');
    info(`选中 id=${e.target.closest('li').dataset.id}`);
  }
});

function addItem() {
  admins.push({ id: counter, name: `admin0${counter}` });
  counter++;
  render();
  info('已添加,新增元素自动支持点击------事件委托的价值');
}

function info(msg) { document.getElementById('info').textContent = msg; }
render();
</script>
</body>
</html>

【代码注释】整段代码只在 #list 上绑定了一个 click 监听器,通过 e.target.dataset.action 判断点击目标,再用 filter 更新数据、调 render() 重绘列表。新增的 admin04 不需要任何额外代码就支持点击删除和高亮------这正是事件委托解决「动态 DOM」的核心价值。data-action + data-id 是业界惯用的委托模式,避免了 if (e.target.classList.contains('btn-del')) 的重复判断。市面应用 :所有含动态增删的列表(购物车、评论区、任务看板)都应用事件委托,jQuery 的 .on('click', 'selector', cb) 就是对这个模式的封装。

实战示例 · data-action 多动作分发

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>data-action 分发</title>
<style>body{font-family:sans-serif;padding:20px}
table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:8px 12px}
th{background:#37474f;color:#fff}tr:hover{background:#f5f5f5}
button{padding:3px 10px;border:none;border-radius:3px;cursor:pointer;margin:2px;font-size:13px}
.btn-edit{background:#1976d2;color:#fff}.btn-del{background:#e53935;color:#fff}
.btn-toggle{background:#fb8c00;color:#fff}#log{margin-top:16px;padding:10px;background:#f5f5f5;border-radius:4px;font-family:monospace}</style>
</head>
<body>
<h2>广告列表 · data-action 多动作委托</h2>
<table>
  <thead><tr><th>ID</th><th>标题</th><th>状态</th><th>操作</th></tr></thead>
  <tbody id="tbody"></tbody>
</table>
<div id="log">点击操作按钮...</div>
<script>
let advList = [
  { id: 1, title: '首页轮播广告', active: true },
  { id: 2, title: '底部促销广告', active: false },
  { id: 3, title: '侧边推荐广告', active: true },
];

function render() {
  document.getElementById('tbody').innerHTML = advList.map(a =>
    `<tr>
       <td>${a.id}</td>
       <td>${a.title}</td>
       <td>${a.active ? '✅ 启用' : '⛔ 停用'}</td>
       <td>
         <button class="btn-edit"   data-action="edit"   data-id="${a.id}">编辑</button>
         <button class="btn-toggle" data-action="toggle" data-id="${a.id}">${a.active?'停用':'启用'}</button>
         <button class="btn-del"    data-action="delete" data-id="${a.id}">删除</button>
       </td>
     </tr>`
  ).join('');
}

// 分发器:action → handler
const dispatch = {
  edit:   id => log(`打开编辑弹窗,id=${id}`),
  toggle: id => {
    const item = advList.find(a => a.id === id);
    item.active = !item.active;
    render();
    log(`切换状态,id=${id} → ${item.active ? '启用' : '停用'}`);
  },
  delete: id => {
    advList = advList.filter(a => a.id !== id);
    render();
    log(`删除成功,id=${id}`);
  }
};

document.getElementById('tbody').addEventListener('click', e => {
  const { action, id } = e.target.dataset;
  if (action && dispatch[action]) dispatch[action](Number(id));
});

function log(msg) { document.getElementById('log').textContent = msg; }
render();
</script>
</body>
</html>

【代码注释】dispatch 对象是「行为映射表」------每个 data-action 对应一个处理函数,事件监听器只负责「读 action → 查表 → 执行」,完全不包含业务逻辑。这种模式可无限扩展:添加新动作只需在 dispatch 里加一项,监听器代码不变,符合「开闭原则」。data-id 把 DOM 节点与数据 id 关联,避免闭包或 DOM 查找的脆弱性。市面应用:React 的事件处理本质上也是这个模式------handler 函数通过 props 传入,onClick 接收事件对象并读取数据。


四、确认交互与 Promise 化弹窗

概念与底层原理:

confirm() 阻塞 UI 主线程、样式不可定制、移动端体验糟。SweetAlert2 是 Promise 风格的现代弹窗库,零阻塞、可定制、API 优雅:

js 复制代码
import swal from 'sweetalert2';

const deleteAdminExec = event => {
  if (!event.target.classList.contains('btn-danger')) return;

  swal.fire({
    title: '确定删除?',
    icon: 'warning',
    showCancelButton: true,
    confirmButtonText: '确定',
    cancelButtonText: '取消'
  }).then(result => {
    if (result.isConfirmed) {
      deleteAdmin(event.target.dataset.id).then(() => {
        getAdminExec();
        swal.fire({ title: '删除成功!', icon: 'success' });
      });
    }
  });
};

【代码注释】swal.fire(options) 返回 Promise------它不阻塞 UI,按钮点击后 resolve,result.isConfirmed 区分确定/取消。「对话框结果 Promise 化」是现代弹窗库的核心设计 ------业务代码用熟悉的 .then 处理点击结果,与异步流程统一,再也不用 if (confirm()) 这种同步阻塞写法。市面应用 :Element UI 的 MessageBox、Ant Design 的 Modal.confirm 都 Promise 化,与 SweetAlert2 同源。

【实战要点】

  • 经典应用场景:删除确认、危险操作二次确认、提交成功反馈。
  • 常见坑result.isConfirmed 在 v11 改名,注意版本。
  • 性能与最佳实践:批量操作加倒计时或验证码防误操作。

【本章小结】

原生 SweetAlert2
alert swal.fire({ icon: 'success' })
confirm swal.fire({ showCancelButton }) Promise

记忆口诀:「Fire + then = Promise 弹窗」。

【面试考点】

Q1:原生 confirm 为什么被淘汰?

A:1)阻塞主线程------弹出时整个 JS 暂停,影响动画、定时器、异步回调;2)样式不可定制;3)同步返回布尔值,无法表达「等异步检查后才确认」;4)移动端体验差。Promise 化弹窗解决了所有这些问题。

入门示例 · Promise 化确认弹窗

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>Promise 确认弹窗</title>
<style>body{font-family:sans-serif;padding:20px}
.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;align-items:center;justify-content:center}
.overlay.show{display:flex}.modal{background:#fff;padding:28px;border-radius:8px;min-width:300px;text-align:center}
.modal h3{margin:0 0 12px}.modal p{margin:0 0 20px;color:#555}
.actions button{padding:8px 24px;margin:4px;border:none;border-radius:4px;cursor:pointer;font-size:15px}
.btn-ok{background:#e53935;color:#fff}.btn-cancel{background:#90a4ae;color:#fff}
ul{list-style:none;padding:0}li{display:flex;justify-content:space-between;padding:10px;
background:#f5f5f5;margin:4px 0;border-radius:4px}
.del-btn{background:#e53935;color:#fff;border:none;border-radius:4px;padding:4px 10px;cursor:pointer}</style>
</head>
<body>
<h2>管理员列表(Promise 确认删除)</h2>
<ul id="list"></ul>
<div class="overlay" id="overlay">
  <div class="modal">
    <h3>⚠️ 确认删除</h3>
    <p id="modalMsg">确定要删除该管理员吗?</p>
    <div class="actions">
      <button class="btn-ok" id="btnOk">确定删除</button>
      <button class="btn-cancel" id="btnCancel">取消</button>
    </div>
  </div>
</div>
<script>
let admins = [{id:1,name:'admin01'},{id:2,name:'admin02'},{id:3,name:'admin03'}];

// Promise 化确认弹窗核心
function confirm(msg) {
  return new Promise((resolve) => {
    document.getElementById('modalMsg').textContent = msg;
    document.getElementById('overlay').classList.add('show');
    document.getElementById('btnOk').onclick = () => {
      document.getElementById('overlay').classList.remove('show');
      resolve(true);
    };
    document.getElementById('btnCancel').onclick = () => {
      document.getElementById('overlay').classList.remove('show');
      resolve(false);
    };
  });
}

async function deleteAdmin(id, name) {
  const ok = await confirm(`确定要删除管理员「${name}」吗?`);
  if (!ok) return;
  admins = admins.filter(a => a.id !== id);
  render();
}

function render() {
  document.getElementById('list').innerHTML = admins.map(a =>
    `<li><span>${a.name}</span>
     <button class="del-btn" onclick="deleteAdmin(${a.id},'${a.name}')">删除</button>
     </li>`).join('') || '<li style="color:#aaa">列表已空</li>';
}
render();
</script>
</body>
</html>

【代码注释】confirm(msg) 返回一个 Promise<boolean>------弹出弹窗后函数暂停 等用户操作,用户点「确定」resolve true、点「取消」resolve false,调用方用 await 拿到结果后决定是否继续。这种「把弹窗包装成 Promise」的技法让异步交互和同步逻辑写在同一段代码里(if (!ok) return),而原生 window.confirm 是同步阻塞、不可定制样式、无法与 async 业务流组合。市面应用 :SweetAlert2 的 swal.fire({...}) 就是这套模式的成熟封装;Element Plus 的 ElMessageBox.confirm 也是。

实战示例 · 带校验的多步流程弹窗

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>多步流程弹窗</title>
<style>body{font-family:sans-serif;padding:20px}
.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;align-items:center;justify-content:center}
.overlay.show{display:flex}.modal{background:#fff;padding:28px;border-radius:8px;min-width:340px}
.modal h3{margin:0 0 16px;color:#333}input{width:100%;box-sizing:border-box;padding:8px;border:1px solid #ddd;border-radius:4px;margin-bottom:12px;font-size:14px}
.err{color:#e53935;font-size:13px;margin:-8px 0 8px}.actions{display:flex;gap:8px;justify-content:flex-end}
button{padding:8px 20px;border:none;border-radius:4px;cursor:pointer}
.btn-ok{background:#1976d2;color:#fff}.btn-cancel{background:#90a4ae;color:#fff}
.list li{display:flex;justify-content:space-between;padding:8px;background:#f5f5f5;margin:4px 0;list-style:none;border-radius:4px}
.btn-del{background:#e53935;color:#fff;border:none;border-radius:4px;padding:3px 10px;cursor:pointer}</style>
</head>
<body>
<h2>管理员管理(多步弹窗)</h2>
<button onclick="openAddModal()" style="background:#388e3c;color:#fff;border:none;padding:8px 16px;border-radius:4px;cursor:pointer">+ 添加管理员</button>
<ul class="list" id="list"></ul>
<div class="overlay" id="overlay">
  <div class="modal">
    <h3 id="title">添加管理员</h3>
    <input id="nameInput" placeholder="账号(纯英文字母)">
    <div class="err" id="nameErr"></div>
    <input id="pwdInput" type="password" placeholder="密码(6-18位,字母数字下划线)">
    <div class="err" id="pwdErr"></div>
    <div class="actions">
      <button class="btn-cancel" onclick="closeModal()">取消</button>
      <button class="btn-ok" onclick="submitModal()">确定</button>
    </div>
  </div>
</div>
<script>
let admins = [], counter = 1;
let _resolve;

function openAddModal() {
  document.getElementById('title').textContent = '添加管理员';
  document.getElementById('nameInput').value = '';
  document.getElementById('pwdInput').value = '';
  document.getElementById('nameErr').textContent = '';
  document.getElementById('pwdErr').textContent = '';
  document.getElementById('overlay').classList.add('show');
  return new Promise(r => _resolve = r);
}
function closeModal() {
  document.getElementById('overlay').classList.remove('show');
  _resolve && _resolve(false);
}
function submitModal() {
  const name = document.getElementById('nameInput').value.trim();
  const pwd  = document.getElementById('pwdInput').value.trim();
  let valid = true;
  if (!/^[a-zA-Z]+$/.test(name)) { document.getElementById('nameErr').textContent = '账号只能由英文字母组成'; valid=false; }
  else document.getElementById('nameErr').textContent = '';
  if (!/^\w{6,18}$/.test(pwd)) { document.getElementById('pwdErr').textContent = '密码须为6-18位字母数字下划线'; valid=false; }
  else document.getElementById('pwdErr').textContent = '';
  if (!valid) return;
  closeModal();
  _resolve({ name, pwd });
}
async function openAndAdd() {
  const result = await openAddModal();
  if (!result) return;
  admins.push({ id: counter++, ...result });
  render();
}
function render() {
  document.getElementById('list').innerHTML = admins.map(a =>
    `<li><span>${a.name}</span><button class="btn-del" onclick="admins=admins.filter(x=>x.id!=${a.id});render()">删除</button></li>`
  ).join('') || '<li style="color:#aaa">暂无管理员</li>';
}
document.querySelector('[onclick="openAddModal()"]').onclick = openAndAdd;
render();
</script>
</body>
</html>

【代码注释】弹窗通过 _resolve 保存 Promise 的 resolve 函数,外部 await openAddModal() 等待用户填表------「确定」时 resolve 表单数据,「取消」时 resolve false。表单校验在 submitModal 里同步完成,只有校验全通过才关弹窗 resolve,否则显示错误提示让用户重填。这是真实项目里最常见的弹窗流程 ------调用方把弹窗当成一次「等待用户输入的 Promise」,业务逻辑不需要知道弹窗的内部实现。市面应用 :Element Plus ElDialog + v-model / Ant Design Modal Modal.confirm 都是这套 Promise 化弹窗的工程化版本。


五、浏览器三大本地存储

名词解释:

  • localStorage:同源永久键值存储,约 5MB,只存字符串。
  • sessionStorage:同源会话级存储,关闭 Tab 自动清除。
  • Cookie:会自动随同源请求发送的小型存储(约 4KB)。

概念与底层原理:

三者的差异决定了各自的适用场景:

维度 localStorage sessionStorage Cookie
容量 ~5MB ~5MB ~4KB
生命周期 永久(除非清除) 关闭 Tab 失效 可设过期
自动随请求发送
适用 Token、用户偏好 临时草稿 服务端 Session

【代码注释】这张图展示了 localStorage 在登录态管理中的中枢作用:登录成功后存 adminName 与 token,侧边栏读 adminName 显示账号、Axios 读 token 注入请求头、退出时 clear() 清空。选 localStorage 的理由 :账号、token 要跨 Tab 跨刷新保留(sessionStorage 不行);token 不需自动随请求发(手动加 header 避免 CSRF)。安全考量 :localStorage 任何 JS 都能读,存在 XSS 风险------高安全项目应把 token 放 HTTP-Only Cookie(JS 无法访问)。市面应用:toC 应用常用 localStorage 简化逻辑;toB 高安全项目用 HTTP-Only Cookie + 同源策略防 XSS。

js 复制代码
// 登录成功后存储
localStorage.setItem('adminName', adminName);   // 只能存字符串
localStorage.setItem('token', res.token);
// 读取
const name = localStorage.getItem('adminName'); // 不存在返回 null
// 退出清空
localStorage.clear();

【代码注释】setItem 只能存字符串------存对象要 JSON.stringify,读取时 JSON.parseclear() 清空当前域名下所有 key(若只想清部分用 removeItem)。市面应用 :实践中通常把读写封装到 utils/storage.js,统一处理 JSON 序列化与 key 命名空间(避免多应用冲突)。

权威参考:

【实战要点】

  • 经典应用场景:登录态、用户偏好、临时缓存。
  • 常见坑:1)只能存字符串------对象要序列化;2)跨域无效;3)XSS 可读------不存敏感信息。
  • 性能与最佳实践:封装读写工具统一处理 JSON 与 key 命名空间。

【本章小结】

API 行为
setItem(k,v) 写字符串
getItem(k) 读(不存在返回 null)
removeItem(k) 删除
clear() 清空全部

记忆口诀:「Local 永久,Session 关 Tab 失,Cookie 随请求」。

【面试考点】

Q1:localStorage / sessionStorage / Cookie 各自适用什么?

A:1)localStorage------用户偏好、token(如不严格考虑 XSS)、跨 Tab 共享数据;2)sessionStorage------表单草稿、单 Tab 步骤向导的中间状态;3)Cookie------服务端 Session ID(HTTP-Only + Secure + SameSite 防 XSS/CSRF)。Cookie 优势是自动随请求发送,劣势是容量小、每次请求都带。

Q2:localStorage 存 token 安全吗?

A:不够安全------localStorage 任何 JS 都能读,若站点有 XSS 漏洞,攻击者可读取 token 冒充用户。更安全做法:token 存 HTTP-Only Cookie(JS 无法访问)+ SameSite 防 CSRF;或短期 token + refresh 机制减少泄露窗口。

入门示例 · localStorage CRUD 可视化

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>localStorage CRUD</title>
<style>body{font-family:sans-serif;padding:20px;max-width:600px}
.row{display:flex;gap:8px;margin-bottom:12px}input{flex:1;padding:8px;border:1px solid #ddd;border-radius:4px}
button{padding:8px 16px;border:none;border-radius:4px;cursor:pointer;color:#fff}
.btn-set{background:#1976d2}.btn-get{background:#388e3c}.btn-del{background:#e53935}.btn-clear{background:#757575}
#output{background:#f5f5f5;padding:12px;border-radius:4px;font-family:monospace;min-height:60px;white-space:pre}
#store-list{margin-top:16px}table{width:100%;border-collapse:collapse}
th,td{border:1px solid #ddd;padding:8px;text-align:left;font-size:14px}th{background:#1976d2;color:#fff}</style>
</head>
<body>
<h2>localStorage CRUD 可视化</h2>
<div class="row">
  <input id="key" placeholder="key" value="userToken">
  <input id="val" placeholder="value" value="eyJhbGciOiJIUzI1NiJ9">
</div>
<div class="row">
  <button class="btn-set" onclick="lsSet()">setItem</button>
  <button class="btn-get" onclick="lsGet()">getItem</button>
  <button class="btn-del" onclick="lsDel()">removeItem</button>
  <button class="btn-clear" onclick="lsClear()">clear</button>
</div>
<div id="output">等待操作...</div>
<div id="store-list"></div>
<script>
const out = s => document.getElementById('output').textContent = s;

function lsSet() {
  const k = document.getElementById('key').value;
  const v = document.getElementById('val').value;
  localStorage.setItem(k, v);
  out(`setItem("${k}", "${v}") ✅`);
  renderTable();
}
function lsGet() {
  const k = document.getElementById('key').value;
  const v = localStorage.getItem(k);
  out(v !== null ? `getItem("${k}") → "${v}"` : `getItem("${k}") → null(不存在)`);
}
function lsDel() {
  const k = document.getElementById('key').value;
  localStorage.removeItem(k);
  out(`removeItem("${k}") ✅`);
  renderTable();
}
function lsClear() { localStorage.clear(); out('clear() ✅ 全部清空'); renderTable(); }

function renderTable() {
  const entries = Object.entries(localStorage);
  if (!entries.length) {
    document.getElementById('store-list').innerHTML = '<p style="color:#aaa">localStorage 当前为空</p>';
    return;
  }
  document.getElementById('store-list').innerHTML =
    `<table><thead><tr><th>key</th><th>value</th></tr></thead><tbody>` +
    entries.map(([k,v]) => `<tr><td>${k}</td><td>${v.length>40?v.slice(0,40)+'...':v}</td></tr>`).join('') +
    `</tbody></table>`;
}
renderTable();
</script>
</body>
</html>

【代码注释】这个 Demo 把 localStorage 的四个核心 API(setItem / getItem / removeItem / clear)可视化------每次操作后调 renderTable() 刷新表格,可以直观看到存储变化。Object.entries(localStorage) 枚举当前所有键值对。值得注意的是 getItem 不存在时返回 null(不是 undefined),调用方要用 !== null 而非 !value 来判断(空字符串也是合法值)。市面应用 :所有存 token、用户偏好、购物车草稿的代码都在用这四个 API,建议封装成 store.js 统一管理,避免 key 分散在各处导致「删漏了哪个」。

实战示例 · sessionStorage 表单步骤向导

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>步骤向导 sessionStorage</title>
<style>body{font-family:sans-serif;padding:20px;max-width:500px}
.step{display:none}.step.active{display:block}.progress{display:flex;gap:0;margin-bottom:24px}
.step-dot{flex:1;text-align:center;padding:8px;background:#e0e0e0;font-size:13px;font-weight:600}
.step-dot.done{background:#1976d2;color:#fff}.step-dot.current{background:#42a5f5;color:#fff}
input,select{width:100%;box-sizing:border-box;padding:8px;border:1px solid #ddd;border-radius:4px;margin:6px 0 14px}
.nav{display:flex;justify-content:space-between}button{padding:8px 20px;border:none;border-radius:4px;cursor:pointer;font-weight:600}
.btn-next{background:#1976d2;color:#fff}.btn-prev{background:#90a4ae;color:#fff}
.summary{background:#f5f5f5;padding:16px;border-radius:4px}
.summary div{margin:6px 0;font-size:15px}</style>
</head>
<body>
<h2>多步表单(sessionStorage 保存草稿)</h2>
<div class="progress">
  <div class="step-dot current" id="dot1">① 基本信息</div>
  <div class="step-dot" id="dot2">② 账号设置</div>
  <div class="step-dot" id="dot3">③ 确认提交</div>
</div>

<div class="step active" id="step1">
  <label>姓名</label><input id="name" placeholder="真实姓名">
  <label>部门</label>
  <select id="dept"><option value="技术部">技术部</option><option value="运营部">运营部</option><option value="产品部">产品部</option></select>
  <div class="nav"><span></span><button class="btn-next" onclick="go(2)">下一步</button></div>
</div>

<div class="step" id="step2">
  <label>账号</label><input id="account" placeholder="英文字母,如 zhang_san">
  <label>初始密码</label><input id="password" type="password" placeholder="6-18位">
  <div class="nav"><button class="btn-prev" onclick="go(1)">上一步</button><button class="btn-next" onclick="go(3)">下一步</button></div>
</div>

<div class="step" id="step3">
  <div class="summary" id="summary"></div>
  <div class="nav">
    <button class="btn-prev" onclick="go(2)">上一步</button>
    <button class="btn-next" onclick="submit()">提交</button>
  </div>
</div>

<script>
let currentStep = 1;

function save(step) {
  if (step === 1) sessionStorage.setItem('step1', JSON.stringify({
    name: document.getElementById('name').value,
    dept: document.getElementById('dept').value
  }));
  if (step === 2) sessionStorage.setItem('step2', JSON.stringify({
    account: document.getElementById('account').value
  }));
}

function restore(step) {
  const d = JSON.parse(sessionStorage.getItem('step'+step) || 'null');
  if (!d) return;
  if (step === 1) { document.getElementById('name').value = d.name||''; document.getElementById('dept').value = d.dept||'技术部'; }
  if (step === 2) { document.getElementById('account').value = d.account||''; }
}

function go(step) {
  save(currentStep);
  currentStep = step;
  document.querySelectorAll('.step').forEach((el,i) => el.classList.toggle('active', i+1===step));
  document.querySelectorAll('.step-dot').forEach((el,i) => {
    el.className = 'step-dot' + (i+1<step?' done':i+1===step?' current':'');
  });
  restore(step);
  if (step === 3) {
    const s1 = JSON.parse(sessionStorage.getItem('step1')||'{}');
    const s2 = JSON.parse(sessionStorage.getItem('step2')||'{}');
    document.getElementById('summary').innerHTML =
      `<div>👤 姓名:${s1.name||'-'}</div><div>🏢 部门:${s1.dept||'-'}</div><div>🔑 账号:${s2.account||'-'}</div>`;
  }
}

function submit() {
  sessionStorage.removeItem('step1'); sessionStorage.removeItem('step2');
  alert('提交成功!(模拟)\n关闭标签页后 sessionStorage 自动清空');
}
</script>
</body>
</html>

【代码注释】sessionStorage 在这里充当「步骤草稿」------用户从步骤一跳步骤二时,步骤一的表单数据先 save 到 sessionStorage;回退时再 restore 填回表单,数据不丢失。相比 localStorage,sessionStorage 有一个天然优势:关闭标签页自动清空 ,不会在浏览器里残留用户的半填写草稿。市面应用:电商下单流程(地址 → 支付方式 → 确认)、多步注册向导常用 sessionStorage 保存草稿状态;复杂的多步流程则用 Redux / Pinia 管理,但原理相同。


六、登录态管理与路由守卫

概念与底层原理:

未登录用户访问内部页面应被踢到登录页。这需要在 SPA 启动时做「登录态检查」------这是「路由守卫」最朴素的实现:

js 复制代码
// app.js
import SMERouter from 'sme-router';
import routes from './routes';

const router = new SMERouter('app', 'html5');
window.router = router;            // 暴露给模板的 onclick

// 全局登录守卫
if (!localStorage.getItem('adminName')) {
  router.go('/login');
}

routes.forEach(({ path, element }) => router.route(path, element));

【代码注释】SPA 启动时检查 localStorage 有无登录标识,没有就跳登录页。但要清醒认识:前端守卫是「体验守卫」而非「安全守卫」 ------它只防止「未登录用户看到内部 UI」,攻击者可绕过守卫直接调接口。真正的安全在后端 :每个接口都校验 token、未授权返回 401。市面应用 :生产项目用「路由级守卫」精细化------给路由配 meta.requireAuth,在 beforeEach 里只拦截需鉴权的路由,登录/注册/公开页放行。

退出登录是「清存储 + 跳登录」两步:

js 复制代码
// 退出按钮事件
document.querySelector('#logoutBtn').addEventListener('click', () => {
  localStorage.clear();            // 清空所有本地存储
  router.go('/login');
});

【代码注释】localStorage.clear() 清空 adminName + token 干净彻底,router.go('/login') 跳回登录页。注意 clear() 会清掉所有 key ------若存了主题、语言偏好也会被清,按需可用 removeItem 单清。市面应用:所有 SaaS 应用的退出登录都是这两步。

【实战要点】

  • 经典应用场景:所有需登录态的应用。
  • 常见坑clear() 会清掉所有 key,包括用户偏好。
  • 性能与最佳实践 :路由级守卫比全局守卫精细,给路由加 meta.requireAuth

【本章小结】

步骤 代码
守卫 if (!adminName) router.go('/login')
退出 localStorage.clear(); router.go('/login')

记忆口诀:「进来先检查,出去先清空」。

【面试考点】

Q1:前端登录守卫安全吗?

A:前端守卫是「UX 守卫」而非「安全守卫」------它只防止「未登录用户看到内部页面 UI」,但攻击者可抓包/写脚本绕过守卫调接口。真正的安全在后端------每个接口校验 token、未授权返回 401。前端守卫只是让体验更顺滑。

入门示例 · Hash 路由守卫

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>路由守卫</title>
<style>body{font-family:sans-serif;padding:20px}nav a{margin-right:12px;color:#1976d2}
#page{margin-top:20px;padding:20px;background:#f5f5f5;border-radius:4px;min-height:80px}
.login-form input{display:block;padding:8px;margin:6px 0;border:1px solid #ddd;border-radius:4px;width:200px}
.login-form button{margin-top:8px;padding:8px 20px;background:#1976d2;color:#fff;border:none;border-radius:4px;cursor:pointer}
#toast{position:fixed;top:20px;right:20px;background:#333;color:#fff;padding:10px 16px;border-radius:4px;display:none}</style>
</head>
<body>
<nav>
  <a href="#/index">首页</a>
  <a href="#/admin">管理员(需登录)</a>
  <a href="#/login">登录</a>
  <a href="#/logout" onclick="logout()">退出</a>
</nav>
<div id="page"></div>
<div id="toast"></div>
<script>
const PROTECTED = ['/admin'];  // 需要登录的路由

const views = {
  '/index': () => `<h2>🏠 首页</h2><p>欢迎,${getUser()||'游客'}</p>`,
  '/admin': () => `<h2>👤 管理员列表</h2><p>已登录为:<strong>${getUser()}</strong></p>
    <ul><li>admin01</li><li>admin02</li></ul>`,
  '/login': () => `<h2>🔐 登录</h2>
    <div class="login-form">
      <input id="u" placeholder="账号" value="admin">
      <input id="p" type="password" placeholder="密码" value="123456">
      <button onclick="login()">登录</button>
    </div>`,
};

// 路由守卫
function guard(path) {
  if (PROTECTED.includes(path) && !getUser()) {
    toast('请先登录!');
    setTimeout(() => location.hash = '#/login', 500);
    return false;
  }
  return true;
}

function render() {
  const hash = location.hash.replace(/^#/, '') || '/index';
  if (!guard(hash)) return;
  const view = views[hash];
  document.getElementById('page').innerHTML = view ? view() : '<p>404 页面不存在</p>';
}

function login() {
  const u = document.getElementById('u').value;
  localStorage.setItem('user', u);
  toast('登录成功!');
  setTimeout(() => location.hash = '#/admin', 500);
}
function logout() { localStorage.removeItem('user'); toast('已退出'); location.hash = '#/index'; }
function getUser() { return localStorage.getItem('user'); }
function toast(msg) {
  const t = document.getElementById('toast');
  t.textContent = msg; t.style.display = 'block';
  setTimeout(() => t.style.display = 'none', 1500);
}

window.addEventListener('hashchange', render);
window.addEventListener('DOMContentLoaded', render);
</script>
</body>
</html>

【代码注释】guard(path) 是路由守卫的最小实现------检查「当前路由是否在受保护名单」且「用户是否已登录(localStorage 有 token)」,两条件同时满足才放行;否则弹提示、跳登录页。这个模式在真实项目里对应 Vue Router 的 beforeEach 钩子:所有路由跳转前都经过守卫函数,决定「放行 / 跳转 / 中断」。市面应用 :所有后台管理系统的路由都有守卫;Next.js 的 middleware.ts、React Router 的 loader 函数也是同样的思路,只是实现层级更高。

实战示例 · 完整登录 + 守卫 + 自动跳转

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>完整登录流程</title>
<style>body{font-family:sans-serif;padding:0;margin:0;background:#f0f2f5;min-height:100vh}
.page{max-width:500px;margin:40px auto;background:#fff;padding:32px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.1)}
h2{margin-top:0}input{width:100%;box-sizing:border-box;padding:10px;border:1px solid #ddd;border-radius:4px;margin:6px 0 14px;font-size:15px}
button{width:100%;padding:10px;background:#1976d2;color:#fff;border:none;border-radius:4px;font-size:16px;cursor:pointer}
.info{color:#888;font-size:14px;margin-top:12px;text-align:center}
.logout-btn{background:#e53935;width:auto;padding:6px 16px;font-size:14px;float:right}
.tag{display:inline-block;background:#e3f2fd;color:#1565c0;padding:3px 10px;border-radius:12px;font-size:13px}</style>
</head>
<body>
<div class="page" id="app">加载中...</div>
<script>
const USERS = { admin: 'admin123', operator: 'op123456' };

function getToken() { return localStorage.getItem('adminToken'); }
function setToken(t) { localStorage.setItem('adminToken', t); }
function clearToken() { localStorage.removeItem('adminToken'); localStorage.removeItem('adminUser'); }
function getUser() { return localStorage.getItem('adminUser'); }

function showLogin(msg) {
  document.getElementById('app').innerHTML = `
    <h2>🔐 管理系统登录</h2>
    ${msg ? '<p style="color:#e53935">'+msg+'</p>' : ''}
    <input id="account" placeholder="账号(admin 或 operator)" value="admin">
    <input id="password" type="password" placeholder="密码" value="admin123">
    <button onclick="doLogin()">登录</button>
    <p class="info">提示:账号 admin/admin123 或 operator/op123456</p>`;
}

function showDashboard() {
  document.getElementById('app').innerHTML = `
    <h2>📊 管理控制台 <button class="logout-btn" onclick="doLogout()">退出登录</button></h2>
    <p>欢迎回来,<span class="tag">${getUser()}</span></p>
    <p style="color:#388e3c">✅ 已通过 Token 鉴权</p>
    <p style="font-size:13px;color:#888">localStorage 中存有 adminToken:<br><code style="word-break:break-all">${getToken()}</code></p>`;
}

async function doLogin() {
  const account  = document.getElementById('account').value.trim();
  const password = document.getElementById('password').value.trim();
  await delay(400);
  if (!USERS[account] || USERS[account] !== password) {
    showLogin('账号或密码错误!');
    return;
  }
  const token = 'mock-jwt.' + btoa(account) + '.' + Date.now();
  setToken(token);
  localStorage.setItem('adminUser', account);
  showDashboard();
}

function doLogout() { clearToken(); showLogin(); }

function delay(ms) { return new Promise(r => setTimeout(r, ms)); }

// 启动时检查登录状态
getToken() ? showDashboard() : showLogin();
</script>
</body>
</html>

【代码注释】这个 Demo 串联了完整的登录态管理生命周期:登录 时生成 mock token 写入 localStorage + 记录用户名;页面刷新getToken() 检查 localStorage,有 token 直接显示控制台(不用重新登录);退出clearToken() 删除所有登录相关数据,下次进来重新走登录流程。btoa(account) 模拟 JWT payload 的 Base64 编码------真实 JWT 是 header.payload.signature 三段,这里只演示结构。市面应用 :所有后台系统的首页都是这段逻辑:pageLoaded → hasToken? → showApp : showLogin,是前端工程的基础设施。


七、Token 无状态鉴权机制

名词解释:

  • Token:服务端发放的「身份证」,前端每次请求带上以证明身份。
  • JWT(JSON Web Token):自描述的 Token 标准,由 Header.Payload.Signature 三段组成。
  • 无状态(Stateless):服务器不存 session,仅靠 Token 自身签名验证身份。

概念与底层原理:

Token 鉴权的完整生命周期:

【代码注释】Token 鉴权的核心是「无状态」------服务器不存 session,仅靠 token 自身签名验证身份。登录后端发 token、前端存 token、每次请求注入 token、token 失效自动跳登录。无状态让横向扩展变简单 ------多台服务器集群中,任意一台都能验证 token 而无需共享 session 存储。市面应用:JWT 是最常见的 token 格式;所有公开 API(GitHub、Stripe、阿里云)都用类似机制。

token 的注入与失效处理由拦截器统一完成:

js 复制代码
// request/advserver.js
// 请求拦截器:每个请求自动加 token
advServer.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) config.headers.token = token;
  return config;
});

// 响应拦截器:统一错误码处理
advServer.interceptors.response.use(
  res => {
    if (res.data.ok === -1) {                 // 业务失败
      toastr.error(res.data.msg);
      return new Promise(() => {});
    }
    if (res.data.ok === -2) {                 // token 失效/未登录
      toastr.error(res.data.msg);
      router.go('/login');
      localStorage.clear();
      return new Promise(() => {});
    }
    return res.data;
  },
  error => { toastr.error('请求错误!'); return new Promise(() => {}); }
);

【代码注释】请求拦截器让每个请求自动带 token------业务代码完全感知不到鉴权细节,只调 postAdmin({})。响应拦截器统一处理两种错误:ok===-1 业务失败单纯提示,ok===-2 登录失效则跳登录 + 清存储。这把「鉴权」这个横切关注点从分散的业务代码彻底抽离 ------业务只写业务,鉴权全在拦截器流水线。市面应用:所有企业级前端的 Axios 封装都做这两件事。

权威参考:

【实战要点】

  • 经典应用场景:所有需登录的后台系统。
  • 常见坑:拦截器里再发请求易死循环(如 token 失效后调 refresh、refresh 也带失效 token)。
  • 性能与最佳实践:用「access token(短期)+ refresh token(长期)」组合;多请求并发失效时做请求队列避免重复刷新。

【本章小结】

操作 拦截器
注入 token 请求拦截器
错误处理 响应拦截器
失效跳转 ok===-2 分支

记忆口诀:「请求拦截加 Token,响应拦截分错码」。

【面试考点】

Q1:JWT 的工作原理?

A:JWT 由 Header(算法).Payload(声明,含用户 id、过期时间).Signature(前两段 + 密钥计算的签名)组成。服务器签发后给前端;前端每次请求带上;服务器用相同密钥重算签名比对,一致即验证通过。优点:无状态、自描述、跨域。缺点:签发后无法主动作废------需短期 + refresh 或黑名单机制。

Q2:HTTP-Only Cookie 与 localStorage 存 token 哪个更安全?

A:HTTP-Only Cookie 更安全------HttpOnly 使 JS 无法读取,阻断 XSS 偷 token;但要配 SameSite 防 CSRF。localStorage 任何脚本可读,XSS 下 token 必失。综合:HTTP-Only Cookie + SameSite + Secure 最安全;localStorage 适合「不严格安全 + 跨子域共享」场景。

入门示例 · JWT 结构解析器

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>JWT 解析器</title>
<style>body{font-family:sans-serif;padding:20px;max-width:700px}
textarea{width:100%;box-sizing:border-box;padding:10px;border:1px solid #ddd;border-radius:4px;font-family:monospace;font-size:13px;resize:vertical}
.section{background:#f5f5f5;padding:14px;border-radius:4px;margin:8px 0}
.section h4{margin:0 0 8px;color:#1976d2}.section pre{margin:0;white-space:pre-wrap;word-break:break-all;font-size:13px}
.ok{color:#2e7d32}.expired{color:#c62828}button{padding:8px 20px;background:#1976d2;color:#fff;border:none;border-radius:4px;cursor:pointer;margin-top:8px}</style>
</head>
<body>
<h2>JWT 结构解析器</h2>
<textarea id="jwt" rows="4">eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMDAxIiwibmFtZSI6IuW8gOS4iiIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDg2NDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c</textarea>
<button onclick="decode()">解析 JWT</button>
<div id="result"></div>
<script>
function b64decode(str) {
  str = str.replace(/-/g, '+').replace(/_/g, '/');
  while (str.length % 4) str += '=';
  try { return JSON.parse(atob(str)); } catch { return atob(str); }
}

function decode() {
  const token = document.getElementById('jwt').value.trim();
  const parts = token.split('.');
  if (parts.length !== 3) { document.getElementById('result').innerHTML = '<p style="color:red">不是有效的 JWT(应为 header.payload.signature 三段)</p>'; return; }

  const header  = b64decode(parts[0]);
  const payload = b64decode(parts[1]);
  const now = Math.floor(Date.now()/1000);
  const expired = payload.exp && payload.exp < now;

  document.getElementById('result').innerHTML = `
    <div class="section">
      <h4>① Header(算法声明)</h4>
      <pre>${JSON.stringify(header, null, 2)}</pre>
    </div>
    <div class="section">
      <h4>② Payload(声明,已 Base64 解码,未加密!)</h4>
      <pre>${JSON.stringify(payload, null, 2)}</pre>
      ${payload.exp ? `<p class="${expired?'expired':'ok'}">${expired?'⚠️ Token 已过期(exp='+payload.exp+')':'✅ Token 有效,过期时间:'+new Date(payload.exp*1000).toLocaleString()}</p>` : ''}
    </div>
    <div class="section">
      <h4>③ Signature(服务端密钥签名,前端无法验证)</h4>
      <pre>${parts[2]}</pre>
      <p style="color:#888;font-size:13px">Signature = HMAC_SHA256(base64(header)+'.'+base64(payload), secret)<br>
      前端只能读 payload,无法验证签名------验证须在后端完成。</p>
    </div>`;
}
decode();
</script>
</body>
</html>

【代码注释】JWT 的三段结构在这里一览无余:atob(b64url) 解码 Base64URL 得到 JSON,payload.exp 是 Unix 时间戳形式的过期时间,与 Date.now()/1000 对比即可判断是否过期。最重要的安全提示 :Payload 只是 Base64 编码(不是加密!),前端可以读取里面的用户信息,但无法伪造签名------任何对 Payload 的修改都会让 Signature 失效,服务端一验证就知道。市面应用 :前端通常解析 JWT 里的 exp 字段做「前置过期检查」,避免带着过期 token 发请求;subrole 字段用于前端根据权限显示/隐藏菜单。

实战示例 · Token 过期检测与自动刷新模拟

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>Token 自动刷新</title>
<style>body{font-family:sans-serif;padding:20px;max-width:600px}
.card{background:#f5f5f5;padding:16px;border-radius:6px;margin:10px 0}
.card h4{margin:0 0 10px;color:#1976d2}button{padding:8px 16px;border:none;border-radius:4px;cursor:pointer;color:#fff;margin:4px}
.btn-blue{background:#1976d2}.btn-green{background:#388e3c}.btn-red{background:#e53935}
#log{background:#212121;color:#a5d6a7;font-family:monospace;font-size:13px;padding:14px;border-radius:6px;min-height:100px;white-space:pre-wrap;margin-top:16px}</style>
</head>
<body>
<h2>Token 过期检测与自动刷新模拟</h2>
<div class="card">
  <h4>当前 Token 状态</h4>
  <p id="tokenStatus">未登录</p>
  <button class="btn-green" onclick="login()">登录(获取 Token)</button>
  <button class="btn-blue"  onclick="sendRequest()">发起接口请求</button>
  <button class="btn-red"   onclick="expireToken()">模拟 Token 过期</button>
</div>
<div id="log">等待操作...</div>
<script>
let token = null;
let tokenExpiry = 0;
let refreshing = false;

function login() {
  const now = Date.now();
  tokenExpiry = now + 10000;  // 10 秒后过期(演示用)
  token = 'eyJhbGciOiJIUzI1NiJ9.' + btoa('{"sub":"admin001"}') + '.signature';
  updateStatus();
  log('🔐 登录成功,Token 有效期 10 秒');
}

function expireToken() {
  tokenExpiry = Date.now() - 1000;
  log('⚠️ 已手动使 Token 过期');
  updateStatus();
}

async function sendRequest() {
  if (!token) { log('❌ 未登录,请先登录'); return; }

  // 请求拦截器逻辑:检查 Token 是否即将过期(提前 2s 刷新)
  if (Date.now() > tokenExpiry - 2000) {
    log('🔄 Token 即将过期,尝试刷新...');
    if (refreshing) { log('⏳ 刷新中,等待...'); return; }
    refreshing = true;
    await delay(800);  // 模拟刷新请求
    tokenExpiry = Date.now() + 10000;
    token = 'eyJhbGciOiJIUzI1NiJ9.' + btoa('{"sub":"admin001","refreshed":true}') + '.new_sig';
    refreshing = false;
    log('✅ Token 刷新成功');
    updateStatus();
  }

  // 模拟发请求
  await delay(300);
  log(`📡 请求成功 → GET /api/admin → [admin01, admin02, admin03]`);
}

function updateStatus() {
  const remaining = Math.max(0, Math.round((tokenExpiry - Date.now()) / 1000));
  document.getElementById('tokenStatus').innerHTML =
    token ? `<span style="color:#2e7d32">✅ 有效,剩余 ${remaining} 秒</span>` : '未登录';
}

let logBuf = '';
function log(msg) { logBuf += msg + '\n'; document.getElementById('log').textContent = logBuf; }
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }

setInterval(updateStatus, 500);
</script>
</body>
</html>

【代码注释】这个 Demo 模拟了生产项目中最常见的 Token 维护策略:请求前检查过期时间 (比过期时间提前 2 秒触发刷新),避免带着过期 token 发请求被 401 拒绝。refreshing 标志防止并发多个刷新请求(「刷新风暴」)。在真实项目中,这段逻辑在 axios 请求拦截器里:advServer.interceptors.request.use(async config => { if (isExpired()) await refreshToken(); config.headers.Authorization = 'Bearer ' + token; return config; })市面应用:所有需要长会话的应用(在线文档、电商、CRM)都有 refresh token 机制,OAuth 2.0 也内置了这个流程。


八、FileReader 与文件本地预览

名词解释:

  • FileReader:浏览器原生 API,把 File/Blob 读取为 ArrayBuffer / DataURL / Text。
  • DataURL(Base64)data:image/jpeg;base64,... 形式的 URL,可直接给 <img src>

概念与底层原理:

用户选了图片后想「上传前先看一眼」------这是图片预览。FileReader 让浏览器在不上传到服务器的前提下读取本地文件:

【代码注释】FileReader 是异步的------readAsDataURL 触发读取,完成时 onload 回调里 reader.result 是 DataURL 字符串。把它赋给 <img src> 即可显示预览,全程不上传服务器。「读 → 异步事件 → 处理结果」是浏览器内 IO API 的通用模式 (XHR、IndexedDB 都类似)。市面应用:所有「头像上传」「图片上传」组件的即时预览都靠 FileReader。

js 复制代码
let preImgEle;

const prevImgExec = function() {
  const file = this.files[0];        // 普通函数才能用 this 取 input
  if (!file) return;
  const reader = new FileReader();
  reader.readAsDataURL(file);        // 异步读取为 DataURL
  reader.onload = () => {
    preImgEle.src = reader.result;   // 显示预览
    preImgEle.style.display = 'block';
  };
};

document.querySelector('#advPic').addEventListener('change', prevImgExec);

【代码注释】用 function() 而非箭头函数------箭头函数继承外层 this,普通函数里 this 指向触发事件的 input,从而 this.files 取到文件。onload 回调里用 reader.result 而非 this.result现代更推荐 URL.createObjectURL(file) ------它同步返回一个 blob: URL,比 FileReader 异步更快、更省内存(不读文件内容、只建映射),用完记得 URL.revokeObjectURL 释放。市面应用 :现代项目几乎全用 createObjectURL,FileReader 仅在需要读取原始内容(如 OCR、Hash 计算)时使用。

【实战要点】

  • 经典应用场景:头像、Banner、商品图、编辑器插图的上传预览。
  • 常见坑 :大图(>10MB)读 DataURL 会卡 UI------用 createObjectURL 更高效。
  • 性能与最佳实践 :仅显示用 createObjectURL;需读内容用 FileReader。

【本章小结】

API 用途
readAsDataURL 读为 Base64
readAsArrayBuffer 读为二进制
createObjectURL 同步生成 blob URL

记忆口诀:「FileReader 异步读,createObjectURL 同步给」。

【面试考点】

Q1:FileReader 与 URL.createObjectURL 区别?

A:FileReader 异步读取文件内容为 DataURL/ArrayBuffer,整个文件读入内存------可访问数据;URL.createObjectURL 同步生成 blob: URL 指向文件、不读内容------只建映射。前者适合「需拿内容」(OCR、Hash),后者适合「只显示」(图片预览),更快更省内存。

入门示例 · FileReader 图片预览

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>FileReader 图片预览</title>
<style>body{font-family:sans-serif;padding:20px}
.upload-area{border:2px dashed #1976d2;border-radius:8px;padding:30px;text-align:center;cursor:pointer;color:#1976d2;transition:.2s}
.upload-area:hover{background:#e3f2fd}.upload-area input{display:none}
#preview{max-width:300px;max-height:300px;margin-top:16px;border-radius:6px;display:none;box-shadow:0 2px 8px rgba(0,0,0,.2)}
#info{margin-top:12px;background:#f5f5f5;padding:12px;border-radius:4px;font-family:monospace;font-size:13px;display:none}</style>
</head>
<body>
<h2>FileReader 图片本地预览</h2>
<div class="upload-area" onclick="document.getElementById('fileInput').click()">
  <p>📁 点击选择图片文件(jpg / png / gif / webp)</p>
  <input type="file" id="fileInput" accept="image/*">
</div>
<img id="preview">
<div id="info"></div>
<script>
document.getElementById('fileInput').addEventListener('change', function() {
  const file = this.files[0];
  if (!file) return;

  // 显示文件元信息
  document.getElementById('info').style.display = 'block';
  document.getElementById('info').textContent =
    `文件名: ${file.name}\n大小:   ${(file.size/1024).toFixed(1)} KB\nMIME:   ${file.type}\n读取方式: FileReader.readAsDataURL`;

  // FileReader 异步读取文件内容为 base64 DataURL
  const reader = new FileReader();

  reader.onload = () => {
    const img = document.getElementById('preview');
    img.src = reader.result;  // DataURL: data:image/png;base64,iVBOR...
    img.style.display = 'block';
    document.getElementById('info').textContent +=
      `\nDataURL 长度: ${reader.result.length} 字节(原文件 ${file.size} 字节,Base64 膨胀约 33%)`;
  };

  reader.readAsDataURL(file);  // 触发异步读取
});
</script>
</body>
</html>

【代码注释】reader.readAsDataURL(file) 触发异步读取,reader.onload 在读取完成时被调用,reader.resultdata:image/png;base64,... 格式的字符串,可直接赋值给 img.src。注意 DataURL 比原文件大约 33%(Base64 编码的固定开销),因此 FileReader 不适合大文件 ------1MB 的图片读进内存变成 1.33MB 的字符串。Demo 故意显示了这个数字,帮助理解为何大文件要用 URL.createObjectURL市面应用:头像上传预览、富文本图片粘贴插入,都用 FileReader 把文件内容转为 DataURL 展示。

实战示例 · FileReader vs createObjectURL 对比

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>两种预览方式对比</title>
<style>body{font-family:sans-serif;padding:20px}
.row{display:flex;gap:16px;flex-wrap:wrap}.card{flex:1;min-width:260px;border:1px solid #ddd;border-radius:8px;padding:16px}
.card h4{margin:0 0 12px;color:#1976d2}.card img{max-width:100%;max-height:180px;border-radius:4px;display:none}
.time{font-size:13px;color:#388e3c;margin:6px 0}.detail{font-size:12px;color:#888;font-family:monospace;word-break:break-all;margin-top:8px;background:#f5f5f5;padding:8px;border-radius:4px}
.upload-btn{padding:8px 16px;background:#1976d2;color:#fff;border:none;border-radius:4px;cursor:pointer}</style>
</head>
<body>
<h2>FileReader vs URL.createObjectURL 对比</h2>
<input type="file" id="fileInput" accept="image/*" style="display:none">
<button class="upload-btn" onclick="document.getElementById('fileInput').click()">选择图片</button>
<div class="row" id="cards" style="margin-top:16px"></div>
<script>
document.getElementById('fileInput').addEventListener('change', function() {
  const file = this.files[0];
  if (!file) return;
  document.getElementById('cards').innerHTML = '';
  testFileReader(file);
  testObjectURL(file);
});

function testFileReader(file) {
  const start = performance.now();
  const reader = new FileReader();
  reader.onload = () => {
    const elapsed = (performance.now() - start).toFixed(1);
    showCard('FileReader', reader.result, elapsed, [
      `DataURL 长度: ${(reader.result.length/1024).toFixed(0)} KB`,
      `原文件大小: ${(file.size/1024).toFixed(0)} KB`,
      `体积比: ${(reader.result.length/file.size).toFixed(2)}x(Base64 膨胀)`,
      `特点: 整个文件读入内存,可访问字节内容(OCR/Hash/Canvas)`,
    ]);
  };
  reader.readAsDataURL(file);
}

function testObjectURL(file) {
  const start = performance.now();
  const url = URL.createObjectURL(file);
  const elapsed = (performance.now() - start).toFixed(1);
  showCard('URL.createObjectURL', url, elapsed, [
    `Blob URL: blob:http://...(仅页内有效)`,
    `不读文件内容,只建映射`,
    `速度: 同步,几乎零延迟`,
    `注意: 用完须 URL.revokeObjectURL(url) 释放内存`,
  ]);
}

function showCard(title, src, elapsed, details) {
  const card = document.createElement('div');
  card.className = 'card';
  card.innerHTML = `
    <h4>${title}</h4>
    <img src="${src}" style="display:block">
    <p class="time">⏱ 耗时: ${elapsed} ms</p>
    <div class="detail">${details.join('<br>')}</div>`;
  document.getElementById('cards').appendChild(card);
}
</script>
</body>
</html>

【代码注释】这个 Demo 同时跑两种预览方式并对比耗时和 URL 格式。URL.createObjectURL 几乎是零耗时(同步,只建一个内存映射),而 FileReader.readAsDataURL 需要读取并编码全部内容。选择建议:需要访问文件字节内容(计算 MD5、OCR 识别、Canvas 绘制)→ FileReader只是展示图片 → createObjectURL (更快更省内存,但用完要 revokeObjectURL 释放,否则内存泄漏)。市面应用:富文本编辑器插入图片用 createObjectURL 即时预览;头像上传组件在上传前用 FileReader 计算文件 hash 做秒传判断。


九、FormData 与复合表单上传

名词解释:

  • FormData :浏览器原生 API,构造 multipart/form-data 格式请求体。
  • multipart/form-data:HTTP 协议中处理文件上传的标准 Content-Type。

概念与底层原理:

广告表单包含文本字段(标题、类别、链接)+ 图片文件------这种「文本 + 文件」复合表单的标准上传方式是 multipart/form-data

【代码注释】new FormData(formEl) 把 form 表单一键转成 FormData 对象,所有 name 属性的字段都被包含。Axios 检测到 body 是 FormData 时自动设 Content-Type: multipart/form-data; boundary=...,无需手动配置。multipart 用边界(boundary)分隔,能同时携带文本与二进制 ------这是它区别于 JSON(纯字符串、二进制要 Base64)的根本。市面应用:所有「图片上传」「Excel 上传」「附件上传」表单都用 FormData。

js 复制代码
const addAdvExec = async event => {
  event.preventDefault();
  const fd = new FormData(document.addAdvForm);    // 一键构造

  const fdArr = Array.from(fd);                     // 转数组校验
  if (!fdArr.every(item => item[1])) {
    toastr.error('请将表单填写完整!'); return;
  }
  if (fd.get('advPic').size === 0) {
    toastr.error('请选择要上传的图片!'); return;
  }

  await postAdv(fd);                                // axios 自动设 multipart
  $('#advModal').modal('hide');
  document.addAdvForm.reset();
};

【代码注释】Array.from(fd) 把 FormData 转成 [[key, value], ...] 二维数组------FormData 不是普通对象不能 Object.keys,但实现了迭代器所以能 Array.fromfdArr.every(item => item[1]) 校验每个字段都非空;fd.get('advPic').size === 0 检查文件字段(未选时 size 为 0)。async/await 让异步上传代码读起来像同步市面应用 :所有 axios + FormData 上传都是这种写法;进一步可用 onUploadProgress 显示上传进度条。

权威参考:

【实战要点】

  • 经典应用场景:图片上传、Excel 上传、富文本编辑器嵌图。
  • 常见坑 :1)忘记设 input 的 name → FormData 拿不到值;2)手动设 Content-Type 反而出错(缺 boundary)------交给 axios 自动设。
  • 性能与最佳实践:大文件用分片上传(slice)+ 断点续传 + MD5 秒传 + 并发限制 + 进度反馈。

【本章小结】

API 用途
new FormData(form) 一键构造请求体
fd.get(name) 读字段
fd.append(name, file) 追加字段
Array.from(fd) 转数组校验

记忆口诀:「Form 直传 FormData,axios 自识 multipart」。

【面试考点】

Q1:FormData 与 JSON 请求体的区别?

A:1)Content-Type------FormData 是 multipart/form-data,JSON 是 application/json;2)编码------FormData 用 boundary 分隔,可同时携带文本与二进制,JSON 只是字符串、二进制要 Base64(体积大 33%);3)用途------FormData 用于含文件上传,JSON 用于纯结构化数据。

Q2:大文件上传怎么做?

A:1)分片上传------file.slice 切成 N 个 chunk 分别上传、后端合并;2)断点续传------每个 chunk 成功后服务端记录,崩溃重启只补未传的;3)秒传------上传前算文件 MD5/SHA1,已存在直接返回 URL;4)并发限制------同时上传 chunk ≤ 5;5)进度反馈------axios onUploadProgress

入门示例 · FormData 内容检查器

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>FormData 检查器</title>
<style>body{font-family:sans-serif;padding:20px;max-width:600px}
form{background:#f5f5f5;padding:20px;border-radius:8px}label{display:block;margin:8px 0 4px;font-weight:500}
input,select{width:100%;box-sizing:border-box;padding:8px;border:1px solid #ddd;border-radius:4px}
button{margin-top:16px;padding:8px 20px;background:#1976d2;color:#fff;border:none;border-radius:4px;cursor:pointer}
table{width:100%;border-collapse:collapse;margin-top:16px}th,td{border:1px solid #ddd;padding:8px;text-align:left;font-size:14px}
th{background:#1976d2;color:#fff}tr:nth-child(even){background:#f9f9f9}
.file-info{color:#888;font-size:13px}</style>
</head>
<body>
<h2>FormData 内容检查器</h2>
<form id="advForm" onsubmit="return false">
  <label>广告标题</label>
  <input name="advTitle" value="首页轮播广告">
  <label>广告类型</label>
  <select name="advType">
    <option value="1">轮播图广告</option>
    <option value="2">底部广告</option>
  </select>
  <label>跳转链接</label>
  <input name="advHref" value="https://example.com/promo">
  <label>广告图片</label>
  <input type="file" name="advPic" accept="image/*">
  <button onclick="inspect()">检查 FormData 内容</button>
</form>
<div id="result"></div>
<script>
function inspect() {
  const fd = new FormData(document.getElementById('advForm'));
  const rows = [];

  for (const [key, value] of fd.entries()) {
    if (value instanceof File) {
      rows.push(`<tr><td><code>${key}</code></td>
        <td><span class="file-info">File: ${value.name||'(未选择)'} / ${value.size} bytes / ${value.type||'未知类型'}</span></td></tr>`);
    } else {
      rows.push(`<tr><td><code>${key}</code></td><td>${value}</td></tr>`);
    }
  }

  document.getElementById('result').innerHTML = rows.length
    ? `<table><thead><tr><th>字段名</th><th>值</th></tr></thead><tbody>${rows.join('')}</tbody></table>
       <p style="font-size:13px;color:#888">Content-Type: multipart/form-data; boundary=----WebkitFormBoundaryXXX</p>`
    : '<p>表单为空</p>';

  // 校验:所有字段都不为空
  const allFilled = [...fd].every(([, v]) =>
    v instanceof File ? v.size > 0 : v.trim() !== '');
  const valid = document.createElement('p');
  valid.style.color = allFilled ? '#2e7d32' : '#c62828';
  valid.textContent = allFilled ? '✅ 校验通过:所有字段已填写' : '❌ 校验失败:有字段未填写或未选图片';
  document.getElementById('result').appendChild(valid);
}
</script>
</body>
</html>

【代码注释】new FormData(formElement) 把表单所有字段(包括文件 input)自动打包,fd.entries() 遍历键值对,value instanceof File 区分文件字段和文本字段。真实项目中常见的校验模式 [...fd].every(([, v]) => ...) 把 FormData 展开为数组再逐项验证,既检查文本是否空也检查文件是否选了(file.size === 0 表示未选择)。市面应用 :前端调用 axios.post('/api/adv', fd) 时 axios 自动检测到 FormData 类型,设置正确的 Content-Type: multipart/form-data,无需手动设置------这是 FormData 最常见的实战用法。

实战示例 · 带进度的图片上传模拟

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>图片上传模拟</title>
<style>body{font-family:sans-serif;padding:20px;max-width:500px}
.upload-box{border:2px dashed #1976d2;border-radius:8px;padding:30px;text-align:center;cursor:pointer;color:#1976d2}
.upload-box:hover{background:#e3f2fd}.upload-box input{display:none}
#preview{max-width:200px;max-height:200px;margin:10px auto;display:none;border-radius:6px}
.progress-wrap{background:#e0e0e0;border-radius:4px;height:8px;margin:12px 0;overflow:hidden;display:none}
.progress-bar{height:100%;background:#1976d2;width:0;transition:width .1s;border-radius:4px}
.status{text-align:center;color:#555;font-size:15px;margin:8px 0}
button{width:100%;padding:10px;background:#388e3c;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:15px;display:none}</style>
</head>
<body>
<h2>广告图片上传模拟</h2>
<div class="upload-box" onclick="document.getElementById('f').click()">
  <p>📁 点击选择图片</p>
  <input type="file" id="f" accept="image/*">
</div>
<img id="preview">
<div class="progress-wrap" id="pw"><div class="progress-bar" id="pb"></div></div>
<p class="status" id="status"></p>
<button id="uploadBtn" onclick="upload()">上传广告图片</button>
<script>
let selectedFile = null;

document.getElementById('f').addEventListener('change', function() {
  selectedFile = this.files[0];
  if (!selectedFile) return;

  // FileReader 预览
  const reader = new FileReader();
  reader.onload = () => {
    const img = document.getElementById('preview');
    img.src = reader.result; img.style.display = 'block';
  };
  reader.readAsDataURL(selectedFile);

  document.getElementById('status').textContent =
    `已选: ${selectedFile.name}(${(selectedFile.size/1024).toFixed(1)} KB)`;
  document.getElementById('uploadBtn').style.display = 'block';
});

async function upload() {
  if (!selectedFile) return;
  const btn = document.getElementById('uploadBtn');
  btn.disabled = true; btn.textContent = '上传中...';

  const pw = document.getElementById('pw');
  const pb = document.getElementById('pb');
  pw.style.display = 'block'; pb.style.width = '0%';

  // 模拟上传进度(真实项目用 axios onUploadProgress)
  for (let i = 0; i <= 100; i += 5) {
    await delay(80);
    pb.style.width = i + '%';
    document.getElementById('status').textContent = `上传中... ${i}%`;
  }

  await delay(300);
  document.getElementById('status').innerHTML =
    '<span style="color:#2e7d32">✅ 上传成功!广告图片已保存,URL: /api/static/adv/abc123.jpg</span>';
  btn.textContent = '重新上传'; btn.disabled = false;
}

function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
</script>
</body>
</html>

【代码注释】这个 Demo 串联了两个关键技术:① FileReader 预览 ------选文件后立即显示本地预览,不依赖网络;② 上传进度条 ------用循环模拟 axios onUploadProgress 的进度回调,生产代码里替换为 axios.post(url, fd, { onUploadProgress: e => pb.style.width = (e.loaded/e.total*100)+'%' })。FormData 让「同时上传图片和文本字段」变得简单------new FormData(form) 自动把所有 <input> 打包,axios 识别 FormData 自动设置 multipart/form-data,服务端用 multer 解析。市面应用:所有含图片上传的表单(广告管理、商品详情、用户头像)都走这套:FileReader 预览 + FormData 上传 + 进度反馈。


附录:可运行 Demo

下面两个完整、零依赖、可直接运行 的 HTML 把本文核心原理变成可触摸的代码------保存为本目录下的 .html 双击打开即可。

Demo 一 · localStorage 驱动的登录态管理

复刻「登录存 token → 显示账号 → 退出清空」的完整本地存储闭环:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8" /><title>登录态 Demo</title>
<style>
  body{font-family:-apple-system,"PingFang SC",sans-serif;padding:24px;line-height:1.8}
  input,button{padding:6px 10px;margin:4px 0;font-size:14px}
  .panel{border:1px solid #ddd;border-radius:8px;padding:16px;max-width:320px}
  .hi{color:#28a745;font-weight:bold}
</style></head>
<body>
  <div id="app"></div>
  <script>
    function render() {
      const name = localStorage.getItem('adminName');   // 读登录态
      const app = document.getElementById('app');
      if (name) {
        // 已登录:显示账号 + 退出按钮
        app.innerHTML = `<div class="panel">
          <p>欢迎您:<span class="hi">${name}</span></p>
          <p>token:${localStorage.getItem('token')}</p>
          <button id="logout">退出登录</button></div>`;
        document.getElementById('logout').onclick = () => {
          localStorage.clear();          // 退出:清空所有本地存储
          render();
        };
      } else {
        // 未登录:显示登录表单
        app.innerHTML = `<div class="panel">
          <h3>管理员登录</h3>
          <input id="u" placeholder="账号(仅字母)" /><br/>
          <input id="p" type="password" placeholder="密码(≥6位)" /><br/>
          <button id="login">登录</button></div>`;
        document.getElementById('login').onclick = () => {
          const u = document.getElementById('u').value.trim();
          const p = document.getElementById('p').value.trim();
          if (u.search(/^[a-zA-Z]+$/) === -1) return alert('账号只能是字母');
          if (p.length < 6) return alert('密码至少 6 位');
          // 模拟登录成功:存账号与 token
          localStorage.setItem('adminName', u);
          localStorage.setItem('token', 'mock-' + Date.now());
          render();
        };
      }
    }
    render();
  </script>
</body>
</html>

【代码注释】这个 Demo 把第五、六章的本地存储与登录态管理跑通:登录成功后 setItem 存账号与 token、页面据此显示账号、退出时 clear() 清空并回到登录态。刷新页面后登录态依然保留 ------这正是 localStorage「永久存储」的体现(换成 sessionStorage 则关闭 Tab 即失效)。打开浏览器 DevTools 的 Application → Local Storage 面板,能实时看到键值变化。市面应用:所有 toC 应用的「记住登录」都建立在这套 localStorage 读写之上。

Demo 二 · FileReader 图片本地预览

复刻第八章的「上传前预览」------选图后无需上传服务器即可看到图片:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8" /><title>图片预览 Demo</title>
<style>body{font-family:-apple-system,sans-serif;padding:24px}img{margin-top:12px;border:1px solid #ddd;border-radius:6px}</style></head>
<body>
  <h3>选择图片即时预览(不上传服务器)</h3>
  <input type="file" id="pic" accept="image/*" />
  <div><img id="preview" height="200" style="display:none" /></div>
  <script>
    const preview = document.getElementById('preview');
    document.getElementById('pic').addEventListener('change', function () {
      const file = this.files[0];          // 普通函数:this 指向 input
      if (!file) return;
      // 方式一:FileReader 异步读为 DataURL
      const reader = new FileReader();
      reader.readAsDataURL(file);
      reader.onload = () => {
        preview.src = reader.result;        // result 是 Base64 DataURL
        preview.style.display = 'block';
      };
      // 方式二(更高效,注释演示):preview.src = URL.createObjectURL(file);
    });
  </script>
</body>
</html>

【代码注释】选择图片后,FileReader.readAsDataURL 异步把文件读成 Base64 的 DataURL,onload 回调里赋给 <img src> 即显示预览------全程不向服务器上传一个字节 。代码用 function() 而非箭头函数,才能用 this.files 取到 input 的文件。注释里的 URL.createObjectURL(file) 是更高效的替代方案(同步生成 blob URL、不读文件内容)。市面应用:所有头像上传、商品图上传的「先预览再提交」体验,底层就是这段代码。

总结

知识体系回顾(思维导图)

#mermaid-svg-WflnwFhFPJuSDg2H{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WflnwFhFPJuSDg2H .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WflnwFhFPJuSDg2H .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WflnwFhFPJuSDg2H .error-icon{fill:#552222;}#mermaid-svg-WflnwFhFPJuSDg2H .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WflnwFhFPJuSDg2H .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WflnwFhFPJuSDg2H .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WflnwFhFPJuSDg2H .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WflnwFhFPJuSDg2H .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WflnwFhFPJuSDg2H .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WflnwFhFPJuSDg2H .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WflnwFhFPJuSDg2H .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WflnwFhFPJuSDg2H .marker.cross{stroke:#333333;}#mermaid-svg-WflnwFhFPJuSDg2H svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WflnwFhFPJuSDg2H p{margin:0;}#mermaid-svg-WflnwFhFPJuSDg2H .edge{stroke-width:3;}#mermaid-svg-WflnwFhFPJuSDg2H .section--1 rect,#mermaid-svg-WflnwFhFPJuSDg2H .section--1 path,#mermaid-svg-WflnwFhFPJuSDg2H .section--1 circle,#mermaid-svg-WflnwFhFPJuSDg2H .section--1 polygon,#mermaid-svg-WflnwFhFPJuSDg2H .section--1 path{fill:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .section--1 text{fill:#ffffff;}#mermaid-svg-WflnwFhFPJuSDg2H .node-icon--1{font-size:40px;color:#ffffff;}#mermaid-svg-WflnwFhFPJuSDg2H .section-edge--1{stroke:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .edge-depth--1{stroke-width:17;}#mermaid-svg-WflnwFhFPJuSDg2H .section--1 line{stroke:hsl(60, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled,#mermaid-svg-WflnwFhFPJuSDg2H .disabled circle,#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:lightgray;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:#efefef;}#mermaid-svg-WflnwFhFPJuSDg2H .section-0 rect,#mermaid-svg-WflnwFhFPJuSDg2H .section-0 path,#mermaid-svg-WflnwFhFPJuSDg2H .section-0 circle,#mermaid-svg-WflnwFhFPJuSDg2H .section-0 polygon,#mermaid-svg-WflnwFhFPJuSDg2H .section-0 path{fill:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-WflnwFhFPJuSDg2H .section-0 text{fill:black;}#mermaid-svg-WflnwFhFPJuSDg2H .node-icon-0{font-size:40px;color:black;}#mermaid-svg-WflnwFhFPJuSDg2H .section-edge-0{stroke:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-WflnwFhFPJuSDg2H .edge-depth-0{stroke-width:14;}#mermaid-svg-WflnwFhFPJuSDg2H .section-0 line{stroke:hsl(240, 100%, 83.5294117647%);stroke-width:3;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled,#mermaid-svg-WflnwFhFPJuSDg2H .disabled circle,#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:lightgray;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:#efefef;}#mermaid-svg-WflnwFhFPJuSDg2H .section-1 rect,#mermaid-svg-WflnwFhFPJuSDg2H .section-1 path,#mermaid-svg-WflnwFhFPJuSDg2H .section-1 circle,#mermaid-svg-WflnwFhFPJuSDg2H .section-1 polygon,#mermaid-svg-WflnwFhFPJuSDg2H .section-1 path{fill:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .section-1 text{fill:black;}#mermaid-svg-WflnwFhFPJuSDg2H .node-icon-1{font-size:40px;color:black;}#mermaid-svg-WflnwFhFPJuSDg2H .section-edge-1{stroke:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .edge-depth-1{stroke-width:11;}#mermaid-svg-WflnwFhFPJuSDg2H .section-1 line{stroke:hsl(260, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled,#mermaid-svg-WflnwFhFPJuSDg2H .disabled circle,#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:lightgray;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:#efefef;}#mermaid-svg-WflnwFhFPJuSDg2H .section-2 rect,#mermaid-svg-WflnwFhFPJuSDg2H .section-2 path,#mermaid-svg-WflnwFhFPJuSDg2H .section-2 circle,#mermaid-svg-WflnwFhFPJuSDg2H .section-2 polygon,#mermaid-svg-WflnwFhFPJuSDg2H .section-2 path{fill:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .section-2 text{fill:#ffffff;}#mermaid-svg-WflnwFhFPJuSDg2H .node-icon-2{font-size:40px;color:#ffffff;}#mermaid-svg-WflnwFhFPJuSDg2H .section-edge-2{stroke:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .edge-depth-2{stroke-width:8;}#mermaid-svg-WflnwFhFPJuSDg2H .section-2 line{stroke:hsl(90, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled,#mermaid-svg-WflnwFhFPJuSDg2H .disabled circle,#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:lightgray;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:#efefef;}#mermaid-svg-WflnwFhFPJuSDg2H .section-3 rect,#mermaid-svg-WflnwFhFPJuSDg2H .section-3 path,#mermaid-svg-WflnwFhFPJuSDg2H .section-3 circle,#mermaid-svg-WflnwFhFPJuSDg2H .section-3 polygon,#mermaid-svg-WflnwFhFPJuSDg2H .section-3 path{fill:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .section-3 text{fill:black;}#mermaid-svg-WflnwFhFPJuSDg2H .node-icon-3{font-size:40px;color:black;}#mermaid-svg-WflnwFhFPJuSDg2H .section-edge-3{stroke:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .edge-depth-3{stroke-width:5;}#mermaid-svg-WflnwFhFPJuSDg2H .section-3 line{stroke:hsl(120, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled,#mermaid-svg-WflnwFhFPJuSDg2H .disabled circle,#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:lightgray;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:#efefef;}#mermaid-svg-WflnwFhFPJuSDg2H .section-4 rect,#mermaid-svg-WflnwFhFPJuSDg2H .section-4 path,#mermaid-svg-WflnwFhFPJuSDg2H .section-4 circle,#mermaid-svg-WflnwFhFPJuSDg2H .section-4 polygon,#mermaid-svg-WflnwFhFPJuSDg2H .section-4 path{fill:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .section-4 text{fill:black;}#mermaid-svg-WflnwFhFPJuSDg2H .node-icon-4{font-size:40px;color:black;}#mermaid-svg-WflnwFhFPJuSDg2H .section-edge-4{stroke:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .edge-depth-4{stroke-width:2;}#mermaid-svg-WflnwFhFPJuSDg2H .section-4 line{stroke:hsl(150, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled,#mermaid-svg-WflnwFhFPJuSDg2H .disabled circle,#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:lightgray;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:#efefef;}#mermaid-svg-WflnwFhFPJuSDg2H .section-5 rect,#mermaid-svg-WflnwFhFPJuSDg2H .section-5 path,#mermaid-svg-WflnwFhFPJuSDg2H .section-5 circle,#mermaid-svg-WflnwFhFPJuSDg2H .section-5 polygon,#mermaid-svg-WflnwFhFPJuSDg2H .section-5 path{fill:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .section-5 text{fill:black;}#mermaid-svg-WflnwFhFPJuSDg2H .node-icon-5{font-size:40px;color:black;}#mermaid-svg-WflnwFhFPJuSDg2H .section-edge-5{stroke:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .edge-depth-5{stroke-width:-1;}#mermaid-svg-WflnwFhFPJuSDg2H .section-5 line{stroke:hsl(180, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled,#mermaid-svg-WflnwFhFPJuSDg2H .disabled circle,#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:lightgray;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:#efefef;}#mermaid-svg-WflnwFhFPJuSDg2H .section-6 rect,#mermaid-svg-WflnwFhFPJuSDg2H .section-6 path,#mermaid-svg-WflnwFhFPJuSDg2H .section-6 circle,#mermaid-svg-WflnwFhFPJuSDg2H .section-6 polygon,#mermaid-svg-WflnwFhFPJuSDg2H .section-6 path{fill:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .section-6 text{fill:black;}#mermaid-svg-WflnwFhFPJuSDg2H .node-icon-6{font-size:40px;color:black;}#mermaid-svg-WflnwFhFPJuSDg2H .section-edge-6{stroke:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .edge-depth-6{stroke-width:-4;}#mermaid-svg-WflnwFhFPJuSDg2H .section-6 line{stroke:hsl(210, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled,#mermaid-svg-WflnwFhFPJuSDg2H .disabled circle,#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:lightgray;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:#efefef;}#mermaid-svg-WflnwFhFPJuSDg2H .section-7 rect,#mermaid-svg-WflnwFhFPJuSDg2H .section-7 path,#mermaid-svg-WflnwFhFPJuSDg2H .section-7 circle,#mermaid-svg-WflnwFhFPJuSDg2H .section-7 polygon,#mermaid-svg-WflnwFhFPJuSDg2H .section-7 path{fill:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .section-7 text{fill:black;}#mermaid-svg-WflnwFhFPJuSDg2H .node-icon-7{font-size:40px;color:black;}#mermaid-svg-WflnwFhFPJuSDg2H .section-edge-7{stroke:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .edge-depth-7{stroke-width:-7;}#mermaid-svg-WflnwFhFPJuSDg2H .section-7 line{stroke:hsl(270, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled,#mermaid-svg-WflnwFhFPJuSDg2H .disabled circle,#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:lightgray;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:#efefef;}#mermaid-svg-WflnwFhFPJuSDg2H .section-8 rect,#mermaid-svg-WflnwFhFPJuSDg2H .section-8 path,#mermaid-svg-WflnwFhFPJuSDg2H .section-8 circle,#mermaid-svg-WflnwFhFPJuSDg2H .section-8 polygon,#mermaid-svg-WflnwFhFPJuSDg2H .section-8 path{fill:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .section-8 text{fill:black;}#mermaid-svg-WflnwFhFPJuSDg2H .node-icon-8{font-size:40px;color:black;}#mermaid-svg-WflnwFhFPJuSDg2H .section-edge-8{stroke:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .edge-depth-8{stroke-width:-10;}#mermaid-svg-WflnwFhFPJuSDg2H .section-8 line{stroke:hsl(330, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled,#mermaid-svg-WflnwFhFPJuSDg2H .disabled circle,#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:lightgray;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:#efefef;}#mermaid-svg-WflnwFhFPJuSDg2H .section-9 rect,#mermaid-svg-WflnwFhFPJuSDg2H .section-9 path,#mermaid-svg-WflnwFhFPJuSDg2H .section-9 circle,#mermaid-svg-WflnwFhFPJuSDg2H .section-9 polygon,#mermaid-svg-WflnwFhFPJuSDg2H .section-9 path{fill:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .section-9 text{fill:black;}#mermaid-svg-WflnwFhFPJuSDg2H .node-icon-9{font-size:40px;color:black;}#mermaid-svg-WflnwFhFPJuSDg2H .section-edge-9{stroke:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .edge-depth-9{stroke-width:-13;}#mermaid-svg-WflnwFhFPJuSDg2H .section-9 line{stroke:hsl(0, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled,#mermaid-svg-WflnwFhFPJuSDg2H .disabled circle,#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:lightgray;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:#efefef;}#mermaid-svg-WflnwFhFPJuSDg2H .section-10 rect,#mermaid-svg-WflnwFhFPJuSDg2H .section-10 path,#mermaid-svg-WflnwFhFPJuSDg2H .section-10 circle,#mermaid-svg-WflnwFhFPJuSDg2H .section-10 polygon,#mermaid-svg-WflnwFhFPJuSDg2H .section-10 path{fill:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .section-10 text{fill:black;}#mermaid-svg-WflnwFhFPJuSDg2H .node-icon-10{font-size:40px;color:black;}#mermaid-svg-WflnwFhFPJuSDg2H .section-edge-10{stroke:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .edge-depth-10{stroke-width:-16;}#mermaid-svg-WflnwFhFPJuSDg2H .section-10 line{stroke:hsl(30, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled,#mermaid-svg-WflnwFhFPJuSDg2H .disabled circle,#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:lightgray;}#mermaid-svg-WflnwFhFPJuSDg2H .disabled text{fill:#efefef;}#mermaid-svg-WflnwFhFPJuSDg2H .section-root rect,#mermaid-svg-WflnwFhFPJuSDg2H .section-root path,#mermaid-svg-WflnwFhFPJuSDg2H .section-root circle,#mermaid-svg-WflnwFhFPJuSDg2H .section-root polygon{fill:hsl(240, 100%, 46.2745098039%);}#mermaid-svg-WflnwFhFPJuSDg2H .section-root text{fill:#ffffff;}#mermaid-svg-WflnwFhFPJuSDg2H .section-root span{color:#ffffff;}#mermaid-svg-WflnwFhFPJuSDg2H .section-2 span{color:#ffffff;}#mermaid-svg-WflnwFhFPJuSDg2H .icon-container{height:100%;display:flex;justify-content:center;align-items:center;}#mermaid-svg-WflnwFhFPJuSDg2H .edge{fill:none;}#mermaid-svg-WflnwFhFPJuSDg2H .mindmap-node-label{dy:1em;alignment-baseline:middle;text-anchor:middle;dominant-baseline:middle;text-align:center;}#mermaid-svg-WflnwFhFPJuSDg2H :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 本地存储与鉴权
数据层
axios.create 实例
API 三层封装
请求拦截器加 token
响应拦截器统一错误
new Promise 阻塞链
本地存储
localStorage 永久
sessionStorage 会话
Cookie 随请求
XSS 安全边界
登录鉴权
路由守卫体验
Token 无状态
JWT 三段结构
退出清存储
access + refresh
业务交互
shell first 异步渲染
事件委托动态 DOM
sweetalert2 Promise 弹窗
文件处理
FileReader 异步读
createObjectURL 同步
FormData multipart
分片断点秒传

【代码注释】这张思维导图把「本地存储与鉴权」拆成数据层、本地存储、登录鉴权、业务交互、文件处理五大主题。核心思想贯穿------横切关注点收口(拦截器)、单一职责(API 分层)、安全边界(存储选型)。市面应用:面试中「你如何设计前端的请求层与鉴权」这类题,可按此图分层回答,体现系统设计能力。

高频面试题速查

  1. axios.create 的优势? 独立配置、互不污染、易测试。
  2. 请求/响应拦截器分别做什么? 加 token;统一错误 / 401 跳登。
  3. 三大本地存储区别? 容量、生命周期、是否随请求发送。
  4. localStorage 存 token 安全吗? XSS 下不安全;HTTP-Only Cookie 更安全。
  5. JWT 工作原理? Header+Payload+Signature,密钥重签验证。
  6. 事件委托用途? 长列表、动态 DOM,省内存 + 覆盖新元素。
  7. event.target vs currentTarget 真实点击者 vs 监听器所在元素。
  8. FileReader vs createObjectURL? 异步读内容 vs 同步给 URL。
  9. FormData vs JSON? multipart 含二进制 vs JSON 纯结构化。
  10. 大文件上传? 分片 + 断点 + 秒传 + 并发限制 + 进度。
  11. new Promise(()=>{}) 用途? 阻塞业务链让错误「消费即止」。
  12. 前端登录守卫安全吗? 不安全,真正安全在后端鉴权。

学习建议

  1. 真正联调一次:用 Express 起 mock 后端,让 axios 真打通完整链路。
  2. 画 Token 流程图:把登录、调接口、token 失效三种场景画出来,深刻记忆。
  3. 尝试 HTTP-Only Cookie:把 token 从 localStorage 改放 Cookie,体会前后端协作差异与安全提升。
  4. 框架对照:用 Vue 3 + Pinia 重写 store,对比响应式存储与命令式 localStorage。
  5. 拦截器进阶:在拦截器加 loading、请求队列、token refresh,每加一个更深理解工程化。