Skip to content

项目初始化配置与设置标题和站标(项目功能实现 1/3) ​

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

学习目标 ​

  • 能够修改 webpack 的 resolve.extensions 与 resolve.alias,实现省略 .ejs 扩展名和 @ 路径别名,并说明修改配置后需重启的原因。
  • 能够使用 sme-router 配置一级与二级路由,结合 next 和 subRouter 将子路由内容渲染到父级模板指定位置。
  • 能够定位并修复二级路由下静态资源相对路径导致的 404 问题,正确使用以 / 开头的站点根路径。
  • 能够将页面拆分为头部、侧边栏、页脚、主要内容等组件,并通过模板数据将组件内容注入页面。
  • 能够将路由回调抽离为独立控制器,并按路由创建对应控制器文件,保证各页面正常访问。
  • 能够将路由配置抽离为独立配置文件,在入口文件中导入并遍历配置完成路由注册。

知识点 ​

一、省略 .ejs 扩展名与配置路径别名 ​

为什么需要:项目里 import 模板时如果每次都要写 .ejs,路径一长就容易写错;同时 src 目录层级较深,频繁写 ../../src 很啰嗦。
怎么想:转写稿强调,.ejs 能被当作模块导入,是 webpack 在处理模块化,所以应该从 webpack 配置层解决,而不是在业务代码里硬改。路径别名同理,也属于 resolve 配置。
怎么做:在 webpack.config.js 的 resolve 中追加 .ejs,并配置 @ 指向 src。注意 extensions 自己写会覆盖默认值,必须保留 .js、.json。

js
// webpack.config.js
resolve: {
  extensions: ['.js', '.json', '.ejs'],
  alias: {
    '@': path.resolve(__dirname, './src')
  }
}

配置后,模板导入可写成 import indexTpl from '@/views/index.ejs',甚至省略 .ejs。
易错点:改 webpack 配置必须重启;改 src、public 里的前端内容才走热更新。转写稿中先直接删 .ejs 立刻报错,改完配置后重启才恢复正常,这是排查方向。

二、用二级路由复用页面框架 ​

为什么需要:管理员列表和广告列表只有中间内容不同,头部、侧边栏、页脚都一样。若每个页面各写一套,重复且难维护。
怎么想:把 /index 当父路由,把 /index/admin、/index/adv 当子路由;父路由加载公共框架,子路由内容嵌入指定位置。
怎么做:父路由不能只用 render,因为 render 会设置 #app 的 innerHTML,后执行的子路由会覆盖父内容。转写稿中改用 next,并在父模板中留占位,把子路由内容作为数据传给模板。关键写法按转写稿整理为:

js
router.route('/index', (req, res, next) => {
  next(indexTpl, { subRouteContent: res.subRoute() })
})
router.route('/index/admin', adminController)
router.route('/index/adv', advController)

父模板中用模板字符串表达式插入 subRouteContent。调试时在父、子路由里加 console.log,可看到请求 /index/admin 时父路由也会经过。
易错点:父路由要写在子路由前面;render 会覆盖,next 才能保留父模板;子路由数据要发给真正使用它的模板。

三、补齐管理员、广告、登录三个页面 ​

为什么需要:路由能切换后,必须让三个页面有真实模板,才能继续做功能。
怎么想:/index/admin、/index/adv 继承 index 框架,只复制主要内容;login 独立,不继承该框架。
怎么做:从设计稿或已有 HTML 中分别取管理员列表、广告列表、登录表单的主体结构,放入 views/admin.ejs、views/adv.ejs、views/login.ejs。
易错点:静态资源路径。转写稿中 logo 在 /index/admin 下 404,因为相对路径被解析成 index 目录下;把图片、CSS 等路径改成以 / 开头,表示从网站根目录找。网络面板可看到 logo.png 404,这是定位依据。

四、页面组件化 ​

为什么需要:index.ejs 里头部、侧边栏、页脚、内容混在一起,复用困难。
怎么想:在 src/components 下拆 Header、Sidebar、Footer、Content,哪里需要就在控制器里导入并传给模板。
怎么做:组件本质是导入后的模板函数,调用后得到字符串。控制器中导入组件,再通过 render 或 next 的数据对象传给模板;模板里用 data.header、data.sidebar 等插入。
易错点:转写稿中把 subRouteContent 发给了 index.ejs,但真正使用它的是 Content 组件,导致页面异常;应把子路由数据传给 Content,再把 Content 放进 index.ejs。

五、抽离控制器 ​

为什么需要:路由后直接写大段函数,路由一多会非常乱;控制器逻辑也应独立维护。
怎么想:一个路由对应一个控制器文件,控制器负责导入模板、组织数据、响应页面。
怎么做:在 src/controllers 下建 index.js、admin.js、adv.js、login.js,每个文件 export default 一个函数;APP.js 只导入控制器并绑定路由。
易错点:绑定路由时传函数引用,不要多写 ();转写稿中抽离后页面空白,就是因为路由后没有正确接上控制器。目录名 controllers 也不要拼错。

六、抽离路由配置 ​

为什么需要:路由几十上百个时,全写在 APP.js 不现实。
怎么想:路由配置只描述“路径 + 控制器”,可以集中到一个数组文件。
怎么做:新建 src/routes/index.js,导出数组:

js
export default [
  { path: '/index', element: indexC },
  { path: '/index/admin', element: adminC },
  { path: '/index/adv', element: advC },
  { path: '/login', element: loginC }
]

APP.js 导入后遍历:

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

易错点:只导入数组不遍历,页面不会报错但什么都不显示;element 是自定义属性名,前后要一致。转写稿中抽离后页面正常,说明“路由配置 → 遍历注册 → 控制器响应”的链路打通。

先看演示与后端仓库 ​

