Skip to content

管理员列表模板页面和广告列表模板页与页面组件化(项目功能实现 2/3) ​

本页对应 课堂视频,共 6 段视频,约 1 小时 10 分。

学习目标 ​

  1. 能够说明项目 src 下控制器、路由、视图、组件及入口文件的职责,并描述一次页面请求从路由匹配到模板渲染的流程。
  2. 能够使用路由模块实现导航跳转,做到点击导航不刷新页面,并根据当前 URL 自动高亮对应导航项。
  3. 能够基于路由配置动态渲染导航列表,通过配置项控制导航显示、标题和图标,而不是在模板中写死。
  4. 能够根据当前 URL 匹配路由配置中的 path,动态获取并显示对应页面的 title。
  5. 能够安装并配置 LESS 相关 loader,在项目中编写、导入 LESS 文件,并验证样式生效。
  6. 能够启动 MongoDB 与后端服务,访问后端接口并验证数据库及接口数据正常返回。

知识点 ​

管理员列表模板页:先搭表格,再挂模态框 ​

为什么需要:管理员列表是后台最基础的管理页,要能看数据、删数据、改密码,还要能新增管理员。模板页先把结构和交互入口搭好,后续控制器把真实数据传进来即可。

怎么想:页面分两块:上方是表格,展示 ID、用户名、最后登录时间、注册时间、操作;下方是“添加管理员”模态框。新增按钮不直接跳页,而是用 Bootstrap 的 data-toggle="modal" 和 data-target="#addAdminModal" 打开模态框,避免离开当前列表页。

怎么做:表格放在 .card-body 中,操作列放“删除”和“修改密码”按钮,删除按钮用 data-id 携带当前记录 ID。模态框里放 form,包含账号、密码、重复密码三个输入框,底部“添加”按钮用 id="addAdminBtn" 方便后续绑定提交逻辑。示例代码如下:

ejs
<div id="__sub-route-view">
    <div class="card adminlist">
        <div class="card-header">
            <button type="button" class="btn btn-primary btn-sm" data-toggle="modal"
                data-target="#addAdminModal">添加管理员</button>
        </div>
        <!-- /.card-header -->
        <div class="card-body" id="amdinListBox">
            <table class="table table-bordered">
                <thead>
                    <tr>
                        <th style="width: 10px">ID</th>
                        <th>用户名</th>
                        <th>最后登录时间</th>
                        <th>注册时间</th>
                        <th>操作</th>
                    </tr>
                </thead>
                <tbody>
                    <tr>
                        <td>648d3b13dbeadd55199ab0a7</td>
                        <td>admin</td>
                        <td>2023-06-17 12:50:22</td>
                        <td>2023-06-17 12:48:19</td>
                        <td>
                            <button type="button" class="btn btn-danger" data-id="648d3b13dbeadd55199ab0a7">删除</button>
                            <button type="button" class="btn btn-success">修改密码</button>
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>
    </div>

    <!-- 模态框 -->
    <div class="modal fade" id="addAdminModal" style="display: none;" aria-hidden="true">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h4 class="modal-title">添加管理员</h4>
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
                        <span aria-hidden="true">×</span>
                    </button>
                </div>
                <div class="modal-body">
                    <form name="adminForm" id="addAdminForm">
                        <div class="form-group">
                            <label for="exampleInputEmail1">管理员账号</label>
                            <input type="text" class="form-control" id="exampleInputEmail1" placeholder="请输入管理员账号">
                        </div>
                        <div class="form-group">
                            <label for="exampleInputPassword1">管理员密码</label>
                            <input type="password" class="form-control" id="exampleInputPassword1"
                                placeholder="请输入管理员密码">
                        </div>
                        <div class="form-group">
                            <label for="exampleInputPassword2">重复密码</label>
                            <input type="password" class="form-control" id="exampleInputPassword2"
                                placeholder="请输入管理员密码">
                        </div>
                    </form>
                </div>
                <div class="modal-footer justify-content-between">
                    <button type="button" class="btn btn-default" data-dismiss="modal">取消</button>
                    <button type="button" id="addAdminBtn" class="btn btn-primary">添加</button>
                </div>
            </div>
        </div>
    </div>
</div>

易错点:新增按钮要写 type="button",否则在表单里可能触发表单提交;模态框关闭按钮依赖 data-dismiss="modal";表单里的 name 目前是空值,实际提交时要按后端字段补齐,否则序列化取不到值。

广告列表模板页:搜索、表格、分页与上传预览 ​

为什么需要:广告列表要展示标题、图片、类别、链接、排序、时间,还要支持关键字搜索、分页和新增广告。图片和上传预览是广告管理的高频操作,必须让管理员所见即所得。

怎么想:顶部 card-header 放“添加广告”按钮和搜索框;中间表格展示广告数据;底部分页;新增广告用模态框,表单里包含标题、类别、链接、排序、图片上传,并预留 #preImg 做本地预览。

怎么做:添加按钮用 data-toggle="modal" 打开 #advModal;搜索框 #keyword 和按钮 #searchAdv 先搭结构;表格图片用 img 并设置 height="80";分页用 data-i 标记页码。转写稿里还提到调试过程:给表格父元素加 table-responsive 让窄屏出现横向滚动,把分页移出 card-body,并在 less 中给表格设最小宽度;同时发现 ID 列对非技术管理员没有意义,直接注释掉。示例代码如下:

