前端工程化项目完整功能与生产部署:从 CRUD 到上线

导读:「Demo 能跑」与「真上线」之间,隔着数据层架构、构建优化与部署工程三道关。本文系统讲解工程化后台的最后一公里------完整 CRUD 闭环(列表 + 服务端分页 + 关键字搜索 + 增删改)、模态框复用与隐藏域控制流的「一表两用」设计、生产构建的 CSS 抽离/压缩/Tree Shaking 流水线、SourceMap 的分级策略与错误监控、DefinePlugin 编译期环境变量注入的原理、changeOrigin 与 CORS 的跨域细节、Nginx 部署 SPA 的 try_files 灵魂配置与长效缓存。每一项都从「解决什么问题、底层如何运作、对应什么业务场景」三维展开,结合 Webpack、Nginx、HTTP 规范的权威设计,适合希望打通「从开发到上线」全链路的中高级前端工程师。

目录


一、列表展示与枚举字典

概念与底层原理:

广告列表比管理员列表复杂------含图片、类别枚举、操作按钮。其中「枚举字典」是来自后端的经典范式:数据库存数字(advType: 1)省空间,前端按需翻译成文案(「轮播图广告」):

js 复制代码
const getAdvExec = async () => {
  // 枚举字典:把后端的数字 advType 翻译成文案
  const advTypeDes = { 1:'轮播图广告', 2:'轮播图底部广告', 3:'热门回收广告', 4:'优品精选广告' };
  const { data } = await getAdv();
  document.querySelector('#advTable').innerHTML =
    AdvTableComponent({ advList: data, advTypeDes });
};

【代码注释】这个控制器函数遵循「shell first, data second」模式------先让骨架在屏幕上,再异步取数据填充表格。advTypeDes 字典与列表数据一起传给组件,由组件决定如何翻译。把字典放在控制器而非组件里 ,是因为字典属于「业务语义」(哪个数字对应哪类广告),归控制器管理;组件只负责「拿到字典就翻译」的纯渲染。市面应用:所有需要枚举翻译或国际化的列表都采用「数据 + 字典分离」的组织方式。

js 复制代码
// api/adv.js ------ GET 接口用 query 传过滤参数
export const getAdv = (pageNo, pageSize, keyword) =>
  advServer.get('/adv', { params: { pageNo, pageSize, keyword } });

【代码注释】枚举字典 advTypeDes 在 controller 里维护,传给模板后用 data.advTypeDes[advItem.advType] 翻译------这种「数字 ID + 字典翻译」模式,数据库存数字省空间、前端按需翻译成文案、还便于国际化(一份字典换语言)。axios.get(url, { params }) 把参数拼到 URL query(/adv?pageNo=1&pageSize=3)------GET 按 HTTP 规范不应有 body,过滤/分页参数放 query 利于缓存与日志可读。市面应用:电商订单状态、商品类目、用户角色都是「数字枚举 + 前端字典」模式。

ejs 复制代码
<!-- components/AdvTable.ejs -->
<% data.advList.forEach(advItem => { %>
  <tr>
    <td><%= advItem.advTitle %></td>
    <td><img height="80" src="<%= `/api/${advItem.advPic}` %>" alt="" /></td>
    <td><%= data.advTypeDes[advItem.advType] %></td>     <!-- 枚举翻译 -->
    <td>
      <button class="btn btn-danger btn-sm">删除</button>
      <button data-id="<%= advItem._id %>" class="btn btn-success btn-sm btn-edit">修改</button>
    </td>
  </tr>
<% }) %>

【代码注释】src="/api/<%= advItem.advPic %>" 让图片通过代理走后端静态服务;data.advTypeDes[advItem.advType] 是字典翻译核心;data-id 把记录 ID 挂在修改按钮,编辑时取用。图片路径用代理前缀 /api/ 而非写死域名 ------这是「本地能跑、上线 404」类问题的关键:写死 http://localhost:8080/... 上线必挂。市面应用:所有「列表 + 字典 + 操作按钮」场景都是这个结构。

【实战要点】

  • 经典应用场景:商品、订单、广告等含枚举与操作的列表。
  • 常见坑:图片/接口路径写死域名 → 部署即 404;用代理前缀或环境变量。
  • 性能与最佳实践 :大列表用虚拟滚动;图片 loading="lazy" 懒加载。

【本章小结】

元素 职责
Controller 备数据 + 字典
API 接口 + query 参数
字典 数字 → 文案

记忆口诀:「Ctrl 备料,API 取数,字典翻译」。

【面试考点】

Q1:为什么 GET 用 query、POST 用 body?

A:HTTP 上 GET 是幂等只读操作,参数放 URL query 让请求可缓存、可日志、可书签;POST 是创建/变更操作,参数复杂(敏感、嵌套)放 body 更合适。实战中分页/过滤用 GET + query,提交/上传用 POST + body。