本节是项目准备阶段,先把目标看清楚:前端演示地址是 http://adv.manage.fuming.site/ ,后端代码仓库是 https://gitee.com/sh230320/backend_project 。为什么需要:先看到最终效果,后续配置和拆目录才有参照;怎么想:本节不是堆业务,而是把工程化流程走一遍;怎么做:启动项目后观察不同路由页面都挂在 #app 舞台上;易错点:不要把“页面切换”误认为整个 HTML 重载,实际是模板内容替换。

省略 ejs 扩展名与路径别名 ​

为什么需要:控制器里频繁 import 模板,如果每次都写 .ejs、都写 ../../src/...,既啰嗦又易错。怎么想:.ejs 能被当模块导入,是 webpack 在解析;要省略扩展名,应从 webpack 配置层解决,而不是改业务代码。怎么做:在 webpack.config.js 的 resolve 中配置:

js
resolve: {
  extensions: ['.js', '.json', '.ejs'],
  alias: {
    '@': path.resolve(__dirname, 'src')
  }
}

这样 @/views/index 就指向 src/views/index.ejs。易错点:extensions 会覆盖默认值,必须保留 .js、.json;改 webpack 配置要重启;改 src、public 才走热更新。

用二级路由复用页面框架 ​

为什么需要:管理员列表和广告列表的头部、侧边栏、页脚基本相同,只换中间内容。怎么想:把 /index 当父路由,把 /index/admin、/index/adv 当子路由。怎么做:先让父路由执行,再用 next() 进入子路由;父模板里用 res.subRouter 占位。调试时会发现:请求 /index/admin 时父路由也执行,若父路由直接 res.render,内容会被子路由覆盖。易错点:父路由要写在子路由前;不要用 render 抢舞台,父层用 next,模板中用:

ejs
<%= data.subRouteContent %>

用 next 和 subRouter 把子页面嵌入父模板 ​

怎么做:父路由类似:

js
router.route('/index', (req, res, next) => {
  res.render(indexTemplate, {
    subRouteContent: res.subRouter
  });
});

子路由再渲染 admin、adv 模板。易错点:subRouter 拼写、next 参数不能漏;只有 next() 才能继续匹配子路由。

补齐管理员、广告、登录三个页面 ​

为什么需要:先把页面骨架补齐,后面才谈组件化。怎么做:从演示页复制主要内容到 views/admin.ejs、views/adv.ejs、views/login.ejs,只保留舞台内部内容。易错点:样式和 logo 的 404 很典型。请求 /index/admin 时,相对路径会基于 /index/ 找资源,所以 public/index.html 中的 CSS、JS 以及模板里的 logo 路径要改成以 / 开头。调试过程就是看 Network:路径多出 index,说明相对路径出错。

把页面拆成组件 ​

为什么需要:头部、侧边栏、页脚、主要内容可复用。怎么想:在 src/components 下拆 Header.ejs、Sidebar.ejs、Footer.ejs、Content.ejs。怎么做:在控制器中导入组件并调用,把返回值传给模板;Content 需要 subRouteContent,所以数据要传给 Content 模板函数,而不是只传给 index.ejs。易错点:组件是函数,调用后才得到字符串;数据发错对象会导致子路由内容不显示。

抽离控制器 ​

为什么需要:路由一多,把函数都写在 APP.js 里不可维护。怎么做:在 src/controllers 下按页面拆 index.js、admin.js、adv.js、login.js,每个文件 export default 控制器函数,各自导入所需模板和组件。易错点:目录名 controllers 不要拼错;路径别名 @ 要指向 src。

抽离路由配置 ​

为什么需要:路由配置应独立于入口文件。怎么做:新建 src/routes/index.js,导出数组:

js
export default [
  { path: '/index/admin', element: adminController },
  { path: '/index/adv', element: advController },
  { path: '/login', element: loginController },
  { path: '*', element: (req, res) => res.redirect('/index/admin') }
]

入口中遍历:

js
routes.forEach(item => router.route(item.path, item.element))

易错点:兜底路由不要跳回不对外展示的 /index;数组对象缺少逗号、括号会直接报错。本节完成后,项目形成“入口—路由配置—控制器—模板—组件”的清晰结构。

初始化项目:先生成 package.json ​

为什么需要:工程化项目要先有 package.json,用它记录项目信息、脚本命令和依赖版本。
怎么想:先在项目根目录初始化,再安装 webpack 工具链;后续所有配置都围绕这个包管理文件展开。
怎么做:

shell
npm init

易错点:npm init 生成的 main 字段对 webpack 项目不是关键,不要误以为它决定入口;真正入口在 webpack 配置的 entry 中。

安装 webpack 工具链 ​

为什么需要:webpack 负责模块打包,webpack-cli 提供命令行,webpack-dev-server 提供开发服务,html-webpack-plugin 生成 HTML,webpack-merge 合并公共配置。
怎么想:这些都属于开发期工具,统一加 -D 安装到 devDependencies。
怎么做:

shell
npm install webpack webpack-cli webpack-dev-server html-webpack-plugin webpack-merge -D

易错点:转写稿里说的“wipe / wi派”应纠正为 webpack;“pack edge”应理解为 package.json。不要把工具包装成生产依赖。

建立入口与 HTML 模板 ​

为什么需要:webpack 需要一个入口文件开始分析依赖,HtmlWebpackPlugin 需要一个 HTML 模板作为页面骨架。
怎么想:源码放 src,静态模板放 public,这样结构清晰。
怎么做:创建 src/app.js、public/index.html。
易错点:入口路径是相对配置文件写的。配置文件在 config 下时,要用 ../src/app.js,不能写成相对项目根目录的 src/app.js。

基础配置:入口、出口与 HTML 插件 ​

为什么需要:开发和生产配置有大量共同项,先抽基础配置,后续用 webpack-merge 合并。
怎么想:入口、出口、插件放基础配置;mode、devServer 放开发配置;生产只改 mode。
怎么做:创建 config/webpack.base.config.js:

js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    // 入口
    entry: path.resolve(__dirname, '../src/app.js'),

    // 出口
    output: {
        path: path.resolve(__dirname, '../dist'),
        filename: 'js/[name].[hash:12].js',
        clean: true,
        publicPath: '/'
    },

    // 插件
    plugins: [
        new HtmlWebpackPlugin({
            // 起始html文件 
            template: path.resolve(__dirname, '../public/index.html'),
            // 将 script 放在 body 结尾
            inject: 'body',
            // 压缩优化
            minify: {
                removeAttributeQuotes: true,  // 去除引号
                removeComments: true, // 去除注释
                collapseWhitespace: true, // 代码折叠
            }
        })
    ]
}

易错点:__dirname 是当前配置文件所在目录,所以 ../src、../public、../dist 不能漏。转写稿调试时提到:HtmlWebpackPlugin 会自动把打包后的 script 插入 HTML,但 public/index.html 里手写的 link、script 不会被自动改写路径;后续做二级路由时,手写资源路径要用 / 开头,避免浏览器按当前路由拼相对路径。

拆分开发与生产配置 ​

为什么需要:开发要热更新和自动打开浏览器,生产要压缩优化,不能混在一起。
怎么想:用 webpack-merge 把基础配置分别和开发、生产配置合并。
怎么做:创建 configs/webpack.dev.config.js:

js
const {merge} = require('webpack-merge');
const baseConfig = require('./webpack.base.config');

module.exports = merge(baseConfig, {

    // 模式
    mode: 'development',

    // 自动开启服务
    devServer: {
        host: 'shirly.com',
        port: '80',
        open: true
    }
})

创建 configs/webpack.prod.config.js:

js
const {merge} = require('webpack-merge');
const baseConfig = require('./webpack.base.config');

module.exports = merge(baseConfig, {

    // 模式
    mode: 'production'
})

易错点:转写稿反复强调:改 src、public 里的前端内容可以热更新;改 webpack 配置文件后要重新启动服务,因为配置不是前端热更新的一部分。

配置 scripts 与运行验证 ​

为什么需要:把长命令收进 scripts,启动和打包更稳定。
怎么想:开发用 webpack server 读开发配置,生产用 webpack 读生产配置。
怎么做:配置 package.json:

json
{
  "name": "frontend_project",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "npx webpack server --config ./config/webpack.dev.config.js",
    "build": "npx webpack --config ./config/webpack.prod.config.js"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "html-webpack-plugin": "^5.5.3",
    "webpack": "^5.87.0",
    "webpack-cli": "^5.1.4",
    "webpack-dev-server": "^4.15.1",
    "webpack-merge": "^5.9.0"
  }
}

运行:

shell
npm start   # 启动
npm run build  # 打包

易错点:--config 后面的路径要对;npm start 启动后浏览器自动打开,初始化阶段页面功能不完整是正常的,先确认脚手架能跑通。转写稿还演示了后续在基础配置中增加 resolve.extensions 和 alias:extensions 要保留默认的 .js、.json 再追加 .ejs,否则会覆盖默认解析;别名 @ 指向 src,改完 webpack 配置仍需重启服务。

本节要解决的两个页面信息 ​

这一节的目标很具体:让打包后的页面有正确的浏览器标签标题,并且能正确加载站点图标 favicon。标题不是写死在 public/index.html 里,而是通过 HtmlWebpackPlugin 的 title 选项注入;站标则是 public 目录里的静态文件,需要靠 CopyWebpackPlugin 原样复制到 dist。先看模板:

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><%= htmlWebpackPlugin.options.title %></title>
    <link rel="icon" href="./favicon.ico">
</head>
<body>
    <h1>Hello Shirly</h1>
</body>
</html>

这里的 <%= htmlWebpackPlugin.options.title %> 是 EJS 模板表达式,取值来自 HtmlWebpackPlugin 配置里的 title。为什么需要这样做?因为标题属于构建配置的一部分,写在模板里以后换项目、换环境都得改模板;放到插件选项里,模板只负责占位,配置负责具体值。怎么想?先确认模板由 HtmlWebpackPlugin 编译,再确认插件选项里有一个自定义 title,模板里就能通过 htmlWebpackPlugin.options.title 拿到。易错点是大小写和层级:htmlWebpackPlugin.options.title 要和配置中的 title 对上,不要写成 htmlwebpackplugin 之类。

站标为什么要用 CopyWebpackPlugin ​

favicon.ico 不是入口 JS 依赖,不会被 entry 打包;它属于 public 里的静态资源。转写稿里说得很直白:public 里的文件是“自己写的”,copy-webpack-plugin 是“一比一复制过去”的,不会像处理模块那样改写里面的 link。所以思路是:public/index.html 作为 HtmlWebpackPlugin 的模板;favicon.ico 作为普通静态文件,由 CopyWebpackPlugin 从 public 复制到 dist。安装命令:

shell
npm install copy-webpack-plugin -D

配置如下:

js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
    // 入口
    entry: path.resolve(__dirname, '../src/app.js'),

    // 出口
    output: {
        path: path.resolve(__dirname, '../dist'),
        filename: 'js/[name].[hash:12].js',
        clean: true,
        publicPath: '/'
    },

    // 插件
    plugins: [
        // 打包 html 资源
        new HtmlWebpackPlugin({
            // 起始html文件 
            template: path.resolve(__dirname, '../public/index.html'),
            // 将 script 放在 body 结尾
            inject: 'body',
            // 压缩优化
            minify: {
                removeAttributeQuotes: true,  // 去除引号
                removeComments: true, // 去除注释
                //collapseWhitespace: true, // 代码折叠
            },
            // 自定义的选项
            title: '尚硅谷广告管理系统 shirly'
        }),

        // 将源目录(public)中的文件复制到目标目录(项目输出目录)
        new CopyWebpackPlugin({
            patterns: [
                { 
                    // 源目录
                    from: path.resolve(__dirname, '../public'), 
                    // 目标目录
                    to: path.resolve(__dirname, '../dist'),
                    globOptions: {
                        ignore: ['**/**.html']  // 忽略所有的html文件
                    }
                },
            ],
        }),
    ]
}

易错点与检查顺序 ​

