前端工程化项目功能实现:MVC 架构与组件化实战

前端工程化项目功能实现:MVC 架构与组件化实战

导读:搭好工程骨架只是起点,把它演进成「十人协作三年不崩」的可维护系统,靠的是架构设计。本文以「广告后台管理系统」为载体,系统讲解前端功能层的架构演进——路径别名消除相对路径地狱、嵌套路由复用页面外壳、视图组件化的函数组合思想、MVC 分层的关注点分离、「路由即数据」的数据驱动 UI、单向数据流下的导航高亮与动态标题、Axios 拦截器对横切关注点(跨域、鉴权、错误)的统一收口。每一项都从「解决什么问题、底层如何运作、对应什么业务场景」三维展开,结合 Express、Axios、MDN 等权威设计理念,适合希望建立「功能层架构思维」的中高级前端工程师。

目录


一、路径别名与模块解析

名词解释:

  • resolve.extensions:webpack 解析 import 路径时按顺序尝试拼接的扩展名。
  • resolve.alias:把一段「别名」映射到绝对路径,消除深层相对路径。

概念与底层原理:

import x from '../../../components/Header' 这种「六个点」的路径是工程恶疾——它把文件引用与「文件所在的物理位置」死死绑定,一旦移动文件,所有引用全部失效。两项 webpack 配置可以根治:

const path = require('path');

module.exports = {
  resolve: {
    extensions: ['.js', '.json', '.ejs'],   // 可省略的扩展名(按序尝试)
    alias: {
      '@': path.resolve(__dirname, '../src') // @ 指向 src 根
    }
  }
};

【代码注释】extensions 是有序数组——import '@/views/admin' 时 webpack 先试 admin.js、再 admin.json、最后 admin.ejs,把高频扩展名放前面可减少 fs 查找次数。alias@ 是社区约定的「src 根目录别名」,让全工程任何位置都能写 @/components/Header 而非数不清的 ../这背后是模块解析(Module Resolution)的工程优化——webpack 在构建依赖图时,要把每个 import 字符串解析成磁盘上的真实文件,别名和扩展名省略就是在这一步介入。市面应用:Vue CLI、Create React App、Nuxt 默认都配 @ 别名;配合编辑器的 jsconfig.json 还能获得 @/xxx 的自动补全与跳转。

要让编辑器也认 @,需配 jsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@/*": ["src/*"] }
  },
  "include": ["src/**/*"]
}

【代码注释】webpack 的 alias 在构建时生效,jsconfig.json 的 paths 让 VS Code 在编辑时获得智能提示与跳转——两者要同步配置,否则会出现「编辑器报红但能跑」或「能补全但跑不通」的割裂。TypeScript 项目改用 tsconfig.json市面应用:所有中型以上前端项目都同时配 webpack alias + jsconfig/tsconfig paths。

【实战要点】

  • 经典应用场景:超过三层目录嵌套的项目;追求「文件可任意移动而引用不断」的健壮性。
  • 常见坑:只配 webpack 不配 jsconfig → 编辑器红波浪线;只配 jsconfig 不配 webpack → 跑不通。
  • 性能与最佳实践extensions 不宜过长,每多一项未命中都会增加文件系统查找开销。

【本章小结】

配置时机作用
resolve.extensions构建省略后缀
resolve.alias构建路径别名
jsconfig.json编辑编辑器识别别名

记忆口诀:「ext 省后缀,alias 缩路径,jsconfig 通编辑器」。

【面试考点】

Q1:resolve.alias 与 TypeScript 的 paths 有什么关系?
A:两者在不同阶段生效但需保持一致。webpack alias构建时把别名改写成真实路径继续解析;TS paths(或 jsconfig)在类型检查/编辑器跳转时生效。一个工程若同时被 webpack 打包、TS 检查、IDE 跳转,则三处都要配相同别名才能完整工作。