ejs
<div id="__sub-route-view">
    <div class="card">
        <div class="card-header">
            <button type="button" id="addAdvBtn" class="btn btn-primary" data-toggle="modal"
                data-target="#advModal">添加广告</button>
            <div class="card-tools">
                <div class="input-group input-group-md">
                    <input type="text" class="form-control" id="keyword" placeholder="搜索关键字">
                    <div class="input-group-append">
                        <div class="btn btn-primary" id="searchAdv">
                            <i class="fas fa-search"></i>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        <!-- /.card-header -->
        <div id="advTable" class="card-body">
            <table id="" class="table table-bordered">
                <thead>
                    <tr>
                        <th>id</th>
                        <th>标题</th>
                        <th>图片</th>
                        <th>类别</th>
                        <th>链接</th>
                        <th>排序</th>
                        <th>添加时间</th>
                        <th>修改时间</th>
                        <th>操作</th>
                    </tr>
                </thead>
                <tbody>
                    <tr>
                        <td>648d1fcd73eabdb6ee308067</td>
                        <td>小米13广告</td>
                        <td><img height="80" src="http://api.fuming.site:54254/2023/06/17/db0e9ef8b55471c83cbcbcc01.jpg"
                                alt="广告图片预览"></td>
                        <td>轮播图</td>
                        <td>http://learn.fuming.site</td>
                        <td>1</td>
                        <td>2023-06-17 10:51:57</td>
                        <td>2023-06-17 10:51:57</td>
                        <td>
                            <button type="button" class="btn btn-danger btn-sm">删除</button>
                            <button type="button" data-id="648d1fcd73eabdb6ee308067"
                                class="btn btn-success btn-sm btn-edit">修改</button>
                        </td>
                    </tr>
                </tbody>
            </table>

            <!-- /.card-body -->
            <div class="card-footer clearfix" id="pageDiv">
                <ul class="pagination pagination-sm m-0 float-right">
                    <li class="page-item"><a href="javascript:;" data-i="0" class="page-link">«</a></li>
                    <li class="page-item active">
                        <a class="page-link" data-i="1" href="javascript:;">1</a>
                    </li>
                    <li class="page-item"><a href="javascript:;" data-i="2" class="page-link">»</a></li>
                </ul>
            </div>
        </div>
    </div>

    <div class="modal fade show" id="advModal" style="padding-right: 17px;" aria-modal="true" role="dialog">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h4 class="modal-title">添加广告</h4>
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
                        <span aria-hidden="true">×</span>
                    </button>
                </div>
                <form name="addAdvForm" class="form-horizontal" id="addAdvForm">
                    <div class="card-body">
                        <div class="form-group row">
                            <label class="col-sm-2 col-form-label" for="advTitle">标题:</label>
                            <div class="col-sm-10">
                                <input id="advTitle" name="" type="text" class="form-control" placeholder="请输入广告标题">
                            </div>
                        </div>
                        <div class="form-group row">
                            <label class="col-sm-2 col-form-label" for="advType">类别:</label>
                            <div class="col-sm-10">
                                <select name="" id="advType" class="form-control">
                                    <option value="1">轮播图广告</option>
                                    <option value="2">轮播图底部广告</option>
                                    <option value="3">热门回收广告</option>
                                    <option value="4">优品精选广告</option>
                                </select>
                            </div>
                        </div>
                        <div class="form-group row">
                            <label class="col-sm-2 col-form-label" for="advHref">链接:</label>
                            <div class="col-sm-10">
                                <input id="advHref" name="" type="text" class="form-control" placeholder="请输入广告链接">
                            </div>
                        </div>
                        <div class="form-group row">
                            <label class="col-sm-2 col-form-label" for="orderNum">排序:</label>
                            <div class="col-sm-10">
                                <input id="orderNum" name="" type="text" class="form-control"
                                    placeholder="请输入广告排序,数字越大越靠前">
                            </div>
                        </div>
                        <div class="form-group row">
                            <label class="col-sm-2 col-form-label" for="advPic">图片:</label>
                            <div class="col-sm-10 custom-file">
                                <!--  可以选择一个文件上传                          -->
                                <input type="file" class="form-control" name="advPic" id="advPic">
                            </div>
                        </div>
                        <div>
                            <img style="display: none;" id="preImg" height="100" alt="上传预览图">
                        </div>
                    </div>
                </form>
                <div class="modal-footer justify-content-between">
                    <button type="button" class="btn btn-default" id="adv-cancel" data-dismiss="modal">取消</button>
                    <button type="submit" class="btn btn-primary" id="adv-save" form="addAdvForm">提交</button>
                </div>
            </div>
        </div>
    </div>
</div>

易错点:提交按钮用了 form="addAdvForm",要确保表单 id 一致;图片上传后要绑定 change 事件给 #preImg 设置 src 并显示;表格窄屏滚动需要 table-responsive 和最小宽度配合,转写稿里就出现过类名/结构没写对导致效果不出来,改完 webpack 配置还要重启。

登录模板页:表单入口与按钮 ​

为什么需要:后台需要登录入口,登录页只负责收集账号密码并提交,不堆业务逻辑。怎么想:用 Bootstrap 的 login-page、login-box、input-group 搭结构,账号和密码各一个输入框,登录按钮 type="submit"。怎么做:保留如下代码,后续在控制器里校验并跳转。易错点:输入框 name 为空,提交前要补;按钮在 form 内,type="submit" 会触发表单提交。

ejs
<div class="wrapper">
    <div class="login-page">
        <div class="login-box">
            <div class="card">
                <div class="card-body login-card-body">
                    <p class="login-box-msg">尚硅谷广告后台管理系统</p>
                    <form name="loginForm">
                        <div class="input-group mb-3">
                            <input name="" type="text" class="form-control" placeholder="管理员账号">
                            <div class="input-group-append">
                                <div class="input-group-text">
                                    <span class="fas fa-user"></span>
                                </div>
                            </div>
                        </div>
                        <div class="input-group mb-3">
                            <input name="" type="password" class="form-control" placeholder="请输入密码">
                            <div class="input-group-append">
                                <div class="input-group-text">
                                    <span class="fas fa-lock"></span>
                                </div>
                            </div>
                        </div>
                        <div class="row">
                            <div class="col-12">
                                <button type="submit" id="loginBtn" class="btn btn-primary btn-block">登陆</button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>

模板页与路由配置、样式和后端的联调 ​

为什么需要:模板页不是孤立的。侧边栏导航要高亮当前页,页面标题要随路由变化,样式要支持 less,数据要来自后端。怎么想:把路由配置当成单一数据源,导航、标题、图标都从配置里取;用 URL 判断当前路由;样式用 less 预处理;后端启动后先确认 MongoDB 和接口可用。怎么做:转写稿里给路由配置加 isNav、title、icon、path,模板里过滤 isNav 后循环生成导航;点击导航用 router.go 跳转并阻止超链接默认刷新;控制器把 URL、routes 传给模板,用 find 找 path === URL 的配置项取 title;安装 style-loader、css-loader、less-loader、less,配置 webpack 后重启;启动 MongoDB,进入后端目录 npm install,再 node app.js,访问 127.0.0.1:8088/admin 能看到 admin 用户。易错点:路由配置路径写错会报“不包含变量”;改 webpack 配置必须重启;导航不要写死,否则新增页面还要改模板;后端更新后要重新 npm install。

组件化的目标:把页面拆成可复用片段 ​

为什么需要:后台页面通常由页头、侧边栏、页脚和内容区组成。如果每个页面都复制一份,后续改导航或页脚就要到处改。怎么想:把稳定不变的部分抽成组件,把变化的内容通过参数传进去。怎么做:在 components/ 下建立 Header.ejs、Sidebar.ejs、Footer.ejs、Content.ejs。易错点:组件名、导入路径和调用时的大小写要一致;Content.ejs 里必须用 <%= data.subRouteContent %> 接收二级路由内容。

components/Header.ejs

ejs
<nav class="main-header navbar navbar-expand navbar-white navbar-light">
    <!-- Left navbar links -->
    <ul class="navbar-nav">
        <li class="nav-item">
            <a class="nav-link" data-widget="pushmenu" href="#" role="button"><i class="fas fa-bars"></i></a>
        </li>
    </ul>
    <!-- Right navbar links -->
    <ul class="navbar-nav ml-auto">
        <li class="nav-item">
            <a class="nav-link" data-widget="control-sidebar" data-slide="true" href="#" role="button">
                <button type="button" class="btn btn-block btn-danger btn-sm">退出登陆</button>
            </a>
        </li>
    </ul>
</nav>

components/Sidebar.ejs