入门示例 · 枚举字典翻译 + 列表渲染

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>枚举字典</title>
<style>
body{font-family:sans-serif;padding:20px;background:#1e1e1e;color:#d4d4d4}
table{width:100%;border-collapse:collapse;font-size:13px;margin:12px 0}
th{background:#3c3c3c;padding:8px;text-align:left}
td{padding:8px;border-bottom:1px solid #333}
.type-badge{padding:2px 8px;border-radius:10px;font-size:11px}
.t1{background:#094771;color:#4fc1ff}
.t2{background:#1c4a1c;color:#3fb950}
.t3{background:#3a1c1c;color:#f85149}
.t4{background:#4a3a1c;color:#ffa657}
.btn{padding:4px 10px;border:none;border-radius:3px;cursor:pointer;font-size:12px;margin:2px}
.del{background:#3a1c1c;color:#f85149}.edit{background:#1c3a1c;color:#3fb950}
</style></head>
<body>
<h2>广告列表 + 枚举字典翻译</h2>
<table>
<thead><tr><th>广告标题</th><th>类型</th><th>状态</th><th>操作</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
<script>
// 枚举字典:后端存数字,前端按需翻译
const advTypeDes = {
  1: {label:'轮播图广告',   cls:'t1'},
  2: {label:'轮播图底部广告', cls:'t2'},
  3: {label:'热门回收广告', cls:'t3'},
  4: {label:'优品精选广告', cls:'t4'},
};
const statusDes = { 1:'上线', 0:'下线' };

// 模拟后端数据(存数字,省空间)
const advList = [
  {_id:'001', advTitle:'双11限时活动', advType:1, status:1},
  {_id:'002', advTitle:'夏日清仓特卖', advType:2, status:1},
  {_id:'003', advTitle:'旧物回收公益', advType:3, status:0},
  {_id:'004', advTitle:'精选好物推荐', advType:4, status:1},
];

document.getElementById('tbody').innerHTML = advList.map(item => {
  const typeInfo = advTypeDes[item.advType];
  return `<tr>
    <td>${item.advTitle}</td>
    <td><span class="type-badge ${typeInfo.cls}">${typeInfo.label}</span></td>
    <td>${statusDes[item.status]}</td>
    <td>
      <button class="btn del">删除</button>
      <button class="btn edit" data-id="${item._id}">修改</button>
    </td>
  </tr>`;
}).join('');
</script>
</body></html>

【代码注释】advTypeDes 字典的 key 是后端存储的数字(节省数据库空间),value 包含前端显示的文案和样式类。advTypeDes[item.advType] 是 O(1) 字典查找,比 if/else 链更简洁可维护。工程价值 :字典集中在 Controller 里管理,所有列表翻译逻辑一处修改;未来增加一种广告类型,只加一行字典条目。data-id 挂在修改按钮上,点击时通过 e.target.dataset.id 取得记录 ID------配合事件委托是后台管理系统的经典模式。

实战示例 · 图片懒加载 + 占位图处理

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>图片加载</title>
<style>
body{font-family:sans-serif;padding:20px;background:#0d1117;color:#c9d1d9}
.grid{display:flex;flex-wrap:wrap;gap:12px;margin:12px 0}
.img-card{width:160px;background:#161b22;border:1px solid #30363d;border-radius:8px;overflow:hidden}
.img-wrap{width:160px;height:90px;background:#21262d;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}
.img-wrap img{width:100%;height:100%;object-fit:cover;display:none}
.img-wrap .skeleton{position:absolute;inset:0;background:linear-gradient(90deg,#21262d 25%,#30363d 50%,#21262d 75%);background-size:200% 100%;animation:shimmer 1.5s infinite}
@keyframes shimmer{to{background-position:-200% 0}}
.img-info{padding:8px;font-size:12px}
.btn{padding:6px 14px;border:none;border-radius:4px;cursor:pointer;background:#238636;color:#fff;font-size:12px}
</style></head>
<body>
<h2>广告图片列表:骨架屏 + 懒加载</h2>
<button class="btn" onclick="loadImages()">▶ 加载广告图片</button>
<div class="grid" id="grid"></div>
<script>
const advs = [
  {title:'双11活动',color:'#1890ff'},
  {title:'夏日清仓',color:'#52c41a'},
  {title:'旧物回收',color:'#eb2f96'},
  {title:'精选好物',color:'#fa8c16'},
];

// 先渲染骨架屏
document.getElementById('grid').innerHTML = advs.map((a,i)=>`
  <div class="img-card">
    <div class="img-wrap">
      <div class="skeleton" id="sk${i}"></div>
      <img id="img${i}" alt="${a.title}" />
    </div>
    <div class="img-info">${a.title}</div>
  </div>`).join('');

function loadImages(){
  advs.forEach((a, i) => {
    setTimeout(() => {
      // 创建 canvas 模拟图片(实际项目是真实 URL)
      const canvas = document.createElement('canvas');
      canvas.width = 160; canvas.height = 90;
      const ctx = canvas.getContext('2d');
      ctx.fillStyle = a.color;
      ctx.fillRect(0,0,160,90);
      ctx.fillStyle = '#fff';
      ctx.font = 'bold 14px sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText(a.title, 80, 50);
      const img = document.getElementById('img'+i);
      img.src = canvas.toDataURL();
      img.style.display = 'block';
      document.getElementById('sk'+i).style.display = 'none';
    }, (i+1)*600);
  });
}
</script>
</body></html>

【代码注释】骨架屏(Skeleton Screen)是比 spinner 更专业的加载态------它预先展示与真实内容形状相近的占位,用户感知的等待时间更短(内容「出现得更早」)。shimmer 动画用 background-position 位移制造扫光效果。实际项目中 :图片 URL 来自接口(/api/${advItem.advPic}),通过代理前缀 /api/ 避免硬编码域名,部署时 Nginx 把 /api 前缀的请求转给后端静态服务。img.loading="lazy" 是原生懒加载属性,视口外的图片不提前下载。


二、服务端分页的工程设计

名词解释:

  • 服务端分页:后端只返回当前页数据 + 总页数,前端负责切页。
  • 客户端分页:前端一次加载全量、本地切片,仅适用小数据。

概念与底层原理:

后端响应通常是 { data: [...], pageNo: 2, pageSum: 10 }。前端用这些数据渲染当前页表格、渲染页码按钮并高亮当前页、点击页码时调接口重拉:

【代码注释】分页本质是「调接口 + 重渲染」。事件委托让动态生成的页码按钮无需重新绑定------与删除按钮的处理完全一致。服务端分页适合大数据集(万级以上) ------每页只传一页数据,首屏快、内存省;客户端分页适合小数据集(< 1000),翻页流畅但首次加载慢。市面应用:电商搜索结果、知乎回答列表、评论分页都用服务端分页。

js 复制代码
const getAdvExec = async (no = 1, size = 3) => {
  const advTypeDes = { 1:'轮播图广告', 2:'轮播图底部广告', 3:'热门回收广告', 4:'优品精选广告' };
  const { data, pageNo, pageSum } = await getAdv(no, size);
  document.querySelector('#advTable').innerHTML =
    AdvTableComponent({ advList: data, advTypeDes, pageNo, pageSum });
};

// 事件委托:点击页码
const setPageExec = event => {
  if (event.target.classList.contains('page-link')) {
    getAdvExec(event.target.dataset.i);
  }
};
document.querySelector('#advTable').addEventListener('click', setPageExec);

【代码注释】getAdvExec 接收 no(页号)与 size(每页条数)参数,默认 (1, 3)------首次加载、点击页码都走它,页号由参数驱动。setPageExec 用事件委托监听整个 #advTable 容器,靠 event.target.classList.contains('page-link') 判定点击的是页码按钮,再用 dataset.i 取目标页号。分页与删除复用同一套「事件委托 + dataset」机制 ------动态生成的页码按钮无需逐个绑定。市面应用:Element/Ant Design 的 Pagination 组件内部也是「点击 → 取页号 → 触发回调拉数据」的同样逻辑。

ejs 复制代码
<!-- 分页按钮:EJS for 循环渲染页码 -->
<li class="page-item">
  <a href="javascript:;" data-i="<%= data.pageNo - 1 %>" class="page-link"><<</a>
</li>
<% for (let i = 1; i <= data.pageSum; i++) { %>
  <li class="page-item <%= data.pageNo === i ? 'active' : '' %>">
    <a class="page-link" data-i="<%= i %>" href="javascript:;"><%= i %></a>
  </li>
<% } %>
<li class="page-item">
  <a href="javascript:;" data-i="<%= data.pageNo + 1 %>" class="page-link">>></a>
</li>

【代码注释】getAdvExec(no, size) 默认 (1, 3)event.target.dataset.i 取按钮上的页码。<% for %> 是 EJS 循环渲染页码,data.pageNo === i ? 'active' : '' 高亮当前页,「上一页/下一页」用 data-i="pageNo±1" 走同一事件委托。「上一页/数字页/下一页」三组按钮共享一个监听器 ------这是事件委托对动态 UI 的典型应用。市面应用:所有分页组件都是这种结构,框架(Element/Ant Design)的 Pagination 只是把它封装成组件。

【实战要点】

  • 经典应用场景:所有大数据集列表。
  • 常见坑pageNo - 1 === 0 时点上一页请求无效页------controller 里加判断;pageSum 极大时做省略号分页。
  • 性能与最佳实践:服务端分页 > 客户端分页(除非数据 < 1000);缓存上次浏览的页号。

【本章小结】

字段 用途
pageNo 当前页
pageSum 总页数
data-i 按钮目标页
事件委托 点击触发拉数据

记忆口诀:「接口三参数,按钮 data-i,委托一处理」。

【面试考点】

Q1:服务端分页与客户端分页的取舍?

A:服务端分页每页拉一次接口,适合大数据集,首屏快但网络往返多;客户端分页一次拉全量、前端切片,适合小数据集,翻页流畅但首次加载慢。中间方案是虚拟滚动------数据全量但只渲染可视区 DOM。

入门示例 · 服务端分页组件实现

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>分页组件</title>
<style>
body{font-family:sans-serif;padding:20px;background:#1e1e1e;color:#d4d4d4;max-width:600px}
table{width:100%;border-collapse:collapse;font-size:13px;margin:10px 0}
th{background:#3c3c3c;padding:7px}td{padding:7px;border-bottom:1px solid #333;font-size:12px}
.pager{display:flex;gap:4px;align-items:center;margin:10px 0;flex-wrap:wrap}
.pager-btn{padding:5px 10px;background:#252526;border:1px solid #3c3c3c;border-radius:4px;cursor:pointer;color:#d4d4d4;font-size:12px}
.pager-btn:hover{background:#3c3c3c}.pager-btn.active{background:#094771;color:#4fc1ff;border-color:#4fc1ff}
.pager-btn:disabled{opacity:0.4;cursor:not-allowed}
.info{color:#888;font-size:12px;margin:6px 0}
.loading{color:#888;font-size:12px;padding:20px;text-align:center}
</style></head>
<body>
<h2>服务端分页演示</h2>
<div id="table"><div class="loading">加载中...</div></div>
<div class="pager" id="pager"></div>
<div class="info" id="info"></div>
<script>
// 模拟数据库(实际是接口)
const totalData = Array.from({length:37}, (_,i) => ({
  id: i+1, title: `广告标题 ${i+1}`, type: ['轮播图','底部广告','热门','精选'][i%4], status: i%3?'上线':'下线'
}));

const pageSize = 5;
let pageNo = 1;

// 模拟服务端分页接口
function fetchPage(pageNo, pageSize){
  return new Promise(resolve => {
    setTimeout(()=>{
      const start = (pageNo-1)*pageSize;
      resolve({
        data: totalData.slice(start, start+pageSize),
        total: totalData.length,
        pageNo, pageSize
      });
    }, 300);
  });
}

async function loadPage(page){
  pageNo = page;
  document.getElementById('table').innerHTML = '<div class="loading">加载中...</div>';
  const {data, total} = await fetchPage(pageNo, pageSize);
  const totalPages = Math.ceil(total/pageSize);
  document.getElementById('table').innerHTML = `
    <table><thead><tr><th>ID</th><th>标题</th><th>类型</th><th>状态</th></tr></thead>
    <tbody>${data.map(r=>`<tr><td>${r.id}</td><td>${r.title}</td><td>${r.type}</td><td>${r.status}</td></tr>`).join('')}</tbody>
    </table>`;
  document.getElementById('info').textContent = `共 ${total} 条,第 ${pageNo}/${totalPages} 页,每页 ${pageSize} 条`;
  renderPager(totalPages);
}

function renderPager(totalPages){
  const btns = [];
  btns.push(`<button class="pager-btn" ${pageNo<=1?'disabled':''} onclick="loadPage(${pageNo-1})">◀ 上一页</button>`);
  for(let i=1;i<=totalPages;i++){
    btns.push(`<button class="pager-btn ${i===pageNo?'active':''}" onclick="loadPage(${i})">${i}</button>`);
  }
  btns.push(`<button class="pager-btn" ${pageNo>=totalPages?'disabled':''} onclick="loadPage(${pageNo+1})">下一页 ▶</button>`);
  document.getElementById('pager').innerHTML = btns.join('');
}

loadPage(1);
</script>
</body></html>

【代码注释】服务端分页的参数固定是 { pageNo, pageSize },接口返回 { data, total }------total 用于计算总页数 Math.ceil(total/pageSize)loadPage 函数职责:① 显示 loading 态;② 调接口;③ 渲染表格;④ 渲染分页器。每次翻页都重新调接口(只取当前页数据),不在前端缓存全量------这是「服务端分页」区别于「客户端分页」的本质。性能边界:数据超过 1000 条用服务端分页;10-100 条可用客户端分页(一次拉全量,前端切片)。

实战示例 · 关键字搜索 + 分页联动

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>搜索分页联动</title>
<style>
body{font-family:sans-serif;padding:20px;background:#0d1117;color:#c9d1d9;max-width:600px}
.toolbar{display:flex;gap:8px;margin:12px 0;align-items:center}
input{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:8px;border-radius:4px;font-size:13px;flex:1}
.btn{padding:8px 14px;border:none;border-radius:4px;cursor:pointer;background:#238636;color:#fff;font-size:13px}
table{width:100%;border-collapse:collapse;font-size:12px}
th{background:#21262d;padding:7px;text-align:left}td{padding:7px;border-bottom:1px solid #21262d}
.pager{display:flex;gap:4px;margin:8px 0}
.pager-btn{padding:4px 8px;background:#21262d;border:1px solid #30363d;border-radius:3px;cursor:pointer;font-size:12px;color:#c9d1d9}
.pager-btn.active{background:#0d419d;color:#fff}.info{color:#8b949e;font-size:12px;margin:6px 0}
</style></head>
<body>
<h2>搜索 + 分页联动</h2>
<div class="toolbar">
  <input id="kw" placeholder="输入关键字搜索广告标题..." oninput="onSearch()" />
  <button class="btn" onclick="search()">搜索</button>
</div>
<div id="table"></div>
<div class="pager" id="pager"></div>
<div class="info" id="info"></div>
<script>
const allData = Array.from({length:25},(_,i)=>({id:i+1,title:`广告 ${['双11','夏日','回收','精选','节日'][i%5]}${i+1}号`,type:['轮播','底部','热门','精选'][i%4]}));
const pageSize=4; let pageNo=1, keyword='';
let debounceTimer;

function onSearch(){ clearTimeout(debounceTimer); debounceTimer = setTimeout(search, 400); }

function search(){
  keyword = document.getElementById('kw').value.trim();
  pageNo = 1;
  loadPage();
}

function loadPage(){
  const filtered = allData.filter(r => !keyword || r.title.includes(keyword));
  const total = filtered.length;
  const totalPages = Math.max(1, Math.ceil(total/pageSize));
  if(pageNo > totalPages) pageNo = totalPages;
  const data = filtered.slice((pageNo-1)*pageSize, pageNo*pageSize);
  document.getElementById('table').innerHTML = `<table>
    <thead><tr><th>ID</th><th>标题</th><th>类型</th></tr></thead>
    <tbody>${data.length ? data.map(r=>`<tr><td>${r.id}</td><td>${r.title}</td><td>${r.type}</td></tr>`).join('') : '<tr><td colspan="3" style="text-align:center;color:#8b949e">无匹配数据</td></tr>'}</tbody>
  </table>`;
  document.getElementById('info').textContent = keyword ? `关键字 "${keyword}" 共 ${total} 条结果,第 ${pageNo}/${totalPages} 页` : `共 ${total} 条,第 ${pageNo}/${totalPages} 页`;
  const btns = [];
  for(let i=1;i<=totalPages;i++) btns.push(`<button class="pager-btn ${i===pageNo?'active':''}" onclick="goPage(${i})">${i}</button>`);
  document.getElementById('pager').innerHTML =
    `<button class="pager-btn" onclick="goPage(${pageNo-1})" ${pageNo<=1?'disabled':''}>◀</button>`+btns.join('')+
    `<button class="pager-btn" onclick="goPage(${pageNo+1})" ${pageNo>=totalPages?'disabled':''}>▶</button>`;
}

function goPage(p){ pageNo=p; loadPage(); }
loadPage();
</script>
</body></html>

【代码注释】搜索与分页的联动关键点:搜索时重置 pageNo = 1 ------用户搜索了新关键字,当然从第 1 页开始看;不重置会出现「第 3 页显示空数据但第 1 页有数据」的 bug。debounce 400msoninput 事件每次键入都触发,debounce 合并为「停止输入 400ms 后才发一次请求」,避免频繁请求。URL 同步 :生产级别的实现还会把 pageNokeyword 同步到 URL query(?page=2&keyword=双11),让用户刷新后状态不丢失,且 URL 可分享。


三、关键字搜索与 query 参数

概念与底层原理:

搜索 = 「读输入框值 + 附加到 query + 触发列表刷新」------前端不做过滤,由数据库 LIKE %keyword%(或全文索引、Elasticsearch)处理:

js 复制代码
const getAdvExec = async (no = 1, size = 3) => {
  const advTypeDes = { 1:'轮播图广告', 2:'轮播图底部广告', 3:'热门回收广告', 4:'优品精选广告' };
  const kw = document.querySelector('#keyword').value.trim();   // 读搜索框
  const { data, pageNo, pageSum } = await getAdv(no, size, kw);
  document.querySelector('#advTable').innerHTML =
    AdvTableComponent({ advList: data, advTypeDes, pageNo, pageSum });
};

document.querySelector('#searchAdvBtn').addEventListener('click', () => getAdvExec());

【代码注释】getAdvExec 内部统一读 #keyword------分页、首次加载、搜索按钮触发都走这一个函数,「搜索状态」隐式跟随 input 值。这种简单方案的局限是复杂搜索(多条件、排序)难扩展 ------届时应把搜索条件提取到独立 state。搜索的精髓是「把输入透传到接口」 ------前端只负责采集,过滤逻辑交给后端。市面应用:电商搜索、文档搜索、用户搜索 99% 走这种模式。

【实战要点】

  • 经典应用场景:所有列表页的关键字过滤。
  • 常见坑:每次输入都触发请求 → 网络狂炸;用 debounce 节流到 300ms。搜索时 pageNo 应重置为 1。
  • 性能与最佳实践:用 lodash debounce 节流;搜索结果分页重置。

【本章小结】

步骤 代码
读输入 #keyword.value.trim()
传 API getAdv(no, size, kw)
触发 搜索按钮 click

记忆口诀:「读框、带参、重渲」。

【面试考点】

Q1:搜索为什么要 debounce?

A:输入框每键入一字都触发请求------10 字搜索就 10 个请求,前 9 个返回时已过时,浪费带宽与后端 CPU。debounce 让「连续输入停下 300ms 后才发请求」,把 N 次合并为 1 次;throttle 是「固定频率发」,适合滚动监听。

入门示例 · debounce vs throttle 可视化对比

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>防抖节流</title>
<style>
body{font-family:monospace;padding:20px;background:#1e1e1e;color:#d4d4d4;max-width:600px}
input{width:100%;box-sizing:border-box;background:#252526;border:1px solid #3c3c3c;color:#d4d4d4;padding:8px;border-radius:4px;font-size:14px;margin:6px 0}
.row{display:flex;gap:16px;margin:12px 0}
.box{flex:1;background:#252526;border:1px solid #3c3c3c;border-radius:6px;padding:12px}
h4{margin:0 0 8px;font-size:13px}
#log1,#log2{background:#0d1117;padding:8px;border-radius:4px;font-size:11px;min-height:60px;line-height:1.8}
.count{color:#4fc1ff;font-weight:bold}
.fired{color:#4ec9b0}
</style></head>
<body>
<h2>在输入框里快速打字:</h2>
<input id="input" placeholder="快速连续输入,观察两种策略的差异..." oninput="handleInput()" />
<div class="row">
<div class="box">
  <h4>Debounce(防抖)--- 停止后触发</h4>
  <div id="log1">等待输入...</div>
</div>
<div class="box">
  <h4>Throttle(节流)--- 固定频率触发</h4>
  <div id="log2">等待输入...</div>
</div>
</div>
<script>
let debounceCount=0, throttleCount=0;
let lastThrottle=0, debTimer;

function handleInput(){
  const val = document.getElementById('input').value;
  // debounce
  clearTimeout(debTimer);
  debTimer = setTimeout(()=>{
    debounceCount++;
    document.getElementById('log1').innerHTML +=
      `<span class="fired">✓ 触发[${debounceCount}]:</span> "${val.slice(-10)}"
`;
    document.getElementById('log1').scrollTop = 99999;
  }, 400);

  // throttle
  const now = Date.now();
  if(now - lastThrottle >= 600){
    lastThrottle = now;
    throttleCount++;
    document.getElementById('log2').innerHTML +=
      `<span class="fired">✓ 触发[${throttleCount}]:</span> "${val.slice(-10)}"
`;
    document.getElementById('log2').scrollTop = 99999;
  }
}
</script>
</body></html>

【代码注释】快速连续输入 10 个字,debounce 只在停止输入 400ms 后触发1 次 (适合搜索框------用户停下来才真正在「搜」);throttle 每 600ms 触发1 次 (适合滚动监听、鼠标移动------需要实时响应但要限制频率)。场景选择 :搜索框 → debounce;滚动懒加载 → throttle;窗口 resize → debounce(resize 结束后计算,避免频繁重排)。面试常问:「防抖和节流的区别?应用场景?」记住两个关键词:「停止后触发 vs 固定频率」。

实战示例 · URL query 参数构建与同步

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>query 参数</title>
<style>
body{font-family:monospace;padding:20px;background:#0d1117;color:#c9d1d9}
.form{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin:12px 0}
label{display:block;font-size:12px;color:#8b949e;margin-bottom:4px}
input,select{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:6px;border-radius:4px;font-size:13px;width:100%;box-sizing:border-box;margin-bottom:10px}
.btn{padding:7px 14px;border:none;border-radius:4px;cursor:pointer;background:#238636;color:#fff;font-size:12px}
.result{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:12px;margin:8px 0;font-size:12px}
.url{color:#79c0ff;word-break:break-all}
.params{color:#e6edf3;font-size:11px;line-height:1.8}
</style></head>
<body>
<h2>GET 请求 query 参数构建</h2>
<div class="form">
  <label>关键字</label>
  <input id="keyword" value="" placeholder="双11" oninput="buildUrl()" />
  <label>页码</label>
  <input id="pageNo" type="number" value="1" min="1" oninput="buildUrl()" />
  <label>每页条数</label>
  <select id="pageSize" onchange="buildUrl()">
    <option value="5">5 条</option>
    <option value="10" selected>10 条</option>
    <option value="20">20 条</option>
  </select>
  <label>广告类型(留空=全部)</label>
  <select id="advType" onchange="buildUrl()">
    <option value="">全部</option>
    <option value="1">轮播图广告</option>
    <option value="2">底部广告</option>
  </select>
</div>
<div class="result" id="result"></div>
<script>
function buildUrl(){
  const keyword = document.getElementById('keyword').value.trim();
  const pageNo = document.getElementById('pageNo').value;
  const pageSize = document.getElementById('pageSize').value;
  const advType = document.getElementById('advType').value;
  const params = { pageNo, pageSize };
  if(keyword) params.keyword = keyword;
  if(advType) params.advType = advType;
  const qs = new URLSearchParams(params).toString();
  const url = `/api/adv?${qs}`;
  const axiosCode = `advServer.get('/adv', { params: { pageNo:${pageNo}, pageSize:${pageSize}${keyword?', keyword:"'+keyword+'"':''}${advType?', advType:'+advType:''} } })`;
  document.getElementById('result').innerHTML =
    `<b>构建后 URL:</b><br><span class="url">${url}</span><br><br>
     <b>Axios 等价写法:</b><br><span class="params">${axiosCode}</span><br><br>
     <b>服务端解析(req.query):</b><br><span class="params">${JSON.stringify(params, null, 2)}</span>`;
}
buildUrl();
</script>
</body></html>

【代码注释】URLSearchParams 是浏览器内置的 query 字符串构建工具------自动处理特殊字符 encode(如中文、空格),比手动拼接 ?a=1&b=2 更安全。axios.get(url, { params: {...} }) 等价于手动构建 query 字符串,axios 内部也是用类似机制处理的。空值过滤的重要性 :不过滤空值会发 ?keyword=&advType=,服务端收到空字符串可能当作有效过滤条件返回空数据------用 if(keyword) params.keyword = keyword 只在有值时加入参数。


四、模态框复用:一个组件两种用途

概念与底层原理:

「添加广告」与「修改广告」表单字段完全一样------重复实现两个模态框是浪费。复用同一模态框,靠两个动作切换状态:改标题文字、决定表单初始数据:

【代码注释】复用模态框是 UI 工程经典优化------节省 30%-50% 模板代码、减少不一致。前提是「新增/编辑表单字段一致」。新增时清空表单、编辑时拉详情回填,两者都打开同一个模态框。判断标准 :表单字段一致率 > 80% → 复用,否则独立。市面应用 :Ant Design 的 <Modal> + 表单库常配合 mode: 'create' | 'edit' 实现复用。

js 复制代码
// 打开「新增」模态框
const openAddModalExec = () => {
  document.addAdvForm.reset();
  document.addAdvForm.id.value = '';              // 清空隐藏域 id
  preImgEle.style.display = 'none';
  document.querySelector('#advModal .modal-title').innerHTML = '添加广告';
  $('#advModal').modal('show');
};

// 打开「编辑」模态框
const openEditModalExec = async event => {
  if (!event.target.classList.contains('btn-edit')) return;
  const { data } = await getAdvById(event.target.dataset.id);
  document.addAdvForm.id.value = data._id;        // 记录 id 写入隐藏域
  document.addAdvForm.advTitle.value = data.advTitle;
  document.addAdvForm.advType.value  = data.advType;
  preImgEle.style.display = 'block';
  preImgEle.src = '/api/' + data.advPic;          // 显示原图
  document.querySelector('#advModal .modal-title').innerHTML = '修改广告';
  $('#advModal').modal('show');
};

【代码注释】$('#advModal').modal('show') 编程式打开模态框(不依赖 data-toggle 属性,因为打开时机由 JS 控制)。document.addAdvForm.advType.value = data.advType 给 select 赋值会自动匹配 option。preImgEle.src = '/api/' + data.advPic 显示原图让用户看到「现在的图片」。核心区别在于「打开前的准备」 ------新增清空、编辑回填,模态框本身同一个。市面应用:所有「列表 + 表单弹层」的后台都用这种编辑回填体验。

【实战要点】

  • 经典应用场景:所有 CRUD 后台。
  • 常见坑:1)忘记 reset → 上次编辑数据残留到新增;2)select 值类型不匹配(数字 vs 字符串)不能回显。
  • 性能与最佳实践:详情接口结合缓存,同 id 短期复用。

【本章小结】

动作 关键
新增 reset + 标题改 + 清 id
编辑 拉详情 + 回填 + 写 id
模态框 .modal('show')

记忆口诀:「新增 reset,编辑回填,标题切换」。

【面试考点】

Q1:模态框复用的取舍?

A:复用收益是模板/样式一处维护、数据流统一;劣势是「新增/编辑」差异大时会出现大量 if/else。判断标准:表单字段一致率 > 80% → 复用,否则独立。

入门示例 · 新增/编辑复用同一模态框

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>模态框复用</title>
<style>
body{font-family:sans-serif;padding:20px;background:#1e1e1e;color:#d4d4d4}
.btn{padding:7px 14px;border:none;border-radius:4px;cursor:pointer;margin:4px;font-size:13px}
.btn-add{background:#238636;color:#fff}.btn-edit{background:#1c4a7c;color:#fff}.btn-del{background:#c72e2e;color:#fff}
.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:100;align-items:center;justify-content:center}
.overlay.show{display:flex}
.modal{background:#252526;border:1px solid #3c3c3c;border-radius:8px;padding:20px;width:320px}
.modal h3{margin:0 0 16px;color:#4fc1ff}
label{display:block;font-size:12px;color:#888;margin-bottom:3px;margin-top:10px}
input,textarea{width:100%;box-sizing:border-box;background:#0d1117;border:1px solid #3c3c3c;color:#d4d4d4;padding:7px;border-radius:4px;font-size:13px}
.modal-footer{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
table{width:100%;border-collapse:collapse;font-size:13px}
th{background:#3c3c3c;padding:7px;text-align:left}td{padding:7px;border-bottom:1px solid #333}
</style></head>
<body>
<h2>一个模态框,两种用途</h2>
<button class="btn btn-add" onclick="openAdd()">+ 新增广告</button>
<table style="margin-top:12px">
<thead><tr><th>标题</th><th>类型</th><th>操作</th></tr></thead>
<tbody id="list"></tbody>
</table>
<div class="overlay" id="modal">
  <div class="modal">
    <h3 id="modalTitle">新增广告</h3>
    <input type="hidden" id="editId" value="" />
    <label>广告标题</label><input id="title" placeholder="请输入广告标题" />
    <label>广告类型</label>
    <select id="type" style="width:100%;background:#0d1117;border:1px solid #3c3c3c;color:#d4d4d4;padding:7px;border-radius:4px;font-size:13px">
      <option value="1">轮播图广告</option><option value="2">底部广告</option>
    </select>
    <div class="modal-footer">
      <button class="btn" style="background:#3c3c3c" onclick="closeModal()">取消</button>
      <button class="btn btn-add" onclick="submitForm()">确定</button>
    </div>
  </div>
</div>
<script>
let advList = [{id:1,title:'双11活动',type:'轮播图广告'},{id:2,title:'夏日清仓',type:'底部广告'}];
let nextId = 3;
function renderList(){
  document.getElementById('list').innerHTML = advList.map(a=>`
    <tr><td>${a.title}</td><td>${a.type}</td>
    <td><button class="btn btn-edit" onclick="openEdit(${a.id})">修改</button>
    <button class="btn btn-del" onclick="del(${a.id})">删除</button></td></tr>`).join('');
}
function openAdd(){
  document.getElementById('modalTitle').textContent = '新增广告';
  document.getElementById('editId').value = '';
  document.getElementById('title').value = '';
  document.getElementById('type').value = '1';
  document.getElementById('modal').classList.add('show');
}
function openEdit(id){
  const adv = advList.find(a=>a.id===id);
  document.getElementById('modalTitle').textContent = '修改广告';
  document.getElementById('editId').value = id;
  document.getElementById('title').value = adv.title;
  document.getElementById('modal').classList.add('show');
}
function closeModal(){ document.getElementById('modal').classList.remove('show'); }
function submitForm(){
  const id = document.getElementById('editId').value;
  const title = document.getElementById('title').value.trim();
  const typeMap = {'1':'轮播图广告','2':'底部广告'};
  const type = typeMap[document.getElementById('type').value];
  if(!title){ alert('请输入标题'); return; }
  if(id){ advList = advList.map(a=>a.id==id?{...a,title,type}:a); }
  else { advList.push({id:nextId++,title,type}); }
  closeModal(); renderList();
}
function del(id){ advList=advList.filter(a=>a.id!==id); renderList(); }
renderList();
</script>
</body></html>

【代码注释】「一表两用」的关键是 <input type="hidden" id="editId">openAdd 时 editId 为空字符串,submitForm 判断 if(id) 走 PUT(更新);openEdit 时 editId 填入记录 ID,走 POST(新增)的逻辑就变成了更新。模态框标题也跟着切换(「新增广告」vs「修改广告」),给用户明确的操作语境。这是后台管理系统 CRUD 的核心设计------避免维护两套几乎相同的表单,代码量减少约 40%。

实战示例 · 模态框状态机(异步确认框)

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>确认对话框</title>
<style>
body{font-family:sans-serif;padding:20px;background:#0d1117;color:#c9d1d9}
.btn{padding:7px 14px;border:none;border-radius:4px;cursor:pointer;margin:4px;font-size:13px}
.btn-del{background:#3a1c1c;color:#f85149;border:1px solid #c72e2e}
.btn-confirm{background:#c72e2e;color:#fff}.btn-cancel{background:#21262d;color:#c9d1d9}
.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:100;align-items:center;justify-content:center}
.overlay.show{display:flex}
.dialog{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:20px;width:280px;text-align:center}
.dialog h4{margin:0 0 8px;color:#f85149}
.dialog p{font-size:13px;color:#8b949e;margin:0 0 16px}
.dialog-footer{display:flex;gap:8px;justify-content:center}
#log{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px;margin:12px 0;font-size:12px;min-height:40px;line-height:1.8}
.ok{color:#3fb950}.no{color:#f85149}
</style></head>
<body>
<h2>Promise 化的确认对话框</h2>
<button class="btn btn-del" onclick="deleteAdmin('Alice')">删除 Alice</button>
<button class="btn btn-del" onclick="deleteAdmin('Bob')">删除 Bob</button>
<div id="log">等待操作...</div>
<div class="overlay" id="overlay">
  <div class="dialog">
    <h4>⚠️ 确认删除</h4>
    <p id="dialogMsg"></p>
    <div class="dialog-footer">
      <button class="btn btn-cancel" onclick="resolve(false)">取消</button>
      <button class="btn btn-confirm" onclick="resolve(true)">确认删除</button>
    </div>
  </div>
</div>
<script>
let resolver = null;
const log = document.getElementById('log');

function confirm(msg){
  document.getElementById('dialogMsg').textContent = msg;
  document.getElementById('overlay').classList.add('show');
  return new Promise(res => { resolver = res; });
}
function resolve(val){
  document.getElementById('overlay').classList.remove('show');
  if(resolver){ resolver(val); resolver = null; }
}

async function deleteAdmin(name){
  const confirmed = await confirm(`确认要删除「${name}」吗?此操作不可撤销。`);
  if(confirmed){
    log.innerHTML += `<span class="ok">✅ 确认删除 ${name},调用 DELETE /api/admin/${name}</span>
`;
  } else {
    log.innerHTML += `<span class="no">❌ 取消删除 ${name}</span>
`;
  }
}
</script>
</body></html>

【代码注释】confirm(msg) 函数返回一个 Promise,把「用户点确认/取消」的异步操作包装成 await 可等待的值------让业务代码的流程从「回调地狱」变成线性的 if(confirmed) 。点击「确认」时 resolve(true),点击「取消」时 resolve(false)------注意都是 resolve(不是 reject),因为「取消」是正常的用户行为,不是错误,不需要 try/catch。这种「Promise 化自定义模态框」是中高级前端的常用技巧,在 Ant Design/Element Plus 的 Modal.confirm 背后也是类似实现。


五、隐藏域控制流与 RESTful 语义

名词解释:

  • 隐藏域<input type="hidden">,存数据不显示 UI、随表单提交。
  • RESTful:用 HTTP 动词(POST/PUT/PATCH/DELETE)表达资源操作语义的 API 风格。

概念与底层原理:

「同一个提交按钮,怎么知道是新增还是修改?」------经典做法是隐藏域携带状态。新增时 id 为空、编辑时写入记录 id,提交时按 id 是否有值分流:

【代码注释】隐藏域是 HTML 古老的「表单流控」机制------浏览器原生支持、FormData 直接收集、值可由 JS 任意修改。提交时 fd.get('id') 有值走 putAdv(修改)、无值走 postAdv(新增)。这体现了 RESTful 语义 :POST 新增(非幂等)、PUT 整体替换(幂等)、PATCH 部分更新。市面应用:表单的编辑状态、权限标识、来源页等流控数据都常用隐藏域携带。

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

  if (fd.get('id')) {
    // 编辑分支:整体替换
    await putAdv(fd);
    toastr.success('修改成功!');
  } else {
    // 新增分支
    fd.delete('id');                          // 删掉空 id 避免污染
    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);
    toastr.success('添加成功!');
  }
  $('#advModal').modal('hide');
  document.addAdvForm.reset();
  getAdvExec();
};

【代码注释】fd.delete('id') 在新增分支删掉空 id 保持请求干净。编辑分支不强制校验「字段完整」 ------用户可能只改一两个字段。PUT 是整体替换、POST 是新增,符合 RESTful 语义。「隐藏域 + 分支提交」是一表两用的标准模式 ,但复杂表单建议用独立 mode state(Vue/React 的 ref/useState)更显式。市面应用:所有「新增/编辑共用表单」的后台都用这种模式。

【实战要点】

  • 经典应用场景:新增/编辑共用表单;多步骤向导的步骤标识。
  • 常见坑 :1)新增时忘清 id → 第一次新增后变成编辑;2)后端不接受空字符串 id → 用 fd.delete 而非 fd.set('id','')
  • 性能与最佳实践:复杂表单引入独立 mode state,比隐藏域更显式。

【本章小结】

场景 id API
新增 postAdv
编辑 记录 _id putAdv

记忆口诀:「隐藏域携状态,有值改无值添」。

【面试考点】

Q1:RESTful 的 POST/PUT/PATCH 区别?

A:POST 创建新资源、非幂等(重复发送创建多个);PUT 整体替换、幂等(重复结果一致);PATCH 部分更新、幂等性视设计而定。一般新增用 POST、整体更新用 PUT、字段级更新用 PATCH。

入门示例 · 隐藏域控制 POST/PUT 语义

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>隐藏域控制流</title>
<style>
body{font-family:sans-serif;padding:20px;background:#1e1e1e;color:#d4d4d4;max-width:400px}
.form{background:#252526;border:1px solid #3c3c3c;border-radius:8px;padding:16px;margin:10px 0}
label{display:block;font-size:12px;color:#888;margin-bottom:3px;margin-top:10px}
input{width:100%;box-sizing:border-box;background:#0d1117;border:1px solid #3c3c3c;color:#d4d4d4;padding:7px;border-radius:4px;font-size:13px}
.mode-badge{display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;margin-bottom:8px}
.add-mode{background:#1c4a1c;color:#3fb950}.edit-mode{background:#1c3a7c;color:#79c0ff}
.btn{padding:8px 14px;border:none;border-radius:4px;cursor:pointer;margin:4px 0;font-size:13px;width:100%}
.btn-green{background:#238636;color:#fff}.btn-blue{background:#1c4a7c;color:#fff}
#log{background:#0d1117;padding:10px;border-radius:6px;margin:10px 0;font-size:12px;line-height:1.8}
.ok{color:#3fb950}.req{color:#79c0ff}
</style></head>
<body>
<h2>隐藏域 + 一个表单,两种 HTTP 语义</h2>
<div>
  <button onclick="switchMode('add')" style="padding:6px 12px;margin:4px;border:1px solid #3c3c3c;background:#252526;color:#3fb950;border-radius:4px;cursor:pointer">新增模式</button>
  <button onclick="switchMode('edit',{id:'adv_007',title:'旧标题',type:2})" style="padding:6px 12px;margin:4px;border:1px solid #3c3c3c;background:#252526;color:#79c0ff;border-radius:4px;cursor:pointer">编辑 adv_007</button>
</div>
<div class="form">
  <span class="mode-badge add-mode" id="badge">新增模式</span>
  <input type="hidden" id="editId" value="" />
  <label>广告标题</label>
  <input id="title" placeholder="请输入广告标题" />
  <button class="btn btn-green" id="submitBtn" onclick="submit()">提交</button>
</div>
<div id="log">等待操作...</div>
<script>
const log = document.getElementById('log');
function append(cls,msg){ log.innerHTML += `<span class="${cls}">${msg}</span>
`; }

function switchMode(mode, data){
  const isEdit = mode === 'edit';
  document.getElementById('editId').value = isEdit ? data.id : '';
  document.getElementById('title').value = isEdit ? data.title : '';
  document.getElementById('badge').textContent = isEdit ? `编辑模式(ID: ${data.id})` : '新增模式';
  document.getElementById('badge').className = 'mode-badge ' + (isEdit ? 'edit-mode' : 'add-mode');
  document.getElementById('submitBtn').textContent = isEdit ? '保存修改' : '提交新增';
  append('req', isEdit ? `→ 切换到编辑模式,填入 id=${data.id} 的数据` : '→ 切换到新增模式,清空表单');
}

function submit(){
  const id = document.getElementById('editId').value;
  const title = document.getElementById('title').value.trim();
  if(!title){ alert('请输入标题'); return; }
  if(id){
    append('ok', `PUT /api/adv/${id}  { title: "${title}" }  ← 更新(有 ID 走 PUT)`);
  } else {
    append('ok', `POST /api/adv  { title: "${title}" }  ← 新增(无 ID 走 POST)`);
  }
}
</script>
</body></html>

【代码注释】隐藏域 <input type="hidden" id="editId"> 是表单「状态机」的关键------它不显示给用户,但控制 submit 函数走哪条分支:有值走 PUT(更新已有记录),空字符串走 POST(创建新记录)。RESTful 语义POST /api/adv(不带 ID,创建新资源)vs PUT /api/adv/:id(带 ID,整体替换)。表单字段通过 data-id 属性在「编辑」按钮上传递 ID------事件处理程序通过 e.target.dataset.id 取得,填入隐藏域,完成状态设置。

实战示例 · RESTful HTTP 方法语义对比

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>RESTful</title>
<style>
body{font-family:monospace;padding:20px;background:#0d1117;color:#c9d1d9}
table{width:100%;border-collapse:collapse;font-size:13px;margin:12px 0}
th{background:#21262d;padding:9px;text-align:left;color:#79c0ff}
td{padding:8px;border-bottom:1px solid #21262d}
.method{font-weight:bold;padding:2px 7px;border-radius:3px;font-size:12px}
.GET{background:#1c4a1c;color:#3fb950}.POST{background:#1c3a7c;color:#79c0ff}
.PUT{background:#4a3a1c;color:#ffa657}.PATCH{background:#3a1c4a;color:#d2a8ff}
.DELETE{background:#3a1c1c;color:#f85149}
.yes{color:#3fb950}.no{color:#f85149}
pre{background:#161b22;padding:10px;border-radius:6px;font-size:11px;margin:10px 0;color:#e6edf3;line-height:1.7}
</style></head>
<body>
<h2>RESTful 广告接口设计</h2>
<table>
<tr><th>方法</th><th>路径</th><th>语义</th><th>幂等</th><th>请求体</th></tr>
<tr><td><span class="method GET">GET</span></td><td>/api/adv</td><td>获取广告列表(支持分页/过滤)</td><td class="yes">✓</td><td>无(用 query)</td></tr>
<tr><td><span class="method GET">GET</span></td><td>/api/adv/:id</td><td>获取单条广告</td><td class="yes">✓</td><td>无</td></tr>
<tr><td><span class="method POST">POST</span></td><td>/api/adv</td><td>创建新广告</td><td class="no">✗</td><td>完整字段</td></tr>
<tr><td><span class="method PUT">PUT</span></td><td>/api/adv/:id</td><td>整体替换广告(缺省字段置 null)</td><td class="yes">✓</td><td>完整字段</td></tr>
<tr><td><span class="method PATCH">PATCH</span></td><td>/api/adv/:id</td><td>部分更新广告</td><td class="yes">↗</td><td>仅变更字段</td></tr>
<tr><td><span class="method DELETE">DELETE</span></td><td>/api/adv/:id</td><td>删除广告</td><td class="yes">✓</td><td>无</td></tr>
</table>
<pre>// Axios 调用示例:
advServer.get('/adv', { params: { pageNo:1, pageSize:10 } })  // GET 列表
advServer.get('/adv/adv_007')                                   // GET 单条
advServer.post('/adv', { title:'新广告', advType:1 })          // POST 新增
advServer.put('/adv/adv_007', { title:'改标题', advType:2 })   // PUT 整体更新
advServer.patch('/adv/adv_007', { title:'仅改标题' })          // PATCH 部分更新
advServer.delete('/adv/adv_007')                                // DELETE 删除</pre>
<p style="font-size:12px;color:#8b949e">幂等:多次调用结果与一次调用相同。POST 不幂等------多次 POST 会创建多条记录。<br>
实际项目中 PUT 和 PATCH 常混用,关键是「有 ID → 更新,无 ID → 新增」。</p>
</body></html>

【代码注释】POST /api/adv(不带 ID)和 PUT /api/adv/:id(带 ID)的区别在于幂等性:多次 POST 会创建多条记录(非幂等),多次 PUT 同一 ID 结果一致(幂等)。实战建议 :管理系统的 CRUD 用 POST(新增)+ PUT/PATCH(更新)+ DELETE(删除)即可,不需要纠结 PUT vs PATCH------保持团队一致最重要。GET 获取数据时把过滤/分页参数放 params(转为 query string),而非 body------符合 HTTP 规范,且 GET 请求可被 CDN/浏览器缓存。


六、生产构建的优化流水线

概念与底层原理:

开发模式 style-loader 内联 CSS、不压缩------适合调试但上线必须切换。生产构建是「翻译 + 优化」的流水线:

【代码注释】生产构建四件事:CSS 抽离(MiniCssExtractPlugin,避免无样式闪烁、支持并行加载)、CSS 压缩(CssMinimizerPlugin,减 30-50%)、JS 压缩(默认 TerserPlugin)、contenthash 命名(长效缓存)。production 模式还自动启用 Tree Shaking、scope hoisting市面应用:所有规模化项目的生产构建都包含「抽离 + 压缩 + Tree Shaking + Hash 命名」四件套。

js 复制代码
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base.config');

module.exports = merge(baseConfig, {
  module: {
    rules: [
      { test: /\.less$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'] },
      { test: /\.css$/,  use: [MiniCssExtractPlugin.loader, 'css-loader'] }
    ]
  },
  plugins: [
    new MiniCssExtractPlugin({ filename: 'css/main.[contenthash:12].css' })
  ],
  optimization: {
    minimizer: ['...', new CssMinimizerPlugin()]   // '...' 保留默认 JS 压缩
  },
  mode: 'production',
  devtool: 'source-map'
});

【代码注释】MiniCssExtractPlugin.loader 替换 style-loader------CSS 抽成独立文件由 <link> 加载。optimization.minimizer 里的 '...' 是 webpack 5 语法------保留默认 Terser(压 JS)、再追加 CssMinimizer(压 CSS)。devtool: 'source-map' 生成完整映射利于线上错误追踪。市面应用:所有上线 SPA 的 prod 配置都长这样。

权威参考:

【实战要点】

  • 经典应用场景:所有上线项目。
  • 常见坑 :1)dev 也用 MiniCssExtract → HMR 失效;2)忘 '...' → JS 不压缩体积爆炸。
  • 性能与最佳实践 :按路由懒加载(import())拆 chunk;splitChunks 抽 vendor;compression-webpack-plugin 预生成 .gz。

【本章小结】

优化 工具
CSS 抽离 MiniCssExtractPlugin
CSS 压缩 CssMinimizerPlugin
JS 压缩 Terser(内置)
SourceMap devtool: source-map

记忆口诀:「抽离压缩摇树,Hash 命名加 Map」。

【面试考点】

Q1:生产构建相对开发多做了什么?

A:1)mode 切 production,自动 Tree Shaking、scope hoisting;2)CSS 从 style-loader 切 MiniCssExtract 独立文件;3)压缩 JS(Terser)+ CSS(CssMinimizer)+ HTML;4)[contenthash] 命名利于强缓存;5)生成 .map 用于错误监控。

入门示例 · 开发构建 vs 生产构建产物对比

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>构建对比</title>
<style>
body{font-family:monospace;padding:20px;background:#1e1e1e;color:#d4d4d4}
.row{display:flex;gap:16px;margin:12px 0}
.box{flex:1;background:#252526;border:1px solid #3c3c3c;border-radius:8px;padding:14px}
h4{margin:0 0 10px;font-size:13px}
.file{padding:5px 8px;border-left:3px solid;margin:3px 0;font-size:12px}
.js{border-color:#dcdcaa}.css{border-color:#4fc1ff}.html{border-color:#4ec9b0}.map{border-color:#888}
.size{color:#888;float:right;font-size:11px}
.big{color:#f85149}.small{color:#3fb950}
pre{background:#0d1117;padding:10px;border-radius:6px;font-size:11px;margin:10px 0;color:#e6edf3;line-height:1.5}
</style></head>
<body>
<h2>开发构建 vs 生产构建</h2>
<div class="row">
<div class="box">
  <h4>🔧 开发构建(npx webpack serve)</h4>
  <div class="file js">main.js<span class="size big">~2.3MB(含 sourcemap 数据)</span></div>
  <div class="file css">(无独立 CSS 文件)</div>
  <div class="file html">index.html<span class="size">1.1KB</span></div>
  <p style="font-size:12px;color:#888;margin-top:8px">• CSS 内联进 JS(style-loader)<br>
  • eval-source-map 快速调试<br>
  • 无 Tree Shaking / 无压缩<br>
  • 产物在内存中(看不到 dist)</p>
</div>
<div class="box">
  <h4>🚀 生产构建(npx webpack --config prod)</h4>
  <div class="file js">main.a1b2c3d4.js<span class="size small">~68KB(Terser 压缩)</span></div>
  <div class="file js">vendor.e5f6a7b8.js<span class="size small">~120KB(第三方库)</span></div>
  <div class="file css">main.c9d0e1f2.css<span class="size small">~14KB(MiniCssExtract)</span></div>
  <div class="file html">index.html<span class="size">1.4KB(自动注入 link/script)</span></div>
  <div class="file map">main.a1b2c3d4.js.map<span class="size">(hidden,不发布 CDN)</span></div>
  <p style="font-size:12px;color:#888;margin-top:8px">• CSS 独立文件(并行加载)<br>
  • Terser 压缩 + Tree Shaking<br>
  • contenthash 利于强缓存<br>
  • hidden-source-map</p>
</div>
</div>
<pre>// webpack.prod.js 关键配置
optimization: {
  minimizer: ['...', new CssMinimizerPlugin()],  // '...' 保留 Terser
  splitChunks: {
    chunks: 'all',
    cacheGroups: {
      vendor: { test: /node_modules/, name: 'vendor', priority: 10 }
    }
  }
}</pre>
</body></html>

【代码注释】开发构建体积大(2MB)是因为包含 eval-source-map 的映射数据、HMR runtime、未压缩代码;生产构建只有 68KB 是因为 Terser 删除了空白/注释/未使用代码,Tree Shaking 删除了未导入的导出,scope hoisting 减少了闭包开销。'...'minimizer 数组中是「保留 webpack 默认的 Terser」------不写这个,只配 CssMinimizerPlugin 后,默认的 Terser JS 压缩会被覆盖掉,JS 不再被压缩,这是一个容易踩的坑。

实战示例 · Tree Shaking + SplitChunks 收益演示

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>构建优化收益</title>
<style>
body{font-family:sans-serif;padding:20px;background:#0d1117;color:#c9d1d9}
.scenario{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;margin:10px 0}
h4{margin:0 0 10px;font-size:14px}
.bar-wrap{display:flex;align-items:center;gap:10px;margin:6px 0;font-size:12px}
.bar{height:22px;border-radius:4px;transition:width 0.5s;display:flex;align-items:center;padding:0 8px;font-size:11px;color:#fff;font-weight:bold;min-width:40px}
.label{width:140px;color:#8b949e;flex-shrink:0}
.before{background:#c72e2e}.after{background:#238636}
.saving{color:#3fb950;font-size:11px}
</style></head>
<body>
<h2>生产优化收益对比</h2>
<div class="scenario">
  <h4>场景:管理系统(含 lodash、moment、EJS)</h4>
  <div class="bar-wrap">
    <div class="label">未优化(含全量库)</div>
    <div class="bar before" style="width:280px">~480KB</div>
  </div>
  <div class="bar-wrap">
    <div class="label">Tree Shaking 后</div>
    <div class="bar after" style="width:170px">~290KB</div>
    <span class="saving">节省 40%</span>
  </div>
  <div class="bar-wrap">
    <div class="label">Terser 压缩后</div>
    <div class="bar after" style="width:100px">~172KB</div>
    <span class="saving">再减 40%</span>
  </div>
  <div class="bar-wrap">
    <div class="label">gzip 传输(Nginx)</div>
    <div class="bar after" style="width:45px">~60KB</div>
    <span class="saving">再减 65%</span>
  </div>
</div>
<div class="scenario">
  <h4>SplitChunks 按需加载收益</h4>
  <div style="font-size:13px;line-height:2">
    <div>无 SplitChunks → main.js 包含所有代码 → 首屏下载 172KB</div>
    <div style="color:#3fb950">有 SplitChunks → vendor 独立文件(第二次访问命中缓存)→ 首屏只需 52KB</div>
    <div style="color:#8b949e;font-size:12px">vendor 文件含 lodash/moment 不频繁更新,contenthash 不变 → 长期缓存命中</div>
  </div>
</div>
<pre style="background:#161b22;border:1px solid #30363d;padding:12px;border-radius:6px;font-size:11px;color:#e6edf3">// Nginx gzip 配置(生产)
gzip on;
gzip_types text/javascript application/javascript text/css;
gzip_min_length 1024;
gzip_comp_level 6;
// 浏览器 Accept-Encoding: gzip → Nginx 压缩响应 → 节省 60-70% 传输体积</pre>
</body></html>

【代码注释】优化收益的叠加效应:Tree Shaking 删除未使用代码(-40%)→ Terser 混淆压缩(再-40%)→ Nginx gzip 传输压缩(再-65%)。最终用户下载的 gzip 后体积是原始代码的约 12%。SplitChunks 的二次访问收益vendor.hash.js(第三方库)contenthash 不变时,浏览器直接从磁盘缓存读取,无需下载------这对用户体验的提升往往比首次加载优化更显著(大多数用户都是回头客)。


七、SourceMap 分级与错误监控

概念与底层原理:

devtool 取值是「精度 vs 速度」的权衡。开发与生产应采用不同策略:

js 复制代码
// dev:兼顾速度与精度
module.exports = merge(baseConfig, {
  mode: 'development',
  devtool: 'cheap-module-source-map'
});

// prod:高精度供错误监控
module.exports = merge(baseConfig, {
  mode: 'production',
  devtool: 'source-map'
});

【代码注释】dev 用 cheap-module-source-map------「cheap」只行映射、「module」处理 loader 前源码,速度比 source-map 快 3-5 倍、调试体验仍好。prod 用 source-map 生成精确 .map,配合 Sentry 精确定位线上错误。生产 .map 不能发布到 CDN ------否则用户可下载完整源码;应上传到错误监控平台后从产物删除。市面应用 :Vue CLI dev 默认 eval-cheap-module-source-map、prod 默认 source-map

devtool 速度 用途
cheap-module-source-map 开发
source-map 生产
hidden-source-map 生产 + 不公开

【代码注释】Sentry 等平台支持「上传 SourceMap」------前端只发布混淆产物(不含 sourceMappingURL 注释),把 .map 上传给平台。线上报错时平台用 .map 反映射回源码位置。CI/CD 标准流程 :构建 prod → 上传 .map 到 Sentry → 从 dist 删除 .map → 发布到 CDN。市面应用:所有规模化项目都接入错误监控并上传 SourceMap。

【实战要点】

  • 经典应用场景:dev 调试;prod 错误监控。
  • 常见坑:把 prod .map 部署到 CDN,源码泄露。
  • 性能与最佳实践:CI 流程构建后上传 .map 再删除。

【本章小结】

环境 devtool
dev cheap-module-source-map
prod + 监控 source-map(上传后删除)
prod + 不监控 false

记忆口诀:「Dev 快 cheap,Prod 全 source,发布前删 Map」。

【面试考点】

Q1:Sentry 如何用 SourceMap 反映射?

A:1)构建产生 main.abc.js + .map;2)CI 用 Sentry CLI 上传 .map(带 release 版本号);3)用户报错时 Sentry 收到混淆位置;4)按 release 找 .map 反查得真实源码位置;5)开发者在面板看到定位。关键:上传后从前端产物删除 .map 避免泄露。

入门示例 · 混淆堆栈 vs SourceMap 反映射

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>SourceMap 分级</title>
<style>
body{font-family:monospace;padding:20px;background:#1e1e1e;color:#d4d4d4}
.btn{padding:7px 14px;border:none;border-radius:4px;cursor:pointer;margin:4px;font-size:12px;background:#388bfd;color:#fff}
.row{display:flex;gap:12px;margin:12px 0}
.box{flex:1;background:#252526;border:1px solid #3c3c3c;border-radius:6px;padding:12px}
h4{margin:0 0 8px;font-size:13px}
pre{font-size:11px;margin:0;line-height:1.7;white-space:pre-wrap}
.err{color:#f85149}.map{color:#4ec9b0}.src{color:#4fc1ff}
</style></head>
<body>
<h2>SourceMap:混淆堆栈 → 真实位置</h2>
<button class="btn" onclick="showMinified()">查看混淆堆栈</button>
<button class="btn" onclick="showMapped()">Sentry 反映射后</button>
<div class="row">
<div class="box">
  <h4>🔴 用户上报的错误</h4>
  <pre id="errLog">点击按钮...</pre>
</div>
<div class="box">
  <h4 id="srcTitle">开发者看到</h4>
  <pre id="srcLog">点击按钮...</pre>
</div>
</div>
<script>
function showMinified(){
  document.getElementById('errLog').innerHTML =
    '<span class="err">TypeError: Cannot read properties of undefined (reading 'map')</span>
' +
    '    at e.default (main.3a4b5c6d.js:1:28743)
' +
    '    at t.r (main.3a4b5c6d.js:1:14821)
' +
    '    at HTMLElement.onclick (main.3a4b5c6d.js:1:3392)';
  document.getElementById('srcTitle').textContent = '😭 无 SourceMap,开发者看到:';
  document.getElementById('srcLog').innerHTML =
    '<span style="color:#8b949e">main.3a4b5c6d.js:1:28743

' +
    '压缩后的一行代码(100KB)根本无法定位

' +
    '需要在 100KB 混淆代码中猜测 e.default 是什么函数

😭 无法修复</span>';
}
function showMapped(){
  document.getElementById('errLog').innerHTML =
    '<span class="err">TypeError: Cannot read properties of undefined (reading 'map')</span>
' +
    '    at main.3a4b5c6d.js:1:28743';
  document.getElementById('srcTitle').textContent = '✅ Sentry + SourceMap 反映射:';
  document.getElementById('srcLog').innerHTML =
    '<span class="src">src/controllers/advController.js:67:22

' +
    '<span style="color:#4fc1ff">65</span>  const renderAdvList = async () => {
' +
    '<span style="color:#4fc1ff">66</span>    const res = await getAdv();
' +
    '<span class="err">67</span>    <b>res.data.advList.map(adv => {</b>  ← 错误在此
' +
    '<span style="color:#4fc1ff">68</span>      return AdvRow(adv);
' +
    '<span style="color:#4fc1ff">69</span>    });

' +
    '原因:接口 code=500,res.data=undefined,未做防御检查</span>';
}
</script>
</body></html>

【代码注释】这个对比展示了 SourceMap 在生产错误监控中的价值:没有 SourceMap,main.3a4b5c6d.js:1:28743 毫无意义(1 行 100KB 的混淆代码);有 SourceMap,Sentry 自动反映射到 src/controllers/advController.js:67:22,直接看到出错行和上下文。关键细节 :生产用 hidden-source-map(不内嵌 //# sourceMappingURL 注释),.map 文件单独上传 Sentry,不发布到 CDN------用户浏览器无法下载 .map,源码不外泄,但 Sentry 内部能用 .map 反映射。

实战示例 · SourceMap 分级选项决策树

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>SourceMap 决策</title>
<style>
body{font-family:sans-serif;padding:20px;background:#0d1117;color:#c9d1d9;max-width:600px}
.node{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:12px 16px;margin:6px 0;font-size:13px}
.question{border-color:#388bfd;color:#79c0ff}
.answer{border-left:4px solid #3fb950;color:#c9d1d9;padding-left:12px}
.answer.dev{border-color:#3fb950}
.answer.prod{border-color:#ffa657}
.answer.secret{border-color:#d2a8ff}
select{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:7px;border-radius:4px;font-size:13px;width:100%;margin:8px 0}
#result{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;margin:10px 0}
.code{background:#0d1117;padding:6px 10px;border-radius:4px;font-family:monospace;font-size:12px;margin:4px 0}
</style></head>
<body>
<h2>SourceMap devtool 选项决策</h2>
<div class="node question">Q1:这是什么环境?
<select id="env" onchange="step2()">
  <option value="">请选择...</option>
  <option value="dev">本地开发</option>
  <option value="prod">生产环境</option>
</select>
</div>
<div id="step2" style="display:none">
  <div class="node question">Q2:生产环境需要什么?
  <select id="prodType" onchange="showResult()">
    <option value="">请选择...</option>
    <option value="err">错误监控(Sentry/Fundebug)</option>
    <option value="open">源码可公开(开源项目)</option>
    <option value="speed">只要构建速度快</option>
  </select>
  </div>
</div>
<div id="result" style="display:none"></div>
<script>
const answers = {
  dev:`<b>推荐:eval-cheap-module-source-map</b><br>
<div class="code">devtool: 'eval-cheap-module-source-map'</div>
优点:构建快(增量)+ 能看到 Loader 处理前的源码(如 Less → .less 文件)<br>
备选:eval-source-map(精度更高,稍慢)`,
  'prod-err':`<b>推荐:hidden-source-map</b><br>
<div class="code">devtool: 'hidden-source-map'</div>
生成独立 .map 文件,但产物中不含 //# sourceMappingURL 注释<br>
CI 构建后用 Sentry CLI 上传 .map,然后从 dist 目录删除<br>
用户无法下载 .map,源码安全;Sentry 能反映射定位错误 ✅`,
  'prod-open':`<b>推荐:source-map</b><br>
<div class="code">devtool: 'source-map'</div>
生成独立 .map 文件,产物末尾有 //# sourceMappingURL 指向 .map<br>
浏览器 DevTools 可自动加载 .map,用户能看到源码<br>
适合:开源库、Demo 项目`,
  'prod-speed':`<b>推荐:false(不生成 SourceMap)</b><br>
<div class="code">devtool: false</div>
完全跳过 SourceMap 生成,构建速度最快<br>
适合:不需要错误定位的 CI 流水线、或者已有其他错误监控方案`
};
function step2(){
  document.getElementById('step2').style.display = document.getElementById('env').value==='prod' ? 'block' : 'none';
  document.getElementById('result').style.display = document.getElementById('env').value==='dev' ? 'block' : 'none';
  if(document.getElementById('env').value==='dev') document.getElementById('result').innerHTML = answers.dev;
}
function showResult(){
  const t = document.getElementById('prodType').value;
  if(!t) return;
  document.getElementById('result').style.display = 'block';
  document.getElementById('result').innerHTML = answers['prod-'+t] || '';
}
</script>
</body></html>

【代码注释】SourceMap 选项的决策依据只有两个维度:调试精度源码安全 。开发环境追求速度,用 eval-* 系列(基于 eval() 包裹,速度最快);生产环境追求安全,用 hidden-source-map(文件在,注释不在)或 false(不生成)。实践规律 :99% 的中大型项目生产用 hidden-source-map + Sentry,小项目/开源用 source-map,纯静态展示用 false


八、DefinePlugin 与环境变量注入

名词解释:

  • DefinePlugin:webpack 内置插件,在编译期把全局常量替换为字面量。

概念与底层原理:

开发环境 baseURL 是 /api(走代理),生产环境是真实后端域名。怎么让同一份代码自动用「对应环境的 URL」?答案是 DefinePlugin 编译期替换:

【代码注释】DefinePlugin 在编译期把源码里的 SERVICE_URL 替换成配置的字面量。与「运行时 if/else 判断 process.env」相比,它是编译期替换 ------未命中的分支根本不进 bundle,Tree Shaking 能彻底移除。市面应用 :Vue CLI 的 process.env.VUE_APP_XXX、Vite 的 import.meta.env.VITE_XXX 底层都是 DefinePlugin。

js 复制代码
// webpack.dev.config.js
const webpack = require('webpack');
module.exports = merge(baseConfig, {
  plugins: [
    new webpack.DefinePlugin({
      SERVICE_URL: JSON.stringify('/api')               // 必须 JSON.stringify
    })
  ]
});

// webpack.prod.config.js
module.exports = merge(baseConfig, {
  plugins: [
    new webpack.DefinePlugin({
      SERVICE_URL: JSON.stringify('http://api.fuming.site:54254')
    })
  ]
});

【代码注释】JSON.stringify('/api') 而非直接 '/api' 是 DefinePlugin 的「设计陷阱」:插件做的是「字符串替换」------源码里的 SERVICE_URL 被替换成你配的「字面量」。直接写 '/api' 会被当成「变量名 /api」(语法错误);JSON.stringify('/api') 得到 "'/api'",注入后变成合法字符串字面量。市面应用:所有「多环境部署」项目都用这种方式注入后端域名。

js 复制代码
// request/advserver.js ------ 使用注入的常量
const advServer = axios.create({
  baseURL: SERVICE_URL,        // 编译期被替换为环境对应值
  timeout: 5000
});

【代码注释】SERVICE_URL 在源码里没有定义------DefinePlugin 在编译期把它替换为对应字符串,构建后这个标识符已不存在。编辑器警告「未定义变量」 :在 globals.d.ts 声明 declare const SERVICE_URL: string 即可消除。市面应用:所有多环境部署项目都用此方式注入后端域名、版本号、功能开关。

权威参考:DefinePlugin 文档:https://webpack.docschina.org/plugins/define-plugin/

【实战要点】

  • 经典应用场景:多环境部署、AB 测试开关、版本号注入。
  • 常见坑 :忘记 JSON.stringify → 编译报「undefined is not defined」。
  • 性能与最佳实践 :环境变量来源于 .env.development/.env.production,CI 注入敏感配置。

【本章小结】

字段 dev prod
SERVICE_URL '/api' 'http://api.x.com'
mode development production

记忆口诀:「Define 编译期替,stringify 必带」。

【面试考点】

Q1:DefinePlugin 为什么要 JSON.stringify

A:DefinePlugin 做的是「字面量替换」------把源码中的标识符替换成你配置的字面量。直接写 '/api' 会被当成变量名导致语法错误;JSON.stringify('/api') 得到带引号的字符串 "'/api'",注入后才是合法的字符串字面量。

入门示例 · DefinePlugin 字面量替换演示

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>DefinePlugin</title>
<style>
body{font-family:monospace;padding:20px;background:#1e1e1e;color:#d4d4d4}
.row{display:flex;gap:16px;margin:12px 0}
.box{flex:1;background:#252526;border:1px solid #3c3c3c;border-radius:6px;padding:12px}
h4{margin:0 0 8px;font-size:13px}
pre{font-size:11px;margin:0;line-height:1.7;white-space:pre-wrap}
.key{color:#79c0ff}.val{color:#ce9178}.replaced{color:#4ec9b0;font-weight:bold}
.err{color:#f85149}.comment{color:#6e7681}
select{background:#3c3c3c;color:#d4d4d4;border:none;padding:5px;border-radius:4px;font-size:12px}
</style></head>
<body>
<h2>DefinePlugin:编译期字面量替换</h2>
<label>切换环境:<select id="env" onchange="showCode()">
  <option value="dev">development</option>
  <option value="prod">production</option>
</select></label>
<div class="row" id="panel">
<div class="box">
  <h4>webpack.config.js 配置</h4>
  <pre id="config"></pre>
</div>
<div class="box">
  <h4>源码 → 编译后代码</h4>
  <pre id="compiled"></pre>
</div>
</div>
<script>
const configs = {
dev:{
config:`plugins: [
  new DefinePlugin({
    <span class="key">BASE_URL</span>: <span class="val">JSON.stringify('http://dev-api:8080')</span>,
    <span class="key">APP_ENV</span>:  <span class="val">JSON.stringify('development')</span>,
    <span class="key">DEBUG</span>:    <span class="val">true</span>,        <span class="comment">// 布尔值不需要 stringify</span>
  })
]`,
compiled:`<span class="comment">// 源码</span>
const baseUrl = BASE_URL;
if (DEBUG) { console.log('调试信息...'); }
if (APP_ENV === 'development') { ... }

<span class="comment">// 编译后(DefinePlugin 字面量替换)</span>
const baseUrl = <span class="replaced">'http://dev-api:8080'</span>;
if (<span class="replaced">true</span>) { console.log('调试信息...'); }
if (<span class="replaced">'development'</span> === 'development') { ... }
<span class="comment">// Terser 不压缩开发构建,if(true){} 保留</span>`
},
prod:{
config:`plugins: [
  new DefinePlugin({
    <span class="key">BASE_URL</span>: <span class="val">JSON.stringify('https://api.prod.com')</span>,
    <span class="key">APP_ENV</span>:  <span class="val">JSON.stringify('production')</span>,
    <span class="key">DEBUG</span>:    <span class="val">false</span>,
  })
]`,
compiled:`<span class="comment">// 源码</span>
const baseUrl = BASE_URL;
if (DEBUG) { console.log('调试信息...'); }
if (APP_ENV === 'development') { ... }

<span class="comment">// 编译后</span>
const baseUrl = <span class="replaced">'https://api.prod.com'</span>;
if (<span class="replaced">false</span>) { console.log('调试信息...'); }
if (<span class="replaced">'production'</span> === 'development') { ... }

<span class="comment">// Terser 压缩:if(false){...} 整块删除</span>
<span class="comment">// → 调试代码从生产包里完全消失 🎉</span>`
}
};
function showCode(){
  const e = document.getElementById('env').value;
  document.getElementById('config').innerHTML = configs[e].config;
  document.getElementById('compiled').innerHTML = configs[e].compiled;
}
showCode();
</script>
</body></html>

【代码注释】DefinePlugin 做的是字面量替换 (编译期的字符串搜索替换),不是运行时的变量注入。JSON.stringify('/api') 产生 "'/api'"(带引号的字符串),注入到源码里就是合法的字符串字面量;直接写 '/api' 产生 '/api'(无引号),注入后是变量名语法,会报 ReferenceErrorTree Shaking 联动if(DEBUG) 在开发时 DEBUG=true 保留调试代码;生产时 DEBUG=false,Terser 分析出 if(false){...} 是死代码直接删除------这是条件编译的前端实现

实战示例 · 多环境变量管理(.env 文件)

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>环境变量</title>
<style>
body{font-family:monospace;padding:20px;background:#0d1117;color:#c9d1d9}
.tabs{display:flex;gap:4px;margin-bottom:0}
.tab{padding:7px 14px;background:#21262d;border:1px solid #30363d;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px}
.tab.active{background:#161b22;border-bottom-color:#161b22;color:#79c0ff}
.panel{background:#161b22;border:1px solid #30363d;border-radius:0 6px 6px 6px;padding:14px}
pre{font-size:11px;margin:0;line-height:1.7;white-space:pre-wrap;color:#e6edf3}
.comment{color:#6e7681}.key{color:#79c0ff}.val{color:#a5d6ff}.warn{color:#d29922}
</style></head>
<body>
<h2>dotenv + DefinePlugin:多环境变量管理</h2>
<div class="tabs">
  <div class="tab active" onclick="show('env')">环境文件</div>
  <div class="tab" onclick="show('config')">webpack 配置</div>
  <div class="tab" onclick="show('usage')">业务代码</div>
</div>
<div class="panel"><pre id="code"></pre></div>
<script>
const codes = {
env:`<span class="comment"># .env.development (开发环境)</span>
<span class="key">API_BASE_URL</span>=<span class="val">http://localhost:8080</span>
<span class="key">APP_NAME</span>=<span class="val">后台管理系统(Dev)</span>
<span class="key">DEBUG</span>=<span class="val">true</span>
<span class="warn"># 注意:.env 文件不要提交到 Git(加入 .gitignore)</span>

<span class="comment"># .env.production (生产环境)</span>
<span class="key">API_BASE_URL</span>=<span class="val">https://api.yourdomain.com</span>
<span class="key">APP_NAME</span>=<span class="val">后台管理系统</span>
<span class="key">DEBUG</span>=<span class="val">false</span>`,
config:`const path = require('path');
const dotenv = require('dotenv');
const { DefinePlugin } = require('webpack');

<span class="comment">// 按构建命令加载对应 .env 文件</span>
const env = dotenv.config({
  path: path.resolve(__dirname,
    process.env.NODE_ENV === 'production'
      ? '.env.production'
      : '.env.development')
}).parsed;

module.exports = {
  plugins: [
    new DefinePlugin({
      <span class="comment">// 把 .env 文件的每个变量注入为全局常量</span>
      ...Object.keys(env).reduce((acc, key) => {
        acc['process.env.' + key] = JSON.stringify(env[key]);
        return acc;
      }, {})
    })
  ]
};`,
usage:`<span class="comment">// 业务代码 ------ 直接用 process.env.XXX</span>
const baseUrl = process.env.API_BASE_URL;
<span class="comment">// 开发编译后:const baseUrl = 'http://localhost:8080'</span>
<span class="comment">// 生产编译后:const baseUrl = 'https://api.yourdomain.com'</span>

if (process.env.DEBUG === 'true') {
  console.log('[DEBUG] 请求参数:', params);
}
<span class="comment">// 生产时 if(false){...} 被 Terser 整块删除</span>

document.title = process.env.APP_NAME;
<span class="comment">// 无需 if/else 判断环境,编译期已替换</span>`
};
function show(key){
  document.querySelectorAll('.tab').forEach((t,i)=>t.className='tab'+(['env','config','usage'][i]===key?' active':''));
  document.getElementById('code').innerHTML = codes[key];
}
show('env');
</script>
</body></html>

【代码注释】dotenv + DefinePlugin 是前端工程中管理多环境配置的标准做法:.env.development.env.production 分别保存不同环境的配置,webpack 构建时按 NODE_ENV 加载对应文件,通过 DefinePlugin 注入为全局常量。安全注意.env 文件中的变量会打进前端 bundle(用户能看到),绝对不要 把数据库密码、JWT secret 等敏感信息放进前端 .env 文件;那些属于后端配置。前端 .env 只放接口 BASE_URL、Feature Flag 等非敏感配置。


九、跨域细节与 changeOrigin

名词解释:

  • changeOrigin:DevServer 代理选项,转发时改写 Host 请求头为目标服务的域名。
  • CORS(Cross-Origin Resource Sharing):跨源资源共享,浏览器同源策略的「补丁」机制。

概念与底层原理:

DevServer 代理有个微妙细节:默认转发的 Host 头是前端域名。后端若基于 Host 做虚拟主机路由,会拒绝错误的 Host。changeOrigin: true 让代理转发时改写 Host 为目标服务域名:

js 复制代码
devServer: {
  proxy: {
    '/api': {
      target: 'http://127.0.0.1:8088',
      pathRewrite: { '^/api': '' },     // 去掉 /api 前缀
      changeOrigin: true                 // 改写 Host 头
    }
  }
}

【代码注释】虚拟主机(vhost)后端按 Host 头分流------若代理转发时 Host 仍是前端域名,后端找不到匹配站点会 404。changeOrigin: true 强制 Host = target 的 Host,正确路由到目标服务。三大常用选项 target(指向)+ pathRewrite(剪前缀)+ changeOrigin(改 Host)构成 DevServer 代理「标准三件套」。市面应用 :跨子域开发(前端 web.x.com、后端 api.x.com)必开 changeOrigin

生产环境跨域由 Nginx 反向代理或后端 CORS 解决:

nginx 复制代码
# 方案 1:Nginx 反向代理(推荐,前端零修改)
location /api/ {
  proxy_pass http://127.0.0.1:8088/;
  proxy_set_header Host $host;
}

【代码注释】Nginx 反向代理是「生产期的 DevServer proxy」------前端请求 /api/admin,Nginx 转发到内网后端,浏览器全程视为同源。这种方式前端零改动。CORS 则是浏览器协议层方案,要后端返回 Access-Control-Allow-Origin 响应头,适合「不同源真的存在」的场景(多域共用 API 网关)。市面应用:内部系统多用 Nginx 反向代理;公开 API(微信开放平台)用 CORS。

【实战要点】

  • 经典应用场景:开发期 proxy 联调、生产期 Nginx 反代/CORS。
  • 常见坑pathRewrite 漏写 ^ 会替换 URL 中间的 /api
  • 性能与最佳实践:生产期用 Nginx 反代而非 webpack 代理。

【本章小结】

阶段 跨域方案
开发 DevServer proxy + changeOrigin
上线 Nginx 反代 / 后端 CORS

记忆口诀:「Dev 用 proxy,Prod 用 Nginx」。

【面试考点】

Q1:浏览器同源策略限制了什么?

A:1)XHR/fetch------不能向不同源发请求(CORS 解锁);2)iframe 通信------不同源 iframe 不能互访 DOM(postMessage 解锁);3)Cookie/Storage------不同源不共享;4)Canvas------跨源图片绘制后无法读像素。不受限:图片、CSS、<script><form action> 跨域可加载(JSONP 利用 <script> 跨域)。

入门示例 · 同源策略限制类别演示

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>同源策略</title>
<style>
body{font-family:sans-serif;padding:20px;background:#1e1e1e;color:#d4d4d4}
table{width:100%;border-collapse:collapse;font-size:13px;margin:12px 0}
th{background:#3c3c3c;padding:8px;text-align:left}
td{padding:8px;border-bottom:1px solid #333}
.y{color:#3fb950;font-weight:bold}.n{color:#f85149;font-weight:bold}.maybe{color:#d29922}
.tag{padding:2px 6px;border-radius:3px;font-size:11px}
.cors{background:#1c4a1c;color:#3fb950}.sop{background:#3a1c1c;color:#f85149}
</style></head>
<body>
<h2>浏览器同源策略:限制什么?不限制什么?</h2>
<p style="font-size:13px;color:#888">当前页面:<code>http://app.example.com:3000</code></p>
<table>
<thead><tr><th>资源类型 / 操作</th><th>是否受限</th><th>说明</th></tr></thead>
<tbody>
<tr><td>XHR / fetch 请求</td><td class="n">❌ 受限</td><td>CORS 策略,需后端设置 Access-Control-Allow-Origin</td></tr>
<tr><td>Cookie 读写</td><td class="n">❌ 受限</td><td>不同源 Cookie 隔离,无法 JS 读取</td></tr>
<tr><td>iframe DOM 访问</td><td class="n">❌ 受限</td><td>不同源 iframe,parent.document 被禁止</td></tr>
<tr><td>localStorage/sessionStorage</td><td class="n">❌ 受限</td><td>按域名+协议+端口完全隔离</td></tr>
<tr><td>&lt;img src="跨域 URL"&gt;</td><td class="y">✅ 允许</td><td>可加载展示,但 canvas.getImageData 被禁止</td></tr>
<tr><td>&lt;script src="跨域 URL"&gt;</td><td class="y">✅ 允许</td><td>JSONP 利用此特性(已过时,用 CORS 替代)</td></tr>
<tr><td>&lt;link rel="stylesheet"&gt;</td><td class="y">✅ 允许</td><td>CDN 字体/图标库的基础</td></tr>
<tr><td>&lt;form action="跨域 URL"&gt;</td><td class="maybe">⚠️ 允许提交</td><td>传统表单可跨域提交,但 CSRF 攻击利用此点</td></tr>
<tr><td>WebSocket 连接</td><td class="y">✅ 允许</td><td>不受同源策略限制(WS 有自己的安全机制)</td></tr>
</tbody>
</table>
<p style="font-size:12px;color:#888">同源策略只限制「脚本发起的读取操作」,不限制标签加载(因为标签加载是单向的------能加载资源但不能读取响应内容)。</p>
</body></html>

【代码注释】同源策略的限制核心是「防止跨源读取数据」------<script> 加载跨源 JS 可以执行,但你无法 xhr.responseText 读取跨源的接口响应;<img> 加载跨源图片可以显示,但 canvas.getImageData() 无法读取像素(防止「canvas fingerprinting」)。CSRF 攻击 正是利用了 <form> 可以跨源提交:恶意网站把 form action 指向你的银行接口,用户不知情点提交就发出了请求------这就是为什么接口需要 CSRF token。

实战示例 · changeOrigin 代理 Host 头演示

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>changeOrigin</title>
<style>
body{font-family:monospace;padding:20px;background:#0d1117;color:#c9d1d9}
.flow{margin:16px 0}
.step{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px 14px;margin:4px 0;font-size:12px;display:flex;gap:10px;align-items:flex-start}
.step .icon{font-size:16px;flex-shrink:0}
.step b{color:#79c0ff}
.step .req{color:#ffa657}.step .resp{color:#3fb950}
.divider{text-align:center;color:#8b949e;margin:8px 0}
.toggle{display:flex;gap:8px;margin:12px 0}
.btn{padding:6px 14px;border:none;border-radius:4px;cursor:pointer;font-size:12px}
.btn.active{background:#388bfd;color:#fff}.btn:not(.active){background:#21262d;color:#c9d1d9}
</style></head>
<body>
<h2>changeOrigin: false vs true</h2>
<div class="toggle">
  <button class="btn" id="btn-false" onclick="show(false)">changeOrigin: false</button>
  <button class="btn active" id="btn-true" onclick="show(true)">changeOrigin: true</button>
</div>
<div class="flow" id="flow"></div>
<script>
function show(co){
  document.getElementById('btn-false').className = 'btn'+(co?'':' active');
  document.getElementById('btn-true').className = 'btn'+(co?' active':'');
  const host = co ? 'localhost:8080(目标地址)' : 'localhost:3000(原始地址)';
  const result = co
    ? '<span style="color:#3fb950">✅ 后端收到 Host=localhost:8080,与监听地址匹配,请求接受</span>'
    : '<span style="color:#f85149">❌ 部分后端框架(Spring/Nginx 虚拟主机)会检查 Host,Host=localhost:3000 与后端 :8080 不匹配 → 可能被拒绝</span>';
  document.getElementById('flow').innerHTML = `
    <div class="step"><span class="icon">🌐</span><div>
      <b>浏览器</b> 发起请求<br>
      <span class="req">GET http://localhost:3000/api/admin</span><br>
      Host: localhost:3000
    </div></div>
    <div class="divider">↓ DevServer proxy 转发</div>
    <div class="step"><span class="icon">🔧</span><div>
      <b>DevServer</b> 转发到后端<br>
      <span class="req">GET http://localhost:8080/api/admin</span><br>
      Host: <b>${host}</b><br>
      ${co?'(changeOrigin: true 修改了 Host 请求头)':'(changeOrigin: false 保留了原始 Host)'}
    </div></div>
    <div class="divider">↓ 后端处理</div>
    <div class="step"><span class="icon">⚙️</span><div>
      <b>后端服务</b> (localhost:8080)<br>
      ${result}
    </div></div>`;
}
show(true);
</script>
</body></html>

【代码注释】changeOrigin: true 让 DevServer 在转发请求时把 HTTP Host 请求头改为目标服务器的地址(localhost:8080)。何时必须开 :当后端是 Nginx 虚拟主机(根据 Host 决定路由到哪个应用)、或后端框架做 Host 校验(Spring Boot 的 CSRF 防护、某些 CORS 实现)时,不开 changeOrigin 会报 403 或被拒绝。何时可不开:后端是简单的 Express 服务,不检查 Host 头时,两种设置效果相同------但建议统一开启,养成习惯,避免在陌生后端上踩坑。


十、Nginx 部署 SPA 与长效缓存

名词解释:

  • try_files:Nginx 指令,按顺序尝试多个文件,常用于 SPA fallback。
  • 长效缓存:配合 contenthash 命名,让浏览器长期缓存静态资源。

概念与底层原理:

SPA 上线后访问 https://x.com/index/admin 刷新会 404------Nginx 找不到这个物理文件。try_files 把这种「前端虚拟路径」回退到 index.html

【代码注释】try_files 让 Nginx 模拟 DevServer 的 historyApiFallback------这是 SPA 部署到 Nginx 的「灵魂配置」。少了它,所有非首页路径刷新都会 404。市面应用:阿里/腾讯/字节的 SPA 上线都靠这条配置。

完整 Nginx 配置:

nginx 复制代码
server {
  listen 80;
  server_name adv.manage.fuming.site;
  root /var/www/adv-frontend/dist;

  gzip on;
  gzip_types text/css application/javascript application/json;

  # SPA fallback:未匹配路径返回 index.html
  location / {
    try_files $uri $uri/ /index.html;
  }

  # 静态资源强缓存(contenthash 命名前提下)
  location ~* \.(js|css|png|jpg|svg|woff2?)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }

  # API 反向代理(避免跨域)
  location /api/ {
    proxy_pass http://127.0.0.1:8088/;
    proxy_set_header Host $host;
  }
}

【代码注释】逐条解释:try_files $uri $uri/ /index.html 先按真实文件找、再按目录、最后回退 index.html------SPA 必备;静态资源 expires 1y 因为文件名带 contenthash(内容变 hash 变 URL 变,浏览器自动拉新),可放心强缓存一年;/api/ 反向代理把请求转发到内网后端,前端零跨域。这三者(fallback + 强缓存 + 反代)是 SPA 部署的标准三件套市面应用:CDN 加速时再把静态资源推到 CDN,Nginx 只负责 HTML + API。

部署上线清单:

复制代码
1. 本地 npm run build 产出 dist
2. 上传 dist 到服务器
3. Nginx 配置:try_files(SPA fallback)+ location /api/(反代)
4. nginx -t 校验配置 → nginx -s reload 平滑重启
5. 浏览器访问域名验证,重点测「刷新子页面」

【代码注释】nginx -t 校验配置语法但不重启(部署前最后防线),nginx -s reload 平滑重载不中断在线连接。市面应用 :所有运维流水线部署 Nginx 都先 -t-s reload

权威参考:Nginx try_files:https://nginx.org/en/docs/http/ngx_http_core_module.html#try_files

【实战要点】

  • 经典应用场景:所有 SPA 上线;微前端主应用承载子应用。
  • 常见坑 :1)忘 try_files → 刷新子页面 404;2)静态资源无 hash 却强缓存 → 用户拿到旧版本;3)proxy_pass 末尾 / 影响 URL 拼接。
  • 性能与最佳实践:CDN 加速静态资源;HTTPS(Let's Encrypt);HTTP/2;Brotli 替代 gzip。

【本章小结】

任务 配置
SPA fallback try_files $uri $uri/ /index.html
强缓存 expires 1y + immutable
API 反代 proxy_pass
gzip gzip on

记忆口诀:「Nginx 三件套:fallback、缓存、反代」。

【面试考点】

Q1:SPA 部署到 Nginx 必须开 try_files 吗?

A:是的,除非用 hash 模式路由。history 模式下,非根路径直接访问/刷新没有对应物理文件,Nginx 默认 404。try_files $uri $uri/ /index.html 让 Nginx 把未匹配路径回退到 index.html,前端路由接管------这是 SPA 部署的灵魂配置。

Q2:Nginx 与 Node Express 部署 SPA 哪个更好?

A:1)性能------Nginx(C 编写、事件驱动)处理静态资源远快于 Node;2)资源占用------Nginx 更低;3)生态------Nginx 有 gzip、HTTP/2、限流、负载均衡等成熟模块;4)复杂业务------Express 更易加 SSR/AB 测试。纯 SPA 选 Nginx;需 SSR/中间逻辑选 Node(或 Nginx + Node 配合)。

入门示例 · try_files SPA 路由回退模拟

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>Nginx SPA</title>
<style>
body{font-family:monospace;padding:20px;background:#1e1e1e;color:#d4d4d4}
.btn{padding:7px 14px;border:none;border-radius:4px;cursor:pointer;margin:4px;font-size:12px;background:#388bfd;color:#fff}
#browser{background:#252526;border:1px solid #3c3c3c;border-radius:8px;padding:12px;margin:10px 0}
#addrBar{background:#0d1117;padding:7px;border-radius:4px;color:#4fc1ff;font-size:13px;margin-bottom:8px}
#page{min-height:60px;padding:10px;font-size:13px}
#log{background:#0d1117;padding:12px;border-radius:6px;margin:10px 0;font-size:12px;min-height:60px;line-height:1.8}
.ok{color:#3fb950}.no{color:#f85149}.nginx{color:#ffa657}
</style></head>
<body>
<h2>Nginx try_files:SPA 的灵魂配置</h2>
<div id="browser">
  <div id="addrBar">http://yourdomain.com/admin</div>
  <div id="page">(模拟浏览器页面)</div>
</div>
<div>
  <button class="btn" onclick="navigate('/')">访问根路径</button>
  <button class="btn" onclick="navigate('/admin')">直接访问 /admin</button>
  <button class="btn" onclick="navigate('/adv/edit/007')">深层路由 /adv/edit/007</button>
  <button class="btn" onclick="navigate('/static/main.js')">请求静态文件</button>
</div>
<div id="log">点击模拟 Nginx 处理流程...</div>
<script>
const log = document.getElementById('log');
function append(cls,msg){ log.innerHTML += `<span class="${cls}">${msg}</span>
`; }

const staticFiles = ['/static/main.js','/static/vendor.js','/static/style.css','/favicon.ico'];

function navigate(path){
  document.getElementById('addrBar').textContent = 'http://yourdomain.com' + path;
  log.innerHTML = '';
  append('nginx',`Nginx 收到请求:${path}`);
  append('nginx','执行:try_files $uri $uri/ /index.html;');
  if(staticFiles.includes(path)){
    append('ok',`1. 尝试 $uri → /dist${path} ← 文件存在!`);
    append('ok',`→ 直接返回静态文件(JS/CSS),不回退 index.html`);
    document.getElementById('page').innerHTML = `📄 返回静态文件: ${path}`;
  } else if(path === '/'){
    append('ok','1. 尝试 $uri → /dist/index.html ← 存在!');
    append('ok','→ 返回 index.html,前端路由渲染首页');
    document.getElementById('page').innerHTML = '🏠 首页(前端路由渲染)';
  } else {
    append('no',`1. 尝试 $uri → /dist${path} ← 不存在`);
    append('no',`2. 尝试 $uri/ → /dist${path}/ ← 不存在`);
    append('ok','3. 回退 /index.html ← 文件存在!');
    append('ok','→ 返回 index.html,前端路由解析 URL 渲染对应页面');
    const pageNames = {'/admin':'管理员列表页','/adv/edit/007':'编辑广告页'};
    document.getElementById('page').innerHTML = `📄 index.html → 前端路由渲染:${pageNames[path]||path}`;
  }
}
navigate('/admin');
</script>
</body></html>

【代码注释】try_files $uri $uri/ /index.html 是 Nginx 部署 history 模式 SPA 的灵魂配置:① 先尝试路径作为文件($uri);② 再尝试作为目录($uri/);③ 都找不到就回退到 /index.html------让前端路由接管。静态文件走缓存/static/main.a1b2c3.js 因为 $uri 能命中真实文件,直接返回而不会回退到 index.html。如果没有 try_files,用户直接访问 /admin(浏览器刷新或分享链接)就会 404------因为服务器上没有 /admin 这个物理文件。

实战示例 · Nginx 长效缓存策略配置

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>Nginx 缓存</title>
<style>
body{font-family:monospace;padding:20px;background:#0d1117;color:#c9d1d9}
.row{display:flex;gap:16px;margin:12px 0}
.box{flex:1;background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px}
h4{margin:0 0 10px;font-size:13px;color:#79c0ff}
pre{font-size:11px;margin:0;line-height:1.7;white-space:pre-wrap;color:#e6edf3}
.comment{color:#6e7681}
.file{padding:5px 8px;border-left:3px solid;margin:3px 0;font-size:12px;border-radius:0 4px 4px 0}
.index{border-color:#ffa657;background:#4a3a1c22}
.asset{border-color:#3fb950;background:#1c4a1c22}
.cache{color:#3fb950}.nocache{color:#ffa657}
</style></head>
<body>
<h2>Nginx 两类文件,两种缓存策略</h2>
<div class="row">
<div class="box">
  <h4>📋 入口文件(index.html)</h4>
  <div class="file index nocache">Cache-Control: no-cache(每次验证)</div>
  <div class="file index nocache">ETag: "abc123" + Last-Modified(协商缓存)</div>
  <p style="font-size:12px;color:#8b949e;margin:8px 0">原因:index.html 注入了带 hash 的 script/link<br>
  必须每次获取最新版本才能拿到正确的 JS/CSS URL</p>
</div>
<div class="box">
  <h4>📦 带 contenthash 的资源文件</h4>
  <div class="file asset cache">Cache-Control: max-age=31536000, immutable</div>
  <div class="file asset cache">(浏览器 1 年内不发请求)</div>
  <p style="font-size:12px;color:#8b949e;margin:8px 0">原因:文件名含 contenthash,内容变化时 hash 变<br>
  hash 不变 = 内容不变 = 缓存永远有效</p>
</div>
</div>
<pre style="background:#161b22;border:1px solid #30363d;padding:14px;border-radius:8px">
<span class="comment"># nginx.conf --- SPA 完整配置模板</span>
server {
    listen 80;
    server_name yourdomain.com;
    root /var/www/dist;
    index index.html;

    <span class="comment"># 入口文件:协商缓存(每次验证 ETag)</span>
    location = /index.html {
        add_header Cache-Control "no-cache";
    }

    <span class="comment"># 带 hash 的静态资源:强缓存 1 年</span>
    location ~* \.(js|css|woff2|png|jpg|svg)$ {
        add_header Cache-Control "max-age=31536000, immutable";
        <span class="comment"># immutable: 浏览器不发条件请求,直接用缓存</span>
    }

    <span class="comment"># history 路由回退</span>
    location / {
        try_files $uri $uri/ /index.html;
    }

    <span class="comment"># gzip 压缩</span>
    gzip on;
    gzip_types text/javascript application/javascript text/css;
}
</pre>
</body></html>

【代码注释】这份 Nginx 配置是部署 SPA 的「三件套」:① try_files 处理 history 路由;② index.html 用协商缓存(no-cache + ETag)确保入口始终最新;③ 带 hash 的 JS/CSS/字体用强缓存(max-age=31536000)最大化性能。immutable 是 Cache-Control 的扩展指令------告诉浏览器「这个文件永远不会变,连条件请求(If-None-Match)也不用发」,进一步减少网络开销。部署流程 :webpack 构建生成 dist/ → 上传到服务器 → Nginx 指向 dist/,每次部署只有 index.html 缓存失效,其余文件按需失效。


附录:可运行 Demo

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

Demo 一 · 分页 + 关键字搜索

复刻第二、三章------分页与搜索复用同一个渲染函数,搜索条件隐式跟随输入框:

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}
  table{border-collapse:collapse;width:480px}th,td{border:1px solid #ddd;padding:8px}
  .page{margin-top:12px}.page button{padding:4px 10px;margin-right:4px}
  .page button.active{background:#007bff;color:#fff}
</style></head>
<body>
  <input id="kw" placeholder="搜索标题" /><button id="search">搜索</button>
  <table><thead><tr><th>ID</th><th>标题</th><th>类别</th></tr></thead><tbody id="list"></tbody></table>
  <div class="page" id="page"></div>
  <script>
    // 模拟后端数据
    const DB = Array.from({ length: 23 }, (_, i) => ({
      id: i + 1, title: '广告' + (i + 1), type: ['轮播', '底部', '热门'][i % 3]
    }));
    const SIZE = 5;
    // 统一渲染函数:分页/搜索都走它(搜索条件隐式读输入框)
    function render(no = 1) {
      const kw = document.getElementById('kw').value.trim();
      const filtered = DB.filter(r => r.title.includes(kw));   // 模拟后端过滤
      const pageSum = Math.ceil(filtered.length / SIZE) || 1;
      const rows = filtered.slice((no - 1) * SIZE, no * SIZE);  // 模拟后端分页切片
      document.getElementById('list').innerHTML = rows
        .map(r => `<tr><td>${r.id}</td><td>${r.title}</td><td>${r.type}</td></tr>`).join('');
      // 渲染页码,高亮当前页
      let html = '';
      for (let i = 1; i <= pageSum; i++)
        html += `<button class="${i === no ? 'active' : ''}" data-i="${i}">${i}</button>`;
      document.getElementById('page').innerHTML = html;
    }
    // 事件委托:点击页码
    document.getElementById('page').addEventListener('click', (e) => {
      if (e.target.dataset.i) render(Number(e.target.dataset.i));
    });
    document.getElementById('search').onclick = () => render(1);  // 搜索重置到第 1 页
    render();
  </script>
</body>
</html>

【代码注释】这个 Demo 把分页与搜索的工程设计跑通:render(no) 是唯一渲染入口------分页传页号、搜索传 1(重置到首页),内部统一读输入框关键字过滤。DB.filterslice 模拟后端的过滤与分页切片,页码用事件委托处理。搜索时把 pageNo 重置为 1 是关键细节(否则搜出 2 条却停在第 3 页会显示空)。市面应用 :真实项目里 filter / slice 由后端数据库完成,前端只传 pageNo / pageSize / keyword 三个参数,逻辑结构完全一致。

Demo 二 · 编译期环境变量替换(DefinePlugin 原理)

复刻第八章------直观演示「编译期字面量替换」与「运行时判断」的区别:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8" /><title>DefinePlugin 原理 Demo</title>
<style>body{font-family:monospace;padding:24px;line-height:1.8}pre{background:#f5f5f5;padding:12px;border-radius:6px}button{padding:6px 12px;margin-right:8px}</style></head>
<body>
  <h3>模拟 DefinePlugin:编译期把 SERVICE_URL 替换为字面量</h3>
  <button data-env="dev">按 dev 构建</button>
  <button data-env="prod">按 prod 构建</button>
  <pre id="out"></pre>
  <script>
    // 模拟两套环境配置(对应 webpack.dev / webpack.prod 的 DefinePlugin)
    const DEFINE = { dev: "'/api'", prod: "'http://api.fuming.site:54254'" };
    // 源码模板:SERVICE_URL 是待替换的占位符
    const SOURCE = `const advServer = axios.create({\n  baseURL: SERVICE_URL,\n  timeout: 5000\n});`;
    document.querySelectorAll('button').forEach(btn => {
      btn.onclick = () => {
        const env = btn.dataset.env;
        // 编译期:把源码里的标识符 SERVICE_URL 整体替换为字面量
        const compiled = SOURCE.replace('SERVICE_URL', DEFINE[env]);
        document.getElementById('out').textContent =
          `【${env} 构建产物】\n` + compiled +
          `\n\n注意:产物里 SERVICE_URL 已不存在,被替换成了字面量。`;
      };
    });
  </script>
</body>
</html>

【代码注释】点击「按 dev 构建」与「按 prod 构建」,能看到同一份源码里的 SERVICE_URL 被替换成不同的字面量------这就是 DefinePlugin 的本质:编译期字符串替换,而非运行时读变量 。产物里 SERVICE_URL 这个标识符已彻底消失,所以源码里它无需定义。这也解释了为什么配置要写 JSON.stringify('/api')------替换进去的必须是合法的字符串字面量 '/api' 而非裸标识符。市面应用 :Vue CLI 的 process.env.VUE_APP_*、Vite 的 import.meta.env.VITE_* 底层都是这种编译期替换。

总结

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

#mermaid-svg-0l7xomqT7fZzkOCg{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-0l7xomqT7fZzkOCg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0l7xomqT7fZzkOCg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0l7xomqT7fZzkOCg .error-icon{fill:#552222;}#mermaid-svg-0l7xomqT7fZzkOCg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0l7xomqT7fZzkOCg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0l7xomqT7fZzkOCg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0l7xomqT7fZzkOCg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0l7xomqT7fZzkOCg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0l7xomqT7fZzkOCg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0l7xomqT7fZzkOCg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0l7xomqT7fZzkOCg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0l7xomqT7fZzkOCg .marker.cross{stroke:#333333;}#mermaid-svg-0l7xomqT7fZzkOCg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0l7xomqT7fZzkOCg p{margin:0;}#mermaid-svg-0l7xomqT7fZzkOCg .edge{stroke-width:3;}#mermaid-svg-0l7xomqT7fZzkOCg .section--1 rect,#mermaid-svg-0l7xomqT7fZzkOCg .section--1 path,#mermaid-svg-0l7xomqT7fZzkOCg .section--1 circle,#mermaid-svg-0l7xomqT7fZzkOCg .section--1 polygon,#mermaid-svg-0l7xomqT7fZzkOCg .section--1 path{fill:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .section--1 text{fill:#ffffff;}#mermaid-svg-0l7xomqT7fZzkOCg .node-icon--1{font-size:40px;color:#ffffff;}#mermaid-svg-0l7xomqT7fZzkOCg .section-edge--1{stroke:hsl(240, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .edge-depth--1{stroke-width:17;}#mermaid-svg-0l7xomqT7fZzkOCg .section--1 line{stroke:hsl(60, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled,#mermaid-svg-0l7xomqT7fZzkOCg .disabled circle,#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:lightgray;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:#efefef;}#mermaid-svg-0l7xomqT7fZzkOCg .section-0 rect,#mermaid-svg-0l7xomqT7fZzkOCg .section-0 path,#mermaid-svg-0l7xomqT7fZzkOCg .section-0 circle,#mermaid-svg-0l7xomqT7fZzkOCg .section-0 polygon,#mermaid-svg-0l7xomqT7fZzkOCg .section-0 path{fill:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-0l7xomqT7fZzkOCg .section-0 text{fill:black;}#mermaid-svg-0l7xomqT7fZzkOCg .node-icon-0{font-size:40px;color:black;}#mermaid-svg-0l7xomqT7fZzkOCg .section-edge-0{stroke:hsl(60, 100%, 73.5294117647%);}#mermaid-svg-0l7xomqT7fZzkOCg .edge-depth-0{stroke-width:14;}#mermaid-svg-0l7xomqT7fZzkOCg .section-0 line{stroke:hsl(240, 100%, 83.5294117647%);stroke-width:3;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled,#mermaid-svg-0l7xomqT7fZzkOCg .disabled circle,#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:lightgray;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:#efefef;}#mermaid-svg-0l7xomqT7fZzkOCg .section-1 rect,#mermaid-svg-0l7xomqT7fZzkOCg .section-1 path,#mermaid-svg-0l7xomqT7fZzkOCg .section-1 circle,#mermaid-svg-0l7xomqT7fZzkOCg .section-1 polygon,#mermaid-svg-0l7xomqT7fZzkOCg .section-1 path{fill:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .section-1 text{fill:black;}#mermaid-svg-0l7xomqT7fZzkOCg .node-icon-1{font-size:40px;color:black;}#mermaid-svg-0l7xomqT7fZzkOCg .section-edge-1{stroke:hsl(80, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .edge-depth-1{stroke-width:11;}#mermaid-svg-0l7xomqT7fZzkOCg .section-1 line{stroke:hsl(260, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled,#mermaid-svg-0l7xomqT7fZzkOCg .disabled circle,#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:lightgray;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:#efefef;}#mermaid-svg-0l7xomqT7fZzkOCg .section-2 rect,#mermaid-svg-0l7xomqT7fZzkOCg .section-2 path,#mermaid-svg-0l7xomqT7fZzkOCg .section-2 circle,#mermaid-svg-0l7xomqT7fZzkOCg .section-2 polygon,#mermaid-svg-0l7xomqT7fZzkOCg .section-2 path{fill:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .section-2 text{fill:#ffffff;}#mermaid-svg-0l7xomqT7fZzkOCg .node-icon-2{font-size:40px;color:#ffffff;}#mermaid-svg-0l7xomqT7fZzkOCg .section-edge-2{stroke:hsl(270, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .edge-depth-2{stroke-width:8;}#mermaid-svg-0l7xomqT7fZzkOCg .section-2 line{stroke:hsl(90, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled,#mermaid-svg-0l7xomqT7fZzkOCg .disabled circle,#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:lightgray;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:#efefef;}#mermaid-svg-0l7xomqT7fZzkOCg .section-3 rect,#mermaid-svg-0l7xomqT7fZzkOCg .section-3 path,#mermaid-svg-0l7xomqT7fZzkOCg .section-3 circle,#mermaid-svg-0l7xomqT7fZzkOCg .section-3 polygon,#mermaid-svg-0l7xomqT7fZzkOCg .section-3 path{fill:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .section-3 text{fill:black;}#mermaid-svg-0l7xomqT7fZzkOCg .node-icon-3{font-size:40px;color:black;}#mermaid-svg-0l7xomqT7fZzkOCg .section-edge-3{stroke:hsl(300, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .edge-depth-3{stroke-width:5;}#mermaid-svg-0l7xomqT7fZzkOCg .section-3 line{stroke:hsl(120, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled,#mermaid-svg-0l7xomqT7fZzkOCg .disabled circle,#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:lightgray;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:#efefef;}#mermaid-svg-0l7xomqT7fZzkOCg .section-4 rect,#mermaid-svg-0l7xomqT7fZzkOCg .section-4 path,#mermaid-svg-0l7xomqT7fZzkOCg .section-4 circle,#mermaid-svg-0l7xomqT7fZzkOCg .section-4 polygon,#mermaid-svg-0l7xomqT7fZzkOCg .section-4 path{fill:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .section-4 text{fill:black;}#mermaid-svg-0l7xomqT7fZzkOCg .node-icon-4{font-size:40px;color:black;}#mermaid-svg-0l7xomqT7fZzkOCg .section-edge-4{stroke:hsl(330, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .edge-depth-4{stroke-width:2;}#mermaid-svg-0l7xomqT7fZzkOCg .section-4 line{stroke:hsl(150, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled,#mermaid-svg-0l7xomqT7fZzkOCg .disabled circle,#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:lightgray;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:#efefef;}#mermaid-svg-0l7xomqT7fZzkOCg .section-5 rect,#mermaid-svg-0l7xomqT7fZzkOCg .section-5 path,#mermaid-svg-0l7xomqT7fZzkOCg .section-5 circle,#mermaid-svg-0l7xomqT7fZzkOCg .section-5 polygon,#mermaid-svg-0l7xomqT7fZzkOCg .section-5 path{fill:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .section-5 text{fill:black;}#mermaid-svg-0l7xomqT7fZzkOCg .node-icon-5{font-size:40px;color:black;}#mermaid-svg-0l7xomqT7fZzkOCg .section-edge-5{stroke:hsl(0, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .edge-depth-5{stroke-width:-1;}#mermaid-svg-0l7xomqT7fZzkOCg .section-5 line{stroke:hsl(180, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled,#mermaid-svg-0l7xomqT7fZzkOCg .disabled circle,#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:lightgray;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:#efefef;}#mermaid-svg-0l7xomqT7fZzkOCg .section-6 rect,#mermaid-svg-0l7xomqT7fZzkOCg .section-6 path,#mermaid-svg-0l7xomqT7fZzkOCg .section-6 circle,#mermaid-svg-0l7xomqT7fZzkOCg .section-6 polygon,#mermaid-svg-0l7xomqT7fZzkOCg .section-6 path{fill:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .section-6 text{fill:black;}#mermaid-svg-0l7xomqT7fZzkOCg .node-icon-6{font-size:40px;color:black;}#mermaid-svg-0l7xomqT7fZzkOCg .section-edge-6{stroke:hsl(30, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .edge-depth-6{stroke-width:-4;}#mermaid-svg-0l7xomqT7fZzkOCg .section-6 line{stroke:hsl(210, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled,#mermaid-svg-0l7xomqT7fZzkOCg .disabled circle,#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:lightgray;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:#efefef;}#mermaid-svg-0l7xomqT7fZzkOCg .section-7 rect,#mermaid-svg-0l7xomqT7fZzkOCg .section-7 path,#mermaid-svg-0l7xomqT7fZzkOCg .section-7 circle,#mermaid-svg-0l7xomqT7fZzkOCg .section-7 polygon,#mermaid-svg-0l7xomqT7fZzkOCg .section-7 path{fill:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .section-7 text{fill:black;}#mermaid-svg-0l7xomqT7fZzkOCg .node-icon-7{font-size:40px;color:black;}#mermaid-svg-0l7xomqT7fZzkOCg .section-edge-7{stroke:hsl(90, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .edge-depth-7{stroke-width:-7;}#mermaid-svg-0l7xomqT7fZzkOCg .section-7 line{stroke:hsl(270, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled,#mermaid-svg-0l7xomqT7fZzkOCg .disabled circle,#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:lightgray;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:#efefef;}#mermaid-svg-0l7xomqT7fZzkOCg .section-8 rect,#mermaid-svg-0l7xomqT7fZzkOCg .section-8 path,#mermaid-svg-0l7xomqT7fZzkOCg .section-8 circle,#mermaid-svg-0l7xomqT7fZzkOCg .section-8 polygon,#mermaid-svg-0l7xomqT7fZzkOCg .section-8 path{fill:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .section-8 text{fill:black;}#mermaid-svg-0l7xomqT7fZzkOCg .node-icon-8{font-size:40px;color:black;}#mermaid-svg-0l7xomqT7fZzkOCg .section-edge-8{stroke:hsl(150, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .edge-depth-8{stroke-width:-10;}#mermaid-svg-0l7xomqT7fZzkOCg .section-8 line{stroke:hsl(330, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled,#mermaid-svg-0l7xomqT7fZzkOCg .disabled circle,#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:lightgray;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:#efefef;}#mermaid-svg-0l7xomqT7fZzkOCg .section-9 rect,#mermaid-svg-0l7xomqT7fZzkOCg .section-9 path,#mermaid-svg-0l7xomqT7fZzkOCg .section-9 circle,#mermaid-svg-0l7xomqT7fZzkOCg .section-9 polygon,#mermaid-svg-0l7xomqT7fZzkOCg .section-9 path{fill:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .section-9 text{fill:black;}#mermaid-svg-0l7xomqT7fZzkOCg .node-icon-9{font-size:40px;color:black;}#mermaid-svg-0l7xomqT7fZzkOCg .section-edge-9{stroke:hsl(180, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .edge-depth-9{stroke-width:-13;}#mermaid-svg-0l7xomqT7fZzkOCg .section-9 line{stroke:hsl(0, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled,#mermaid-svg-0l7xomqT7fZzkOCg .disabled circle,#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:lightgray;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:#efefef;}#mermaid-svg-0l7xomqT7fZzkOCg .section-10 rect,#mermaid-svg-0l7xomqT7fZzkOCg .section-10 path,#mermaid-svg-0l7xomqT7fZzkOCg .section-10 circle,#mermaid-svg-0l7xomqT7fZzkOCg .section-10 polygon,#mermaid-svg-0l7xomqT7fZzkOCg .section-10 path{fill:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .section-10 text{fill:black;}#mermaid-svg-0l7xomqT7fZzkOCg .node-icon-10{font-size:40px;color:black;}#mermaid-svg-0l7xomqT7fZzkOCg .section-edge-10{stroke:hsl(210, 100%, 76.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .edge-depth-10{stroke-width:-16;}#mermaid-svg-0l7xomqT7fZzkOCg .section-10 line{stroke:hsl(30, 100%, 86.2745098039%);stroke-width:3;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled,#mermaid-svg-0l7xomqT7fZzkOCg .disabled circle,#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:lightgray;}#mermaid-svg-0l7xomqT7fZzkOCg .disabled text{fill:#efefef;}#mermaid-svg-0l7xomqT7fZzkOCg .section-root rect,#mermaid-svg-0l7xomqT7fZzkOCg .section-root path,#mermaid-svg-0l7xomqT7fZzkOCg .section-root circle,#mermaid-svg-0l7xomqT7fZzkOCg .section-root polygon{fill:hsl(240, 100%, 46.2745098039%);}#mermaid-svg-0l7xomqT7fZzkOCg .section-root text{fill:#ffffff;}#mermaid-svg-0l7xomqT7fZzkOCg .section-root span{color:#ffffff;}#mermaid-svg-0l7xomqT7fZzkOCg .section-2 span{color:#ffffff;}#mermaid-svg-0l7xomqT7fZzkOCg .icon-container{height:100%;display:flex;justify-content:center;align-items:center;}#mermaid-svg-0l7xomqT7fZzkOCg .edge{fill:none;}#mermaid-svg-0l7xomqT7fZzkOCg .mindmap-node-label{dy:1em;alignment-baseline:middle;text-anchor:middle;dominant-baseline:middle;text-align:center;}#mermaid-svg-0l7xomqT7fZzkOCg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 完整功能与部署
CRUD 闭环
列表 + 枚举字典
服务端分页
关键字搜索
模态框复用
隐藏域控制流
生产构建
MiniCssExtract 抽离
CssMinimizer 压缩
Terser 默认压缩
contenthash 缓存
SourceMap
dev cheap-module
prod source-map
Sentry 上传删除
多环境
DefinePlugin 编译期替换
JSON.stringify 必带
SERVICE_URL 注入
跨域
DevServer proxy
changeOrigin 改 Host
CORS
Nginx 反向代理
部署
try_files SPA fallback
强缓存 immutable
gzip
nginx -t / -s reload

【代码注释】这张思维导图把「完整功能与部署」拆成 CRUD 闭环、生产构建、SourceMap、多环境、跨域、部署六大主题。它回答一个问题:如何把开发完的项目安全、高效地送上线 。核心思想------配置驱动(枚举字典、环境变量)、性能优化(分页、缓存、压缩)、工程闭环(构建 → 监控 → 部署)。市面应用:面试中「你如何把一个前端项目部署上线」这类题,可按此图从构建优化讲到 Nginx 配置,体现全链路工程能力。

高频面试题速查

  1. 服务端分页 vs 客户端分页? 数据量、首屏速度、翻页流畅度。
  2. POST/PUT/PATCH 区别? 新增、整体替换、部分更新。
  3. 隐藏域控制 mode 的设计? 一表两用、提交按 id 分流。
  4. DefinePlugin 为何要 JSON.stringify? 替换的是字面量而非变量。
  5. MiniCssExtractPlugin 解决什么? 抽离 CSS、避免 FOUC、并行加载。
  6. SourceMap 选型? dev cheap-module、prod source-map 上传监控。
  7. changeOrigin 作用? 转发时改 Host,匹配虚拟主机后端。
  8. Nginx try_files 用途? SPA fallback,未匹配路径回 index.html。
  9. 生产跨域怎么解决? Nginx 反代 / 后端 CORS。
  10. contenthash 配合强缓存原理? 内容变 hash 变文件名变,URL 不命中缓存。
  11. nginx -t 与 -s reload 区别? 校验配置 vs 平滑重启。
  12. Tree Shaking 在 prod 才生效原因? dev 不压缩不删 dead code,prod mode 自动启用。

学习建议

  1. 真正上线一次:买台轻量云服务器,把项目从 git push 到 Nginx reload 全流程走一遍。
  2. 接 Sentry:接入错误监控,触发一个故意 bug,验证 SourceMap 反映射。
  3. 优化首屏 :用 webpack-bundle-analyzer 看 bundle 体积,引入 splitChunks 与路由懒加载。
  4. 学容器化:把项目 Dockerfile 化,配合 Nginx 镜像部署。
  5. 学 CDN:把静态资源推到 CDN,理解「源站 + 边缘节点」模型。
  6. 现代化迁移:用 Vue 3 + Vite + Pinia 重写,体会现代工具链的速度提升。