第一,CopyWebpackPlugin 一定要忽略 HTML,否则 public/index.html 可能被复制到 dist,和 HtmlWebpackPlugin 生成的 dist/index.html 冲突;笔记里用 ignore: ['**/**.html'] 就是干这个的。第二,favicon.ico 要放在 public 根目录,复制后位于 dist 根目录,模板里的 href="./favicon.ico" 才能对应上;如果 404,先看 dist 下有没有这个文件,再看 link 路径。第三,转写稿反复强调:改 src、public 里的前端代码可以热更新,但改 config/webpack.base.config.js 这种 webpack 自身配置后要重新启动开发服务;新增 CopyWebpackPlugin 和 title 配置后不要只等热更新。最后检查:浏览器标签标题是否为“尚硅谷广告管理系统 shirly”,Network 里 favicon.ico 是否 200。

先明确 sme-router 要解决什么问题 ​

为什么需要:单页面应用要在不刷新整个页面的前提下,根据地址切换不同视图。sme-router 负责监听地址变化,匹配路由,再把对应内容放进页面里的“舞台”。

怎么想:转写稿里反复强调,不管请求 /index、/admin 还是 /adv,检查元素都会发现内容最终都进入 id="app" 的元素。也就是说,路由不是换整张 HTML,而是换 #app 里面的内容。new SMERouter('app') 里的 'app' 就是舞台容器的 id,不是路由名称。

怎么做:先安装依赖:

shell
npm install sme-router

易错点:页面里必须存在 id="app" 的容器;安装后要确认开发服务正常启动;不要把 new SMERouter('app') 误写成某个路径。

创建路由实例并注册基础路由 ​

为什么需要:只有创建实例并逐条注册路径,地址和视图之间才有对应关系。

怎么想:把路由表看成“路径 → 回调”的映射。回调里的 res.render(...) 本质上是把内容写进舞台。转写稿里验证过:render 更像给 #app 设置 innerHTML,后执行的 render 会覆盖先执行的内容。

怎么做:在 src/app.js 中使用:

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

// 创建实例
// 默认是 hash 模式,特点  http://adv.manage.fuming.site/#/index/advlist
// 可以改为 history 模式,特点  http://adv.manage.fuming.site/index/advlist
const router = new SMERouter('app')

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

router.route('/admin', (req, res) => {
    res.render('管理员列表');
});

router.route('/adv', (req, res) => {
    res.render('广告列表');
});

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

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

易错点:路径一般要写前导斜杠,如 /index;通配 '*' 应作为兜底放在路由表后面;res.render('首页') 这里只是示例字符串,后续替换成模板函数返回值时,要保证拿到的是字符串内容。

理解 hash 模式与 history 模式 ​

为什么需要:默认 hash 模式地址中带 #,例如 http://adv.manage.fuming.site/#/index/advlist;history 模式地址更干净,例如 http://adv.manage.fuming.site/index/advlist。

怎么想:hash 变化不会触发浏览器向服务器请求新页面,所以前端路由容易工作;history 模式依赖 History API,开发服务器必须把未知路径回退到 index.html,否则刷新 /index/advlist 会 404。

怎么做:笔记中允许使用 history 模式,配置文件 config/webpack.dev.config.js 写为:

js
const {merge} = require('webpack-merge');
const baseConfig = require('./webpack.base.config');

module.exports = merge(baseConfig, {

    // 模式
    mode: 'development',

    // 自动开启服务
    devServer: {
        host: 'shirly.com',
        port: '80',
        open: true,
        // 开发中,允许前端路由使用 history 模式
        historyApiFallback: true
    }
})

易错点:只把前端路由改成 history 模式,却没有配置 historyApiFallback: true,刷新子路径容易 404;修改 webpack 配置后通常要重启开发服务,转写稿中也专门提醒“改了 webpack 自己的配置要重新启动”,而改 src 里的前端代码才走热更新。

用通配路由做兜底重定向 ​

为什么需要:用户可能输入不存在的地址,或者项目根路径没有对应路由,需要统一跳回一个可用页面。

怎么想:先让具体路由匹配,最后用 '*' 接住所有未匹配地址,再 redirect 到 /index。这样不会出现空白页。

怎么做:保留笔记中的:

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

易错点:通配路由不要放在具体路由前面,否则可能抢先匹配;重定向目标本身也要有对应路由,否则会继续落到兜底逻辑。调试时可以先在回调里观察是否进入,再检查 #app 内容是否被后续 render 覆盖。

安装 ejs-loader ​

为什么需要: Webpack 默认更熟悉 .js、.json 这类模块,.ejs 模板不能直接当模块 import。要让路由里能引入模板、调用模板函数,就需要 loader 把 .ejs 转成 Webpack 能处理的模块。
怎么想: 转写稿里反复强调“Webpack 里一切资源都是模块”,模板也一样。既然 import 模板时 Webpack 报错,就要从 loader 层面解决,而不是手工读文件。
怎么做: 先安装开发依赖:

shell
npm install ejs-loader -D

易错点: 这是开发期 loader,用 -D;不要装成运行依赖。安装后还要配置规则,否则 Webpack 仍不认识 .ejs。

配置 ejs-loader ​

为什么需要: 只安装不配置,Webpack 不知道哪些文件交给 ejs-loader。本节要让所有 .ejs 走模板编译,并约定模板内接收数据的变量名。
怎么想: 在 module.rules 里用 test: /\.ejs$/ 匹配模板,用 loader: 'ejs-loader' 处理;options.variable: 'data' 表示模板里通过 data 拿传入对象。转写稿还提示:如果想让 import './views/index.ejs' 省略 .ejs,要在 resolve.extensions 中追加 .ejs,并保留默认的 .js、.json;改 Webpack 配置后要重启。
怎么做: 按笔记配置 config/webpack.base.config.js:

js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
    // 入口
    entry: path.resolve(__dirname, '../src/app.js'),

    // 出口
    output: {
        path: path.resolve(__dirname, '../dist'),
        filename: 'js/[name].[hash:12].js',
        clean: true,
        publicPath: '/'
    },

    // loaders
    module: {
        rules: [
            {
                test: /\.ejs$/,
                loader: 'ejs-loader',
                options: {
                  variable: 'data',
                  //interpolate : '\\{\\{(.+?)\\}\\}',
                  //evaluate : '\\[\\[(.+?)\\]\\]'
                }
            }
        ]
    },

    // 插件
    plugins: [
        // 打包 html 资源
        new HtmlWebpackPlugin({
            // 起始html文件 
            template: path.resolve(__dirname, '../public/index.html'),
            // 将 script 放在 body 结尾
            inject: 'body',
            // 压缩优化
            minify: {
                removeAttributeQuotes: true,  // 去除引号
                removeComments: true, // 去除注释
                //collapseWhitespace: true, // 代码折叠
            },
            // 自定义的选项
            title: '尚硅谷广告管理系统 shirly'
        }),

        // 将源目录(public)中的文件复制到目标目录(项目输出目录)
        new CopyWebpackPlugin({
            patterns: [
                { 
                    from: path.resolve(__dirname, '../public'), 
                    to: path.resolve(__dirname, '../dist'),
                    globOptions: {
                        ignore: ['**/**.html']
                    }
                },
            ],
        }),
    ]
}

易错点: variable 的名字要和模板里取值一致;模板函数调用时传的是对象。若改的是 webpack.base.config.js,要重启开发服务;改 src 或 public 里的前端代码才主要靠热更新。

创建模板并在路由中使用 ​

为什么需要: 路由切换时,不同地址要显示不同页面。模板负责页面字符串,路由负责把字符串渲染到舞台元素里。转写稿中说明:import 进来的模板会变成函数,调用后返回模板字符串;res.render(...) 本质是把内容写入 ID 为 app 的舞台。
怎么想: 先准备模板文件,再在 src/app.js 中导入模板;每个路由匹配后调用模板函数,把结果交给 res.render。模板需要数据时,给模板函数传对象,模板内用 data.xxx 取。
怎么做: 按笔记创建模板文件:views/index.js、views/admin.js、views/login.js、views/adv.js。在 src/app.js 中引入并使用:

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

// 导入一个模板
import indexV from  './views/index.ejs';
import adminV from './views/admin.ejs';
import loginV from './views/login.ejs';
import advV from './views/adv.ejs';

// 创建实例
// 默认是 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) => {
    //console.log(indexV);
    res.render(indexV({title:'Shirly', message:'锄禾日当午'}));
});

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

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

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

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

易错点: 笔记里列出的是 views/index.js 等模板文件,而引入代码写的是 .ejs,实际要让模板文件走 ejs-loader。不要写成 res.render(indexV) 而漏掉调用;indexV({...}) 才是把数据传给模板。转写稿还演示了:直接省略 .ejs 会报错,需要配置 resolve.extensions;配置路径别名 @ 可简化导入,但不是本节必需。

为什么需要省略 ejs 扩展名和配置路径别名 ​

在 src/app.js 里导入模板时,原来要写 @/views/index.ejs 这类完整路径。项目文件一多,路径会变长;目录层级一调整,相对路径还容易写错。我们希望做到两件事:导入模板时省略 .ejs 扩展名;用 @ 代表 src 目录。这样 import indexV from '@/views/index' 就能被 webpack 正确解析。

为什么能这么想?因为 import 一个 .ejs 文件并不是浏览器天生支持的能力,而是 webpack 在模块化解析时做的处理。既然扩展名省略和路径别名都属于“模块怎么找”的问题,就应该去 webpack 的解析配置里解决,也就是 resolve。转写稿里的推导是:先直接删掉 .ejs,马上报错;于是判断“这是 webpack 解析模块的职责”,查文档找到 resolve.extensions;再想到路径别名对应 resolve.alias。方向确定后,配置就不难了。

在 webpack 配置中加入 resolve ​

修改 config/webpack.base.config.js。resolve 与 plugins 同级,不要塞进 plugins 里。extensions 用来设置可以省略的扩展名;alias 用来给路径起别名。注意:extensions 一旦自己写,会覆盖默认值,所以必须把常用的 .js、.json 保留,再追加 .ejs。

js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {

    // 插件
    plugins: [
        
    ],

    // 解析
    resolve: {
        // 设置可以省略的扩展名
        extensions: ['.js', '.json', '.ejs'],
        // 设置路径别名
        alias: {
            '@': path.resolve(__dirname, '../src')
        }
    }
}

易错点:不要写成 extensions: ['.ejs'],否则 .js、.json 的省略能力会丢失;alias 的值要用 path.resolve(__dirname, '../src') 得到明确目录,不要随手写 '../src' 字符串;改的是 webpack 配置文件,必须重启开发服务,改 src 下的前端代码才走热更新。转写稿中就是先加 extensions、重启,确认生效后,再加 alias、再次重启验证。

导入模板时省略扩展名并使用 @ 别名 ​

配置完成后,src/app.js 里的模板导入可以写成下面这样。@/views/index 等价于 src/views/index,.ejs 由 extensions 补全。路由部分保持不变,仍然是把模板函数执行后的字符串交给 res.render。

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

// 导入一个模板
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) => {
    //console.log(indexV);
    res.render(indexV({title:'Shirly', message:'锄禾当午'}));
});

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

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

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

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

易错点:@ 只在 webpack 解析范围内有效,不要把它当成浏览器认识的路径;省略扩展名只对 extensions 中列出的后缀生效,.ejs 不在数组里就仍然要写全;路径大小写要和真实文件一致;如果改完 app.js 页面没变化,先看控制台是否还有解析报错,再确认 webpack 配置是否已重启生效。转写稿的调试过程是:删 .ejs 报错 → 查 resolve.extensions → 加 .ejs 并保留默认值 → 重启成功 → 配 alias → 改 app.js 后热更新成功。按这个顺序排查,问题基本都能定位。