ejs
<aside class="main-sidebar sidebar-dark-primary elevation-4">
    <!-- Brand Logo -->
    <a href="index3.html" class="brand-link">
        <img src="/img/logo.png" alt="广告管理LOGO" class="brand-image img-circle elevation-3" style="opacity: .8">
        <span class="brand-text font-weight-light">硅谷后台管理系统</span>
    </a>

    <!-- Sidebar -->
    <div class="sidebar">
        <!-- Sidebar user panel (optional) -->
        <div class="user-panel mt-3 pb-3 mb-3 d-flex">
            <div class="info">
                <a href="#" class="d-block">欢迎您:xxxxx</a>
            </div>
        </div>

        <!-- Sidebar Menu -->
        <nav class="mt-2">
            <ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false">
                <!-- 菜单列表 -->
                <li class="nav-item menu-open">
                    <a href="#" class="nav-link active">
                        <i class="nav-icon fas fa-tachometer-alt"></i>
                        <p>控制台</p>
                    </a>
                </li>
                <li class="nav-item menu-open">
                    <a href="#" class="nav-link">
                        <i class="nav-icon fas fa-user"></i>
                        <p>管理员列表页</p>
                    </a>
                </li>
                <li class="nav-item menu-open">
                    <a href="#" class="nav-link">
                        <i class="nav-icon fas fa-audio-description"></i>
                        <p>广告管理列表</p>
                    </a>
                </li>
            </ul>
        </nav>
        <!-- /.sidebar-menu -->
    </div>
    <!-- /.sidebar -->
</aside>

components/Footer.ejs

ejs
<footer class="main-footer">
    <!-- To the right -->
    <div class="float-right d-none d-sm-inline">
        Anything you want
    </div>
    <!-- Default to the left -->
    <strong>Copyright © 2023-2024 <a href="http://learn.fuming.site">尚硅谷</a>.</strong> All rights reserved.
</footer>

components/Content.ejs

js
<div class="content-wrapper">
    <!-- Content Header (Page header) -->
    <div class="content-header">
        <div class="container-fluid">
            <div class="row mb-2">
                <div class="col-sm-6">
                    <h1 class="m-0">页面标题</h1>
                </div>
            </div><!-- /.row -->
        </div><!-- /.container-fluid -->
    </div>
    <!-- /.content-header -->

    <!-- Main content -->
    <div class="content">
        <div class="container-fluid">
            <%= data.subRouteContent %>
        </div><!-- /.container-fluid -->
    </div>
    <!-- /.content -->
</div>

控制器里组合组件:让 /index 成为“壳” ​

为什么需要:组件本身只是字符串片段,必须有一个入口把它们拼成完整页面。怎么想:转写稿反复强调“入口读配置,路由找控制器,控制器读模板和组件,再交给模板组合”。本节用 SMERouter 的 next(indexV({...})) 把页头、侧边栏、页脚和内容区交给 index 模板。怎么做:在 src/app.js 导入组件与视图,/index 路由里调用组件函数,并把 res.subRoute() 作为 subRouteContent 传入。易错点:/index/admin、/index/adv 要用 res.render(adminV());/index 要用 next(...) 承载二级内容;Content 传参写成对象,别把 subRoute() 写成普通字符串。

src/app.js

js
// 导入
import SMERouter from 'sme-router'

// 导入组件
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';
import adminV from  '@/views/admin';
import loginV from  '@/views/login';
import advV from  '@/views/adv';

// 创建实例
// 默认是 hash 模式,特点  http://adv.manage.fuming.site/#/index/advlist
// 可以改为 history 模式,需要第二个参数设置为 html5 特点  http://adv.manage.fuming.site/index/advlist
const router = new SMERouter('app', 'html5')

router.route('/index', (req, res, next) => {
    // next函数可以 让二级路由的页面内容 呈现该该路由页面内容的内部
    next(indexV({
        // 页头组件
        Header: HeaderComponent(),
        // 侧边栏组件
        Sidebar: SidebarComponent(),
        // 页脚组件
        Footer: FooterComponent(),
        // 主要内容组件
        Content: ContentComponent({
            subRouteContent: res.subRoute()
        }),
    }))
});

router.route('/index/admin', (req, res) => {
    res.render(adminV());
});

router.route('/index/adv', (req, res) => {
    res.render(advV());
    console.log('/index/adv');

});

router.route('/login', (req, res) => {
    res.render(loginV());
});

router.route('*', (req, res) => {
    res.redirect('/index');
})

路由导航:点击不刷新,并让当前项高亮 ​

为什么需要:单页应用的价值是切页面不刷新;同时用户要知道自己当前在哪个菜单。怎么想:超链接 <a href="..."> 会触发浏览器默认跳转,所以要用 javascript:; 阻止默认行为,再调用 router.go('/index/admin') 这类路由跳转。转写稿调试时出现 router is not defined,原因是模块化后每个文件有独立作用域,router 默认不是全局,需在 app.js 中主动暴露到全局。高亮则把当前 url 传给 Sidebar,在模板里用三元判断是否加 active。易错点:只写 href 会刷新;router.go 路径要写全;高亮判断的字段要和路由配置里的 path 对上。

根据路由配置渲染导航列表 ​

为什么需要:菜单写死在 Sidebar.ejs 里,每加一个页面都要改模板。怎么想:把导航信息放回路由配置,给需要显示的项加 isNav、title、icon,控制器把 routes 传给 Sidebar。怎么做:Sidebar 里先 filter(item => item.isNav),再 forEach 生成 <li>;当前项用 item.path === data.url ? 'active' : ''。转写稿中曾把 @/routes 路径写错,导致模板报“不包含变量”,修正导入路径后恢复。易错点:filter 不是 forEach;data.routes 名字要和控制器传参一致;find/filter 的回调参数别漏写。

不同页面显示不同 title ​

为什么需要:Content.ejs 里固定“页面标题”会让所有二级页标题一样。怎么想:路由配置里已有 title,控制器拿到当前 url 后,用 routes.find(item => item.path === url) 找到对应项,再取 .title 传给 Content。怎么做:ContentComponent({ subRouteContent: res.subRoute(), title: ... }),模板里用 <%= data.title %>。易错点:find 找不到会返回 undefined,路径必须匹配;模板替换要用 <%= %>,循环/判断用 <% %>。

支持 less 与联调前检查 ​

为什么需要:页面自带样式不满足时,需要写 less 并让 webpack 处理。怎么想:装 style-loader、css-loader、less-loader、less,在 webpack 的 loaders 里加 test:/\.less$/,use 从右到左执行。怎么做:新建 src/assets/less/app.less,在 app.js 里 import,改完 webpack 配置必须重启。转写稿里改完配置未重启导致不识别 less,重启后生效。联调后端时先启动 MongoDB,再 npm i 更新依赖,最后 node app.js,访问 127.0.0.1:8088/admin 验证。易错点:loader 顺序写反;less 路径写错;后端更新后忘记 npm i。

为什么要抽离控制器 ​

