主题
执行修改或者添加与打包 生产模式下配置(功能完善与部署 2/2)
本页对应 课堂视频,共 4 段视频,约 1 小时 27 分。
学习目标
- 能够根据 FormData 中
id是否有值判断当前操作是添加还是修改,并分别调用对应 API 完成提交,且在添加时删除空的id字段。 - 能够配置生产模式 Webpack,完成 CSS 抽离与压缩,并正确设置生产/开发模式的 source map。
- 能够使用 DefinePlugin 在开发模式和生产模式中注入不同的
SERVICE_URL,并在请求封装与模板中引用,避免硬编码 baseURL。 - 能够执行生产打包并识别
dist产物,说明打包后前端请求真实后端地址的原因。 - 能够将
dist部署到 Nginx 静态资源服务器,配置 history 路由回退到index.html,并说明后端需允许前端静态资源服务器地址跨域。
知识点
先明确目标:一个表单同时承担“添加”和“修改”
为什么需要:广告管理里添加和修改的字段几乎一样,如果维护两套表单、两套提交逻辑,代码重复且容易漏字段。
怎么想:共用同一个表单和同一个提交事件,提交时再判断“这次是添加还是修改”。
怎么做:在表单里放一个隐藏域 id。修改时把当前广告 _id 写进去;添加时让它是空值。提交时创建 FormData,看 fd.get('id') 是否有值。
易错点:form.reset() 不会自动清空隐藏域的值,所以打开“添加”模态框时必须手动执行 document.addAdvForm.id.value = '',否则上一次修改留下的 id 会让添加被误判成修改。
打开添加/修改模态框:把状态准备干净
为什么需要:同一个模态框既要显示“添加广告”,也要显示“修改广告”,必须先把上一次的残留清掉。
怎么想:添加时清空表单、清空隐藏域、隐藏预览图、改标题;修改时先根据 id 请求详情,再把详情填回表单。
怎么做:openAddModalExec 中执行 reset()、id.value=''、隐藏 preImgEle;openEditModalExec 中通过 getAdvById 获取数据,给 advTitle、id、advType、advHref、orderNum 赋值,并显示预览图。
易错点:修改时图片预览地址要拼后端静态资源路径;添加时如果不清空隐藏域,后续提交会走 putAdv,出现“明明点的是添加却修改了数据”的问题。转写稿里调试时也专门验证了:打开添加后隐藏域应为空,打开修改后隐藏域应有当前广告 id。
提交时按 id 分流:有值修改,无值添加
为什么需要:后端接口不同,修改用 putAdv,添加用 postAdv。
怎么想:FormData 里只要有 id 值,就说明当前是编辑状态;没有 id 值才是新增。
怎么做:提交事件里先 event.preventDefault(),创建 new FormData(document.addAdvForm);有 id 时 await putAdv(fd);无 id 时先 fd.delete('id'),再校验字段完整性和图片是否选择,最后 await postAdv(fd)。
易错点:添加时隐藏域虽然值为空,但字段名 id 仍在 FormData 中。原逻辑用 Array.from(fd).every(item => item[1]) 校验时,会把空的 id 当成“未填写完整”,从而提示“请将表单填写完整”。所以添加分支必须先 fd.delete('id')。另外图片校验要看 fd.get('advPic').size === 0。修改时允许不重新选图片,后端只改提交的字段。
提交成功后的统一收尾
为什么需要:无论添加还是修改,成功后都要让界面回到一致状态。
怎么想:关闭模态框、清空表单、隐藏预览图、清空搜索框、重新请求列表。
怎么做:$('#advModal').modal('hide')、document.addAdvForm.reset()、preImgEle.style.display='none'、清空 #keyword,最后 getAdvExec()。
易错点:只重置表单不隐藏预览图,会出现“表单空了但旧图还在”的假预览;不刷新列表则看不到最新数据。
完整控制器代码
js
import toastr from 'toastr';
// 导入模板页面
import advV from '@/views/adv';
// 导入组件
import AdvTableComponent from '../components/AdvTable';
// 导入api函数
import {postAdv, getAdv, getAdvById, putAdv} from '../api/adv';
// 创建变量 记录预览图元素
let preImgEle;
// 执行获取广告列表并渲染页面
const getAdvExec = async (no=1, size=3) => {
// 定义广告类型的描述 1:轮播图广告 2:轮播图底部广告 3:热门回收广告 4:优品精选广告
const advTypeDes = {
1: '轮播图广告',
2: '轮播图底部广告',
3: '热门回收广告',
4: '优品精选广告'
};
// 获取搜索框中的数据
const kw = document.querySelector('#keyword').value.trim();
// 调用后端API获取数据
const {data, pageNo, pageSum} = await getAdv(no, size, kw);
//console.log(res);
// 将数据传入组件
document.querySelector('#advTable').innerHTML = AdvTableComponent({
advList:data,
advTypeDes,
pageNo,
pageSum
});
};
// 执行分页渲染的函数
const setPageExec = event => {
// 判断是否是分页按钮
if (event.target.classList.contains('page-link')) {
getAdvExec(event.target.dataset.i);
}
}
// 打开添加广告的模态框
const openAddModalExec = () => {
// 清空表单
document.addAdvForm.reset();
// 清空隐藏域
document.addAdvForm.id.value = '';
// 隐藏预览图
preImgEle.style.display = 'none';
// 修改标题
document.querySelector('#advModal .modal-title').innerHTML = '添加广告';
// 打开模态框
$('#advModal').modal('show');
}
// 打开修改广告的模态框 获取当前广告的详细信息,
const openEditModalExec = async event => {
// 判断点击是修改按钮
if (event.target.classList.contains('btn-edit')) {
// 获取该条广告的信息
const {data} = await getAdvById(event.target.dataset.id);
// 将广告信息显示在表单里,作为表单控件的默认值
document.addAdvForm.advTitle.value = data.advTitle;
document.addAdvForm.id.value = data._id;
document.addAdvForm.advType.value = data.advType;
document.addAdvForm.advHref.value = data.advHref;
document.addAdvForm.orderNum.value = data.orderNum;
// 显示预览图
preImgEle.style.display = 'block';
preImgEle.src = '/api/' + data.advPic;
// 修改模态框标题
document.querySelector('#advModal .modal-title').innerHTML = '修改广告';
// 打开模态框
$('#advModal').modal('show');
}
}
// 执行图片预览
const prevImgExec = function() {
// 获取file对象
const file = this.files[0];
// 判断
if (!file) {
return;
}
// 实例化 FilerReader
const reader = new FileReader()
// 将文件读取成 base64 编码的形式 异步
reader.readAsDataURL(file);
// 监听读取完毕
reader.onload = () => {
// 将 base64编码的图片作为 src 属性的值
preImgEle.src = reader.result;
preImgEle.style.display = 'block';
}
}
// 执行广告添加或修改
const addAdvExec = async event => {
// 阻止默认行为
event.preventDefault();
// 创建 FomrData 对象,以 form元素作为参数
const fd = new FormData(document.addAdvForm);
// 如果表单内容中有id
if ((fd.get('id'))) {
// 执行修改
await putAdv(fd);
toastr.success('修改成功!');
} else {
// 执行添加
// 将 id 从fd中删除
fd.delete('id');
// 利用纯数组进行判断
const fdArr = Array.from(fd);
if (!fdArr.every(item => item[1])) {
toastr.error('请将表单填写完整!');
return;
}
if (fd.get('advPic').size === 0) {
toastr.error('请选择要上传的图片!');
return;
}
// 使用后端API添加广告
await postAdv(fd)
toastr.success('添加成功!');
}
// 模态框关闭
$('#advModal').modal('hide');
// 表单清空
document.addAdvForm.reset();
// 隐藏预览图
preImgEle.style.display = 'none';
// 清空搜索框
document.querySelector('#keyword').value = '';
// 更新列表
getAdvExec();
}
export default (req, res) => {
// 渲染页面
res.render(advV());
// 获取广告列表并渲染到页面
getAdvExec();
// 点击分页按钮 事件委托
document.querySelector('#advTable').addEventListener('click', setPageExec);
// 点击搜索按钮
document.querySelector('#searchAdvBtn').addEventListener('click', () => {
getAdvExec();
});
// 点击添加广告的按钮
document.querySelector('#addAdvBtn').addEventListener('click', openAddModalExec);
// 点击表格中的修改按钮 事件委托
document.querySelector('#advTable').addEventListener('click', openEditModalExec);
// 获取预览图元素
preImgEle = document.querySelector('#preImg');
// 给图片上传input监听change事件,实现图片实时预览
document.querySelector('#advPic').addEventListener('change', prevImgExec);
// 给表单监听提交事件 添加或修改
document.addAdvForm.addEventListener('submit', addAdvExec);
}模板中的隐藏域与表单
ejs
<form name="addAdvForm" class="form-horizontal" id="addAdvForm">
<!-- 隐藏域:修改时写入广告id,添加时为空 -->
<input type="hidden" name="id" value="">
<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="advTitle" 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="advType" 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="advHref" 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="orderNum" 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">
</div>
</div>
</form>为什么生产模式要单独配置
开发阶段我们关心的是启动快、改代码能及时看到效果,所以用 webpack.dev.config.js 启 devServer、配代理。生产阶段关心的是产物能否部署、体积是否可控、错误能否追踪。因此需要把公共配置放在 webpack.base.config.js,再用 webpack-merge 分别合并开发配置和生产配置。这样做的“怎么想”是:公共 loader、入口、输出不动;生产只追加 CSS 抽离、压缩、mode 和 devtool。易错点是不要把生产插件写进开发配置,也不要把 devServer 写进生产配置。
安装抽离与压缩插件
生产环境如果继续用 style-loader,CSS 会打进 JS,页面加载时再插入 <style>,不利于缓存和并行加载。所以要把 CSS 抽成独立文件,并进一步压缩。转写稿里明确是先装两个插件:
shell
npm i mini-css-extract-plugin -D
npm i css-minimizer-webpack-plugin -D-D 表示开发依赖。易错点:插件名不要写成 mini-css-extract 之类的简写;安装后要在生产配置中 require,不是只在命令里装了就生效。
生产配置:抽离 CSS 并压缩
生产配置 config/webpack.prod.config.js 要做三件事:用 MiniCssExtractPlugin.loader 替换原来的 style-loader;用插件输出 CSS 文件;用 CssMinimizerPlugin 压缩 CSS。代码如下:
js
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const {merge} = require('webpack-merge');
const baseConfig = require('./webpack.base.config');
module.exports = merge(baseConfig, {
// loaders
module: {
rules: [
{
test: /\.less$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader']
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader']
}
]
},
// 插件
plugins: [
new MiniCssExtractPlugin({
filename: 'css/main.[hash:12].css'
})
],
optimization: {
minimizer: [
// 在 webpack@5 中,你可以使用 `...` 语法来扩展现有的 minimizer(即 `terser-webpack-plugin`),将下一行取消注释
`...`,
new CssMinimizerPlugin(),
],
},
// 模式
mode: 'production',
// sourceMap
devtool:'source-map',
})“怎么想”:MiniCssExtractPlugin.loader 负责把 CSS 抽离,filename 里的 [hash:12] 用于缓存控制;optimization.minimizer 中的 ... 很关键,它表示保留 webpack 默认的 JS 压缩器,再追加 CSS 压缩器。易错点:如果只写 new CssMinimizerPlugin() 而丢掉 ...,默认 JS 压缩可能被覆盖;转写稿中还出现过 devtool 拼错导致打包报错,改对后再打包才成功。验证时看 dist/css/main.xxx.css 是否生成、是否被压成一行,并确认 HTML 中自动引入了该 CSS。
开发配置:模式、sourceMap、devServer
开发配置 config/webpack.dev.config.js 负责开发体验:
js
const {merge} = require('webpack-merge');
const baseConfig = require('./webpack.base.config');
module.exports = merge(baseConfig, {
// 模式
mode: 'development',
// sourceMap
devtool:'cheap-module-source-map',
// 自动开启服务
devServer: {
host: 'shirly.com',
port: '80',
open: true,
// 开发中,允许前端路由使用 history 模式
historyApiFallback: true,
proxy: {
// 默认替换方式:/api/admin -> http://127.0.0.1:8088/api/admin
// '/api': 'http://127.0.0.1:8088'
'/api': {
target: 'http://127.0.0.1:8088',
// /api/admin -> http://127.0.0.1:8088/admin 去掉 /api
pathRewrite: { '^/api': ''},
// 默认值请求中 Host http://shirly.com
// 设置为 true 请求头 Host http://127.0.0.1:8088
changeOrigin: true,
}
}
}
})“为什么需要”:开发时前端服务端口和后端不同,直接用 /api 请求会跨域,devServer.proxy 由开发服务器转发。historyApiFallback 用于前端 history 路由刷新不 404。devtool: 'cheap-module-source-map' 比生产 source-map 更轻,适合开发调试。易错点:pathRewrite、changeOrigin 拼写要准确;代理只在开发服务器生效,打包后不能依赖它。
生产与开发使用不同 baseURL
转写稿强调:开发时请求 /api 走代理没问题;打包部署后不能再指望 devServer 代理,应改成真实后端地址。做法是用 webpack 内置的 DefinePlugin 注入全局变量,例如开发配置注入 service_URL: JSON.stringify('/api'),生产配置注入真实后端地址。项目里 request 和模板中不再写死 /api,而使用该变量。易错点:DefinePlugin 注入的是代码片段,字符串必须用 JSON.stringify 包一层,否则会把字符串当代码执行而报错。最后分别跑开发启动和生产打包,检查请求地址是否随模式切换。
先明确问题:为什么要两套 baseURL
后端服务地址:http://api.fuming.site:54254/
代理服务器地址:/api
为什么需要:开发阶段用 webpack devServer 的代理,把 /api 转发到后端,避免跨域;打包部署后,前端是静态资源服务器提供页面,devServer 不在了,继续请求 /api 就找不到后端。
怎么想:同一份源码里不要写死地址,而是把 baseURL 做成编译期变量:开发注入 /api,生产注入真实后端地址。
怎么做:用 webpack 内置的 DefinePlugin,在开发配置和生产配置里分别注入 SERVICE_URL。
易错点:不要以为打包后还能走 /api 代理;代理是开发阶段工具,生产一般由后端允许前端静态资源服务器的跨域,或由运维配置代理。
开发配置:在 webpack.dev.config.js 注入 /api
为什么需要:开发时请求 /api,由 devServer 代理到后端,浏览器看到的是同源请求,跨域问题交给代理处理。
怎么想:开发配置只负责开发环境,所以 SERVICE_URL 的值是 /api。
怎么做:在 config/webpack.dev.config.js 的 plugins 中写:
js
plugins: [
new webpack.DefinePlugin({
SERVICE_URL: JSON.stringify('/api')
})
],
// 模式
mode: 'development',易错点:DefinePlugin 是 webpack 内置插件,不需要额外安装,但配置文件中要能拿到 webpack,转写稿里也强调了先 require('webpack');不要把这段写到生产配置里。
生产配置:在 webpack.prod.config.js 注入真实地址
为什么需要:生产打包后不再有开发代理,必须请求真实后端服务地址。
怎么想:生产配置只负责打包产物,所以 SERVICE_URL 换成 http://api.fuming.site:54254。
怎么做:在 config/webpack.prod.config.js 中写:
js
// 插件
plugins: [
new webpack.DefinePlugin({
SERVICE_URL: JSON.stringify('http://api.fuming.site:54254')
})
],易错点:不要放错文件;开发是 /api,生产是真实地址;地址前后的斜杠策略要和项目拼接处保持一致,避免拼出双斜杠或漏斜杠。
项目中使用:axios 实例统一读 SERVICE_URL
为什么需要:所有请求统一走一个 axios 实例,环境切换只改 webpack 配置,不改业务代码。
怎么想:axios.create 的 baseURL 直接使用编译期注入的 SERVICE_URL。
怎么做:src/request/advserver.js 保留如下代码:
js
// 导入 axios
import axios from 'axios';
import toastr from 'toastr';
// 创建 axios 实例
const advServer = axios.create({
baseURL: SERVICE_URL,
timeout: 5000
});
// 给实例添加请求拦截器
advServer.interceptors.request.use(config => {
// 如果localStorage中有token,就添加到请求头
if (localStorage.getItem('token')) {
config.headers.token = localStorage.getItem('token');
}
return config;
});
// 给 实例设置响应拦截器
advServer.interceptors.response.use(res => {
if (res.data.ok === -1) {
toastr.error(res.data.msg);
// 返回不改变状态的 Promise,执行链结束了
return new Promise(()=>{});
}
if (res.data.ok === -2) {
toastr.error(res.data.msg);
// 跳转到 登录页
router.go('/login');
// 清空localStorage
localStorage.clear();
// 返回不改变状态的 Promise,执行链结束了
return new Promise(()=>{});
}
return res.data;
}, error => {
toastr.error('请求错误!');
// 返回不改变状态的 Promise,执行链结束了
return new Promise(()=>{});
});
// 暴露实例
export default advServer;易错点:不要在业务代码里写死 '/api';如果模板里拼接图片等地址,也要用 SERVICE_URL,不能继续写死 /api。转写稿中提到图片地址、编辑弹窗里的地址都要一起替换,否则开发能显示、生产会失效。
DefinePlugin 的坑:为什么值要 JSON.stringify
为什么需要:DefinePlugin 注入的不是普通“值”,而是会被当成代码片段替换进源码。
怎么想:如果直接写 SERVICE_URL: '/api',编译后相当于把 /api 当代码执行,会报错;JSON.stringify('/api') 会生成带引号的字符串字面量,作为代码执行后才是字符串。
怎么做:统一写成 JSON.stringify(...),如上面的开发、生产配置。
易错点:不要漏 JSON.stringify;转写稿里说的“接信”“sfer”就是 JSON 和 JSON.stringify,结论是字符串值必须包一层。
验证与易错点
为什么需要:确认开发和生产替换正确,避免打包后请求错地址。
怎么想:开发运行后看 Network 中 XHR 请求是否以 /api 开头;生产打包后检查产物或请求地址是否变成真实后端地址。
怎么做:开发模式启动后过滤 XHR 观察请求;生产打包后搜索产物中的 SERVICE_URL 替换结果。转写稿中开发模式已验证能看到请求带 /api,生产模式需按打包配置检查。
易错点:开发/生产配置写反;生产请求真实地址时后端未允许前端静态资源服务器跨域;若使用 history 路由,部署后刷新 404 还要让静态服务器回退到 index.html,这是部署阶段额外注意点。
本节要解决的三件事
部署上线不是“把代码传上去”就完了。开发阶段我们用的是 devServer,它自带静态服务和代理,所以本地能跑;打包后得到的是 dist 里的纯静态文件,必须交给前端静态资源服务器(本节用 nginx 搭建,示例地址为 http://adv.manage.fuming.site)。上线后还要解决跨域和 history 路由刷新 404。本节笔记原始清单如下:
text
1. 将输出目录中的东西上传至前端静态资源服务
示例地址: http://adv.manage.fuming.site
该静态资源服务器使用nginx搭建
2. 在后端服务器允许跨域, 允许前端静态资源服务器的协议、域名、端口 http://adv.manage.fuming.site
3. 在前端静态资源服务器配置,允许history模式第一步:把输出目录上传到前端静态资源服务器
为什么需要:开发时页面由 devServer 提供,打包后 dist 里只有 index.html、JS、CSS、图片等静态文件,浏览器需要向一个真正的静态服务器请求它们。
怎么想:静态服务器的网站根目录应该“直接看到 index.html”,而不是再多一层 dist。
怎么做:先执行打包,把输出目录中的内容上传到 nginx 站点的根目录。转写稿中是用宝塔面板把 dist 里的文件拖到网站根目录,然后访问 http://adv.manage.fuming.site 验证。
易错点:把整个 dist 文件夹传上去,导致访问路径多一层;漏传 assets;上传后仍看到旧欢迎页,通常是根目录不对或浏览器缓存;地址字母拼错也会打不开,转写稿里就出现过 manage 少字母导致访问失败。
第二步:后端允许前端静态资源服务器跨域
为什么需要:上线后前端在 http://adv.manage.fuming.site,后端在另一个地址/端口,浏览器同源策略会拦截请求。开发阶段靠 devServer 代理绕过了跨域,生产环境不能再依赖那个代理。
怎么想:不是允许所有来源,而是只允许前端静态资源服务器的“协议、域名、端口”。示例地址没有写端口,默认就是 80。
怎么做:在后端 CORS 配置中允许 http://adv.manage.fuming.site,保存后重启后端服务。转写稿中改完后端配置后明确重启了 Node 程序,再刷新页面,广告列表能出来,Network 里的跨域报错消失。
易错点:只写域名漏协议;地址写错;图省事配成 *,安全性差且不符合本节要求;改完后端配置不重启;前端请求地址还写着 /api 代理前缀,上线后应换成真实后端地址。
第三步:nginx 配置 history 模式回退
为什么需要:前端用的是 history 路由。刷新 /adv 这类地址时,浏览器会真的向服务器请求 /adv,nginx 会以为要找 adv 目录,找不到就 404。
怎么想:服务器不要按路径找目录,而是统一返回 index.html,让前端路由接管路径。转写稿里说得很直白:不管地址怎么写,服务器老老实实打开根目录下的 index.html。
怎么做:在 nginx 站点配置中启用 history 回退到 index.html 的规则。转写稿中是打开预先写好的注释配置并保存,宝塔面板会自动重载;不放心也可重启 nginx。验证方式:进入广告列表后按刷新,不再 404;直接访问子路径也能打开。
易错点:只配前端路由不配服务器;配置保存后不重载;把回退规则写错造成循环;接口请求和静态页面不是同一个服务时,要确认接口没有误走静态回退。
调试顺序复盘
上线后先看页面是否出来:页面出来但请求报错,优先查跨域;能进列表但一刷新就 404,优先查 history 回退;上传后还是旧页面,查网站根目录和缓存。转写稿中的“黑ory 路由”应纠正为 history 路由,“跨域”“宝塔”“Xshell”“dist”也都是同音/口误易混词。按“打包 → 上传静态文件 → 后端 CORS 并重启 → nginx history 回退 → 刷新和接口验证”的顺序做,部署就稳了。
课堂画面




代码示例
1. 执行修改或者添加(src/controllers/adv.js + 表单隐藏域)
js
// src/controllers/adv.js
import toastr from 'toastr';
// 导入模板页面
import advV from '@/views/adv';
// 导入组件
import AdvTableComponent from '../components/AdvTable';
// 导入api函数
import {postAdv, getAdv, getAdvById, putAdv} from '../api/adv';
// 创建变量 记录预览图元素
let preImgEle;
// 执行获取广告列表并渲染页面
const getAdvExec = async (no = 1, size = 3) => {
// 定义广告类型的描述 1:轮播图广告 2:轮播图底部广告 3:热门回收广告 4:优品精选广告
const advTypeDes = {
1: '轮播图广告',
2: '轮播图底部广告',
3: '热门回收广告',
4: '优品精选广告'
};
// 获取搜索框中的数据
const kw = document.querySelector('#keyword').value.trim();
// 调用后端API获取数据
const {data, pageNo, pageSum} = await getAdv(no, size, kw);
// 将数据传入组件
document.querySelector('#advTable').innerHTML = AdvTableComponent({
advList: data,
advTypeDes,
pageNo,
pageSum
});
};
// 执行分页渲染的函数
const setPageExec = event => {
// 判断是否是分页按钮
if (event.target.classList.contains('page-link')) {
getAdvExec(event.target.dataset.i);
}
};
// 打开添加广告的模态框
const openAddModalExec = () => {
// 清空表单
document.addAdvForm.reset();
// 清空隐藏域(reset 不会重置隐藏域,必须手动清)
document.addAdvForm.id.value = '';
// 隐藏预览图
preImgEle.style.display = 'none';
// 修改标题
document.querySelector('#advModal .modal-title').innerHTML = '添加广告';
// 打开模态框
$('#advModal').modal('show');
};
// 打开修改广告的模态框 获取当前广告的详细信息
const openEditModalExec = async event => {
// 判断点击是修改按钮
if (event.target.classList.contains('btn-edit')) {
// 获取该条广告的信息
const {data} = await getAdvById(event.target.dataset.id);
// 将广告信息显示在表单里,作为表单控件的默认值
document.addAdvForm.advTitle.value = data.advTitle;
document.addAdvForm.id.value = data._id; // 关键:把 id 写进隐藏域
document.addAdvForm.advType.value = data.advType;
document.addAdvForm.advHref.value = data.advHref;
document.addAdvForm.orderNum.value = data.orderNum;
// 显示预览图
preImgEle.style.display = 'block';
preImgEle.src = '/api/' + data.advPic;
// 修改模态框标题
document.querySelector('#advModal .modal-title').innerHTML = '修改广告';
// 打开模态框
$('#advModal').modal('show');
}
};
// 执行图片预览
const prevImgExec = function () {
// 获取file对象
const file = this.files[0];
if (!file) {
return;
}
// 实例化 FileReader
const reader = new FileReader();
// 将文件读取成 base64 编码的形式 异步
reader.readAsDataURL(file);
// 监听读取完毕
reader.onload = () => {
preImgEle.src = reader.result;
preImgEle.style.display = 'block';
};
};
// 执行广告添加或修改
const addAdvExec = async event => {
// 阻止默认行为
event.preventDefault();
// 创建 FormData 对象,以 form 元素作为参数
const fd = new FormData(document.addAdvForm);
// 如果表单内容中有 id
if (fd.get('id')) {
// 执行修改
await putAdv(fd);
toastr.success('修改成功!');
} else {
// 执行添加:先把空的 id 字段删掉,否则会被校验成“没填完整”
fd.delete('id');
// FormData 没有 every,转成纯数组再判断
const fdArr = Array.from(fd);
if (!fdArr.every(item => item[1])) {
toastr.error('请将表单填写完整!');
return;
}
if (fd.get('advPic').size === 0) {
toastr.error('请选择要上传的图片!');
return;
}
// 使用后端API添加广告
await postAdv(fd);
toastr.success('添加成功!');
}
// 模态框关闭
$('#advModal').modal('hide');
// 表单清空
document.addAdvForm.reset();
// 隐藏预览图
preImgEle.style.display = 'none';
// 清空搜索框
document.querySelector('#keyword').value = '';
// 更新列表
getAdvExec();
};
export default (req, res) => {
// 渲染页面
res.render(advV());
// 获取广告列表并渲染到页面
getAdvExec();
// 点击分页按钮 事件委托
document.querySelector('#advTable').addEventListener('click', setPageExec);
// 点击搜索按钮
document.querySelector('#searchAdvBtn').addEventListener('click', () => {
getAdvExec();
});
// 点击添加广告的按钮
document.querySelector('#addAdvBtn').addEventListener('click', openAddModalExec);
// 点击表格中的修改按钮 事件委托
document.querySelector('#advTable').addEventListener('click', openEditModalExec);
// 获取预览图元素
preImgEle = document.querySelector('#preImg');
// 给图片上传input监听change事件,实现图片实时预览
document.querySelector('#advPic').addEventListener('change', prevImgExec);
// 给表单监听提交事件 添加或修改
document.addAdvForm.addEventListener('submit', addAdvExec);
};html
<!-- src/views/adv.ejs 表单中的隐藏域(放在 form 内任意位置) -->
<form name="addAdvForm" class="form-horizontal" id="addAdvForm">
<!-- 隐藏域:有值=修改,无值=添加 -->
<input type="hidden" name="id" value="">
<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="advTitle" 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="advType" 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="advHref" 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="orderNum" 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">
</div>
</div>
</form>关键行与易错点
- 判断依据只有一句:
fd.get('id')有值走putAdv(修改),无值走postAdv(添加)。id 靠<input type="hidden" name="id">偷偷带进 FormData。 - 易错点 1:
form.reset()不会重置隐藏域。所以打开“添加”模态框时必须手动document.addAdvForm.id.value = '',否则上一次修改的 id 残留,点添加会变成修改。 - 易错点 2:添加时表单里其实有 id 字段,只是值为空串。直接走
fdArr.every(item => item[1])会判空失败、一直提示“请将表单填写完整”。所以先fd.delete('id')再校验。 FormData没有数组方法,必须Array.from(fd)才能用every;其中advPic是 File 对象,用.size === 0判断是否选图,不能判断真假值。- 修改时可以不重选图片:后端只改传上去的字段,图片不传就不动。
- 图片预览用
FileReader.readAsDataURL读成 base64 赋给src,读完后记得style.display = 'block'。
2. 生产 / 开发两套 webpack 配置(抽离压缩 CSS + DefinePlugin 注入 SERVICE_URL)
js
// config/webpack.prod.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const webpack = require('webpack');
const {merge} = require('webpack-merge');
const baseConfig = require('./webpack.base.config');
module.exports = merge(baseConfig, {
// loaders
module: {
rules: [
{
test: /\.less$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader']
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader']
}
]
},
// 插件
plugins: [
new MiniCssExtractPlugin({
filename: 'css/main.[hash:12].css'
}),
new webpack.DefinePlugin({
// 生产环境:真实后端服务地址(不要带结尾斜杠,与开发保持一致)
SERVICE_URL: JSON.stringify('http://api.fuming.site:54254')
})
],
optimization: {
minimizer: [
// webpack@5 中用 `...` 扩展现有 minimizer(即 terser-webpack-plugin)
`...`,
new CssMinimizerPlugin(),
],
},
// 模式
mode: 'production',
// sourceMap
devtool: 'source-map',
});js
// config/webpack.dev.config.js
const webpack = require('webpack');
const {merge} = require('webpack-merge');
const baseConfig = require('./webpack.base.config');
module.exports = merge(baseConfig, {
plugins: [
new webpack.DefinePlugin({
// 开发环境:走 devServer 代理
SERVICE_URL: JSON.stringify('/api')
})
],
// 模式
mode: 'development',
// sourceMap
devtool: 'cheap-module-source-map',
// 自动开启服务
devServer: {
host: 'shirly.com',
port: '80',
open: true,
// 开发中,允许前端路由使用 history 模式
historyApiFallback: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:8088',
// /api/admin -> http://127.0.0.1:8088/admin 去掉 /api
pathRewrite: {'^/api': ''},
// 请求头 Host 变成 target 的 host
changeOrigin: true,
}
}
}
});关键行与易错点
- 装依赖:
npm i mini-css-extract-plugin -D、npm i css-minimizer-webpack-plugin -D(--save-dev就是-D)。 - 抽离 CSS 两件事缺一不可:loader 里把
style-loader换成MiniCssExtractPlugin.loader,插件里new MiniCssExtractPlugin({filename: 'css/main.[hash:12].css'})(filename 相对输出目录,不写 path)。 - 易错点 1:
optimization.minimizer里必须写`...`,否则默认的 terser(JS 压缩)被覆盖掉,JS 就不压缩了。 - 易错点 2:
devtool拼写错误会直接报配置错误(转写稿里就是devTool写错导致打包失败)。生产source-map,开发cheap-module-source-map。 - 易错点 3:
DefinePlugin注入的是代码片段,不是字符串值。直接写SERVICE_URL: '/api'会被当代码执行而报错,必须JSON.stringify('/api')。 DefinePlugin是 webpack 内置插件,不用安装,但两个配置文件都要先require('webpack')。- 压缩后如果发现 CSS 没被压缩,把 dist 目录删掉重新打包一次再验证。
3. 项目中使用 SERVICE_URL(src/request/advserver.js)
js
// src/request/advserver.js
// 导入 axios
import axios from 'axios';
import toastr from 'toastr';
// 创建 axios 实例:baseURL 使用编译期注入的全局变量
const advServer = axios.create({
baseURL: SERVICE_URL, // 开发=/api(走代理),生产=http://api.fuming.site:54254
timeout: 5000
});
// 给实例添加请求拦截器
advServer.interceptors.request.use(config => {
// 如果localStorage中有token,就添加到请求头
if (localStorage.getItem('token')) {
config.headers.token = localStorage.getItem('token');
}
return config;
});
// 给实例设置响应拦截器
advServer.interceptors.response.use(res => {
if (res.data.ok === -1) {
toastr.error(res.data.msg);
// 返回不改变状态的 Promise,执行链结束了
return new Promise(() => {});
}
if (res.data.ok === -2) {
toastr.error(res.data.msg);
// 跳转到 登录页
router.go('/login');
// 清空localStorage
localStorage.clear();
// 返回不改变状态的 Promise,执行链结束了
return new Promise(() => {});
}
return res.data;
}, error => {
toastr.error('请求错误!');
// 返回不改变状态的 Promise,执行链结束了
return new Promise(() => {});
});
// 暴露实例
export default advServer;js
// 模板 / 组件里拼图片地址(原来写死 '/api/' 的地方都要改)
// 组件中渲染列表图片:
`${SERVICE_URL}/${item.advPic}`
// 控制器 openEditModalExec 中显示预览图:
preImgEle.src = SERVICE_URL + '/' + data.advPic;关键行与易错点
SERVICE_URL是DefinePlugin在编译期注入的全局变量,项目里不需要 import,直接写即可;编辑器报“未定义”是正常的。- 开发模式
SERVICE_URL = '/api',由 devServer 的proxy转发并pathRewrite去掉/api;打包后没有任何代理,所以生产必须是真实可访问的后端地址(含协议、域名、端口)。 - 易错点 1:两套地址结尾风格要统一(都不带结尾
/),拼接时自己补'/',否则会出现//或缺少分隔符的图片 404。 - 易错点 2:凡是写死
'/api/'的地方(组件里的图片地址、控制器里的预览图)都必须换成SERVICE_URL,漏一处打包后就取不到图。 - 响应拦截器里
return new Promise(() => {})是让 Promise 链“停住”,后续await不会再往下走,这是故意为之,不是笔误。
常见错误
现象:添加广告时提示“请将表单填写完整”。
原因:隐藏域id存在于FormData中但值为空,被全字段非空校验拦截。
修法:进入添加分支后先fd.delete('id'),再做字段完整性与图片校验。现象:修改过广告后,再点“添加广告”提交却执行了修改。
原因:form.reset()不会清空type=hidden的id,隐藏域仍保留上次修改的 ID。
修法:打开添加模态框时手动执行document.addAdvForm.id.value = ''。现象:生产打包后 CSS 未抽离、未压缩,或样式未自动注入 HTML。
原因:生产配置未正确使用MiniCssExtractPlugin.loader、MiniCssExtractPlugin,或optimization.minimizer未保留...。
修法:生产环境替换 CSS/less loader,配置MiniCssExtractPlugin,并在minimizer中加入...与CssMinimizerPlugin。现象:使用
DefinePlugin注入SERVICE_URL后构建或运行报错。
原因:直接注入普通字符串,被当作代码片段执行。
修法:使用JSON.stringify('/api')或JSON.stringify('http://api.fuming.site:54254')。现象:部署后刷新
/adv等子路由出现 404。
原因:前端使用 history 路由,静态资源服务器把路径当目录查找。
修法:在 Nginx 中配置回退到index.html,如try_files $uri $uri/ /index.html;。现象:部署后接口请求跨域,或请求仍发往
/api。
原因:生产SERVICE_URL未切换为真实后端地址;后端未允许前端静态资源服务器 Origin。
修法:生产注入真实后端地址;后端 CORS 精确允许前端静态资源服务器的协议、域名、端口。
练习
- 补全
addAdvExec中“修改/添加”的分支判断,并说明为什么添加分支要先删除id再校验。 - 分别写出打开“添加广告”和“修改广告”模态框时,对隐藏域
id、表单字段、预览图的处理要点。 - 写出生产配置中 CSS 抽离与压缩的关键配置片段,并指出容易漏掉的
optimization.minimizer写法。 - 用
DefinePlugin分别配置开发和生产环境的SERVICE_URL,并解释为什么必须使用JSON.stringify。 - 列出部署到 Nginx 后需要检查的 3 件事:history 回退、生产接口地址、后端跨域允许来源。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 执行添加或修改的函数 | 21:58 |
| 2 | 打包-生产模式下的配置 | 17:25 |
| 3 | 生产模式和开发模式使用不同的baseURL | 25:30 |
| 4 | 部署上线 | 22:06 |
小结
- 添加或修改共用同一表单时,应以
FormData中id是否有值作为分支依据。 - 隐藏域不会随
form.reset()自动清空,打开添加模态框时必须手动清空。 - 添加分支应先删除空
id,否则会干扰非空校验并可能误判为修改。 - 生产配置重点包括 CSS 抽离、CSS 压缩、
source-map与生产模式设置。 - 开发与生产应通过
DefinePlugin注入不同的SERVICE_URL,避免写死/api。 - 部署后 history 路由必须由静态资源服务器回退到
index.html。 - 后端跨域应精确允许前端静态资源服务器地址,而不是简单放开所有来源。
- 修改/添加完成后统一关闭模态框、重置表单、隐藏预览图、清空搜索并刷新列表。