入门示例 · resolve.alias 路径解析演示

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>路径别名</title>
<style>
body{font-family:monospace;padding:20px;background:#1e1e1e;color:#d4d4d4}
.btn{padding:7px 14px;border:none;border-radius:4px;cursor:pointer;background:#388bfd;color:#fff;margin:4px;font-size:12px}
.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:12px;margin:0;line-height:1.7;white-space:pre-wrap}
.bad{color:#f85149}.good{color:#3fb950}.comment{color:#6e7681}
</style></head>
<body>
<h2>resolve.alias:路径地狱 → 可读路径</h2>
<div class="row">
<div class="box">
  <h4 class="bad">❌ 无别名:相对路径地狱</h4>
  <pre><span class="bad">// src/views/dashboard/widgets/Chart.js
import Header from '../../../components/Header';
import { formatDate } from '../../../utils/date';
import { getAdminList } from '../../../api/admin';
// 三层 ../  ← 移动文件就全部失效</span></pre>
</div>
<div class="box">
  <h4 class="good">✅ 有别名:@ 指向 src 根</h4>
  <pre><span class="good">// src/views/dashboard/widgets/Chart.js
import Header from '@/components/Header';
import { formatDate } from '@/utils/date';
import { getAdminList } from '@/api/admin';
// 任意深度都一样短,移动文件无需改引用</span></pre>
</div>
</div>
<div style="background:#0d1117;padding:12px;border-radius:6px;font-size:12px;margin:8px 0">
  <b style="color:#79c0ff">webpack.base.js 配置:</b>
  <pre style="margin:4px 0;color:#e6edf3">resolve: {
  alias: { '@': path.resolve(__dirname, '../src') },
  extensions: ['.js', '.json', '.ejs']  <span class="comment">// 省略后缀</span>
}</pre>
  <b style="color:#79c0ff">jsconfig.json(同步给编辑器):</b>
  <pre style="margin:4px 0;color:#e6edf3">{ "compilerOptions": { "paths": { "@/*": ["src/*"] } } }</pre>
  <span class="comment">// 两处同步配置:webpack 管构建,jsconfig 管编辑器补全与跳转</span>
</div>
</body></html>

【代码注释】resolve.alias 的核心机制:webpack 在解析 import 字符串时,先检查是否命中别名前缀(@src/),命中就替换成绝对路径再继续查找文件。别名只在构建时生效,不影响运行时——这就是为什么还需要 jsconfig.json:让 VS Code 的 TypeScript 语言服务在编辑时识别 @/xxx 并提供跳转、补全。两处不同步就会出现「代码能跑、编辑器报红」的经典问题。

实战示例 · extensions 扩展名查找顺序演示

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>extensions 演示</title>
<style>
body{font-family:monospace;padding:20px;background:#0d1117;color:#c9d1d9}
.btn{padding:7px 14px;border:none;border-radius:4px;cursor:pointer;background:#238636;color:#fff;margin:4px;font-size:12px}
#log{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:12px;margin:10px 0;font-size:12px;min-height:60px;line-height:1.8}
.try{color:#8b949e}.hit{color:#3fb950;font-weight:bold}.miss{color:#f85149}
input{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:6px;border-radius:4px;font-size:13px;width:200px}
</style></head>
<body>
<h2>extensions 扩展名解析顺序</h2>
<p style="font-size:13px;color:#8b949e">配置:<code>extensions: ['.js', '.json', '.ejs']</code></p>
<label>import 路径:<input id="path" value="@/views/admin" /></label>
<button class="btn" onclick="resolve()">模拟 webpack 解析</button>
<div id="log">输入路径后点击解析…</div>
<script>
const files = ['@/views/admin.js','@/views/admin.json','@/views/admin/index.js'];
function resolve(){
  const p = document.getElementById('path').value.trim();
  const exts = ['.js','.json','.ejs'];
  let html = `<b>解析 "${p}"</b>
`;
  let found = false;
  for(const ext of exts){
    const full = p + ext;
    const exists = files.includes(full);
    html += `<span class="${exists?'hit':'try'}">${exists?'✅':' '}尝试 ${full}${exists?' ← 命中!':''}</span>
`;
    if(exists){ found=true; break; }
  }
  if(!found){
    html += `<span class="try">  尝试 ${p}/index.js</span>
`;
    if(files.includes(p+'/index.js')) html += `<span class="hit">✅ 命中 ${p}/index.js</span>
`;
    else html += `<span class="miss">❌ Module not found: ${p}</span>
`;
  }
  html += `
<span style="color:#8b949e">高频扩展名放前面 → 减少 fs.stat 查找次数 → 构建更快</span>`;
  document.getElementById('log').innerHTML = html;
}
resolve();
</script>
</body></html>

【代码注释】extensions 数组是有序的——webpack 按顺序尝试拼接后缀并查询文件系统。数组越长、高频扩展名放后面,每次 import 解析就会多几次 fs.stat 系统调用,在模块多的项目里会明显拖慢构建速度。最佳实践:把项目最常用的后缀(通常是 .js.ts)放第一位;不要把所有后缀都加进去,只加项目实际用到的。


二、嵌套路由与页面布局复用

名词解释:

  • 嵌套路由(Nested Route):父路由作为「外壳布局(Layout)」,子路由内容渲染在父路由的占位区域。
  • Layout(布局):页面中跨路由不变的部分(顶导、侧边栏、页脚)。

概念与底层原理:

后台管理系统有个共性:页面 80% 区域(Header、Sidebar、Footer)跨路由不变,只有 20%(Content)随路由变化。如果每个子路由都重新渲染整个页面,既浪费性能、又会丢失外壳的状态(如侧边栏展开状态、滚动位置)。

嵌套路由的解法是把不变的外壳提到「父路由」,子路由只负责变化的内容:

在这里插入图片描述

【代码注释】这张图揭示了嵌套路由的核心机制:父路由 /indexnext(layout) 渲染外壳并留出占位 res.subRoute(),子路由 /index/admin/index/advres.render(content) 把内容填入占位。nextrender 的区别是关键——render 是「终止渲染、直接写入」,next 是「我先渲染外壳,子路由内容稍后填进来」。市面应用:Vue Router 的 <router-view>、React Router 的 <Outlet> 正是这个「占位插槽」的框架级实现。

import SMERouter from 'sme-router';
import indexV from '@/views/index';
import adminV from '@/views/admin';

const router = new SMERouter('app', 'html5');

// 父路由:渲染外壳,子路由内容占位
router.route('/index', (req, res, next) => {
  next(indexV({ subRouteContent: res.subRoute() }));
});

// 子路由:只负责 Content
router.route('/index/admin', (req, res) => res.render(adminV()));
router.route('/index/adv',   (req, res) => res.render(advV()));

【代码注释】父路由 /index 没有 res.render,而是 next(indexV({ subRouteContent: res.subRoute() }))——indexV 模板里有 <%= data.subRouteContent %> 占位,被替换为子路由的 HTML。理解「父渲外壳、子填内容」的协作,是看懂所有前端框架嵌套路由的钥匙市面应用:所有后台管理系统、文档站、控制台类应用都采用这种 Layout + 子路由结构。

【实战要点】

  • 经典应用场景:后台系统、文档站、控制台;登录页这种「独立全屏」布局不走嵌套。
  • 常见坑:父路由误用 render 而非 next,子路由内容显示不出来。
  • 性能与最佳实践:外壳渲染应不依赖子路由数据,避免每次切子路由都重渲染外壳。

【本章小结】

角色行为
父路由next(layout) 渲外壳
子路由res.render(content) 填内容
插槽<%= data.subRouteContent %>

记忆口诀:「Next 渲外壳,subRoute 留位,子路由填坑」。

【面试考点】

Q1:嵌套路由解决了什么问题?
A:管理系统页面大部分区域(Header/Sidebar/Footer)跨路由不变,只有内容区变化。嵌套路由把不变部分提到父路由外壳,子路由只渲染变化的内容区——避免重复渲染、保持外壳状态(滚动位置、展开态)、降低 DOM 重建开销。Vue/React Router 都内置支持。

入门示例 · 嵌套路由外壳复用演示

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>嵌套路由</title>
<style>
body{font-family:sans-serif;padding:0;margin:0;background:#1e1e1e;color:#d4d4d4}
.layout{display:flex;height:100vh}
.sidebar{width:160px;background:#252526;border-right:1px solid #3c3c3c;padding:12px}
.sidebar h3{color:#4fc1ff;font-size:13px;margin:0 0 12px}
.nav-item{padding:8px;cursor:pointer;border-radius:4px;font-size:13px;margin:2px 0}
.nav-item:hover{background:#3c3c3c}.nav-item.active{background:#094771;color:#4fc1ff}
.main{flex:1;display:flex;flex-direction:column}
.header{background:#323233;padding:10px 16px;font-size:13px;color:#888;border-bottom:1px solid #3c3c3c}
.content{flex:1;padding:20px;overflow:auto}
.page{background:#252526;border-radius:6px;padding:16px;min-height:120px}
.badge{background:#094771;color:#4fc1ff;padding:2px 8px;border-radius:10px;font-size:11px}
</style></head>
<body>
<div class="layout">
  <div class="sidebar">
    <h3>后台管理</h3>
    <div class="nav-item active" onclick="navigate('admin')">👥 管理员</div>
    <div class="nav-item" onclick="navigate('adv')">📢 广告</div>
    <div class="nav-item" onclick="navigate('stats')">📊 统计</div>
  </div>
  <div class="main">
    <div class="header" id="breadcrumb">首页 / 管理员列表</div>
    <div class="content">
      <div class="page" id="pageContent">
        <h3>管理员列表</h3>
        <p>当前页面内容区(父路由外壳——Header+Sidebar——不重建)</p>
        <p style="color:#888;font-size:13px">路由切换时只有这个区域替换,Sidebar 和 Header 保持不变</p>
      </div>
    </div>
  </div>
</div>
<script>
const pages = {
  admin:{title:'管理员列表',bc:'首页 / 管理员',content:'<h3>管理员列表</h3><p>这里显示管理员表格</p>'},
  adv:{title:'广告管理',bc:'首页 / 广告管理',content:'<h3>广告列表</h3><p>这里显示广告表格 + 图片</p>'},
  stats:{title:'数据统计',bc:'首页 / 数据统计',content:'<h3>统计图表</h3><p>这里显示 ECharts 图表</p>'},
};
function navigate(key){
  document.querySelectorAll('.nav-item').forEach(el=>el.classList.remove('active'));
  event.target.classList.add('active');
  document.getElementById('breadcrumb').textContent = pages[key].bc;
  document.getElementById('pageContent').innerHTML = pages[key].content +
    '<p style="color:#888;font-size:12px;margin-top:12px">↑ 只有此处替换,Sidebar/Header 无重建</p>';
}
</script>
</body></html>

【代码注释】点击左侧菜单,只有右侧内容区变化,Header 和 Sidebar 保持不变——这正是嵌套路由的价值。无嵌套的对比:如果每个路由都是完整页面,切换时 Header+Sidebar 会销毁重建,不仅浪费性能,还会丢失 Sidebar 的展开状态。真实项目中:父路由的组件就是这个带 <router-view> 的外壳,子路由只渲染到 <router-view> 插槽里——父组件的生命周期不重新触发,onMounted 里的一次性初始化代码不会重复执行。

实战示例 · 嵌套路由配置结构

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>路由配置</title>
<style>
body{font-family:monospace;padding:20px;background:#0d1117;color:#c9d1d9}
.tree{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;font-size:13px;line-height:2}
.route{color:#79c0ff}.comp{color:#a5d6ff}.path{color:#ffa657}.meta{color:#7ee787}
.indent1{padding-left:20px}.indent2{padding-left:40px}.indent3{padding-left:60px}
pre{background:#0d1117;padding:12px;border-radius:6px;font-size:12px;margin:12px 0;color:#e6edf3}
</style></head>
<body>
<h2>嵌套路由配置结构</h2>
<div class="tree">
  <div><span class="route">/ </span> <span class="comp">→ App.vue</span> <span class="meta">(根,router-view)</span></div>
  <div class="indent1"><span class="path">/layout</span> <span class="comp">→ Layout.vue</span> <span class="meta">(外壳:Header+Sidebar+router-view)</span></div>
  <div class="indent2"><span class="path">/admin</span> <span class="comp">→ AdminView.vue</span> <span class="meta">(内容区)</span></div>
  <div class="indent2"><span class="path">/adv</span> <span class="comp">→ AdvView.vue</span> <span class="meta">(内容区)</span></div>
  <div class="indent2"><span class="path">/stats</span> <span class="comp">→ StatsView.vue</span> <span class="meta">(内容区)</span></div>
  <div class="indent1"><span class="path">/login</span> <span class="comp">→ LoginView.vue</span> <span class="meta">(独立页,不走 Layout)</span></div>
</div>
<pre>// router.js
const routes = [
  {
    path: '/layout',
    component: Layout,   // ← 外壳(含 Sidebar/Header)
    children: [          // ← 子路由渲染到 Layout 的 &lt;router-view&gt;
      { path: '/admin', component: AdminView, meta: { title: '管理员', isNav: true } },
      { path: '/adv',   component: AdvView,   meta: { title: '广告',   isNav: true } },
      { path: '/stats', component: StatsView, meta: { title: '统计',   isNav: true } },
    ]
  },
  { path: '/login', component: Login },   // ← 无 Layout 外壳
];
// 切换 /admin → /adv:Layout 不重建,只有内容区替换</pre>
</body></html>

【代码注释】嵌套路由配置的核心是 children 数组——子路由会渲染到父路由组件里的 <router-view> 插槽。/login 不在 /layout 的 children 里,因为登录页不需要 Sidebar/Header 外壳,它是独立的全屏页面。meta 字段在这里先出现——isNav: true 后续用来自动生成导航菜单(哪些路由显示在 Sidebar),这是「路由即数据」的前置铺垫。


三、视图组件化的函数组合思想

名词解释:

  • 组件化(Componentization):把视图拆成可复用的片段,主视图通过组合片段构建。
  • 函数组合(Function Composition):用小函数组合出复杂行为的编程范式。

概念与底层原理:

嵌套路由把内容与外壳分开了,但外壳自己还是个「巨石」——Header、Sidebar、Footer 全挤在一个文件里。组件化把它进一步拆碎,每个组件是一个「返回 HTML 字符串的函数」:

在这里插入图片描述

【代码注释】组件化的本质是函数组合——每个 Component() 是函数,调用得到一段 HTML 字符串,主视图把这些字符串作为数据聚合。indexV 模板里 <%= data.Header %><%= data.Sidebar %> 等占位被各组件的渲染结果替换。这正是 React「组件 = 纯函数 props → UI」思想的雏形——视图被拆成可组合的纯函数单元。市面应用:所有现代框架(Vue / React / Svelte / Solid)都以组件化作为核心范式,差别只在「是否带响应式状态」。

import HeaderComponent from '@/components/Header';
import SidebarComponent from '@/components/Sidebar';
import FooterComponent from '@/components/Footer';
import ContentComponent from '@/components/Content';
import indexV from '@/views/index';

export default (req, res, next) => {
  next(indexV({
    Header: HeaderComponent(),
    Sidebar: SidebarComponent(),
    Footer: FooterComponent(),
    Content: ContentComponent({ subRouteContent: res.subRoute() })
  }));
};

【代码注释】主视图 indexV 不知道 Header 长什么样,只知道「这里要插入 Header 的渲染结果」——这与 React 的 {props.children}、Vue 的 <slot> 是同一思路:父组件接收子组件作为占位,关注点彻底分离ContentComponent 内部还要再嵌套子路由占位,但那是 Content 自己的职责,外壳不必关心。市面应用:这种「视图聚合层只负责组合子组件」的写法,是所有大型前端项目的默认实践。

【实战要点】

  • 经典应用场景:管理后台、文档站、CMS 系统都依赖 Layout + 子组件结构。
  • 常见坑:过度拆分——拆到「能复用的颗粒」即可,不要为拆而拆。
  • 性能与最佳实践:组件接收的 data 约定为不可变结构,便于后续迁移到响应式框架。

【本章小结】

组件职责
Header顶导栏
Sidebar侧边菜单
Footer页脚
Content内容区 + 子路由占位
index 视图聚合以上组件

记忆口诀:「组件即函数,视图是聚合」。

【面试考点】

Q1:组件化与模板的关系?
A:模板是「带占位符的 HTML 字符串」;组件是「带行为的可复用模板单元」。EJS 阶段每个组件还是无状态模板;进入 Vue/React 后组件不仅有视图,还有 state、lifecycle、事件。可以理解为:组件 = 模板 + 行为 + 状态 + 复用单元

入门示例 · EJS 模板函数组合演示

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>组件化演示</title>
<style>
body{font-family:monospace;padding:20px;background:#1e1e1e;color:#d4d4d4}
.btn{padding:7px 14px;border:none;border-radius:4px;cursor:pointer;background:#238636;color:#fff;margin:4px;font-size:12px}
.output{background:#252526;border:1px solid #3c3c3c;border-radius:6px;padding:12px;margin:10px 0}
.comp{border-left:3px solid #4fc1ff;padding-left:10px;margin:6px 0}
pre{font-size:11px;margin:0;color:#dcdcaa}
</style></head>
<body>
<h2>EJS 风格组件函数组合</h2>
<button class="btn" onclick="render()">渲染页面</button>
<div id="output" class="output"><p style="color:#888">点击渲染查看组件树…</p></div>
<script>
const admins = [
  {username:'alice',role:'超级管理员',createTime:'2024-01-15'},
  {username:'bob',role:'普通管理员',createTime:'2024-03-20'},
];

// 子组件:AdminRow
const AdminRow = (admin) => `
  <tr>
    <td>${admin.username}</td>
    <td><span style="background:#094771;color:#4fc1ff;padding:2px 6px;border-radius:3px;font-size:12px">${admin.role}</span></td>
    <td>${admin.createTime}</td>
    <td>
      <button style="padding:2px 8px;background:#c72e2e;color:#fff;border:none;border-radius:3px;cursor:pointer">删除</button>
      <button style="padding:2px 8px;background:#1c7c3a;color:#fff;border:none;border-radius:3px;cursor:pointer;margin-left:4px">修改</button>
    </td>
  </tr>`;

// 父组件:AdminTable(组合 AdminRow)
const AdminTable = ({adminList}) => `
  <div>
    <h3 style="margin:0 0 10px;color:#4fc1ff">管理员列表</h3>
    <table style="width:100%;border-collapse:collapse;font-size:13px">
      <thead><tr style="background:#3c3c3c">
        <th style="padding:6px;text-align:left">用户名</th>
        <th style="padding:6px;text-align:left">角色</th>
        <th style="padding:6px;text-align:left">创建时间</th>
        <th style="padding:6px;text-align:left">操作</th>
      </tr></thead>
      <tbody>${adminList.map(AdminRow).join('')}</tbody>
    </table>
  </div>`;

function render(){
  document.getElementById('output').innerHTML = AdminTable({ adminList: admins });
}
</script>
</body></html>

【代码注释】AdminTable 调用 AdminRow 的方式体现了函数组合思想:adminList.map(AdminRow) 把每个数据项送入子组件函数,得到 HTML 字符串数组,再 join('') 组合成完整 tbody。这与 React 的 JSX 组件树、Vue 的 SFC 在本质上是一样的模式——组件是数据到视图的纯函数映射Component(data) → HTMLString。EJS 阶段用字符串拼接,Vue 阶段换成虚拟 DOM,但「数据驱动视图」的核心思想不变。

实战示例 · 组件复用:同一组件多处渲染

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>组件复用</title>
<style>
body{font-family:sans-serif;padding:20px;background:#0d1117;color:#c9d1d9}
.card-grid{display:flex;flex-wrap:wrap;gap:12px;margin:12px 0}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;width:160px}
.avatar{width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px;margin-bottom:8px}
.name{font-weight:bold;font-size:14px}.role{font-size:12px;color:#8b949e;margin-top:3px}
.status{font-size:11px;padding:2px 6px;border-radius:8px;margin-top:6px;display:inline-block}
.online{background:#1c4a1c;color:#3fb950}.offline{background:#3a1c1c;color:#f85149}
pre{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px;font-size:11px;color:#e6edf3}
</style></head>
<body>
<h2>AdminCard 组件:同一函数,不同数据</h2>
<div class="card-grid" id="cards"></div>
<pre id="code"></pre>
<script>
const admins = [
  {name:'Alice',role:'超级管理员',status:'online',color:'#4fc1ff',emoji:'👑'},
  {name:'Bob',role:'普通管理员',status:'offline',color:'#dcdcaa',emoji:'👤'},
  {name:'Carol',role:'编辑',status:'online',color:'#4ec9b0',emoji:'✏️'},
];

// 可复用的 AdminCard 组件函数
const AdminCard = ({name, role, status, color, emoji}) => `
  <div class="card">
    <div class="avatar" style="background:${color}22">${emoji}</div>
    <div class="name">${name}</div>
    <div class="role">${role}</div>
    <span class="status ${status}">${status==='online'?'● 在线':'○ 离线'}</span>
  </div>`;

document.getElementById('cards').innerHTML = admins.map(AdminCard).join('');

document.getElementById('code').textContent =
`// 同一个 AdminCard 函数,传不同数据,渲染 3 个不同卡片
admins.map(AdminCard).join('')

// 复用优势:
// 1. 只在一处维护卡片样式
// 2. 数据驱动 → 增删管理员只改数组,不改视图代码
// 3. 与 React/Vue 组件思想完全一致(都是 data → view 纯映射)`;
</script>
</body></html>

【代码注释】admins.map(AdminCard) 是函数式组件的精髓:组件是纯函数,给定相同数据返回相同视图,无副作用。后续进化到 React/Vue 时,AdminCard 变成一个真正的组件(有 props、state、lifecycle),但「数据 → 视图」的映射关系本质不变。团队协作价值:Alice 维护 AdminCard 样式,Bob 只管传入正确的数据对象——关注点分离,互不干扰。


四、MVC 分层与控制器抽离

名词解释:

  • MVC:Model(数据/接口)、View(视图模板)、Controller(控制器)的分层架构。
  • Controller(控制器):处理「路由匹配后做什么」的逻辑——取数据、调接口、渲染视图、绑事件。

概念与底层原理:

app.js 越写越胖,最终会变成堆满所有路由 handler 与事件绑定的「上帝文件」。MVC 的「C」就是来分流的——每个路由对应一个独立的 controller 文件:

在这里插入图片描述

【代码注释】控制器是「业务汇聚点」——它知道这个页面需要哪些数据(Model)、渲染哪个视图(View)、绑定哪些事件。把这些聚到 controller 文件,让 app.js 退化为「路由表 ↔ 控制器」的薄薄一层。这与 Express 后端的 routes/ + controllers/ + services/ 三层分层同源——前端 MVC 借鉴了成熟的后端架构思想。市面应用:所有 jQuery 时代的中大型项目都按这种 MVC 风格组织代码。

// controllers/admin.js —— 一个职责单一的控制器
import adminV from '@/views/admin';

export default (req, res) => {
  res.render(adminV());
};

【代码注释】每个 controller 极简——只「拿模板 + 渲染」。后续会逐步在这里加表单校验、接口调用、列表分页等业务行为,但 app.js 永远只负责路由表。这种「一个路由一个 controller 文件」的物理隔离,是 MVC 的核心收益——改某个页面的逻辑只动它自己的 controller,不波及其他页面。市面应用:Express 后端的 controllers/ 目录、NestJS 的 Controller 类都是同一组织方式。

// app.js —— 瘦身为「路由表」
import SMERouter from 'sme-router';
import indexC from '@/controllers/index';
import adminC from '@/controllers/admin';

const router = new SMERouter('app', 'html5');
router.route('/index', indexC);
router.route('/index/admin', adminC);
router.route('*', (req, res) => res.redirect('/index/admin'));

【代码注释】app.js 从「上帝文件」退化成「路由器」——清爽得让人感动。新增页面只需「写 view + 写 controller + 在 app.js 加一行」,互不干扰。这就是关注点分离的威力:每一层只做该层的事,改一处不波及全局。市面应用:Vue/React 也是这个流程——加页面 = 写组件 + 加一条路由配置。

【实战要点】

  • 经典应用场景:当 app.js 超过 100 行就该考虑拆 controller。
  • 常见坑:把多个 controller 写在一个文件里——物理隔离是 MVC 的核心收益。
  • 性能与最佳实践:相关的 controller/view/style 放同名目录(feature folder),比按类型分目录更直观。

【本章小结】

文件
Controllercontrollers/admin.js
Viewviews/admin.ejs
Modelapi/admin.js

记忆口诀:「路由表是路标,Controller 是司机」。

【面试考点】

Q1:MVC 与 MVVM 区别?
A:MVC 中 View 与 Model 通过 Controller 显式连接——Controller 调 Model 拿数据、手动调 View 更新;MVVM 多了 ViewModel 层做双向绑定,View 和 Model 通过 ViewModel 自动同步,开发者不再写 DOM 更新代码。Vue/Angular 是 MVVM 代表;本项目用 EJS + jQuery 是 MVC。MVVM 大幅减少模板与数据同步的样板代码,MVC 流程更可控易调试。

入门示例 · MVC 三层职责分工演示

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>MVC 演示</title>
<style>
body{font-family:monospace;padding:20px;background:#1e1e1e;color:#d4d4d4}
.layer{background:#252526;border:1px solid #3c3c3c;border-radius:6px;padding:12px;margin:8px 0}
.layer h4{margin:0 0 6px;font-size:13px}
.M{border-left:3px solid #4fc1ff}.V{border-left:3px solid #dcdcaa}.C{border-left:3px solid #4ec9b0}
.btn{padding:7px 14px;border:none;border-radius:4px;cursor:pointer;background:#388bfd;color:#fff;margin:4px;font-size:12px}
pre{font-size:11px;margin:4px 0;color:#e6edf3}
#result{background:#0d1117;padding:10px;border-radius:6px;margin-top:8px;font-size:12px;min-height:60px}
</style></head>
<body>
<h2>MVC 分层:各层只做自己的事</h2>
<div class="layer M">
  <h4 style="color:#4fc1ff">M — Model 层(api/admin.js):只管数据</h4>
  <pre>export const getAdminList = () => advServer.get('/admin/list');
export const deleteAdmin = (id) => advServer.delete('/admin/' + id);</pre>
</div>
<div class="layer V">
  <h4 style="color:#dcdcaa">V — View 层(components/AdminTable.ejs):只管渲染</h4>
  <pre>const AdminTable = ({adminList}) =&gt;
  `&lt;table&gt;${adminList.map(row =&gt; `&lt;tr&gt;&lt;td&gt;${row.username}&lt;/td&gt;...`).join('')}&lt;/table&gt;`;</pre>
</div>
<div class="layer C">
  <h4 style="color:#4ec9b0">C — Controller 层(controllers/adminCtrl.js):只管流程</h4>
  <pre>const getAdminExec = async () => {
  const { data } = await getAdminList();     // 调 Model
  table.innerHTML = AdminTable({ adminList: data }); // 调 View
};</pre>
</div>
<button class="btn" onclick="simulate()">▶ 模拟 Controller 调用</button>
<div id="result">点击查看 MVC 调用流程…</div>
<script>
function simulate(){
  const steps=[
    '1. 用户点击「刷新列表」按钮',
    '2. Controller.getAdminExec() 被调用',
    '3. → 调用 Model.getAdminList() 发起 HTTP GET /api/admin/list',
    '4. → 等待接口返回 [{username:"alice",...}, {username:"bob",...}]',
    '5. → 把数据传给 View.AdminTable({adminList: data})',
    '6. → View 返回 HTML 字符串,Controller 设置 DOM',
    '7. 完成:UI 更新,Controller 退出',
    '',
    '【职责边界】Model 不知道页面存在;View 不知道数据来源;Controller 粘合两者。',
  ];
  let i=0, html='';
  const iv=setInterval(()=>{
    if(i>=steps.length){ clearInterval(iv); return; }
    html+=steps[i]+'<br>'; i++;
    document.getElementById('result').innerHTML=html;
  },300);
}
</script>
</body></html>

【代码注释】MVC 最重要的约束是单向依赖:Controller 依赖 Model 和 View,但 Model 和 View 互不依赖——Model 不关心数据会被谁渲染,View 不关心数据从哪来。可测试性:这个分层的最大工程价值是可测试——测 Model 只需 mock HTTP,测 View 只需传假数据,测 Controller 把两者 mock 掉就行。如果三层混在一起,测哪个都要启动全部依赖。

实战示例 · MVC vs 面条代码对比

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>MVC vs 面条代码</title>
<style>
body{font-family:monospace;padding:20px;background:#0d1117;color:#c9d1d9}
.tabs{display:flex;gap:4px;margin-bottom:0}
.tab{padding:8px 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{margin:0;font-size:11px;line-height:1.7;white-space:pre-wrap;color:#e6edf3}
.bad{color:#f85149}.good{color:#3fb950}
</style></head>
<body>
<h2>面条代码 vs MVC 分层</h2>
<div class="tabs">
  <div class="tab active" onclick="show('bad')">❌ 面条代码</div>
  <div class="tab" onclick="show('good')">✅ MVC 分层</div>
</div>
<div class="panel"><pre id="code"></pre></div>
<script>
const codes = {
bad:`<span class="bad">// main.js —— 所有逻辑混在一起(1000行后的样子)</span>
document.querySelector('#btnRefresh').addEventListener('click', async () => {
  // ① 直接发请求(API 细节暴露在控制流里)
  const res = await axios.get('http://localhost:8080/api/admin/list', {
    headers: { Authorization: 'Bearer ' + localStorage.getItem('token') }
  });
  // ② 手写 HTML 字符串(视图代码混在业务逻辑里)
  document.querySelector('#table').innerHTML = '<table>' +
    res.data.data.map(row =>
      '<tr><td>' + row.username + '</td><td>' + row.role + '</td></tr>'
    ).join('') + '</table>';
  // ③ 错误处理也写在这里(每个按钮重复一遍)
  if(res.data.code !== 200) alert('请求失败');
});
// 问题:重构 API URL、表格样式、错误处理 → 每处都要改`,
good:`<span class="good">// api/admin.js — 只管 HTTP</span>
export const getAdminList = () => advServer.get('/admin/list');

<span class="good">// components/AdminTable.js — 只管渲染</span>
export const AdminTable = ({adminList}) =>
  \`&lt;table&gt;\${adminList.map(row =>
    \`&lt;tr&gt;&lt;td&gt;\${row.username}&lt;/td&gt;&lt;td&gt;\${row.role}&lt;/td&gt;&lt;/tr&gt;\`
  ).join('')}&lt;/table&gt;\`;

<span class="good">// controllers/adminCtrl.js — 只管流程</span>
export const getAdminExec = async () => {
  const { data } = await getAdminList();       // API 层封装了 URL/header
  table.innerHTML = AdminTable({ adminList: data }); // View 封装了渲染细节
};
// 修改 API URL → 只改 api/admin.js
// 修改表格样式 → 只改 AdminTable.js
// 添加 loading → 只改 Controller`
};
function show(tab){
  document.querySelectorAll('.tab').forEach((t,i)=>t.className='tab'+(['bad','good'][i]===tab?' active':''));
  document.getElementById('code').innerHTML = codes[tab];
}
show('bad');
</script>
</body></html>

【代码注释】「面条代码」的本质问题是高耦合:修改 API URL 要找遍所有 addEventListener 里的硬编码字符串;修改表格样式要在逻辑流程中挑出 HTML 部分。MVC 把这三件事拆到三个文件后,每个文件只有一个变更原因(Single Responsibility Principle)——这是 MVC 从工程角度最核心的价值,而非「三个字母的命名游戏」。


五、路由即数据:配置驱动的架构

概念与底层原理:

app.js 虽然瘦了,但「路由信息」仍散落在代码里——别处想知道「项目有哪些路由」必须全文搜索。把路由整理成一份数据(数组),再遍历注册,会带来质变:同一份数据可以同时驱动「注册路由」「生成菜单」「构建面包屑」「权限校验」多个场景。

// routes/index.js —— 路由即数据
import indexC from '@/controllers/index';
import adminC from '@/controllers/admin';
import advC   from '@/controllers/adv';

export default [
  { path: '/index',       element: indexC },
  { path: '/index/admin', element: adminC, isNav: true, title: '管理员列表', icon: 'fa-user' },
  { path: '/index/adv',   element: advC,   isNav: true, title: '广告列表',   icon: 'fa-audio-description' },
  { path: '*', element: (req, res) => res.redirect('/index/admin') }
];

【代码注释】这份数组是整个项目的「路由地图」——一眼看清有哪些页面、哪些进菜单、各自的标题与图标。element 字段对应控制器函数,其余字段是「路由元信息」。把路由从「分散在代码里的注册调用」收敛成「一份集中的数据」,是配置驱动架构的起点市面应用:Vue Router、React Router、Ant Design Pro 的路由配置都是这种数组结构。

// app.js —— 遍历配置注册
import routes from './routes';
const router = new SMERouter('app', 'html5');
routes.forEach(({ path, element }) => router.route(path, element));

【代码注释】element 字段命名刻意致敬 React Router 的 element 概念。isNavtitleicon 等元信息让「路由」与「菜单」「标题」「权限」解耦——它们都从这一份数据派生。「配置即数据」是工程化的核心思想:单一数据源驱动多个场景,任何修改只需改一处,所有依赖它的地方自动同步。市面应用:Vue Router 的 createRouter({ routes })、React Router 的 createBrowserRouter([...])、Ant Design Pro 的路由配置,都是同一思想的工业化版本。

【实战要点】

  • 经典应用场景:所有中型以上项目;配合权限管理时尤其有价值(动态路由可从后端下发)。
  • 常见坑:通配 * 路由必须放最后,否则会拦截所有路径。
  • 性能与最佳实践:路由数组保持扁平,用字符串路径 /index/admin 表达嵌套关系即可。

【本章小结】

字段驱动
pathURL 路径
element控制器
isNav是否进菜单
title菜单/页面标题
icon菜单图标

记忆口诀:「路由即数据,一源驱动菜单标题权限」。

【面试考点】

Q1:为什么用「数据」描述路由而非「函数式注册」?
A:1)单一数据源——菜单、面包屑、权限共享同一份数据;2)易序列化——可从后端动态下发实现动态路由权限;3)易测试——纯数据可断言;4)自描述——读一份数组就懂整个项目结构。

入门示例 · 路由配置数组驱动菜单生成

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>路由即数据</title>
<style>
body{font-family:sans-serif;padding:20px;background:#1e1e1e;color:#d4d4d4;display:flex;gap:20px}
.sidebar{width:180px;background:#252526;border:1px solid #3c3c3c;border-radius:8px;padding:12px}
.sidebar h3{color:#4fc1ff;font-size:13px;margin:0 0 10px}
.nav-item{padding:8px 10px;cursor:pointer;border-radius:4px;font-size:13px;display:flex;align-items:center;gap:6px;margin:2px 0}
.nav-item:hover{background:#3c3c3c}.nav-item.active{background:#094771;color:#4fc1ff}
.main{flex:1;background:#252526;border:1px solid #3c3c3c;border-radius:8px;padding:16px}
pre{background:#0d1117;padding:10px;border-radius:6px;font-size:11px;color:#e6edf3;line-height:1.7}
.meta{color:#888;font-size:11px}
</style></head>
<body>
<div class="sidebar">
  <h3>菜单(自动生成)</h3>
  <div id="nav"></div>
</div>
<div class="main">
  <h3>路由配置(单一数据源)</h3>
  <pre id="config"></pre>
  <p class="meta" id="info">← 菜单由此数组自动生成,权限控制也在这里</p>
</div>
<script>
const routes = [
  {path:'/admin', title:'管理员', icon:'👥', isNav:true, auth:['super']},
  {path:'/adv',   title:'广告',   icon:'📢', isNav:true, auth:['super','editor']},
  {path:'/stats', title:'统计',   icon:'📊', isNav:true, auth:['super','viewer']},
  {path:'/login', title:'登录',   icon:'🔑', isNav:false, auth:[]},
];
const role = 'editor'; // 当前用户角色

// 数据驱动菜单:过滤 isNav + 权限
const navRoutes = routes.filter(r => r.isNav && r.auth.includes(role));

const nav = document.getElementById('nav');
navRoutes.forEach(r => {
  const el = document.createElement('div');
  el.className = 'nav-item';
  el.textContent = r.icon + ' ' + r.title;
  el.onclick = () => {
    document.querySelectorAll('.nav-item').forEach(e=>e.classList.remove('active'));
    el.classList.add('active');
    document.getElementById('info').textContent = `当前路由:${r.path},角色 ${role} 有权限`;
  };
  nav.appendChild(el);
});

document.getElementById('config').textContent = JSON.stringify(routes, null, 2);
</script>
</body></html>

【代码注释】菜单不是手写 HTML,而是从 routes 数组 filter(r => r.isNav && r.auth.includes(role))map 生成——增加一个路由,菜单自动更新;修改权限,菜单自动过滤。这就是「单一数据源」的工程价值:菜单、面包屑、路由守卫都从同一份 routes 数组读取,没有数据不一致的风险。动态路由的进阶:把这份数组从后端接口返回(按用户角色裁剪),就实现了「后台配置权限,前端动态菜单」。

实战示例 · 路由配置驱动面包屑与页面标题

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>路由元信息</title>
<style>
body{font-family:sans-serif;padding:20px;background:#0d1117;color:#c9d1d9}
.breadcrumb{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px 14px;font-size:13px;margin:12px 0;display:flex;gap:6px;align-items:center}
.sep{color:#8b949e}.current{color:#79c0ff;font-weight:bold}
.btn{padding:6px 12px;border:none;border-radius:4px;cursor:pointer;background:#21262d;color:#c9d1d9;margin:4px;font-size:12px;border:1px solid #30363d}
.btn:hover{background:#30363d}.btn.active{background:#0d419d;color:#fff;border-color:#388bfd}
</style></head>
<body>
<h2>路由元信息驱动面包屑 + 标题</h2>
<div>
  <button class="btn active" onclick="navigate('/layout/admin',['首页','管理员列表'])">管理员</button>
  <button class="btn" onclick="navigate('/layout/adv',['首页','广告管理'])">广告</button>
  <button class="btn" onclick="navigate('/layout/adv/edit',['首页','广告管理','编辑广告'])">编辑广告</button>
  <button class="btn" onclick="navigate('/layout/stats',['首页','数据统计'])">统计</button>
</div>
<div class="breadcrumb" id="bc"></div>
<p id="titleInfo" style="font-size:13px;color:#8b949e"></p>
<pre style="background:#161b22;border:1px solid #30363d;padding:12px;border-radius:6px;font-size:12px;color:#e6edf3">// 路由守卫(路由变化时自动更新标题和面包屑)
router.afterEach((to) => {
  // 1. 更新页面标题
  document.title = to.meta.title + ' - 管理系统';
  // 2. 生成面包屑(遍历 matched 路由链)
  breadcrumb = to.matched.map(r => r.meta.title);
});</pre>
<script>
function navigate(path, bc){
  document.querySelectorAll('.btn').forEach(b=>b.classList.remove('active'));
  event.target.classList.add('active');
  const title = bc[bc.length-1] + ' - 管理系统';
  document.getElementById('titleInfo').textContent = `document.title = "${title}"`;
  const items = bc.map((t,i)=>
    i < bc.length-1
      ? `<span style="color:#79c0ff;cursor:pointer">${t}</span>`
      : `<span class="current">${t}</span>`
  ).join('<span class="sep"> / </span>');
  document.getElementById('bc').innerHTML = items;
}
navigate('/layout/admin',['首页','管理员列表']);
</script>
</body></html>

【代码注释】router.afterEach 是路由变化的统一收口——所有路由切换都经过这里,不需要在每个页面组件里手动 document.title = xxx。面包屑通过 to.matched(当前路由的完整父子链)自动生成,三层嵌套路由 首页 / 广告管理 / 编辑广告 也能正确生成。工程价值:路由 meta 字段把「页面的业务属性」从组件代码中提取出来,集中在路由配置里管理——这就是「配置驱动」让系统更易维护的具体体现。


六、导航交互与单向数据流

名词解释:

  • 编程式导航:通过 JS 调用 API 切换路由,而非点击 <a href>
  • 单向数据流:数据从上层(控制器)流向下层(组件),组件据此决定 UI。

概念与底层原理:

侧边栏「点击切换视图」有两种实现:<a href="/index/admin"> 触发浏览器原生导航(会刷新整页、丢状态);<a onclick="router.go('/index/admin')"> 触发编程式导航(修改 history 不刷新)。SPA 一律用后者。

导航高亮则是「单向数据流」的典型应用——控制器把当前 URL 传给侧边栏组件,组件据此决定哪一项高亮:

// controllers/index.js —— 把 url 透传给 Sidebar
export default ({ url }, res, next) => {
  next(indexV({
    Header: HeaderComponent(),
    Sidebar: SidebarComponent({ url }),     // 数据从控制器流向组件
    Footer: FooterComponent(),
    Content: ContentComponent({ subRouteContent: res.subRoute() })
  }));
};

【代码注释】控制器从 req 解构出当前 url,再把它作为参数透传给 Sidebar 组件——这是「数据自上而下流动」的单向数据流。控制器是「数据组装者」,组件是「数据消费者」。这种分工让组件保持「无状态、纯渲染」,数据来源完全由上层控制,便于测试与复用。市面应用:React 的容器组件(container)/展示组件(presentational)分离模式,本质就是这种「控制器组装数据、组件纯渲染」的思想。

<!-- Sidebar.ejs —— 根据 url 决定高亮 -->
<a href="javascript:;"
   class="nav-link <%= data.url === '/index/admin' ? 'active' : '' %>"
   onclick="router.go('/index/admin')">
  <p>管理员列表</p>
</a>

【代码注释】href="javascript:;" 保留 <a> 的语义(手型光标、可聚焦)但禁止默认导航;onclick="router.go()" 触发编程式导航。<%= data.url === '/index/admin' ? 'active' : '' %> 是 EJS 三元运算符,匹配当前 URL 给菜单项加 active 高亮。「数据从控制器流向组件、组件决定 UI」是单向数据流的核心方向——这与 React 的「props 自上而下」完全一致。市面应用:所有 SPA 菜单的高亮都靠「当前 URL ↔ 菜单项」匹配实现。

【实战要点】

  • 经典应用场景:所有侧边栏、顶导菜单的当前项高亮。
  • 常见坑:编程式导航需要 router 实例可被模板访问(如挂到 window.router),否则 onclick 报错。
  • 性能与最佳实践:长远迁移到 <Link> 组件式 API,避免 inline JS 与全局污染。

【本章小结】

模式写法体验
直链<a href>刷新
编程式onclick router.go不刷新
高亮url 匹配加 active视觉反馈

记忆口诀:「href 真链刷新,router.go 静悄悄」。

【面试考点】

Q1:编程式导航与浏览器原生导航的区别?
A:原生导航发起新 HTTP 请求、销毁当前 JS 上下文、丢失内存状态;编程式导航只修改 history、触发 popstate,JS 上下文保留。SPA 的「丝滑」感全靠后者。

入门示例 · History API pushState 与 popstate 演示

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>History API</title>
<style>
body{font-family:monospace;padding:20px;background:#1e1e1e;color:#d4d4d4}
.btn{padding:7px 14px;border:none;border-radius:4px;cursor:pointer;background:#388bfd;color:#fff;margin:4px;font-size:12px}
.btn-back{background:#c72e2e}
#log{background:#0d1117;padding:12px;border-radius:6px;margin:10px 0;font-size:12px;min-height:60px;line-height:1.8}
#urlBar{background:#252526;border:1px solid #3c3c3c;padding:8px;border-radius:4px;font-size:13px;margin:8px 0;color:#4fc1ff}
.note{color:#888;font-size:12px}
</style></head>
<body>
<h2>pushState vs 传统导航</h2>
<div id="urlBar">当前 URL:/admin</div>
<div>
  <button class="btn" onclick="navTo('/adv','广告管理')">导航到 /adv</button>
  <button class="btn" onclick="navTo('/stats','数据统计')">导航到 /stats</button>
  <button class="btn btn-back" onclick="goBack()">← 后退</button>
</div>
<div id="log"></div>
<p class="note">
  传统 &lt;a href&gt; 导航:发起新 HTTP 请求 + 整页重载 + JS 上下文销毁<br>
  pushState 导航:只修改 URL + 触发 popstate + JS 上下文保留 ✅
</p>
<script>
const history_stack = ['/admin'];
const log = document.getElementById('log');
function append(msg){ log.innerHTML += msg + '<br>'; }

function navTo(path, title){
  append(`<b>pushState('${path}')</b>`);
  append('  ↳ URL 变为 ' + path + '(无网络请求!)');
  append('  ↳ JS 运行时保留:表单值、滚动位置、内存状态全在');
  append('  ↳ 前端路由监听 popstate/hashchange → 渲染对应组件');
  append('');
  history_stack.push(path);
  document.getElementById('urlBar').textContent = '当前 URL:' + path;
}

function goBack(){
  if(history_stack.length <= 1){ append('已是起始页面'); return; }
  history_stack.pop();
  const prev = history_stack[history_stack.length-1];
  append(`<b>history.back()</b> → popstate 事件触发`);
  append('  ↳ 回到:' + prev);
  document.getElementById('urlBar').textContent = '当前 URL:' + prev;
}
</script>
</body></html>

【代码注释】history.pushState(state, title, url) 修改浏览器地址栏 URL 但不发 HTTP 请求——这是 SPA 路由的技术基础。配合 window.addEventListener('popstate', handler) 监听前进/后退事件,前端路由就能完整拦截所有导航行为,按 URL 渲染对应组件,而不触发真正的页面加载。Hash 模式对比location.hash = '#/admin' 不会发 HTTP 请求(hash 不上传服务器),但 URL 里有 #;history 模式 URL 更干净,但需要服务端配 try_files 回退到 index.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}
.nav{display:flex;gap:4px;background:#161b22;padding:8px;border-radius:8px;margin:10px 0;border:1px solid #30363d}
.nav-item{padding:8px 16px;border-radius:4px;cursor:pointer;font-size:13px;transition:all 0.2s}
.nav-item.active{background:#0d419d;color:#fff}
.content{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin:10px 0;min-height:60px}
.flow{font-size:12px;background:#0d1117;padding:10px;border-radius:6px;margin-top:8px;line-height:1.8}
.arrow{color:#3fb950}
</style></div>
<body>
<h2>单向数据流:currentPath → 高亮</h2>
<div class="nav" id="nav"></div>
<div class="content" id="content">导航到某页查看流程…</div>
<script>
const routes = [
  {path:'/admin',title:'管理员',icon:'👥'},
  {path:'/adv',title:'广告',icon:'📢'},
  {path:'/stats',title:'统计',icon:'📊'},
];
let currentPath = '/admin';

function render(){
  // 单向流:state(currentPath) → view(高亮+内容)
  const nav = document.getElementById('nav');
  nav.innerHTML = routes.map(r => `
    <div class="nav-item ${currentPath===r.path?'active':''}"
         onclick="navigate('${r.path}')">
      ${r.icon} ${r.title}
    </div>`).join('');
  document.getElementById('content').innerHTML = `
    <h3>${routes.find(r=>r.path===currentPath)?.title || '未知'}</h3>
    <div class="flow">
      <b>单向数据流:</b><br>
      用户点击 "${currentPath}" <span class="arrow">→</span>
      currentPath = '${currentPath}' <span class="arrow">→</span>
      重新渲染导航(active 样式由 currentPath 决定)<br>
      <br>高亮不靠 DOM 操作(removeActive/addClass),靠状态驱动视图
    </div>`;
}

function navigate(path){
  currentPath = path;  // 只改状态
  render();            // 重新渲染(状态 → 视图)
}
render();
</script>
</body></html>

【代码注释】这个演示的核心是高亮不靠 DOM 操作——没有 querySelector('.active').removeClass('active') 的命令式代码,而是每次导航时 currentPath 更新后完整重新渲染导航栏,currentPath === r.path 的那个菜单项自然得到 active 类。单向数据流的价值:状态是唯一真相(currentPath),视图是状态的函数(view = f(state)),不需要手动同步 DOM 状态——这正是 React/Vue 「响应式」的核心理念,比 jQuery 的 DOM 操作更可预测、更易调试。


七、数据驱动菜单与路由元信息

概念与底层原理:

菜单若硬编码,加一个页面就要改 Sidebar 模板。把菜单信息存进路由配置后,菜单可自动生成——UI 成为数据的投影:

在这里插入图片描述

【代码注释】这张图体现了「数据驱动 UI」的核心理念——菜单是路由数据的投影:路由数据变,菜单自动跟着变。新增页面只需在 routes 加一条 isNav: true,菜单立刻多一项。这就是「配置即 UI」市面应用:所有低代码平台、CMS 后台、admin starter(Ant Design Pro / Element Admin)都用这种「meta-data 驱动菜单」的方式。

<!-- Sidebar.ejs —— filter 出 isNav 的路由渲染菜单 -->
<% data.routes.filter(item => item.isNav).forEach(item => { %>
  <li class="nav-item">
    <a href="javascript:;"
       class="nav-link <%= data.url === item.path ? 'active' : '' %>"
       onclick="router.go('<%= item.path %>')">
      <i class="nav-icon fas <%= item.icon %>"></i>
      <p><%= item.title %></p>
    </a>
  </li>
<% }) %>

【代码注释】data.routes.filter(item => item.isNav).forEach(...) 是数据驱动菜单的核心——只取 isNav: true 的路由渲染成菜单项,item.iconitem.titleitem.path 全部来自路由配置。新增页面只需在 routes 加一条 isNav: true,菜单立刻多一项,模板代码一行不改市面应用:所有可视化低代码工具背后都是这种 schema → UI 的映射。

// controllers/index.js —— 同时驱动菜单与动态标题
import routes from '../routes';
export default ({ url }, res, next) => {
  const currentRoute = routes.find(item => item.path === url) || {};
  next(indexV({
    Sidebar: SidebarComponent({ url, routes }),                 // 驱动菜单
    Content: ContentComponent({
      title: currentRoute.title,                                // 驱动标题
      subRouteContent: res.subRoute()
    })
  }));
};

【代码注释】data.routes.filter(item => item.isNav) 只取 isNav: true 的路由渲染菜单——登录页是路由但不在菜单、/index 是 layout 父路由也不在菜单。同一份 routes 数据既驱动菜单、又驱动页面标题(currentRoute.title)。这是「容器组件」模式——controller 负责数据组装、视图组件只负责渲染。市面应用:Vue Router 的 meta 字段、React Router 的 handle 字段都是这种「路由元信息」机制。

【实战要点】

  • 经典应用场景:所有需要「菜单与路由一致」的后台系统。
  • 常见坑:路由配置改了忘改 sidebar 硬编码——用元信息驱动后这种 bug 消失。
  • 性能与最佳实践:可序列化的元信息(title/icon/isNav)放配置,便于从后端动态下发实现权限菜单。

【本章小结】

字段驱动
isNav是否显示菜单
title菜单文字 + 页面标题
icon菜单图标

记忆口诀:「isNav 筛选,title 双用,icon 装饰」。

【面试考点】

Q1:路由元信息(meta)有什么作用?
A:1)生成菜单——按 isNav/title/icon 自动生成侧边栏;2)权限控制——按 auth/roles 字段决定能否访问;3)面包屑——按父子 path 与 title 自动构建;4)SEO——按 seo 字段在路由变化时更新 title 与 meta。一句话:meta 是「给路由附加业务语义」的载体。

入门示例 · 路由 meta 驱动菜单与权限过滤

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>路由 meta</title>
<style>
body{font-family:sans-serif;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}
.nav-item{padding:7px 10px;cursor:pointer;border-radius:4px;font-size:13px;margin:2px 0;display:flex;align-items:center;gap:6px}
.nav-item:hover{background:#3c3c3c}
.badge{font-size:11px;padding:1px 5px;border-radius:3px;background:#1c4a1c;color:#3fb950;margin-left:auto}
select{background:#3c3c3c;color:#d4d4d4;border:none;padding:5px;border-radius:4px;font-size:12px;margin-bottom:8px}
</style></head>
<body>
<h2>路由 meta:一份配置,多种用途</h2>
<div class="row">
<div class="box">
  <h4>角色:<select id="roleSelect" onchange="renderNav()">
    <option value="super">超级管理员</option>
    <option value="editor">编辑</option>
    <option value="viewer">只读</option>
  </select></h4>
  <div id="nav">(菜单自动生成)</div>
</div>
<div class="box">
  <h4>路由 meta 配置</h4>
  <pre style="font-size:11px;color:#e6edf3;margin:0">const routes = [
  { path:'/admin', meta:{
    title:'管理员', icon:'👥', isNav:true,
    auth:['super'], order:1 } },
  { path:'/adv', meta:{
    title:'广告', icon:'📢', isNav:true,
    auth:['super','editor'], order:2 } },
  { path:'/stats', meta:{
    title:'统计', icon:'📊', isNav:true,
    auth:['super','editor','viewer'], order:3 } },
  { path:'/login', meta:{
    isNav:false, auth:[] } },
];</pre>
</div>
</div>
<script>
const routes = [
  {path:'/admin',meta:{title:'管理员',icon:'👥',isNav:true,auth:['super'],order:1}},
  {path:'/adv',meta:{title:'广告',icon:'📢',isNav:true,auth:['super','editor'],order:2}},
  {path:'/stats',meta:{title:'统计',icon:'📊',isNav:true,auth:['super','editor','viewer'],order:3}},
  {path:'/login',meta:{isNav:false,auth:[]}},
];
function renderNav(){
  const role = document.getElementById('roleSelect').value;
  const navItems = routes
    .filter(r => r.meta.isNav && r.meta.auth.includes(role))
    .sort((a,b)=>a.meta.order-b.meta.order);
  document.getElementById('nav').innerHTML = navItems.map(r=>
    `<div class="nav-item">${r.meta.icon} ${r.meta.title}
     <span class="badge">${r.path}</span></div>`
  ).join('') + `<p style="font-size:12px;color:#888;margin-top:8px">
  ${role} 角色显示 ${navItems.length}/${routes.filter(r=>r.meta.isNav).length} 个菜单项</p>`;
}
renderNav();
</script>
</body></html>

【代码注释】切换角色下拉框,菜单自动变化——同一份 routes 配置,权限、菜单、顺序全部内联在 meta 里,不需要维护单独的「菜单配置文件」和「权限配置文件」,消除了两份数据不同步的风险。meta.order 控制菜单排序,meta.auth 控制谁能看,meta.isNav 控制是否显示在菜单——这些都是业务语义,放在路由 meta 里最合适。

实战示例 · 动态标题 + 路由守卫权限控制

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>路由守卫</title>
<style>
body{font-family:monospace;padding:20px;background:#0d1117;color:#c9d1d9}
.btn{padding:7px 12px;border:none;border-radius:4px;cursor:pointer;background:#388bfd;color:#fff;margin:4px;font-size:12px}
.btn-danger{background:#c72e2e}
#log{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:12px;margin:10px 0;font-size:12px;min-height:80px;line-height:1.8}
.ok{color:#3fb950}.no{color:#f85149}.info{color:#79c0ff}
#status{background:#21262d;padding:8px;border-radius:4px;font-size:12px;margin:8px 0}
</style></head>
<body>
<h2>路由守卫 + 权限 meta 控制访问</h2>
<div id="status">当前用户:未登录 | 角色:-</div>
<div>
  <button class="btn" onclick="login('editor')">登录(编辑角色)</button>
  <button class="btn btn-danger" onclick="logout()">退出登录</button>
</div>
<div>
  <button class="btn" onclick="navigate('/admin')">访问 /admin(需 super)</button>
  <button class="btn" onclick="navigate('/adv')">访问 /adv(需 editor+)</button>
  <button class="btn" onclick="navigate('/login')">访问 /login</button>
</div>
<div id="log"></div>
<script>
const routes = {
  '/admin':{meta:{title:'管理员',auth:['super']}},
  '/adv':{meta:{title:'广告管理',auth:['super','editor']}},
  '/login':{meta:{title:'登录',auth:[]}},
};
let user = null;
const log = document.getElementById('log');
function append(cls,msg){ log.innerHTML += `<span class="${cls}">${msg}</span>
`; }

function login(role){ user={role}; update(); append('ok',`✅ 登录成功,角色:${role}`); }
function logout(){ user=null; update(); append('info','用户退出登录'); }
function update(){ document.getElementById('status').textContent =
  user ? `当前用户:已登录 | 角色:${user.role}` : '当前用户:未登录 | 角色:-'; }

function navigate(path){
  const route = routes[path];
  if(!route){ append('no','❌ 路由不存在'); return; }
  append('info',`→ 导航到 ${path}`);
  if(route.meta.auth.length > 0 && !user){
    append('no','❌ 未登录,重定向 → /login'); return;
  }
  if(route.meta.auth.length > 0 && !route.meta.auth.includes(user?.role)){
    append('no',`❌ 角色 "${user.role}" 无权访问 ${path}(需要:${route.meta.auth.join('/')}`); return;
  }
  append('ok',`✅ 允许访问,页面标题更新为:${route.meta.title} - 管理系统`);
}
</script>
</body></html>

【代码注释】路由守卫(beforeEach)是路由 meta 的典型消费方:每次路由变化时检查 to.meta.auth 决定是否放行。两道检查:① 是否登录(token 是否存在);② 角色是否有权限(meta.auth.includes(userRole))。工程细节:把 auth 放 meta 而非组件里的好处是守卫集中处理——不需要每个页面组件都写权限检查逻辑,删除/修改路由权限只改一处配置。


八、Less 与样式工程

概念与底层原理:

Bootstrap 适配 99% 后台,但总有局部需要微调(如广告表最小宽度)。把这些样式抽到 .less 文件,用变量、嵌套等高级特性紧凑表达,再由 webpack 的 loader 链处理:

{ test: /\.less$/, use: ['style-loader', 'css-loader', 'less-loader'] }

【代码注释】这条 loader 规则匹配所有 .less 文件,loader 链从右到左:less-loader 把 less 编译成 CSS、css-loader 把 CSS 解析成 JS 模块、style-loader 注入 DOM。这是上一篇讲过的「预处理器在最右、注入器在最左」固定顺序的实例市面应用:Vue/React 项目处理 less/scss 的 loader 配置都是这个结构。

@brand-primary: #007bff;        // 变量

#advTable {
  table { min-width: 1200px; }  // 嵌套
}
.adminlist .btn-danger { margin-right: 4px; }

【代码注释】loader 链从右到左:less-loader 编译 less → css-loader 解析 CSS → style-loader 注入 DOM。@brand-primary 变量、#advTable { table {} } 嵌套是原生 CSS 不支持的语法,必须由 less-loader 翻译。入口处 import '@/assets/less/app.less' 是副作用 import,触发 webpack 处理。市面应用:Ant Design 的主题定制就基于 less 变量覆盖;Bootstrap 4 之前也用 less 实现主题切换。

【实战要点】

  • 经典应用场景:项目级全局样式微调、第三方 UI 库的样式补丁层。
  • 常见坑:组件样式全写全局 less 里 → 维护噩梦;优先组件样式就近放。
  • 性能与最佳实践:生产环境把 style-loader 换成 MiniCssExtractPlugin.loader 抽离 CSS。

【本章小结】

Loader作用
less-loaderless → css
css-loadercss → JS 字符串
style-loader注入 DOM

记忆口诀:「Less 三件套,编译解析注入」。

【面试考点】

Q1:less 与 Sass 的区别?
A:1)变量符号——less 用 @,Sass 用 $;2)编译——less 用 JS 引擎,Sass 用 Dart;3)功能——Sass 控制结构(@if/@for)、Map 数据结构更强;4)生态——与 AdminLTE/Bootstrap 4- 配套用 less,与 Bootstrap 4+/Vuetify 配套用 Sass。

入门示例 · Less 核心特性对比演示

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>Less 特性</title>
<style>
body{font-family:monospace;padding:20px;background:#1e1e1e;color:#d4d4d4}
.tabs{display:flex;gap:4px;margin-bottom:0}
.tab{padding:7px 14px;background:#252526;border:1px solid #3c3c3c;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px}
.tab.active{background:#0d1117;border-bottom-color:#0d1117;color:#4fc1ff}
.panel{background:#0d1117;border:1px solid #3c3c3c;border-radius:0 6px 6px 6px;padding:14px}
.row{display:flex;gap:12px}
.box{flex:1}
h5{margin:0 0 6px;font-size:12px;color:#888}
pre{font-size:11px;margin:0;line-height:1.6;white-space:pre-wrap;color:#ce9178}
.compiled{color:#4fc1ff}
</style></head>
<body>
<h2>Less 核心特性</h2>
<div class="tabs">
  <div class="tab active" onclick="show('var')">变量</div>
  <div class="tab" onclick="show('nest')">嵌套</div>
  <div class="tab" onclick="show('mixin')">混入</div>
  <div class="tab" onclick="show('calc')">运算</div>
</div>
<div class="panel"><div class="row" id="panel"></div></div>
<script>
const features = {
var:{less:`// Less 变量 —— @ 前缀
@primary: #1890ff;
@font-base: 14px;
@border-radius: 4px;

.btn {
  color: @primary;
  font-size: @font-base;
  border-radius: @border-radius;
}
.link {
  color: @primary; // 复用变量
}`,css:`/* 编译后 CSS */
.btn {
  color: #1890ff;
  font-size: 14px;
  border-radius: 4px;
}
.link {
  color: #1890ff;
}`},
nest:{less:`// Less 嵌套 —— 映射 HTML 结构
.sidebar {
  width: 200px;
  background: #252526;

  .nav-item {        // 编译成 .sidebar .nav-item
    padding: 8px;
    &:hover {        // & = 父选择器
      background: #3c3c3c;
    }
    &.active {       // 编译成 .sidebar .nav-item.active
      color: #4fc1ff;
    }
  }
}`,css:`/* 编译后 CSS */
.sidebar { width: 200px; background: #252526; }

.sidebar .nav-item { padding: 8px; }

.sidebar .nav-item:hover {
  background: #3c3c3c;
}
.sidebar .nav-item.active {
  color: #4fc1ff;
}`},
mixin:{less:`// Mixin —— 可复用样式片段
.flex-center() {   // () 表示不输出此规则
  display: flex;
  align-items: center;
  justify-content: center;
}
.ellipsis(@lines: 1) {  // 带参数 mixin
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: @lines;
}

.card-title { .flex-center(); }
.description { .ellipsis(2); }`,css:`/* 编译后 CSS */
.card-title {
  display: flex;
  align-items: center;
  justify-content: center;
}
.description {
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
}`},
calc:{less:`// Less 运算 —— 布局计算
@sidebar-width: 200px;
@gap: 16px;
@total: 100%;

.layout {
  width: @total;
  .sidebar { width: @sidebar-width; }
  .content {
    width: calc(@total - @sidebar-width - @gap * 2);
    padding: @gap;
  }
}`,css:`/* 编译后 CSS */
.layout { width: 100%; }
.layout .sidebar { width: 200px; }
.layout .content {
  width: calc(100% - 200px - 32px);
  padding: 16px;
}`}
};
function show(key){
  document.querySelectorAll('.tab').forEach((t,i)=>t.className='tab'+(['var','nest','mixin','calc'][i]===key?' active':''));
  const f = features[key];
  document.getElementById('panel').innerHTML = `
    <div class="box"><h5>Less 源码</h5><pre>${f.less.replace(/</g,'&lt;')}</pre></div>
    <div class="box"><h5 class="compiled">编译后 CSS</h5><pre class="compiled">${f.css.replace(/</g,'&lt;')}</pre></div>`;
}
show('var');
</script>
</body></html>

【代码注释】Less 的四大特性对应四种工程痛点:变量 解决颜色/尺寸的硬编码(改主色只改一处);嵌套 消除 .parent .child 的重复前缀(代码结构与 HTML 结构一致);Mixin 消除重复样式片段(flex-center 一处维护);运算 让布局计算可读。市面现状:AdminLTE、Bootstrap 3/4 用 Less,Bootstrap 5/Vuetify/TailwindCSS 用 Sass——选哪个跟着框架走。

实战示例 · Less 变量统一主题色管理

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>Less 主题</title>
<style>
body{font-family:sans-serif;padding:20px;background:#0d1117;color:#c9d1d9}
.theme-switcher{display:flex;gap:8px;margin:10px 0}
.theme-btn{padding:6px 14px;border:2px solid;border-radius:4px;cursor:pointer;font-size:12px}
.preview{margin:16px 0;padding:16px;border-radius:8px;background:#161b22;border:1px solid #30363d}
.btn-demo{padding:8px 16px;border:none;border-radius:4px;cursor:pointer;font-size:13px;margin:4px}
.badge-demo{padding:3px 8px;border-radius:10px;font-size:12px;margin:4px;display:inline-block}
pre{background:#0d1117;padding:10px;border-radius:6px;font-size:11px;color:#e6edf3;margin:10px 0}
</style></head>
<body>
<h2>Less 变量:一改全改主题色</h2>
<div class="theme-switcher">
  <button class="theme-btn" style="border-color:#1890ff;color:#1890ff" onclick="applyTheme('#1890ff','默认蓝')">默认蓝</button>
  <button class="theme-btn" style="border-color:#52c41a;color:#52c41a" onclick="applyTheme('#52c41a','森林绿')">森林绿</button>
  <button class="theme-btn" style="border-color:#722ed1;color:#722ed1" onclick="applyTheme('#722ed1','神秘紫')">神秘紫</button>
  <button class="theme-btn" style="border-color:#eb2f96;color:#eb2f96" onclick="applyTheme('#eb2f96','玫瑰红')">玫瑰红</button>
</div>
<div class="preview" id="preview"></div>
<pre id="lessCode"></pre>
<script>
function applyTheme(color, name){
  document.getElementById('preview').innerHTML = `
    <p style="color:${color};font-weight:bold;margin:0 0 10px">${name} 主题</p>
    <button class="btn-demo" style="background:${color};color:#fff">主要按钮</button>
    <button class="btn-demo" style="background:${color}22;color:${color};border:1px solid ${color}">次要按钮</button>
    <span class="badge-demo" style="background:${color}22;color:${color}">标签</span>
    <p style="color:${color};font-size:13px;margin-top:10px">链接文字</p>`;
  document.getElementById('lessCode').textContent =
`// variables.less —— 修改这一行,整个系统主题色变化
@primary: ${color};  // ← 只改这里

// 所有依赖 @primary 的地方自动更新:
.btn-primary { background: @primary; }
.tag          { background: fade(@primary, 15%); color: @primary; }
.link         { color: @primary; }
.border-color { border-color: @primary; }`;
}
applyTheme('#1890ff','默认蓝');
</script>
</body></html>

【代码注释】切换主题只需修改 variables.less 中的 @primary,所有依赖这个变量的 .btn.tag.link 等组件都自动更新。如果用原生 CSS 硬编码颜色,需要用全局搜索替换——漏掉一个就出 bug。Less 变量 vs CSS 变量:Less 变量在编译期替换(打包后不存在),CSS 变量(--primary: #1890ff)在运行时有效(可用 JS 动态修改),两者场景不同,不互斥。


九、表单校验与防御式编程

概念与底层原理:

「添加管理员」是后台典型表单。提交前的校验是「防御式编程」的体现——在数据进入系统前拦截非法输入:

const addAdminExec = () => {
  // 通过 form name + input name 取值(HTML 原生 DOM API)
  const adminName = document.adminForm.adminName.value.trim();
  const passWord  = document.adminForm.passWord.value.trim();
  const rePassWord = document.adminForm.rePassWord.value.trim();

  if (adminName.search(/^[a-zA-Z]+$/) === -1) {       // 仅英文字母
    toastr.error('管理员账号只能由英文字母组成!'); return;
  }
  if (passWord.search(/^\w{6,18}$/) === -1) {          // 6-18 位 word 字符
    toastr.error('密码必须由 6 到 18 位数字、字母、下划线组成'); return;
  }
  if (passWord !== rePassWord) {
    toastr.error('两次密码不一致'); return;
  }
  // 通过校验 → 提交
};

【代码注释】document.adminForm.adminName 利用 HTML4 的「form/input name 访问」机制——<form name><input name> 都设了 name 才能这样访问。正则 ^\w{6,18}$\w 等价 [A-Za-z0-9_]{6,18} 限定长度、^$ 锚定首尾确保整串匹配。前端校验是「体验优化」而非「安全保障」——攻击者可绕过前端直接 POST 接口,所以后端必须再校验一次,前后端「双重保险」。市面应用:所有后台添加表单都是这种前端即时校验 + 后端最终校验的模式。

【实战要点】

  • 经典应用场景:注册、登录、密码重置、信息编辑。
  • 常见坑:只前端校验不后端校验——攻击者直接 POST 绕过。
  • 性能与最佳实践:复杂表单用 Formik / Vee-Validate 等声明式校验库,避免手写 if 链。

【本章小结】

校验正则
英文字母/^[a-zA-Z]+$/
6-18 位 word/^\w{6,18}$/
一致a === b

记忆口诀:「前端校验优体验,后端校验保安全」。

【面试考点】

Q1:前端校验能替代后端校验吗?
A:不能。前端校验只是「体验优化」——即时反馈、减少无效请求;但它运行在用户可控的浏览器里,攻击者可用 Postman/脚本直接调接口绕过。真正的数据安全必须由后端校验保证,前端校验是「锦上添花」而非「安全防线」。

入门示例 · 实时表单校验 + 提交拦截

<!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-group{margin:12px 0}
label{display:block;font-size:13px;margin-bottom:4px;color:#888}
input{width:100%;box-sizing:border-box;background:#252526;border:1px solid #3c3c3c;color:#d4d4d4;padding:8px;border-radius:4px;font-size:13px}
input.valid{border-color:#1c7c3a}
input.invalid{border-color:#c72e2e}
.msg{font-size:11px;margin-top:4px;min-height:16px}
.msg.err{color:#f85149}.msg.ok{color:#3fb950}
.btn{width:100%;padding:10px;background:#388bfd;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:14px;margin-top:12px}
.btn:disabled{background:#3c3c3c;cursor:not-allowed;color:#888}
</style></head>
<body>
<h2>用户注册表单</h2>
<form id="form" onsubmit="return false">
  <div class="form-group">
    <label>用户名</label>
    <input id="username" type="text" placeholder="3-16位字母数字下划线" oninput="validateUsername()" />
    <div class="msg" id="usernameMsg"></div>
  </div>
  <div class="form-group">
    <label>密码</label>
    <input id="password" type="password" placeholder="6-20位,含字母和数字" oninput="validatePassword()" />
    <div class="msg" id="passwordMsg"></div>
  </div>
  <div class="form-group">
    <label>确认密码</label>
    <input id="confirm" type="password" placeholder="再次输入密码" oninput="validateConfirm()" />
    <div class="msg" id="confirmMsg"></div>
  </div>
  <button class="btn" id="submitBtn" disabled onclick="submit()">注册</button>
</form>
<script>
const state = { username: false, password: false, confirm: false };

function setField(id, valid, msg){
  const el = document.getElementById(id);
  const msgEl = document.getElementById(id+'Msg');
  el.className = valid ? 'valid' : (el.value ? 'invalid' : '');
  msgEl.className = 'msg ' + (valid ? 'ok' : 'err');
  msgEl.textContent = msg;
  state[id] = valid;
  document.getElementById('submitBtn').disabled = !Object.values(state).every(Boolean);
}

function validateUsername(){
  const v = document.getElementById('username').value;
  if(!v) return setField('username', false, '');
  if(v.length < 3) return setField('username', false, '至少 3 位');
  if(v.length > 16) return setField('username', false, '最多 16 位');
  if(!/^[a-zA-Z0-9_]+$/.test(v)) return setField('username', false, '只允许字母、数字、下划线');
  setField('username', true, '✓ 用户名合法');
}

function validatePassword(){
  const v = document.getElementById('password').value;
  if(!v) return setField('password', false, '');
  if(v.length < 6) return setField('password', false, '至少 6 位');
  if(!/[a-zA-Z]/.test(v) || !/[0-9]/.test(v)) return setField('password', false, '需同时含字母和数字');
  setField('password', true, '✓ 密码强度合格');
  validateConfirm();
}

function validateConfirm(){
  const p = document.getElementById('password').value;
  const c = document.getElementById('confirm').value;
  if(!c) return setField('confirm', false, '');
  if(c !== p) return setField('confirm', false, '两次密码不一致');
  setField('confirm', true, '✓ 密码一致');
}

function submit(){
  alert('✅ 前端校验通过!
(真实场景:提交到后端,后端还会再校验一遍)');
}
</script>
</body></html>

【代码注释】oninput 实现即时校验——每次击键后立即反馈,不需要等提交按钮。submitBtndisabled 状态由 state 对象驱动:只有三个字段都通过校验,提交按钮才可用。防御式编程的体现validateConfirmvalidatePassword 中也调用了一次——当用户先填确认密码、再修改密码时,确认密码字段会自动重新校验,防止「旧校验状态与当前值不一致」的 bug。

实战示例 · 防御式编程边界条件处理

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>防御式编程</title>
<style>
body{font-family:monospace;padding:20px;background:#0d1117;color:#c9d1d9}
.case{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:12px;margin:8px 0}
.case h4{margin:0 0 6px;font-size:13px}
.bad{color:#f85149}.good{color:#3fb950}
pre{font-size:11px;margin:4px 0;line-height:1.6;white-space:pre-wrap}
.btn{padding:6px 12px;border:none;border-radius:4px;cursor:pointer;background:#388bfd;color:#fff;margin:4px;font-size:12px}
#output{background:#161b22;border:1px solid #30363d;padding:10px;border-radius:6px;margin:10px 0;font-size:12px;min-height:40px}
</style></head>
<body>
<h2>防御式编程:边界条件保护</h2>
<div class="case">
  <h4>场景:渲染管理员列表</h4>
  <pre class="bad">// ❌ 不防御 —— 接口数据异常直接崩溃
const renderList = (data) => {
  table.innerHTML = data.adminList.map(admin =>
    AdminRow(admin)).join('');
};
// 当 data = null 或 data.adminList = undefined → 报错 💥</pre>
  <pre class="good">// ✅ 防御式 —— 每层都做空值保护
const renderList = (data) => {
  const list = data?.adminList ?? [];      // 可选链 + 空值合并
  if(list.length === 0) {
    table.innerHTML = '&lt;tr&gt;&lt;td colspan="4"&gt;暂无数据&lt;/td&gt;&lt;/tr&gt;';
    return;
  }
  table.innerHTML = list.map(admin => AdminRow(admin)).join('');
};</pre>
</div>
<div style="font-size:12px;margin:8px 0">
  <b style="color:#79c0ff">模拟不同接口返回:</b>
  <button class="btn" onclick="test({adminList:[{name:'Alice'},{name:'Bob'}]})">正常数据</button>
  <button class="btn" onclick="test({adminList:[]})">空列表</button>
  <button class="btn" onclick="test(null)">接口返回 null</button>
  <button class="btn" onclick="test({})">缺少 adminList 字段</button>
</div>
<div id="output">等待测试…</div>
<script>
function AdminRow(admin){ return `<tr><td style="padding:4px 8px">${admin.name}</td></tr>`; }
function test(data){
  const list = data?.adminList ?? [];
  if(list.length === 0){
    document.getElementById('output').innerHTML = '<table><tr><td style="color:#8b949e;padding:8px">暂无数据</td></tr></table>';
  } else {
    document.getElementById('output').innerHTML =
      '<table>' + list.map(AdminRow).join('') + '</table>';
  }
  const desc = data === null ? 'null' : JSON.stringify(data).slice(0,50);
  document.getElementById('output').innerHTML +=
    `<p style="font-size:11px;color:#8b949e;margin-top:6px">输入:${desc} → 处理成功,无崩溃</p>`;
}
</script>
</body></html>

【代码注释】data?.adminList ?? [] 是两个运算符的组合:?.(可选链)处理 datanull/undefined 的情况,??(空值合并)处理 data.adminListnull/undefined 的情况,两者都用 [] 兜底。防御式编程的核心原则:系统边界(接口返回、用户输入、URL 参数)的数据不可信,必须防御;内部函数之间可以信任约定。过度防御(内部函数也加大量断言)会让代码臃肿、掩盖真正的 bug——只在边界防御是合理的工程实践。


十、用户反馈与 toastr

概念与底层原理:

alert 阻塞 UI 主线程、样式不可定制、移动端体验糟。专业应用一律用 toast 风格的轻量提示。toastr 提供 success/error/warning/info 四种:

import toastr from 'toastr';
import 'toastr/build/toastr.css';      // 样式不可省略

toastr.error('管理员账号只能由英文字母组成!');
toastr.success('管理员添加成功');

【代码注释】import 'toastr/build/toastr.css' 不能省略——只 import JS 弹出的 toast 没有样式,看不到边框圆角。toastr.error 触发右上角红色提示并自动消失。toast 相对 alert 的根本优势是「非阻塞」——它是浮层 DOM 而非浏览器模态,不会暂停 JS 执行、不打断用户当前操作。市面应用:toastr 是 Bootstrap 时代的标配;现代项目用 Ant Design message、Element UI Message 等同类组件。

【实战要点】

  • 经典应用场景:表单校验失败、接口报错、操作成功反馈。
  • 常见坑:忘记 import css,看不到样式。
  • 性能与最佳实践:开 preventDuplicates: true 防同一秒连发重复提示。

【本章小结】

类型API
成功toastr.success
错误toastr.error
警告toastr.warning
信息toastr.info

记忆口诀:「Toast 非阻塞,四级提示分颜色」。

【面试考点】

Q1:alert 与 toast 的区别?
A:alert 是浏览器原生模态,阻塞 JS 主线程直到点击,样式不可定制;toast 是浮层 DOM,不阻塞、可定制、短暂浮现不打断操作。专业产品几乎都用 toast。

入门示例 · Toast 通知队列实现

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>Toast 通知</title>
<style>
body{font-family:sans-serif;padding:20px;background:#1e1e1e;color:#d4d4d4}
.btn{padding:8px 14px;border:none;border-radius:4px;cursor:pointer;margin:4px;font-size:13px}
.toast-container{position:fixed;top:16px;right:16px;display:flex;flex-direction:column;gap:8px;z-index:9999;pointer-events:none}
.toast{background:#252526;border:1px solid #3c3c3c;border-radius:6px;padding:12px 16px;font-size:13px;
  min-width:200px;transform:translateX(120%);transition:transform 0.3s;display:flex;align-items:center;gap:8px;pointer-events:auto}
.toast.show{transform:translateX(0)}
.toast.success{border-left:3px solid #3fb950}
.toast.error{border-left:3px solid #f85149}
.toast.warning{border-left:3px solid #d29922}
.toast.info{border-left:3px solid #79c0ff}
</style></head>
<body>
<h2>Toast 通知队列演示</h2>
<div>
  <button class="btn" style="background:#1c7c3a;color:#fff" onclick="toast('✅ 管理员删除成功','success')">成功</button>
  <button class="btn" style="background:#c72e2e;color:#fff" onclick="toast('❌ 操作失败,请重试','error')">失败</button>
  <button class="btn" style="background:#c28e00;color:#fff" onclick="toast('⚠️ 数据将在 5 分钟后过期','warning')">警告</button>
  <button class="btn" style="background:#1c4a7c;color:#fff" onclick="toast('ℹ️ 正在同步数据...','info')">信息</button>
  <button class="btn" style="background:#3c3c3c;color:#d4d4d4" onclick="multiToast()">多条同时</button>
</div>
<div class="toast-container" id="container"></div>
<script>
function toast(msg, type='info', duration=2500){
  const container = document.getElementById('container');
  const el = document.createElement('div');
  el.className = 'toast ' + type;
  el.textContent = msg;
  container.appendChild(el);
  requestAnimationFrame(()=>{ requestAnimationFrame(()=>{ el.classList.add('show'); }); });
  setTimeout(()=>{
    el.classList.remove('show');
    setTimeout(()=>el.remove(), 300);
  }, duration);
}
function multiToast(){
  toast('✅ 保存成功','success');
  setTimeout(()=>toast('ℹ️ 正在上传图片...','info'),200);
  setTimeout(()=>toast('⚠️ 网络较慢,请等待','warning'),400);
}
</script>
</body></html>

【代码注释】Toast 实现的关键技术点:① CSS transform 动画代替 JS 动画,性能更好(走合成层);② requestAnimationFrame 双帧触发过渡动画——先 append 到 DOM(不触发过渡),下一帧再添加 show 类(触发 transform transition),如果同一帧做两件事浏览器会合并导致无动画;③ 队列自然形成——每个 toast 是独立 DOM 元素,flex-direction: column 让它们自然堆叠;④ duration 后自动删除 DOM,避免内存泄漏。

实战示例 · 操作状态三态反馈(loading/success/error)

<!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:400px}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin:12px 0}
.admin-row{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid #21262d}
.btn{padding:6px 12px;border:none;border-radius:4px;cursor:pointer;font-size:12px}
.btn-del{background:#3a1c1c;color:#f85149;border:1px solid #c72e2e}
.btn-del:disabled{background:#21262d;color:#8b949e;border-color:#30363d;cursor:not-allowed}
.spinner{display:inline-block;width:12px;height:12px;border:2px solid #388bfd;border-top-color:transparent;border-radius:50%;animation:spin 0.6s linear infinite;vertical-align:middle;margin-right:4px}
@keyframes spin{to{transform:rotate(360deg)}}
#status{font-size:12px;min-height:20px;margin-top:8px;padding:6px;border-radius:4px}
</style></head>
<body>
<h2>异步操作三态反馈</h2>
<div class="card" id="list"></div>
<div id="status"></div>
<script>
let admins = [{id:1,name:'Alice'},{id:2,name:'Bob'},{id:3,name:'Carol'}];
function renderList(){
  document.getElementById('list').innerHTML = admins.map(a=>`
    <div class="admin-row">
      <span>${a.name}</span>
      <button class="btn btn-del" id="del-${a.id}" onclick="deleteAdmin(${a.id})">删除</button>
    </div>`).join('');
}
function setStatus(msg, color='#8b949e'){
  document.getElementById('status').style.color = color;
  document.getElementById('status').innerHTML = msg;
}
async function deleteAdmin(id){
  const btn = document.getElementById('del-'+id);
  btn.disabled = true;
  btn.innerHTML = '<span class="spinner"></span>删除中';
  setStatus('<span class="spinner"></span>正在请求接口...', '#388bfd');
  await new Promise(r=>setTimeout(r, 1500));
  const success = Math.random() > 0.3;
  if(success){
    admins = admins.filter(a=>a.id!==id);
    renderList();
    setStatus('✅ 删除成功', '#3fb950');
  } else {
    btn.disabled = false;
    btn.textContent = '删除';
    setStatus('❌ 删除失败,请重试(网络异常)', '#f85149');
  }
}
renderList();
</script>
</body></html>

【代码注释】三态反馈是异步操作的标准 UX 模式:loading 态(按钮 disabled + spinner,防止重复提交)→ success 态(移除数据、刷新列表、绿色提示)或 error 态(恢复按钮可用、红色提示)。关键设计btn.disabled = true 在请求期间禁用按钮,防止用户多次点击发送重复请求;无论成功还是失败,都给用户明确的反馈,不能「静默失败」(接口报错但页面无任何变化)。


十一、跨域与 DevServer 代理

名词解释:

  • 同源策略:协议 + 域名 + 端口三者一致才算同源,跨源 AJAX 受 CORS 限制。
  • DevServer 代理:把特定前缀的请求转发到后端,让浏览器「以为」自己在调同源接口。

概念与底层原理:

前端 localhost:80 无法直接请求后端 127.0.0.1:8088——浏览器同源策略拦截。DevServer 代理巧妙绕过:浏览器始终请求同源的 /api/admin,由 DevServer 在 Node 端转发到真实后端(Node 没有浏览器的同源限制):

在这里插入图片描述

【代码注释】关键点:浏览器始终认为自己在请求 localhost:80/api/admin(同源),跨域请求由 DevServer 在 Node 端发起——Node 不受同源策略约束。pathRewrite 在转发前剥掉 /api 前缀,符合后端真实路径。市面应用:所有「前后端分离」项目开发期都用这种代理方案规避跨域,生产期由 Nginx 实现等效配置。

// webpack.dev.config.js
devServer: {
  proxy: {
    '/api': {
      target: 'http://127.0.0.1:8088',
      pathRewrite: { '^/api': '' }     // /api/admin → /admin
    }
  }
}

【代码注释】'/api' 是匹配前缀,target 是后端地址,pathRewrite: { '^/api': '' } 删掉 /api 前缀。前端加 /api 前缀只是为了「让代理识别」,到后端要还原成真实路径。市面应用:现代前端项目几乎都是这种 /api 代理模式;微服务架构下可配多入口代理到不同后端服务。

【实战要点】

  • 经典应用场景:开发期联调后端、用 mock 服务替代真后端。
  • 常见坑pathRewrite 正则漏写 ^ 会把 URL 中间的 /api 也替换。
  • 性能与最佳实践:生产期用 Nginx location /api { proxy_pass } 而非 webpack 代理。

【本章小结】

字段作用
target后端真实地址
pathRewriteURL 重写
changeOrigin改写 Host 头

记忆口诀:「同源伪装,Target 指向,Rewrite 修剪」。

【面试考点】

Q1:为什么前后端分离会跨域?怎么解决?
A:浏览器同源策略要求协议+域名+端口完全一致,否则 AJAX 被 CORS 限制。解决:1)开发期用 DevServer proxy 把跨域伪装成同源;2)生产期用 Nginx 反向代理或后端开 CORS 响应头;3)JSONP(仅 GET);4)postMessage(iframe)。Proxy 对前端代码零侵入,是开发期首选。

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

<!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: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}
.origin{background:#0d1117;padding:6px 10px;border-radius:4px;font-size:12px;margin:4px 0}
.ok{color:#3fb950;border-left:3px solid #3fb950;padding-left:8px}
.no{color:#f85149;border-left:3px solid #f85149;padding-left:8px}
.maybe{color:#d29922;border-left:3px solid #d29922;padding-left:8px}
</style></head>
<body>
<h2>同源策略:协议 + 域名 + 端口三者完全一致才算同源</h2>
<div class="row">
<div class="box">
  <h4 style="color:#79c0ff">当前页面源</h4>
  <div class="origin">http://localhost:3000</div>
  <div style="font-size:12px;margin-top:8px;color:#888">协议:http | 域名:localhost | 端口:3000</div>
</div>
<div class="box">
  <h4>与以下 URL 的关系</h4>
  <div class="origin ok">✅ http://localhost:3000/api/admin → 同源(完全一致)</div>
  <div class="origin ok">✅ http://localhost:3000/static/img.png → 同源</div>
  <div class="origin no">❌ http://localhost:8080/api → 不同源(端口 3000 ≠ 8080)</div>
  <div class="origin no">❌ https://localhost:3000/api → 不同源(协议 http ≠ https)</div>
  <div class="origin no">❌ http://api.example.com/data → 不同源(域名不同)</div>
  <div class="origin maybe">⚠️ img src、script src → 不受同源限制,可跨域加载</div>
  <div class="origin maybe">⚠️ AJAX/fetch → 受同源限制,需 CORS 或代理</div>
</div>
</div>
<div style="background:#0d1117;padding:12px;border-radius:6px;font-size:12px;margin:8px 0">
  <b style="color:#79c0ff">DevServer proxy 如何绕过跨域:</b><br>
  浏览器 → <span style="color:#4ec9b0">http://localhost:3000/api/admin</span>(同源,允许)<br>
  DevServer → <span style="color:#ffa657">http://localhost:8080/api/admin</span>(服务端无同源限制)<br>
  DevServer 把后端响应转给浏览器 → 浏览器以为始终在和 :3000 通信
</div>
</body></html>

【代码注释】同源策略是浏览器的安全机制,限制的对象是脚本发起的请求(XHR/fetch),而不是 <img src><script src><link href>——这些标签可以跨域加载资源(JSONP 就是利用 <script> 不受限的特性)。DevServer proxy 的本质是「服务端转发」:浏览器发请求到同源的 DevServer,DevServer 再去请求不同源的后端——因为 CORS 是浏览器策略,服务端对服务端的请求完全不受限。

实战示例 · DevServer proxy 配置原理演示

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>代理配置</title>
<style>
body{font-family:monospace;padding:20px;background:#0d1117;color:#c9d1d9}
.flow{display:flex;align-items:center;gap:0;margin:16px 0;flex-wrap:wrap}
.node{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px 14px;font-size:12px;text-align:center;min-width:120px}
.arrow{color:#8b949e;font-size:18px;padding:0 6px}
.highlight{border-color:#388bfd;color:#79c0ff}
pre{background:#161b22;border:1px solid #30363d;padding:12px;border-radius:6px;font-size:11px;color:#e6edf3;line-height:1.7;margin:12px 0}
.tabs{display:flex;gap:4px;margin-bottom:0}
.tab{padding:6px 12px;background:#21262d;border:1px solid #30363d;border-radius:5px 5px 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:12px}
</style></head>
<body>
<h2>DevServer Proxy:开发期跨域解决方案</h2>
<div class="flow">
  <div class="node">浏览器<br><small>localhost:3000</small></div>
  <div class="arrow">→ /api/admin</div>
  <div class="node highlight">DevServer<br><small>localhost:3000</small></div>
  <div class="arrow">→ /api/admin</div>
  <div class="node">后端<br><small>localhost:8080</small></div>
</div>
<div class="tabs">
  <div class="tab active" onclick="show('basic')">基础配置</div>
  <div class="tab" onclick="show('rewrite')">路径重写</div>
  <div class="tab" onclick="show('multi')">多目标代理</div>
</div>
<div class="panel"><pre id="code"></pre></div>
<script>
const codes = {
basic:`// webpack.dev.js
devServer: {
  proxy: {
    '/api': {
      target: 'http://localhost:8080',
      changeOrigin: true,    // 修改请求头 Host = 目标地址
      // /api/admin → http://localhost:8080/api/admin
    }
  }
}
// 前端代码无需改动:
// fetch('/api/admin')  ← 始终写相对路径`,
rewrite:`// 路径重写:前端用 /api 前缀,后端没有此前缀
devServer: {
  proxy: {
    '/api': {
      target: 'http://localhost:8080',
      pathRewrite: { '^/api': '' },  // 去掉 /api 前缀
      // /api/admin → http://localhost:8080/admin
    }
  }
}`,
multi:`// 同项目多个后端服务
devServer: {
  proxy: {
    '/api/user': { target: 'http://user-service:3001' },
    '/api/order': { target: 'http://order-service:3002' },
    '/api/pay': {
      target: 'http://pay-service:3003',
      secure: false,    // 忽略 HTTPS 证书错误
    }
  }
}`
};
function show(key){
  document.querySelectorAll('.tab').forEach((t,i)=>t.className='tab'+(['basic','rewrite','multi'][i]===key?' active':''));
  document.getElementById('code').textContent = codes[key];
}
show('basic');
</script>
</body></html>

【代码注释】changeOrigin: true 是代理配置里最容易忘的选项——它让 DevServer 修改转发请求的 Host 请求头为目标地址(localhost:8080),而不是保留原始的 localhost:3000。某些后端框架(如 Spring)会验证 Host 头,如果 Host 是 localhost:3000 而服务监听 localhost:8080,请求可能被拒绝。生产环境不能用 DevServer proxy(DevServer 只在开发时运行),生产的跨域由 Nginx 反向代理或后端 CORS 响应头解决。


十二、Axios 拦截器与横切关注点

名词解释:

  • 横切关注点(Cross-cutting Concern):跨越多个业务模块的通用逻辑(鉴权、日志、错误处理)。
  • 拦截器(Interceptor):在请求发出前/响应到达后统一插入处理的中间件机制。

概念与底层原理:

直接在每个 controller 里 axios.post('/api/admin', data) 有三个问题:重复 /api 前缀、错误处理分散、无法统一加 token。axios.create + 拦截器把这些「横切关注点」从分散的业务代码中抽出,集中收口:

在这里插入图片描述

【代码注释】这张图体现了 AOP(面向切面编程) 思想在前端的落地——业务代码只写「业务流程」(.then 关心成功),token 注入、错误处理、loading 等横切逻辑全在拦截器统一处理。请求拦截器加 token、响应拦截器处理错误码与 401 跳转,业务代码因此大幅瘦身。市面应用:所有企业项目的 axios 都会被二次封装,几乎没有例外。

// 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;

【代码注释】关键技巧 return new Promise(() => {}) 返回永远 pending 的 Promise——它不 resolve 也不 reject,业务的 .then/.catch 都不执行。为什么这样写? 错误已被 toastr 提示给用户,业务代码不必再被错误打扰;若用 Promise.reject 会触发每个调用点的 .catch,封装效益归零。这种「拦截器消费错误、业务只关心成功」是企业级 axios 封装的标准手法市面应用:Vue Element Admin、Ant Design Pro 内部的 axios 封装思路与此一致。

业务调用因此极简:

import { postAdmin } from '../api/admin';
postAdmin({ adminName, passWord }).then(res => {
  toastr.success('管理员添加成功');     // 只关心成功路径
});

【代码注释】controller 不关心「URL 是什么、要不要加前缀、错了怎么提示」——只调 postAdmin(body)、处理成功逻辑。这是关注点分离的极致体现市面应用:所有规模化项目的业务代码都长这样,错误统一收口、token 自动注入。

【实战要点】

  • 经典应用场景:所有业务接口调用;token 注入、错误处理、loading 显示。
  • 常见坑:拦截器里 return Promise.reject 导致每个调用点都要 catch,封装失效。
  • 性能与最佳实践:请求拦截器加 token + loading;响应拦截器处理 401 自动跳登、5xx 重试。

【本章小结】

元素作用
axios.create独立配置实例
请求拦截器加 token / loading
响应拦截器统一错误 / 401 跳登
new Promise(()=>{})阻塞业务链

记忆口诀:「Create 立实例,Interceptor 收口,业务只看 then」。

【面试考点】

Q1:Axios 拦截器的设计意义?
A:把横切关注点(token、错误、loading、日志、重试)从分散的业务代码抽出,集中到拦截器。业务代码只写业务流程,可读性、可维护性显著提升——这是 AOP(面向切面编程)思想在前端的体现。

Q2:new Promise(() => {}) 的用途?
A:在拦截器吞掉错误后让业务 .then 不再触发——错误已被统一提示,业务无需重复处理。它返回永久 pending 的 Promise,既不 resolve 也不 reject,从而中断后续链式调用。

入门示例 · 请求/响应拦截器链执行演示

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>Axios 拦截器</title>
<style>
body{font-family:monospace;padding:20px;background:#1e1e1e;color:#d4d4d4}
.btn{padding:7px 14px;border:none;border-radius:4px;cursor:pointer;background:#388bfd;color:#fff;margin:4px;font-size:12px}
.btn.danger{background:#c72e2e}
#log{background:#0d1117;padding:12px;border-radius:6px;margin:10px 0;font-size:12px;min-height:80px;line-height:2}
.req{color:#4fc1ff}.res{color:#4ec9b0}.err{color:#f85149}.biz{color:#dcdcaa}
</style></head>
<body>
<h2>Axios 拦截器链:请求 → 响应 → 业务</h2>
<button class="btn" onclick="request(200)">正常请求(200)</button>
<button class="btn danger" onclick="request(401)">未授权(401)</button>
<button class="btn danger" onclick="request(500)">服务器错误(500)</button>
<div id="log">点击按钮查看拦截器执行顺序…</div>
<script>
const log = document.getElementById('log');
function append(cls,msg){ log.innerHTML += `<span class="${cls}">${msg}</span>
`; }

function request(statusCode){
  log.innerHTML = '';
  // ── 请求拦截器
  append('req','[请求拦截器] 1. 添加 Content-Type: application/json');
  const token = 'eyJ0eXAi...(从 localStorage 取)';
  append('req','[请求拦截器] 2. 注入 Authorization: Bearer ' + token.slice(0,12)+'...');
  append('req','[请求拦截器] 3. 记录请求日志,URL: /api/admin/list');
  append('','');
  append('','⟶ HTTP ' + (statusCode===200?'GET':'GET') + ' /api/admin/list');
  append('','⟵ HTTP Response ' + statusCode);
  append('','');

  // ── 响应拦截器
  if(statusCode === 200){
    append('res','[响应拦截器] 状态 200 → 解包 res.data(去掉 axios 包装层)');
    append('res','[响应拦截器] 业务状态码 200 → 直接 return res.data');
    append('','');
    append('biz','[业务代码] 收到 { adminList: [...] }');
    append('biz','[业务代码] AdminTable({ adminList }) → 渲染表格 ✅');
  } else if(statusCode === 401){
    append('err','[响应拦截器] HTTP 401 → Token 失效');
    append('err','[响应拦截器] 清除 localStorage token');
    append('err','[响应拦截器] 跳转 /login');
    append('err','[响应拦截器] return new Promise(()=>{}) ← 永久 pending,中断后续');
    append('','');
    append('biz','[业务代码] 此行不执行(Promise pending)');
  } else {
    append('err','[响应拦截器] HTTP 500 → 服务器异常');
    append('err','[响应拦截器] toast.error("服务器异常,请稍后重试")');
    append('err','[响应拦截器] return Promise.reject(error) ← 错误向下传');
    append('','');
    append('biz','[业务代码] .catch(err) 可选处理,或静默');
  }
}
</script>
</body></html>

【代码注释】拦截器链的执行顺序:请求拦截器(后注册先执行,类似栈)→ HTTP 请求 → 响应拦截器(先注册先执行,类似队列)→ 业务代码。return new Promise(()=>{}) 的妙用:在 401 场景里,拦截器已经处理了(跳转登录页、清除 token),业务代码的 .then 不应该再执行(它会试图渲染空数据),返回永久 pending 的 Promise 就静默中断了整条链——这是 Axios 拦截器里最常见的「吞掉请求」技巧

实战示例 · AOP 横切关注点:Token 注入 + 错误统一

<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>AOP 拦截器</title>
<style>
body{font-family:sans-serif;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 8px;font-size:13px;color:#79c0ff}
pre{font-size:11px;margin:0;line-height:1.7;white-space:pre-wrap;color:#e6edf3}
.comment{color:#6e7681}
.bad{color:#f85149}.good{color:#3fb950}
</style></head>
<body>
<h2>AOP 思想:横切关注点集中管理</h2>
<div class="row">
<div class="box">
  <h4 class="bad">❌ 无拦截器:每处重复</h4>
  <pre><span class="bad">// 每个 API 函数都要手写 token、错误处理</span>
const getAdmin = async () => {
  const token = localStorage.getItem('token');
  try {
    const res = await axios.get('/api/admin', {
      headers: { Authorization: 'Bearer ' + token }
    });
    if(res.data.code !== 200) { alert(res.data.msg); return; }
    return res.data.data;
  } catch(e) { alert('网络异常'); }
};

const getAdv = async () => {
  const token = localStorage.getItem('token');
  try { <span class="bad">// 同样的代码再写一遍...</span>
  } catch(e) { alert('网络异常'); }
};</pre>
</div>
<div class="box">
  <h4 class="good">✅ 有拦截器:横切关注点集中</h4>
  <pre><span class="good">// advServer.js —— 只写一次</span>
const advServer = axios.create({ baseURL: '/api' });

<span class="comment">// 请求拦截:token 注入(横切)</span>
advServer.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if(token) config.headers.Authorization = 'Bearer ' + token;
  return config;
});

<span class="comment">// 响应拦截:统一错误处理(横切)</span>
advServer.interceptors.response.use(
  res => res.data.code === 200 ? res.data : Promise.reject(res.data),
  err => { toast.error('网络异常'); return Promise.reject(err); }
);

<span class="good">// API 函数:只写业务意图</span>
const getAdmin = () => advServer.get('/admin');
const getAdv   = () => advServer.get('/adv');
<span class="comment">// token 自动注入,错误自动处理</span></pre>
</div>
</div>
</body></html>

【代码注释】这个对比展示了 **AOP(面向切面编程)**在前端的应用:token 注入和错误处理是「横切关注点」——它们与业务逻辑(「获取管理员列表」)正交,应该集中在一个地方管理,而不是散落在每个 API 函数里。拦截器就是 AOP 的具体实现:在请求/响应的「横截面」上植入逻辑,业务代码完全不感知。可维护性:token 存储位置从 localStorage 改到 sessionStorage,只改拦截器里一行,不需要改 N 个 API 函数。


附录:可运行 Demo

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

Demo 一 · 事件委托处理动态列表

复刻第三章的事件委托——列表行是动态生成的,但删除事件只绑在父容器一次:

<!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:420px}th,td{border:1px solid #ddd;padding:8px}
  button{padding:4px 10px;cursor:pointer}
</style></head>
<body>
  <h3>事件委托:删除按钮绑在父容器(动态行也生效)</h3>
  <button id="add">新增一行</button>
  <table><tbody id="list"></tbody></table>
  <script>
    const list = document.getElementById('list');
    let seq = 0;
    // 渲染函数:每次用 innerHTML 重建行(会清掉旧的子元素监听)
    function addRow() {
      seq++;
      const tr = document.createElement('tr');
      tr.innerHTML = `<td>记录 ${seq}</td>
        <td><button class="del" data-id="${seq}">删除</button></td>`;
      list.appendChild(tr);
    }
    // ⭐ 事件委托:只在父容器 list 上绑一次,靠 event.target 区分
    list.addEventListener('click', (e) => {
      if (e.target.classList.contains('del')) {
        if (confirm('删除记录 ' + e.target.dataset.id + '?')) {
          e.target.closest('tr').remove();
        }
      }
    });
    document.getElementById('add').onclick = addRow;
    addRow(); addRow(); addRow();
  </script>
</body>
</html>

【代码注释】关键在于 list.addEventListener('click', ...) 只绑定一次,却能处理所有动态新增行的删除按钮——因为子元素的点击会冒泡到父容器,再用 e.target.classList.contains('del') 判定是不是删除按钮、e.target.dataset.id 取记录 ID。不断点「新增一行」再删除,验证「动态生成的 DOM 无需重新绑定事件」。市面应用:所有长列表、动态表格的行内操作都靠这套机制,jQuery 的 .on('click', '.del', fn) 就是它的语法糖。

Demo 二 · 路由元信息驱动菜单与高亮

复刻第五、六、七章——一份路由数据同时驱动菜单渲染、当前项高亮、动态标题:

<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8" /><title>数据驱动菜单 Demo</title>
<style>
  body{margin:0;font-family:-apple-system,sans-serif;display:flex}
  aside{width:200px;background:#343a40;min-height:100vh;padding:12px 0}
  aside a{display:block;color:#adb5bd;padding:10px 20px;text-decoration:none}
  aside a.active{background:#007bff;color:#fff}
  main{flex:1;padding:24px}
</style></head>
<body>
  <aside id="menu"></aside>
  <main><h1 id="title"></h1><p id="body"></p></main>
  <script>
    // ① 路由即数据:一份配置驱动菜单/高亮/标题
    const routes = [
      { path: '/admin', title: '管理员列表', isNav: true, icon: '👤' },
      { path: '/adv',   title: '广告列表',   isNav: true, icon: '📢' },
      { path: '/set',   title: '系统设置',   isNav: true, icon: '⚙️' }
    ];
    function render() {
      const cur = location.hash.replace(/^#/, '') || '/admin';
      // ② filter(isNav) 渲染菜单,匹配 cur 加 active 高亮
      document.getElementById('menu').innerHTML = routes
        .filter(r => r.isNav)
        .map(r => `<a href="#${r.path}" class="${r.path === cur ? 'active' : ''}">${r.icon} ${r.title}</a>`)
        .join('');
      // ③ 当前路由的 title 驱动页面标题
      const route = routes.find(r => r.path === cur) || {};
      document.getElementById('title').textContent = route.title || '404';
      document.getElementById('body').textContent = '当前路径:#' + cur;
    }
    window.addEventListener('hashchange', render);
    window.addEventListener('DOMContentLoaded', render);
  </script>
</body>
</html>

【代码注释】这个 Demo 把「配置即 UI」演绎得淋漓尽致:① routes 是唯一数据源;② filter(r => r.isNav) 渲染菜单、r.path === cur 决定高亮;③ find 出当前路由的 title 驱动页面标题。想加一个菜单项,只需在 routes 数组加一条——菜单、高亮、标题全自动生效,模板代码一行不改。这正是数据驱动 UI 的威力。市面应用:Ant Design Pro、Element Admin 的菜单系统就是这套「路由元信息 → 菜单」的工业级实现。

总结

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

功能层架构

模块解析

路径别名 @

扩展名省略

jsconfig 同步

路由架构

嵌套路由复用外壳

路由即数据

isNav/title/icon 元信息

编程式导航

视图层

组件化函数组合

占位聚合

数据驱动菜单

动态标题

MVC 分层

Controller 汇聚

容器组件模式

单向数据流

样式工程

less 变量嵌套

loader 链

交互与数据

表单校验防御

toastr 非阻塞提示

DevServer 代理跨域

Axios 拦截器 AOP

Promise 阻塞链

【代码注释】这张思维导图把「功能层架构」拆成模块解析、路由架构、视图层、MVC 分层、样式工程、交互与数据六大主题。它们共同回答一个问题:如何把骨架演进成可维护的系统。核心思想贯穿始终——关注点分离、单一数据源、横切关注点收口。市面应用:面试中「你如何组织一个中大型前端项目的代码」这类题,可按此图分层回答,体现架构设计能力。

高频面试题速查

  1. resolve.alias 解决什么? 消除相对路径地狱。
  2. 嵌套路由的用途? 复用外壳,避免重复渲染。
  3. MVC 与 MVVM 区别? 手动连接 vs 自动双向绑定。
  4. 「路由即数据」的好处? 单一数据源驱动菜单/标题/权限。
  5. 编程式导航 vs 原生导航? 改 history 不刷新 vs 浏览器导航刷新。
  6. 数据驱动菜单原理? routes filter isNav → 渲染。
  7. 前端校验能替代后端校验吗? 不能,前端是体验、后端是安全。
  8. alert 与 toast 区别? 阻塞模态 vs 非阻塞浮层。
  9. 跨域怎么解决? Proxy / CORS / Nginx / JSONP。
  10. Axios 拦截器的意义? AOP,统一收口横切关注点。
  11. new Promise(()=>{}) 用途? 永久 pending 阻塞业务链。

学习建议

  1. 逐步重构:从单文件 app.js 开始,按本文顺序一步步演进,亲历「为何要分层」。
  2. 画依赖图:把 routes/controllers/views/components 的引用关系画出来,确认每条依赖都「向下」。
  3. 接入真后端:用 Express/NestJS 起 mock 后端,让 axios 真正调通完整链路。
  4. 框架对照:把同一项目用 Vue 3 + Vue Router + Pinia 重写,对比 EJS + jQuery 模式的体力消耗。
  5. 优化加载:用 Code Splitting 让每个 controller 独立打包、按需加载,提升首屏。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值