为什么需要:如果入口 app.js 里既建路由、又写模板渲染、又导入组件,文件会越来越乱;页面一多,改一处容易牵动全身。
怎么想:把“路由匹配”和“页面渲染”分开:app.js 只负责装配路由,controllers 目录负责每个路由要渲染什么。转写稿里反复强调“控制器是中心”:路由先匹配到控制器,控制器再读取 views 模板和 components 组件,组合后发回模板。
怎么做:在 src/controllers 下按页面拆文件,每个文件默认导出一个处理函数。
易错点:控制器必须 export default 一个函数;模板、组件导入路径要写对;不要把所有逻辑继续堆回 app.js。

编写 index 控制器 ​

为什么需要:/index 是父级布局,页头、侧边栏、页脚、内容区要在这里组合;/index/admin、/index/adv 是二级路由,内容要嵌进 Content 内部。
怎么想:indexV 是整体模板,Header/Sidebar/Footer/Content 是组件函数;res.subRoute() 取二级路由内容,塞给 ContentComponent,最后用 next(indexV({...})) 交给路由模块。
怎么做:

js
// src/controllers/index.js

// 导入组件
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函数可以 让二级路由的页面内容 呈现该该路由页面内容的内部
    next(indexV({
        // 页头组件
        Header: HeaderComponent(),
        // 侧边栏组件
        Sidebar: SidebarComponent(),
        // 页脚组件
        Footer: FooterComponent(),
        // 主要内容组件
        Content: ContentComponent({
            subRouteContent: res.subRoute()
        }),
    }))
}

易错点:next 和 res.render 不要混用;res.subRoute() 要调用;组件是函数,写 HeaderComponent() 而不是只写组件名;index 是父级路由,二级路由才能被 subRoute() 取到。

编写普通页面控制器 ​

为什么需要:admin、adv、login 不需要父级布局组合,只要把各自模板渲染出来。
怎么想:一个控制器对应一个模板,导出函数里 res.render(模板()) 即可。
怎么做:

js
// src/controllers/admin.js

// 导入模板页
import adminV from  '@/views/admin';

export default (req, res) => {
    res.render(adminV());
}
js
// src/controllers/adv.js

// 导入模板页面
import advV from  '@/views/adv';

export default (req, res) => {
    res.render(advV());
}
JS
// src/controllers/login.js

// 导入模板
import loginV from  '@/views/login';

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

易错点:adminV()、advV()、loginV() 是调用模板函数;路径别名 @ 要能被构建工具识别;不要漏写 export default。

在 app.js 装配路由 ​

为什么需要:入口只做“创建路由实例 + 导入控制器 + 注册路径”,这样职责清晰。
怎么想:SMERouter 第二个参数 html5 表示 history 模式,地址不带 #;默认 hash 模式地址会带 #。注册时先写具体路由,最后写通配 * 做兜底重定向。
怎么做:

js
// src/app.js

// 导入
import SMERouter from 'sme-router'

// 导入控制器
import indexC from '@/controllers/index';
import adminC from '@/controllers/admin';
import advC from '@/controllers/adv';
import loginC from '@/controllers/login';

// 创建实例
// 默认是 hash 模式,特点  http://adv.manage.fuming.site/#/index/advlist
// 可以改为 history 模式,需要第二个参数设置为 html5 特点  http://adv.manage.fuming.site/index/advlist
const router = new SMERouter('app', 'html5')

router.route('/index', indexC);

router.route('/index/admin', adminC);

router.route('/index/adv', advC);

router.route('/login', loginC);

router.route('*', (req, res) => {
    res.redirect('/index/admin');
})

易错点:通配 * 要放最后;redirect 路径要写完整;/index/admin、/index/adv 是二级路由,父级 /index 控制器里的 next 和 res.subRoute() 要配合;控制器导入名和注册名要一致。

调试与小结 ​

抽离后排查顺序是:先看 app.js 是否导入并注册了控制器;再看控制器是否默认导出函数;再看 views、components 路径是否正确;最后看 index 控制器里 next(indexV({...})) 是否执行、res.subRoute() 是否有内容。转写稿里“控制器导入组件、模板,再发到 index 模板”的过程,就是这条链路。这样拆完后,后续给导航传 url、给标题传 title、按路由配置渲染列表,都可以继续在控制器和模板数据里扩展,而不用把入口文件写乱。

为什么要抽离路由配置 ​

为什么需要:如果所有路由都写在 src/app.js 里,入口文件会同时承担“创建实例、注册路由、配置路径”多件事,后续每加一个页面都要改入口,容易乱,也不利于复用。把路由表单独放到 src/routes/index.js,入口只负责读取和注册,职责更清楚。

怎么想:路由本质是“路径 → 控制器函数”的映射。先把映射关系集中成一个数组,再由入口遍历注册。这样以后导航、标题等也能围绕这份配置生成。

怎么做:新建 src/routes/index.js,导入控制器并默认导出一个数组:

js
// 导入控制器
import indexC from '@/controllers/index';
import adminC from '@/controllers/admin';
import advC from '@/controllers/adv';
import loginC from '@/controllers/login';

export default [
    {
        path: '/index',
        element: indexC
    },
    {
        path: '/index/admin',
        element: adminC,
    },
    {
        path: '/index/adv',
        element: advC
    },
    {
        path: '/login',
        element: loginC
    },
    {
        path: '*',
        element: (req, res) => {
            res.redirect('/index/admin')
        }
    }
]

易错点:element 放的是控制器函数,不是组件;通配符 * 要放在最后,否则可能提前拦截正常路径;导入路径别名 @ 要确保构建配置支持;默认导出别写成具名导出。

在入口中注册路由 ​

为什么需要:路由表只是数据,必须交给 SMERouter 实例注册后才会生效。入口 app.js 负责初始化,不负责维护具体路径。

怎么想:先创建路由器实例,再遍历路由配置,逐项调用 router.route(path, element)。转写稿里也强调:程序从入口进入,入口读取路由配置,再根据路径匹配到对应控制器。

怎么做:在 src/app.js 中导入 SMERouter 和路由配置:

js
// 导入
import SMERouter from 'sme-router'
// 导入路由配置
import routes from './routes';

// 创建实例
// 默认是 hash 模式,特点  http://adv.manage.fuming.site/#/index/advlist
// 可以改为 history 模式,需要第二个参数设置为 html5 特点  http://adv.manage.fuming.site/index/advlist
const router = new SMERouter('app', 'html5')

// 遍历路由配置 设置路由
routes.forEach(routeItem => {
    router.route(routeItem.path, routeItem.element);
})

易错点:new SMERouter('app', 'html5') 的第一个参数是挂载容器的 id,要确认页面里有对应元素;html5 模式 URL 不带 #,但需要服务端支持 history 回退;如果只导入 routes 却没有 forEach 注册,路由不会生效;import routes from './routes' 默认会找 routes/index.js。

用路由配置驱动导航与标题 ​

为什么需要:转写稿里进一步把导航列表和页面标题也接到路由配置上。如果导航写死在 SideBar 模板里,以后加页面要改两处;如果标题写死,不同页面会显示同一个标题。

