Skip to content

执行修改或者添加与打包 生产模式下配置(功能完善与部署 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

课堂画面 2

课堂画面 3

课堂画面 4

代码示例 ​

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 不会再往下走,这是故意为之,不是笔误。

常见错误 ​

  1. 现象:添加广告时提示“请将表单填写完整”。
    原因:隐藏域 id 存在于 FormData 中但值为空,被全字段非空校验拦截。
    修法:进入添加分支后先 fd.delete('id'),再做字段完整性与图片校验。

  2. 现象:修改过广告后,再点“添加广告”提交却执行了修改。
    原因:form.reset() 不会清空 type=hidden 的 id,隐藏域仍保留上次修改的 ID。
    修法:打开添加模态框时手动执行 document.addAdvForm.id.value = ''。

  3. 现象:生产打包后 CSS 未抽离、未压缩,或样式未自动注入 HTML。
    原因:生产配置未正确使用 MiniCssExtractPlugin.loader、MiniCssExtractPlugin,或 optimization.minimizer 未保留 ...。
    修法:生产环境替换 CSS/less loader,配置 MiniCssExtractPlugin,并在 minimizer 中加入 ... 与 CssMinimizerPlugin。

  4. 现象:使用 DefinePlugin 注入 SERVICE_URL 后构建或运行报错。
    原因:直接注入普通字符串,被当作代码片段执行。
    修法:使用 JSON.stringify('/api') 或 JSON.stringify('http://api.fuming.site:54254')。

  5. 现象:部署后刷新 /adv 等子路由出现 404。
    原因:前端使用 history 路由,静态资源服务器把路径当目录查找。
    修法:在 Nginx 中配置回退到 index.html,如 try_files $uri $uri/ /index.html;。

  6. 现象:部署后接口请求跨域,或请求仍发往 /api。
    原因:生产 SERVICE_URL 未切换为真实后端地址;后端未允许前端静态资源服务器 Origin。
    修法:生产注入真实后端地址;后端 CORS 精确允许前端静态资源服务器的协议、域名、端口。

练习 ​

  1. 补全 addAdvExec 中“修改/添加”的分支判断,并说明为什么添加分支要先删除 id 再校验。
  2. 分别写出打开“添加广告”和“修改广告”模态框时,对隐藏域 id、表单字段、预览图的处理要点。
  3. 写出生产配置中 CSS 抽离与压缩的关键配置片段,并指出容易漏掉的 optimization.minimizer 写法。
  4. 用 DefinePlugin 分别配置开发和生产环境的 SERVICE_URL,并解释为什么必须使用 JSON.stringify。
  5. 列出部署到 Nginx 后需要检查的 3 件事:history 回退、生产接口地址、后端跨域允许来源。

原视频对照 ​

#视频时长
1执行添加或修改的函数21:58
2打包-生产模式下的配置17:25
3生产模式和开发模式使用不同的baseURL25:30
4部署上线22:06

小结 ​

  1. 添加或修改共用同一表单时,应以 FormData 中 id 是否有值作为分支依据。
  2. 隐藏域不会随 form.reset() 自动清空,打开添加模态框时必须手动清空。
  3. 添加分支应先删除空 id,否则会干扰非空校验并可能误判为修改。
  4. 生产配置重点包括 CSS 抽离、CSS 压缩、source-map 与生产模式设置。
  5. 开发与生产应通过 DefinePlugin 注入不同的 SERVICE_URL,避免写死 /api。
  6. 部署后 history 路由必须由静态资源服务器回退到 index.html。
  7. 后端跨域应精确允许前端静态资源服务器地址,而不是简单放开所有来源。
  8. 修改/添加完成后统一关闭模态框、重置表单、隐藏预览图、清空搜索并刷新列表。