主题
项目初始化配置与设置标题和站标(项目功能实现 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:省略 .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,路径写错只会报模块找不到,不容易一眼看出。
常见错误
改完 webpack 配置,页面行为没变或直接报错
- 现象:刚在
webpack.base.config.js里加了resolve.alias,Ctrl+S 后浏览器仍报Module not found,甚至 devServer 直接崩。 - 原因:devServer 的热更新只覆盖
src/、public/这类前端源码;config/下是 webpack 自己的配置,属于"工具侧",进程启动时已读入内存。 - 修法:凡改动
config/*.js、package.jsonscripts、.browserslistrc等工具配置,一律 Ctrl+C 终止后重新npm start;只有改src/、public/才靠热更新。
- 现象:刚在
配了
resolve.extensions: ['.ejs']后,原本正常的.js导入全挂了- 现象:模板导入能省扩展名了,但
import SMERouter from 'sme-router'等一堆裸模块解析失败。 - 原因:
extensions一旦手写就整体覆盖默认值['.js', '.json'],不是与默认值合并(webpack-merge合并的是你自己的几个配置文件,不合并 webpack 内置默认项)。 - 修法:写全
['.js', '.json', '.ejs'],且把最常用的放前面以减少解析尝试次数。
- 现象:模板导入能省扩展名了,但
二级路由页面刷新后 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中所有手写静态资源引用统一改成以/开头的绝对路径(从站点根算),不要依赖相对路径。
- 现象:
父路由渲染后子路由内容不见了
- 现象:访问
/index/admin,控制台能看到父、子两个控制器的日志都执行了,但页面上只剩子页面那一点点内容,父级框架消失。 - 原因:
res.render()本质是直接替换#app的innerHTML,父路由先渲染、子路由后渲染,后者把前者覆盖了;且父路由没有把res.subRoute()的占位交给模板。 - 修法:父控制器改用
next(indexV({ subRouteContent: res.subRoute() })),并在父模板对应位置写<%= data.subRouteContent %>占位。顺序上父路由写在子路由之前。
- 现象:访问
模板里写
<%= subRouteContent %>报undefined/ ReferenceError- 现象:数据明明传了,模板里就是取不到。
- 原因:
ejs-loader配了variable: 'data',传入的对象整体挂在data这个局部变量上,不是展开成顶层变量。 - 修法:写成
<%= data.subRouteContent %>;同时确认传参是在"调用模板函数"时给的(indexV({...})),而不是给了别的模板或只给了res.render的字符串。
抽离控制器/路由后
Module not found- 现象:把路由函数剪切到
src/controllers/*.js后,原来能跑的import indexV from '@/views/index'报错,或相对路径./views/index失效。 - 原因:相对路径以"当前文件所在目录"为基准,文件一移动基准就变了;且控制器文件内需要自己重新导入用到的模板与组件(不会继承
app.js里的导入)。 - 修法:统一改用
@别名(@/views/index、@/components/Header)避免层级变化带来的连锁修改;迁移后逐个控制器补齐自己的 import。
- 现象:把路由函数剪切到
兜底路由
*抢在前面,任何地址都跳首页- 现象:新增的
/login永远进不去,全被重定向。 - 原因:路由按注册顺序匹配,
*写在前面就先把所有请求吃掉了。 - 修法:注册顺序为 父路由 → 子路由 → 兜底
*放最后;抽离成路由配置数组时同样要保证数组元素顺序。
- 现象:新增的
练习
新增二级页面:在现有框架下增加
/index/user(用户列表)。要求复用 index 的头部/侧边栏/页脚,只替换主内容区。写出你需要新建/修改的全部文件清单,并说明每处改动的作用。别名扩展:新增别名
@c指向src/components,并把某个控制器里对Header组件的引用改成用@c。改完后页面报错,请说明原因与处理步骤;再解释为什么这次改动后@/views/index的引用不需要动。结构验证:新增一个"广告位管理"页
/index/advspace,要求只动两个文件即可跑通。写下这两个文件及各自新增的代码片段,并借此说明抽离控制器与路由配置后,扩展一个新页面的固定套路是什么。排查题:某同学在
src/views/index.ejs的侧边栏里写<img src="img/logo.png">,访问/index/adv时图片 404,但访问/时正常。请给出至少两种修法,并说明哪种更稳妥、为什么。组件化改造:把
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的体量:它应当只做"建实例、读配置、注册路由"三件事,任何具体业务逻辑出现在这里都是结构没拆干净。