怎么想:给需要出现在导航里的路由项加 isNav、title、icon 等属性;SideBar 遍历 routes,用 filter 只保留 isNav 为真的项。当前 URL 由控制器通过 req.url 传给模板,模板里用当前 URL 和 item.path 比较,相等就给 active 类实现高亮。标题则在控制器里用 routes.find(item => item.path === url) 找到当前路由项,再取 title。

怎么做:转写稿中的调试过程是:一开始导航用 <a href>,点击会刷新页面;后来改为阻止默认行为,并调用 router.go('/index/admin')。因为模板里拿不到 router,就把 router 暴露到全局;再后来把 routes 也传给 SideBar,模板中 forEach/filter 渲染导航。标题部分把 title 传给内容模板,用 find 根据 URL 匹配 path。

易错点:router 和 routes 不是一回事;模板中循环输出不要漏 % 和等号;在控制器里导入 routes 时相对路径要写对,转写稿里就出现过路径指错导致“找不到变量”的调试;find 返回的是对象,要再取 .title;高亮判断要用 item.path 与当前 URL 比较,不要写死。

调试与收尾 ​

抽离路由配置后,后续新增页面优先改 routes/index.js,再确认入口是否遍历注册。若使用 html5 模式,刷新子路径要确认服务端回退;若用 hash 模式,URL 会带 #。本节笔记里的两个代码块是核心:路由表负责“有哪些路径”,app.js 负责“把路径注册给路由器”。

路由导航要解决什么 ​

后台侧边栏点击菜单时,如果直接写超链接,整页会刷新;单页应用希望只切换内容区,不刷新页面;同时还要让当前菜单高亮。转写稿里反复强调:先找到导航写在 Sidebar.ejs,再改 a 标签;router.go 不是 routes,两者不要混。模板里用控制器传过来的数据时,必须用 <%= data.xxx %> 这种 EJS 输出语法。

情况一:超链接点击会刷新 ​

为什么需要:先验证默认行为。
怎么想:菜单项本来就是 a 标签,把 href 改成对应路由即可。
怎么做:在 src/components/Sidebar.ejs 中写:

ejs
<aside class="main-sidebar sidebar-dark-primary elevation-4">
    <a href="index3.html" class="brand-link">
        <img src="/img/logo.png" alt="广告管理系统LOGO" class="brand-image img-circle elevation-3" style="opacity: .8">
        <span class="brand-text font-weight-light">硅谷后台管理系统</span>
    </a>
    <div class="sidebar">
        <div class="user-panel mt-3 pb-3 mb-3 d-flex">
            <div class="info">
                <a href="#" class="d-block">欢迎您:xxxxx</a>
            </div>
        </div>
        <nav class="mt-2">
            <ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false">
                <li class="nav-item menu-open">
                    <a href="/index/admin" class="nav-link">
                        <i class="nav-icon fas fa-user"></i>
                        <p>管理员列表</p>
                    </a>
                </li>
                <li class="nav-item menu-open">
                    <a href="/index/adv" class="nav-link">
                        <i class="nav-icon fas fa-audio-description"></i>
                        <p>广告管理列表</p>
                    </a>
                </li>
            </ul>
        </nav>
    </div>
</aside>

易错点:超链接默认会刷新;href="#" 也不是无刷新路由,可能改变 hash 或触发刷新。单页应用不能靠普通超链接完成导航。

情况二:点击不刷新,改用 router.go ​

为什么需要:单页应用不想整页刷新。
怎么想:不能用普通超链接跳转,改为阻止 a 的默认行为,调用路由模块的 go 方法。转写稿中写成 href="javascript:;",并加 onclick="router.go('/index/admin')"。
怎么做:Sidebar.ejs 其余结构不变,只改两个菜单项:

ejs
<a href="javascript:;" class="nav-link" onclick="router.go('/index/admin')">
    <i class="nav-icon fas fa-user"></i>
    <p>管理员列表</p>
</a>
<a href="javascript:;" class="nav-link" onclick="router.go('/index/adv')">
    <i class="nav-icon fas fa-audio-description"></i>
    <p>广告管理列表</p>
</a>

转写稿调试时直接写 onclick="router.go(...)" 会报 router is not defined,因为前端工程化后每个 JS 文件是模块作用域,router 默认不是全局变量。解决方式是在 app.js 创建 router 后,把它主动暴露到全局,模板里才能访问。
易错点:href 不要留空;两个菜单都要改;router 与 routes 不是一回事;路径必须与路由配置一致。

情况三:设置导航高亮 ​

为什么需要:让用户知道当前在哪个页面。
怎么想:控制器能拿到 req.url,把 url 传给 Sidebar;模板里用 data.url 与菜单路径比较,相等就加 active。
怎么做:Sidebar.ejs 中两个菜单项改为:

ejs
<a href="javascript:;" class="nav-link <%= data.url === '/index/admin' ? 'active' : '' %>" onclick="router.go('/index/admin')">
    <i class="nav-icon fas fa-user"></i>
    <p>管理员列表</p>
</a>
<a href="javascript:;" class="nav-link <%= data.url === '/index/adv' ? 'active' : '' %>" onclick="router.go('/index/adv')">
    <i class="nav-icon fas fa-audio-description"></i>
    <p>广告管理列表</p>
</a>

控制器 src/controllers/index.js:

js
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 ({url}, res, next) => {
    next(indexV({
        Header: HeaderComponent(),
        Sidebar: SidebarComponent({ url }),
        Footer: FooterComponent(),
        Content: ContentComponent({
            subRouteContent: res.subRoute()
        }),
    }))
}

易错点:模板中只有 <%= %> 才会输出/替换数据;active 要加在 class 里;路径必须和 href、router.go 中的路径完全一致。

转写稿补充:按路由配置渲染导航与标题 ​

转写稿还演示了进阶做法:在路由配置每项加 isNav、title、icon,控制器导入 routes 并传给 Sidebar;模板用 filter 过滤 isNav,再循环渲染,高亮判断改成 item.path === data.url。这里曾因 import ROUTES 路径写错报“模板不包含 ROUTES”,从 controllers 找 routes 应写 ../routes。不同页面标题则用 ROUTES.find(item => item.path === url)?.title 取当前路由标题,再传给 Content。核心易错点:router 是路由实例,routes 是配置数组;path 不要拼错;find 返回对象,要取 .title。

先明确目标:导航列表要由路由配置生成 ​

为什么需要:后台系统的导航项会不断增加、改名、换图标。如果直接把“管理员列表”“广告列表”写死在 Sidebar.ejs 里,每加一个页面就要改模板,容易漏改,也不利于维护。
怎么想:把路由配置当成“单一数据源”,只在需要出现在导航里的路由对象上增加 isNav、title、icon,侧边栏只负责过滤和渲染。
怎么做:先看 src/routes/index.js,给管理员、广告、首页三个导航项补上标记;再让控制器把 routes 和当前 url 传给 Sidebar;最后在 EJS 中 filter(item => item.isNav) 后循环。
易错点:不是所有路由都要进导航,登录页、通配路由不要加 isNav;isNav 是布尔值,不要写成字符串 'true'。