为什么需要二级路由 ​

为什么需要:管理员列表和广告列表的头部、侧边栏、页脚基本相同,只有中间内容不同。如果每个页面都复制一份完整框架,维护成本高。
怎么想:把公共框架抽成一级路由 /index,把变化部分做成二级路由 /index/admin、/index/adv。
怎么做:在 src/app.js 中把原来的 /admin、/adv 改成 /index/admin、/index/adv,让它们成为 /index 的子路由。
易错点:/login 与 /index 没有公共框架关系,不要硬塞成子路由;一级路由要写在二级路由前面。

路由层级与执行顺序 ​

为什么需要:SMERouter 不是 Express 那种精确匹配。请求 /index/admin 时,会先经过 /index,再经过 /index/admin。
怎么想:一级路由负责“框架”,二级路由负责“中间内容”。一级路由不能直接把最终页面 render 死,否则后执行的二级路由会覆盖它。
怎么做:一级路由使用 next(...),并调用 res.subRoute() 预留子路由出口。
易错点:若一级路由仍用 res.render(...),二级路由执行后会把一级内容覆盖掉;控制台可先加 console.log 验证两个路由都经过了。

js
import SMERouter from 'sme-router'
import indexV from '@/views/index'
import adminV from '@/views/admin'
import loginV from '@/views/login'
import advV from '@/views/adv'

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

router.route('/index', (req, res, next) => {
  next(`
    <h1>首页</h1>
    ${res.subRoute()}
  `)
})

router.route('/index/admin', (req, res) => {
  res.render(`<h2>管理员列表</h2>`)
})

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

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

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

用 next 和 subRoute 呈现子页面 ​

为什么需要:一级路由要加载完整页面模板,同时把子路由内容放进模板内部。
怎么想:next 可以把一级路由模板写入舞台,res.subRoute() 负责把二级路由页面放到预留位置。
怎么做:把 indexV 调用后传入 subRouteContent: res.subRoute(),子路由仍用 res.render 加载自己的模板。
易错点:res.subRoute() 是方法,必须加括号;属性名要和 EJS 模板中取值一致。

js
router.route('/index', (req, res, next) => {
  next(indexV({
    subRouteContent: res.subRoute()
  }))
})

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

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

在 index.ejs 中预留出口 ​

为什么需要:框架模板必须知道子路由内容放在哪。
怎么想:在主要内容区域放 EJS 变量,把 subRouteContent 输出到页面。
怎么做:在 src/views/index.ejs 的主要内容区写 <%= data.subRouteContent %>。
易错点:EJS 中变量来自传入对象,属性名写错会取不到内容;不要把管理员/广告内容写死。

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

调试:路径与资源 404 ​

为什么需要:切到 /index/admin 后,页面可能样式丢失、logo 不显示。
怎么想:浏览器按当前路径解析相对地址,css/... 会被拼成 /index/css/...,而资源实际在网站根目录。
怎么做:打开 Network 看 404 路径;公共资源统一写成以 / 开头的绝对路径,如 /css/...、/js/...、/logo.png。
易错点:public/index.html 中手写的 link、img 不会自动加斜杠;webpack 自动插入的打包 JS 才可能带 publicPath。若后续做了页面组件化,subRouteContent 要传给真正使用它的内容组件,而不是传给不使用它的外层模板。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例 1:省略 .ejs 扩展名 + 路径别名(webpack 配置 + 入口使用) ​

js
// config/webpack.base.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
    // 入口
    entry: path.resolve(__dirname, '../src/app.js'),

    // 出口
    output: {
        path: path.resolve(__dirname, '../dist'),
        filename: 'js/[name].[hash:12].js',
        clean: true,
        publicPath: '/'
    },

    // loaders
    module: {
        rules: [
            {
                test: /\.ejs$/,
                loader: 'ejs-loader',
                options: {
                    variable: 'data'   // 模板中可直接用 data.xxx
                }
            }
        ]
    },

    // 解析(独立于五大组成,与 plugins 同级)
    resolve: {
        // 设置可以省略的扩展名
        extensions: ['.js', '.json', '.ejs'],
        // 设置路径别名
        alias: {
            '@': path.resolve(__dirname, '../src')
        }
    },

    // 插件
    plugins: [
        new HtmlWebpackPlugin({
            template: path.resolve(__dirname, '../public/index.html'),
            inject: 'body',
            minify: {
                removeAttributeQuotes: true,
                removeComments: true
            },
            title: '尚硅谷广告管理系统 shirly'
        }),
        new CopyWebpackPlugin({
            patterns: [
                {
                    from: path.resolve(__dirname, '../public'),
                    to: path.resolve(__dirname, '../dist'),
                    globOptions: {
                        ignore: ['**/**.html']
                    }
                }
            ]
        })
    ]
}
js
// src/app.js
import SMERouter from 'sme-router'

// 省略 .ejs 扩展名 + 用 @ 指向 src
import indexV from '@/views/index';
import adminV from '@/views/admin';
import loginV from '@/views/login';
import advV from  '@/views/adv';

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

router.route('/index', (req, res) => {
    res.render(indexV({ title: 'Shirly', message: '锄禾日当午' }));
});
router.route('/admin', (req, res) => {
    res.render(adminV());
});
router.route('/adv', (req, res) => {
    res.render(advV());
});
router.route('/login', (req, res) => {
    res.render(loginV());
});
router.route('*', (req, res) => {
    res.redirect('/index');
})

关键行与易错点

  • resolve.extensions 是覆盖默认值,不是合并。默认里本来有 .js、.json,只写 ['.ejs'] 会让 import './views/index' 反而解析不到,必须把 .js、.json 补回来。
  • resolve 与 plugins 同级,是五大组成(entry/output/module/plugins/mode)之外的新配置项。
  • 改了 config/ 下的 webpack 配置必须重启 npm start;只有改 src/、public/ 里的前端代码才是热更新,不用重启。
  • alias['@'] 用 path.resolve(__dirname, '../src'),__dirname 是配置文件所在目录,所以是 ../src 而不是 ./src。
  • 别名写好后 import '@/views/index' 里不要再写 ./,否则路径会变成相对当前文件的 @/views。