js
// 导入控制器
import indexC from '@/controllers/index';
import adminC from '@/controllers/admin';
import advC from '@/controllers/adv';
import loginC from '@/controllers/login';

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: '/index',
        element: advC,
        isNav: true,
        title: '首页',
        icon: 'fa-home'
    },
    {
        path: '/login',
        element: loginC
    },
    {
        path: '*',
        element: (req, res) => {
            res.redirect('/index/admin')
        }
    }
]

控制器:把当前 url 和路由配置交给侧边栏 ​

为什么需要:Sidebar 自己拿不到当前路径,也拿不到路由配置。控制器是布局入口,负责导入组件、模板和路由配置。
怎么想:转写稿里先在控制器 console.log(req.url),发现刷新和切换时能拿到 /index/admin、/index/adv,于是把 url 传给 Sidebar;渲染导航列表时再把 routes 一并传过去。
怎么做:SidebarComponent({ url, routes }),同时 next(indexV(...)) 让二级路由内容进入 Content。
易错点:转写稿中曾把 routes 的导入路径写错,导致模板报“不包含这个数据/无效命名”;routes 是路由配置数组,router 是路由实例,两者不要混。

js
// 导入组件
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';

// 导入路由配置对象
import routes from '../routes';

export default ({url}, res, next) => {

    // next函数可以 让二级路由的页面内容 呈现该该路由页面内容的内部
    next(indexV({
        // 页头组件
        Header: HeaderComponent(),
        // 侧边栏组件
        Sidebar: SidebarComponent({
            url,
            routes
        }),
        // 页脚组件
        Footer: FooterComponent(),
        // 主要内容组件
        Content: ContentComponent({
            subRouteContent: res.subRoute()
        }),
    }))
}

为什么需要:导航要能点击切换,且不能整页刷新;当前所在页面还要高亮。
怎么想:转写稿中最初用 <a href="/index/admin">,点击会刷新;改成 href="javascript:;" 阻止默认行为,再用 onclick="router.go(...)"。第一次报 router is not defined,后来在 app.js 把 router 暴露到全局才可用。高亮则比较 data.url === item.path,成立时加 active。
怎么做:data.routes.filter(item=>item.isNav).forEach(...) 生成 li;item.title 输出文字,item.icon 拼到 fas 后面。
易错点:EJS 循环用 <% %>,输出用 <%= %>;onclick 里路径要加引号;href 不能只写 # 或真实地址,否则可能刷新;router.go 依赖全局 router。

ejs
<aside class="main-sidebar sidebar-dark-primary elevation-4">
    <!-- Brand Logo -->
    <a href="index3.html" class="brand-link">
        <img src="/img/logo.png" alt="广告管理LOGO" class="brand-image img-circle elevation-3" style="opacity: .8">
        <span class="brand-text font-weight-light">硅谷后台管理系统</span>
    </a>

    <!-- Sidebar -->
    <div class="sidebar">
        <!-- Sidebar user panel (optional) -->
        <div class="user-panel mt-3 pb-3 mb-3 d-flex">
            <div class="info">
                <a href="#" class="d-block">欢迎您:xxxxx</a>
            </div>
        </div>

        <!-- Sidebar Menu -->
        <nav class="mt-2">
            <ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false">
                <!-- 菜单列表 -->
         
  
                <% data.routes.filter(item=>item.isNav).forEach(item => { %> 
  
                <li class="nav-item menu-open">
                    <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>
                <% }) %>
  
        
            </ul>
        </nav>
        <!-- /.sidebar-menu -->
    </div>
    <!-- /.sidebar -->
</aside>

调试与检查 ​

转写稿中的关键调试顺序是:写死链接会刷新 → 改 javascript:; + router.go → 报 router 未定义 → 暴露全局 → 两个链接都改 → 观察页面不刷新、网络不重新请求 → 加 active 高亮 → 再把写死菜单改成从 routes 过滤生成。最后检查字体图标:fontawesome 已引入,fa-user、fa-audio-description、fa-home 能正常显示。这样导航列表就由路由配置驱动,后续增删导航只需改 src/routes/index.js。

先定位:标题到底在哪里渲染 ​

为什么需要:不同页面要显示不同 title,第一步不是改路由,而是找到页面上那行标题是谁渲染的。否则容易在路由配置里改了半天,页面仍然不动。

怎么想:从页面结构看,标题在内容区域,而内容区域由 Content 组件负责。控制器 src/controllers/index.js 把 ContentComponent 组合进 indexV,所以标题数据应该由控制器传给 Content 组件。

怎么做:打开 src/components/Content.ejs,找到原来写死标题的位置,把静态文字换成模板变量:

ejs
<h1 class="m-0"><%= data.title %></h1>

易错点:模板里接收控制器传来的数据,要用 <%= data.xxx %> 这种形式;不要直接写 title,也不要漏掉等号。转写稿里反复强调“模板中替换数据用的是这个”,说的就是 EJS 的输出语法。

为什么不能把 title 写死 ​

为什么需要:如果控制器里直接写 title: '首页',那么管理员列表、广告列表都会显示同一个标题。转写稿中先“随便写一个”,结果两个页面标题一样,这说明写死只能验证传参链路,不能完成需求。

怎么想:标题其实已经存在路由配置里。每个路由对象有 path、title 等属性,当前访问哪个页面,就应该取对应路由对象的 title。控制器里已经导入了 routes,可以直接用。

怎么做:在控制器中,根据当前 url 去 routes 数组里找匹配项,再取它的 title:

js
title: routes.find(item => item.path === url).title

易错点:routes 是路由配置数组,不是 router 实例;find 返回的是第一个符合条件的对象,所以后面还要加 .title。如果直接写 routes.find(...) 传给组件,传过去的是整个路由对象,不是标题字符串。

用 url 匹配路由配置 ​

为什么需要:控制器函数参数里有 { url },它代表当前请求的路径。不同页面的 url 不同,路由配置里的 path 也不同,用二者相等来判断当前页面。

怎么想:routes.find(item => item.path === url) 的含义是:遍历 routes,找到第一个 item.path 等于当前 url 的元素。找到后返回该元素,再取 .title。

怎么做:保持控制器中 ContentComponent 的传参:

js
Content: ContentComponent({
    title: routes.find(item => item.path === url).title,
    subRouteContent: res.subRoute()
}),

易错点:find 是数组方法,参数是回调函数;不要写成 routes.find(url)。匹配属性是 path,不是 pass 或 patch。转写稿里出现“pass”是同音误写,代码里应以路由配置的 path 为准。

完整代码对照 ​

src/controllers/index.js:

js
// 导入组件
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';

// 导入路由配置对象
import routes from '../routes';

export default ({url}, res, next) => {

    // next函数可以 让二级路由的页面内容 呈现该该路由页面内容的内部
    next(indexV({
        // 页头组件
        Header: HeaderComponent(),
        // 侧边栏组件
        Sidebar: SidebarComponent({
            url,
            routes
        }),
        // 页脚组件
        Footer: FooterComponent(),
        // 主要内容组件
        Content: ContentComponent({
            title: routes.find(item=>item.path===url).title,
            subRouteContent: res.subRoute()
        }),
    }))
}