示例 2:二级路由 + 模板嵌套(父路由 next + res.subRoute()) ​

js
// src/app.js
import SMERouter from 'sme-router'

import indexV from '@/views/index';
import adminV from '@/views/admin';
import advV from '@/views/adv';
import loginV from '@/views/login';

// 第二个参数 'html5' 启用 history 模式(需 devServer.historyApiFallback: true)
const router = new SMERouter('app', 'html5')

// 一级(父)路由:必须写在二级路由前面
router.route('/index', (req, res, next) => {
    // next 才能把子路由内容塞进父模板,render 会被后面的子路由覆盖
    next(indexV({
        subRouteContent: res.subRoute()
    }))
});

// 二级(子)路由
router.route('/index/admin', (req, res) => {
    res.render(adminV());
});
router.route('/index/adv', (req, res) => {
    res.render(advV());
});

// 独立路由,与 /index 无关
router.route('/login', (req, res) => {
    res.render(loginV());
});

// 兜底
router.route('*', (req, res) => {
    res.redirect('/index/admin');
})
html
<!-- views/index.ejs:父模板,给子路由留一个"坑" -->
<div class="wrapper">
    <!-- 顶部导航 / 侧边栏 ... -->

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

    <!-- 页脚 ... -->
</div>
html
<!-- public/index.html:手写的静态资源必须用 / 开头 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title><%= htmlWebpackPlugin.options.title %></title>
    <link rel="icon" href="/favicon.ico">
    <link rel="stylesheet" href="/css/adminlte.min.css">
    <link rel="stylesheet" href="/css/style.css">
</head>
<body>
    <div id="app"></div>
    <script src="/js/jquery.min.js"></script>
    <script src="/js/bootstrap.bundle.min.js"></script>
</body>
</html>

关键行与易错点

  • 请求 /index/admin 时,/index 的回调也会先执行(sme-router 不是精确匹配),所以父路由必须写在子路由前面,后面的 render 会覆盖前面的。
  • 父路由用 next(...) 而不是 res.render(...),next 才能让子路由内容嵌进父模板;用 render 只剩子页面内容。
  • res.subRoute() 是一个方法,必须在 next()/render() 调用时求值,不能提前拿出来存成变量。
  • 子路由页面里 <link>、<img src> 等手写资源要用 / 开头。因为页面在 /index/admin 下,相对路径 css/xxx.css 会被浏览器拼成 /index/css/xxx.css → 404。HtmlWebpackPlugin 自动注入的 <script> 会用 publicPath 补斜杠,手写的不处理;CopyWebpackPlugin 只是原样拷贝,也不会改。
  • 模板里取值用 data.subRouteContent,变量名 data 来自 ejs-loader 的 variable: 'data' 配置。

示例 3:抽离控制器 + 抽离路由配置 ​

js
// src/controllers/index.js
import indexV from '@/views/index';
import headerComponent from '@/components/Header';
import sidebarComponent from '@/components/Sidebar';
import footerComponent from '@/components/Footer';
import contentComponent from '@/components/Content';

// 导出的是函数本身,不要在这里调用
export default (req, res, next) => {
    next(indexV({
        header: headerComponent(),
        sidebar: sidebarComponent(),
        footer: footerComponent(),
        // 子路由内容要发给真正使用它的 Content 组件
        content: contentComponent({
            subRouteContent: res.subRoute()
        })
    }))
}
js
// src/controllers/admin.js
import adminV from '@/views/admin';

export default (req, res) => {
    res.render(adminV());
}
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 },
    { path: '/index/adv',   element: advC },
    { path: '/login',       element: loginC },
    {
        path: '*',
        element: (req, res) => {
            res.redirect('/index/admin');
        }
    }
];
js
// src/app.js —— 入口只剩"建实例 + 遍历注册"
import SMERouter from 'sme-router'
import routes from '@/routes'   // 目录下有 index.js,可只写目录名

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

routes.forEach(item => {
    // router 是实例(带 r),routes 是配置(带 s),别混
    router.route(item.path, item.element)
})

关键行与易错点

  • 控制器导出的必须是函数,注册时写 router.route(item.path, item.element),不能写成 item.element()——那会立刻执行并把返回值(undefined)当回调。
  • routes/index.js 用 index.js 命名,import routes from '@/routes' 才能只写目录名。
  • 每个控制器自己导入自己用到的模板和组件(谁用谁导入),不要再统一塞到 app.js 里,否则入口文件又会膨胀。
  • subRouteContent 要传给真正渲染它的那个组件(Content),传给 indexV 是没用的——模板是函数,数据只能通过调用它时传参。
  • 兜底路由 * 建议跳 /index/admin(/index 只是上层框架,不对外展示),且 * 必须放在数组最后。
  • 目录名易错:是 controllers,不是 conpoints/controler,路径写错只会报模块找不到,不容易一眼看出。