src/components/Content.ejs:

ejs
<div class="content-wrapper">
    <!-- Content Header (Page header) -->
    <div class="content-header">
        <div class="container-fluid">
            <div class="row mb-2">
                <div class="col-sm-6">
                    <h1 class="m-0"><%= data.title %></h1>
                </div>
            </div><!-- /.row -->
        </div><!-- /.container-fluid -->
    </div>
    <!-- /.content-header -->

    <!-- Main content -->
    <div class="content">
        <div class="container-fluid">
            <%= data.subRouteContent %>
        </div><!-- /.container-fluid -->
    </div>
    <!-- /.content -->
</div>

调试顺序与易错点 ​

怎么想:先确认 Content.ejs 是否用了 data.title,再确认控制器是否把 title 传给了 ContentComponent,最后确认 url 能否在 routes 中匹配到 path。转写稿中的调试过程也是先写死看是否显示,再改成按 url 查找。

怎么做:改完后刷新管理员列表和广告列表,观察标题是否随页面变化。若标题不显示,先检查模板变量名;若两个页面标题相同,检查是否仍然写死;若报错“找不到 title”,检查 find 是否匹配到了路由对象。

易错点:routes.find(...).title 假定一定能找到匹配项;当前项目访问的页面都在 routes 中,所以这样写成立。若以后有未配置的路由,直接取 .title 会报错,需要额外兜底。不要把 routes 和 router 混用,也不要漏掉控制器里已经导入的 routes。

本节要解决的是:让 webpack 项目能够编译 .less 文件,并把样式应用到广告列表页。转写稿里有些口误,比如“赖子”实际是 less、“楼点”实际是 loader、“柚子”实际是 use、“转移点儿”实际是 test,配置时按正确英文写即可。

为什么需要支持 LESS ​

为什么需要:项目自带样式不一定符合需求,后面要自己写样式;直接写 CSS 可以,但用 less 这类预处理器更方便组织嵌套和变量。怎么想:既然项目已经用 webpack 管理 js,就应该让 webpack 也接管 less,通过 loader 把 less 编译成 css,再注入页面。怎么做:安装 loader 和 less,改 webpack 配置,写 less 文件,并在入口导入。易错点:不要以为写了 .less 文件就会自动生效,必须配置 loader,并且入口要导入。

安装 less 相关依赖 ​

为什么需要:webpack 本身不认识 less,需要 less-loader 调用 less 编译器,css-loader 处理 css 中的依赖,style-loader 把 css 注入页面。怎么想:这四个包是一组,少一个都可能报错。怎么做:在项目根目录执行:

shell
npm install style-loader css-loader less-loader less -D

易错点:-D 表示开发依赖;不要漏装 less 本身;安装后确认 package.json 里出现依赖。转写稿里“杠大D”就是 -D。

配置 webpack 的 less loader ​

为什么需要:只安装不配置,webpack 仍然不知道 .less 文件交给谁处理。怎么想:在 module.rules 里增加一条规则,用 test 匹配 .less,用 use 指定 loader 链。怎么做:修改 config/webpack.base.config.js:

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

易错点:loader 执行顺序是从右往左,所以必须是 less-loader 先编译,再 css-loader,最后 style-loader,数组顺序不能反;test 要写正则 /\.less$/,不要写成普通字符串。改完 webpack 配置后要重启开发服务,否则会提示不认识 less。转写稿中就是改完配置没重启,重新启动后才正常。

编写 app.less 并落到表格选择器上 ​

为什么需要:配置只是让 less 能编译,真正要验证还得写一段样式。怎么想:广告列表里表格在窄屏下显示不开,可以给表格设置最小宽度,让外层出现横向滚动。怎么做:新建 src/assets/less/app.less:

less
#advTable {
    table {
        min-width: 1200px;
    }
}

这里用了 less 的嵌套写法,编译后相当于 #advTable table。易错点:选择器要和模板里的 id 对上,笔记里是 #advTable,转写稿口误成“ADVT”,实际要写 advTable;路径不要写错。

入口导入与 adv.ejs 验证 ​

为什么需要:less 文件写好后不会自动进入页面,需要在入口导入。怎么想:转写稿中是在 src/app.js 里导入样式,路径按目录写成 ./assets/less/app.less 或使用 @ 别名。怎么做:在入口加:

js
import './assets/less/app.less';

同时按笔记修改 src/views/adv.ejs:

src/views/adv.ejs 自行修改。

转写稿中的做法是给表格外层加响应式容器类(如 Bootstrap 的 table-responsive),并把分页移到外层;具体以项目模板为准。易错点:模板不要直接整体复制覆盖,转写稿特意改过结构,直接覆盖会和示例不一致;响应式类名容易拼错,查文档不能省;改完 webpack 配置必须重启,再打开广告列表看最小宽度是否生效。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例一:路由配置 + 控制器(src/routes/index.js / src/controllers/index.js) ​

js
// src/routes/index.js
import indexC from '@/controllers/index';
import adminC from '@/controllers/admin';
import advC from '@/controllers/adv';
import loginC from '@/controllers/login';

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: '/index',
        element: advC,
        isNav: true,
        title: '首页',
        icon: 'fa-home'
    },
    {
        path: '/login',
        element: loginC
    },
    {
        path: '*',
        element: (req, res) => {
            res.redirect('/index/admin')
        }
    }
]
js
// src/controllers/index.js
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';

// 导入路由配置对象
import routes from '../routes';

export default ({ url }, res, next) => {
    next(indexV({
        Header: HeaderComponent(),
        Sidebar: SidebarComponent({
            url,
            routes
        }),
        Footer: FooterComponent(),
        Content: ContentComponent({
            title: routes.find(item => item.path === url).title,
            subRouteContent: res.subRoute()
        }),
    }))
}

关键行与易错点

  • export default ({ url }, res, next) => {}:对第一个参数做了解构赋值,等价于 (req, res, next) => { const url = req.url },后面模板里就能直接用 url,不必再写 req.url。
  • import routes from '../routes':控制器在 src/controllers/ 下,路由配置在 src/routes/ 下,必须写 ../routes(.. 回退一层)。写成 ./routes 会直接报“找不到模块 / data.routes 不包含变量”这类错误,是本节最常踩的坑。
  • routes.find(item => item.path === url):find 返回第一个满足条件的元素(对象),所以还要再取 .title;别误以为 find 返回的是标题字符串。
  • 只有 isNav: true 的路由才会渲染成导航,/login、* 不带该属性,所以不会出现在侧边栏里——新增导航时忘了加 isNav 是最常见的“菜单不显示”原因。
  • 新增一条导航只需在 routes 里复制一个对象,改 path / title / icon 即可,不需要动模板,这正是抽离路由配置的意义。

示例二:侧边栏模板(src/components/Sidebar.ejs) ​