常见错误 ​

  1. 改完 webpack 配置,页面行为没变或直接报错

    • 现象:刚在 webpack.base.config.js 里加了 resolve.alias,Ctrl+S 后浏览器仍报 Module not found,甚至 devServer 直接崩。
    • 原因:devServer 的热更新只覆盖 src/、public/ 这类前端源码;config/ 下是 webpack 自己的配置,属于"工具侧",进程启动时已读入内存。
    • 修法:凡改动 config/*.js、package.json scripts、.browserslistrc 等工具配置,一律 Ctrl+C 终止后重新 npm start;只有改 src/、public/ 才靠热更新。
  2. 配了 resolve.extensions: ['.ejs'] 后,原本正常的 .js 导入全挂了

    • 现象:模板导入能省扩展名了,但 import SMERouter from 'sme-router' 等一堆裸模块解析失败。
    • 原因:extensions 一旦手写就整体覆盖默认值 ['.js', '.json'],不是与默认值合并(webpack-merge 合并的是你自己的几个配置文件,不合并 webpack 内置默认项)。
    • 修法:写全 ['.js', '.json', '.ejs'],且把最常用的放前面以减少解析尝试次数。
  3. 二级路由页面刷新后 logo / 图标 404,路径里莫名多出 /index

    • 现象:/index/admin 下 logo 显示为 …/index/img/logo.png,Network 里 404。
    • 原因:history 模式下浏览器按"当前地址的目录"解析相对路径,/index/admin 的目录被当成 /index/;output.publicPath: '/' 只作用于 HtmlWebpackPlugin 自动注入的 script 标签,手写在 HTML/模板里的 src、href 不会被改写;CopyWebpackPlugin 只是原样拷贝 public,不解析内部引用。
    • 修法:模板与 public/index.html 中所有手写静态资源引用统一改成以 / 开头的绝对路径(从站点根算),不要依赖相对路径。
  4. 父路由渲染后子路由内容不见了

    • 现象:访问 /index/admin,控制台能看到父、子两个控制器的日志都执行了,但页面上只剩子页面那一点点内容,父级框架消失。
    • 原因:res.render() 本质是直接替换 #app 的 innerHTML,父路由先渲染、子路由后渲染,后者把前者覆盖了;且父路由没有把 res.subRoute() 的占位交给模板。
    • 修法:父控制器改用 next(indexV({ subRouteContent: res.subRoute() })),并在父模板对应位置写 <%= data.subRouteContent %> 占位。顺序上父路由写在子路由之前。
  5. 模板里写 <%= subRouteContent %> 报 undefined / ReferenceError

    • 现象:数据明明传了,模板里就是取不到。
    • 原因:ejs-loader 配了 variable: 'data',传入的对象整体挂在 data 这个局部变量上,不是展开成顶层变量。
    • 修法:写成 <%= data.subRouteContent %>;同时确认传参是在"调用模板函数"时给的(indexV({...})),而不是给了别的模板或只给了 res.render 的字符串。
  6. 抽离控制器/路由后 Module not found

    • 现象:把路由函数剪切到 src/controllers/*.js 后,原来能跑的 import indexV from '@/views/index' 报错,或相对路径 ./views/index 失效。
    • 原因:相对路径以"当前文件所在目录"为基准,文件一移动基准就变了;且控制器文件内需要自己重新导入用到的模板与组件(不会继承 app.js 里的导入)。
    • 修法:统一改用 @ 别名(@/views/index、@/components/Header)避免层级变化带来的连锁修改;迁移后逐个控制器补齐自己的 import。
  7. 兜底路由 * 抢在前面,任何地址都跳首页

    • 现象:新增的 /login 永远进不去,全被重定向。
    • 原因:路由按注册顺序匹配,* 写在前面就先把所有请求吃掉了。
    • 修法:注册顺序为 父路由 → 子路由 → 兜底 * 放最后;抽离成路由配置数组时同样要保证数组元素顺序。

练习 ​

  1. 新增二级页面:在现有框架下增加 /index/user(用户列表)。要求复用 index 的头部/侧边栏/页脚,只替换主内容区。写出你需要新建/修改的全部文件清单,并说明每处改动的作用。

  2. 别名扩展:新增别名 @c 指向 src/components,并把某个控制器里对 Header 组件的引用改成用 @c。改完后页面报错,请说明原因与处理步骤;再解释为什么这次改动后 @/views/index 的引用不需要动。

  3. 结构验证:新增一个"广告位管理"页 /index/advspace,要求只动两个文件即可跑通。写下这两个文件及各自新增的代码片段,并借此说明抽离控制器与路由配置后,扩展一个新页面的固定套路是什么。

  4. 排查题:某同学在 src/views/index.ejs 的侧边栏里写 <img src="img/logo.png">,访问 /index/adv 时图片 404,但访问 / 时正常。请给出至少两种修法,并说明哪种更稳妥、为什么。

  5. 组件化改造:把 content.ejs 中"页面标题 + 面包屑"那一块再抽成 PageHeader 组件。要求说明:组件文件放哪、在哪个文件导入、数据(如子路由内容)应传给谁、index.ejs 最终应剩下什么。

原视频对照 ​

#视频时长
1省略ejs扩展名和路径别名16:46
2二级路由呈现页面36:39
3管理员广告登录三个页面05:22
4页面组件化18:18
5抽离控制器15:17
6抽离路由配置10:20

小结 ​

  • 配置改动与源码改动是两条不同的生效路径:config/ 下改动必须重启,src/、public/ 下改动走热更新。养成"改完先判断改的是哪一侧"的习惯。
  • resolve.extensions 是覆盖而非追加,resolve.alias 是应对文件搬迁与深层相对路径的最有效手段;工程化配置的价值在项目变大后才显现。
  • history 模式下,页面内一切静态资源引用都应以 / 开头,publicPath 只管自动注入的 script,管不了你手写的 link/img。
  • 二级路由的本质是"父控制器先渲染框架并留占位,子控制器把内容填进占位",靠 next() + res.subRoute() + 模板插值三者配合,缺一不可;顺序必须是父在前、子在后、* 在最后。
  • 模板的职责是"呈现",控制器的职责是"取数据 + 决定渲染哪个模板",路由的职责只是"路径 → 控制器"的映射表。三者分离后,新增页面只需在映射表加一行、在控制器目录加一个文件。
  • 组件化解决的是"页面内部重复",控制器抽离解决的是"路由回调膨胀",路由配置抽离解决的是"映射表膨胀"——三次重构针对的是三个不同的膨胀点,不要混为一谈。
  • 项目前期花在目录结构与职责划分上的时间,会在后续写增删改查业务时成倍收回;结构没规划好就写业务,改一处牵全身。
  • 判断一个工程化项目是否健康,看入口文件 app.js 的体量:它应当只做"建实例、读配置、注册路由"三件事,任何具体业务逻辑出现在这里都是结构没拆干净。