ejs
<aside class="main-sidebar sidebar-dark-primary elevation-4">
    <!-- Brand Logo -->
    <a href="index3.html" class="brand-link">
        <img src="/img/logo.png" alt="广告管理LOGO" class="brand-image img-circle elevation-3" style="opacity: .8">
        <span class="brand-text font-weight-light">硅谷后台管理系统</span>
    </a>

    <!-- Sidebar -->
    <div class="sidebar">
        <div class="user-panel mt-3 pb-3 mb-3 d-flex">
            <div class="info">
                <a href="#" class="d-block">欢迎您:xxxxx</a>
            </div>
        </div>

        <nav class="mt-2">
            <ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false">
                <!-- 菜单列表:根据路由配置动态渲染 -->
                <% data.routes.filter(item => item.isNav).forEach(item => { %>

                <li class="nav-item menu-open">
                    <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>
                <% }) %>
            </ul>
        </nav>
    </div>
</aside>

关键行与易错点

  • href="javascript:;":把超链接的默认跳转“废掉”,阻止默认行为,否则用 <a href="/index/adv"> 一定会整页刷新,单页应用的意义就没了。
  • onclick="router.go('/index/adv')":router.go 内部用的是 location + history,属于 history 模式跳转,页面不刷新;前提是 src/app.js 里创建实例后把路由对象挂到全局:
    js
    const router = new SMERouter('app', 'html5')
    window.router = router
    否则浏览器控制台会报 router is not defined——这是本节第一个大坑。
  • <% ... %> 与 <%= ... %> 要分清:forEach 循环用 <% %>(纯代码,不输出),输出变量用 <%= %>。循环里误写成 <%= 会报语法错。
  • <%= data.url === item.path ? 'active' : '' %>:高亮靠给 nav-link 加 active 类,data.url 由控制器从 req.url 传下来,路径对得上才亮。写死 active 会导致永远只有第一项亮。
  • <i class="nav-icon fas <%= item.icon %>"></i>:fas 是 fontawesome 的固定前缀,item.icon 只写后半段(如 fa-user),两个拼起来图标才显示。

示例三:支持 LESS(config/webpack.base.config.js + src/assets/less/app.less) ​

js
// config/webpack.base.config.js
module.exports = {
    module: {
        rules: [
            {
                test: /\.ejs$/,
                use: ['ejs-loader']
            },
            {
                test: /\.less$/,
                use: ['style-loader', 'css-loader', 'less-loader']
            }
        ]
    },
};
less
// src/assets/less/app.less
#advTable {
    table {
        min-width: 1200px;
    }
}
js
// src/app.js 顶部
import '@/assets/less/app.less';

关键行与易错点

  • 先装依赖:npm install style-loader css-loader less-loader less -D,四个缺一不可,漏装 less 会报 Cannot find module 'less'。
  • use 数组从右往左执行:less-loader → css-loader → style-loader,顺序写反直接报错。
  • 改完 webpack 配置必须重启 dev server,热更新不会重新加载配置,否则仍提示不认识 .less。
  • app.less 放在 src/assets/less/ 下(assets 用来统一放样式及其依赖的图片等资源),最后在 src/app.js 里 import 一次即可,单页应用全局只导一遍。
  • 表格出现横向滚动条还依赖模板配合:把 table 包进带 table-responsive 类的容器,并且把分页 card-footer 移到该容器外面,否则分页会跟着一起横向滚动。

常见错误 ​

  • 现象:点击侧边栏后整页刷新,单页应用状态丢失。原因:仍使用 <a href="/index/admin"> 这类普通超链接。修法:改为 href="javascript:;",用 onclick="router.go('/index/admin')" 跳转。
  • 现象:控制台报 router is not defined。原因:router 只在 app.js 模块作用域内,未暴露到全局。修法:在 app.js 中显式挂到 window.router,或在组件中通过可控方式注入跳转能力。
  • 现象:侧边栏导航不显示或报 data.routes 相关错误。原因:Sidebar.ejs 中使用了 data.routes,但控制器未传入 routes,或 routes 导入路径写错。修法:在 controllers/index.js 正确导入路由配置并传给 SidebarComponent({ url, routes })。
  • 现象:导航高亮始终不正确。原因:高亮条件写死,或未把当前 url 传入侧边栏。修法:控制器解构 url 并传给 Sidebar,模板中用 data.url === item.path 判断 active。
  • 现象:新增路由后侧边栏不自动出现,或非导航路由也出现在菜单中。原因:未给路由配置加 isNav/title/icon,或遍历前未过滤。修法:导航项配置 isNav: true,模板中先 filter(item => item.isNav) 再渲染。
  • 现象:不同页面标题仍显示同一个“页面标题”。原因:Content 的 title 写死,或控制器未按 url 匹配路由配置。修法:控制器用 routes.find(item => item.path === url).title 取值,并传给 ContentComponent。
  • 现象:引入 LESS 后启动报 loader 相关错误。原因:未安装 style-loader/css-loader/less-loader/less,或 loader 顺序错误,或改配置后未重启。修法:安装依赖,按 ['style-loader', 'css-loader', 'less-loader'] 配置,并重启构建。
  • 现象:表格横向滚动/分页布局不生效。原因:模板结构调整不完整,table-responsive 包裹层级或分页位置与样式不匹配。修法:按当前模板结构核对包裹层,将分页移出表格容器,再配合 LESS 最小宽度验证。

练习 ​

  1. 新增一个“用户列表”路由页,使其自动出现在侧边栏导航中,并验证点击后不刷新、当前项高亮、内容区标题正确。
  2. 将侧边栏中写死的两个导航项彻底改为基于 routes 配置渲染,并保证 login 和通配路由不会出现在菜单中。
  3. 修复一个场景:点击导航后页面刷新且地址栏变化,但不应整页刷新;请给出最小修改方案并说明验证点。
  4. 为广告列表表格增加横向滚动能力,同时保证分页区域不被包进滚动容器;写出模板与 LESS 的关键修改点。
  5. 排查场景:访问 /index/adv 时只显示外层布局,不显示广告列表内容;请按路由配置、控制器、next/res.subRoute()、模板容器顺序定位问题。

原视频对照 ​

#视频时长
1当前项目目录结构分析05:23
2路由导航20:30
3根据路由配置渲染导航列表15:09
4不同页面显示不同title08:48
5支持less13:44
6启动后端服务06:50

小结 ​

  1. 路由配置应作为导航、标题、图标等页面元信息的单一来源,避免模板和控制器各写一份。
  2. 控制器负责“取路由信息 + 组装组件 + 交给模板”,模板只负责按传入数据渲染。
  3. 单页应用导航优先使用路由实例跳转,普通超链接会破坏无刷新体验。
  4. 组件间传值要显式、可追踪;url、routes、title 缺失通常先在控制器传参处排查。
  5. 模板结构调整会影响样式选择器和布局效果,改结构后要同时验证 DOM 层级与 LESS 选择器。
  6. 新增 loader、依赖或 Webpack 配置后必须重启构建;否则错误往往不是代码逻辑问题。
  7. 前后端联调前先确认 MongoDB、后端服务、依赖安装和接口路径,再排查前端请求问题。
  8. 调试顺序建议固定为:路由是否匹配 → 控制器是否执行 → 组件数据是否传入 → 模板是否渲染 → 样式是否生效。