Skip to content

设置标题和站标与使用 sme-router 构建单页面应用(项目初始化 2/2) ​

本页对应 课堂视频-project,共 7 段视频,约 1 小时 30 分。

学习目标 ​

  • 能够为生产模式配置 postcss-loader 与 autoprefixer,并通过构建产物验证目标 CSS 属性已按兼容性要求添加浏览器私有前缀。
  • 能够安装并配置 ejs-loader,在 views 目录创建 EJS 模板,并在路由模块中导入、调用模板函数、传入数据后渲染到指定挂载节点。
  • 能够解释 EJS 模板导入后的函数返回值及渲染机制,并独立完成首页、登录页、管理员列表页、广告列表页等模板的路由接入与页面展示验证。
  • 能够区分 webpack 开发模式与生产模式配置,独立完成 CSS 压缩配置,并验证 CSS 与 JS 在生产构建后均被压缩且互不覆盖。
  • 能够克隆并启动后端项目,使用 API 测试工具按接口文档完成管理员添加与列表查询请求,并验证前端可获取后端返回数据。
  • 能够依据功能需求文档分析后台管理系统的登录、管理员增删改查、广告管理、图片上传与权限控制等模块,并说明单页面应用中路由切换与标题站标设置的基本实现方式。

知识点 ​

先理解 public/index.html 的角色 ​

为什么需要:这一节要解决两个“门面”问题:浏览器标签页上显示什么标题,以及标签页左侧显示什么站标(favicon)。后台管理系统打包后,最终入口仍是 dist/index.html,所以标题和站标必须在模板阶段配置好。

怎么想:public/index.html 不是普通静态页,而是 HtmlWebpackPlugin 的模板。标题不要写死,而是从插件配置里取;站标是静态资源,放在 public 根目录,由复制插件带到 dist。转写稿里反复提到的“舞台”,就是模板中最终承载页面内容的挂载点;模板内容会进入这个舞台,再生成最终的 dist/index.html。

怎么做:先把模板写成下面这样,保留标题变量和站标引用:

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>

易错点:title 里的变量名必须和 HtmlWebpackPlugin 配置中的自定义选项一致;favicon.ico 要真实放在 public 根目录,且 href 路径要和复制后的位置一致。转写稿强调“路径保持一致”,这里就是 ./favicon.ico 对应 dist/favicon.ico。

用 HtmlWebpackPlugin 注入标题 ​

为什么需要:把标题写成配置项,后续改项目名、环境名时只改 webpack 配置,不用反复改模板;这也是工程化里“模板与数据分离”的思路。

怎么想:HtmlWebpackPlugin 实例上的 title 会成为模板中的 htmlWebpackPlugin.options.title。模板用 EJS 语法 <%= %> 输出即可。转写稿里讲模板引擎时说过,模板就是做替换:把特殊符号替换成指定数据。这里替换的数据就是标题。

怎么做:在 config/webpack.base.config.js 的 HtmlWebpackPlugin 中写 title: '尚硅谷广告管理系统 shirly'。完整配置如下:

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文件
                    }
                },
            ],
        }),
    ]
}

易错点:title 要写在 new HtmlWebpackPlugin({...}) 内部;模板变量拼写不能错;改完 webpack 配置后要重启 devServer,转写稿里就出现过“加了 loader 但没重新运行导致解析错误”的调试过程。

安装并配置 CopyWebpackPlugin 复制站标 ​

为什么需要:favicon.ico 不是 JS/CSS 模块,webpack 不会自动把它当依赖打包进 dist。如果不复制,dist/index.html 里的 <link rel="icon" href="./favicon.ico"> 会 404,浏览器标签页就没有站标。

怎么想:把 public 里的静态资源整体复制到 dist,但要忽略 public/index.html。因为最终 index.html 要由 HtmlWebpackPlugin 生成,不能被原样复制覆盖。转写稿里也明确说:public 里的 CSS、JS 等文件会被复制插件复制,路径保持一致;这里 favicon 同理。

怎么做:先安装插件:

shell
npm install copy-webpack-plugin -D

然后按上面的完整配置加入 CopyWebpackPlugin,关键是 from 指向 public,to 指向 dist,并用 globOptions.ignore 忽略所有 html。

易错点:patterns 是数组;ignore 写成 ['**/**.html'],不要误伤 favicon.ico;from/to 用 path.resolve 保证路径稳定;不要复制 public/index.html 覆盖生成文件。转写稿提到 devServer 会把内容打到内存里,所以开发时看不到 dist 文件是正常的;要看复制结果,执行生产构建后检查 dist。

构建验证与调试 ​

为什么需要:配置写完不等于生效,必须确认标题变量被替换、favicon 被复制到正确位置。

怎么想:先跑构建,检查 dist/index.html 的 <title> 是否变成“尚硅谷广告管理系统 shirly”,再检查 dist/favicon.ico 是否存在;启动开发服务器后看浏览器标签页和 Network 面板中 favicon 是否 200。转写稿里说“改配置要重新运行”,这里同样适用。

怎么做:执行构建,打开 dist/index.html 或启动 devServer;若 favicon 仍不显示,先查 dist 根目录有没有文件,再查 href 是否写错,最后强刷浏览器排除缓存。

易错点:clean: true 会清空 dist,每次构建后要重新确认复制结果;favicon 浏览器缓存很顽固,调试时用强刷;标题不生效先看模板变量和 title 选项是否对应;站标 404 先看复制插件是否忽略了 ico。

安装 sme-router ​

为什么需要:本节要构建单页面应用。转写稿里提到,单页面应用的特点是“浏览器从不刷新”,切换内容时是“把原来的删掉,换成新的”,再根据 Ajax 请求数据更新视图。要让不同 URL 对应不同视图,就需要前端路由。sme-router 就是本节使用的路由库。

怎么想:先把它作为项目依赖安装,再在入口文件里创建路由实例。安装是工程化项目使用第三方库的第一步,不能直接把库代码抄进项目。

怎么做:

shell
npm install sme-router

易错点:安装命令要在项目根目录执行;安装后要确认依赖写入 package.json,再继续写入口代码。

创建路由实例与默认 hash 模式 ​

为什么需要:路由实例负责监听地址变化,并把匹配到的视图渲染到页面容器中。笔记中 new SMERouter('app') 的 'app' 对应页面里的容器,路由最终要把内容放进这个“舞台”。

怎么想:先明确默认模式。笔记说明默认是 hash 模式,地址形如:

text
http://adv.manage.fuming.site/#/index/advlist

hash 模式的特点是 URL 中带 #。转写稿反复强调单页面应用“浏览器不刷新”,所以这里不是每换一个地址就重新请求整页,而是由前端路由接管视图切换。

怎么做:在 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')

易错点:new SMERouter('app') 里的 'app' 要和页面容器 id 对应;如果容器不存在,路由渲染就没有落点。还要分清 hash 模式地址带 #,history 模式地址不带 #。

注册路由与兜底重定向 ​

为什么需要:单页面应用有多个视图,例如首页、管理员列表、广告列表、登录。每个地址要告诉路由“匹配到谁,就渲染什么”。同时用户可能输入不存在的地址,需要兜底,避免出现空白页。

怎么想:先注册具体路由,再写通配符 *。从笔记代码看,res.render('首页') 是把对应视图内容交给路由容器;res.redirect('/index') 是在未知路径时跳回首页。转写稿里说“所有内容都要展示到 APP 里面”,可以理解为路由容器就是最终承载视图的舞台。

怎么做:

js
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 和 res.redirect 的用途不同,前者渲染视图,后者跳转地址。

允许使用 history 模式 ​

为什么需要:hash 模式地址带 #,不够直观;history 模式地址形如:

text
http://adv.manage.fuming.site/index/advlist

但 history 模式在开发环境下有一个问题:直接访问或刷新 /index/advlist 这类前端路由地址时,开发服务器可能把它当成真实文件路径,从而返回 404。因此需要让 devServer 支持前端路由回退。

怎么想:笔记给出的是在 config/webpack.dev.config.js 中开启 historyApiFallback: true。它的作用是让开发服务器在找不到对应静态资源时回退到入口页面,再由前端路由接管地址。转写稿里也提到“改了 webpack 配置,要重新运行”,所以改完配置不能只刷新页面,要重启开发服务。

怎么做:

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;hash 模式地址带 #,不依赖这个回退配置。修改 webpack.dev.config.js 后要重启 devServer,否则新配置可能不生效。还要注意 devServer 的 host、port 要与本地访问地址一致。

为什么需要 ejs 模板 ​

路由切换时,如果每个页面都手写字符串,页面一复杂就难维护。模板的作用是把 HTML 里的特殊标记替换成指定数据,再把结果字符串交给路由渲染到 #app 舞台中。转写里反复强调:这里的 EJS 模板不是完整 HTML,不需要 html/head/body,只写要插入舞台的局部内容。

Webpack 默认能处理 JS 等模块,但 import './views/index.ejs' 这种扩展名它不认识,所以必须加 loader。加上 ejs-loader 后,导入的模板会变成一个函数;调用函数并传入数据,返回值才是替换好的模板字符串。

安装 ejs-loader ​

shell
npm install ejs-loader -D

-D 表示开发依赖:loader 是给 Webpack 构建用的。易错点:装完忘记改配置,或改完配置不重启 dev server。转写中先遇到 parse failed,重启 start 后才正常。

配置 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']
                    }
                },
            ],
        }),
    ]
}

怎么想:test: /\.ejs$/ 锁定模板文件;loader: 'ejs-loader' 做转换;variable: 'data' 决定模板里接收数据的变量名。易错点:只用一个 loader 可写 loader,多个 loader 才常用 use 数组;interpolate/evaluate 是改定界符的,转写中选择注释掉,继续用默认 <%= %>、<% %>。

创建局部模板文件 ​

创建 views/index.ejs、views/admin.ejs、views/login.ejs、views/adv.ejs(笔记列表里的 .js 按转写与下方 import 应为 .ejs)。先写局部内容,例如 index.ejs 里写 <h1>hello</h1>,再测试数据:<%= data.title %>、<%= data.message %>。

易错点:不要写完整 HTML;模板最终插入 public/index.html 的 #app。模板里引用的 CSS/JS 放到 public,由 CopyWebpackPlugin 复制到 dist,路径与 public/index.html 中保持一致。

在 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');
})

怎么想:import indexV 拿到的是函数;indexV({...}) 才返回模板字符串;res.render(字符串) 把内容放进舞台。转写中先 console.log(indexV) 看到函数,再 console.log(indexV()) 看到模板内容,这就是推导过程。易错点:直接 res.render(indexV) 传了函数;忘记调用;模板里写 data.title 但传参名字不对;以为 console.log 输出在终端,实际运行在浏览器,应看浏览器控制台。验证时访问 /index、/admin、/adv、/login,* 会重定向到 /index。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

1. 设置标题和站标(public/index.html + config/webpack.base.config.js) ​

html
<!-- public/index.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">
    <!-- 标题由 HtmlWebpackPlugin 的自定义选项注入 -->
    <title><%= htmlWebpackPlugin.options.title %></title>
    <!-- 站标,配合 CopyWebpackPlugin 从 public 复制到 dist -->
    <link rel="icon" href="./favicon.ico">
</head>
<body>
    <!-- 单页面应用的“舞台”,sme-router 会把模板渲染到这里 -->
    <div id="app"></div>
</body>
</html>
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: '/'
    },

    // 插件
    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 文件
                    }
                },
            ],
        }),
    ]
}

关键行与易错点

  • <%= htmlWebpackPlugin.options.title %> 里的 htmlWebpackPlugin 是插件注入的模板变量,options.title 对应配置中自定义的 title 字段,两处名字必须一致,否则标题变成 undefined。
  • CopyWebpackPlugin 必须 ignore: ['**/**.html'],否则 public/index.html 会被原样复制到 dist,覆盖掉 HtmlWebpackPlugin 生成并自动注入 <script> 的成品页面。
  • 安装时用 npm install copy-webpack-plugin -D,-D 表示开发依赖;转写稿强调 loader/插件装成开发依赖即可。
  • favicon.ico 用的是相对路径 ./favicon.ico,因为复制后与 index.html 同级,不要写成 public/xxx。
  • 改了 webpack 配置文件后,正在运行的 dev server 必须重启,否则报 “Module parse failed” 之类的解析错误(转写稿中加完 ejs-loader 后就踩过这个坑)。

2. 用 sme-router + ejs 模板构建单页面应用(src/app.js + views/index.ejs) ​

ejs
<!-- src/views/index.ejs -->
<h1><%= data.title %></h1>
<p><%= data.message %></p>
js
// src/app.js
// 导入路由
import SMERouter from 'sme-router'

// 导入模板(ejs-loader 把模板编译成一个函数)
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
// 第二个参数设为 'html5' 即 history 模式:http://adv.manage.fuming.site/index/advlist
const router = new SMERouter('app', 'html5')

router.route('/index', (req, res) => {
    // 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');
})
js
// config/webpack.base.config.js —— 与第 1 段相同的文件,仅补充 module.rules
module: {
    rules: [
        {
            test: /\.ejs$/,
            loader: 'ejs-loader',
            options: {
                variable: 'data',            // 模板中数据的变量名
                //interpolate : '\\{\\{(.+?)\\}\\}',
                //evaluate : '\\[\\[(.+?)\\]\\]'
            }
        }
    ]
}
js
// config/webpack.dev.config.js —— 允许前端路由使用 history 模式
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
    }
})

关键行与易错点

  • new SMERouter('app', 'html5') 的第一个参数 'app' 必须与 index.html 里 <div id="app"> 的 id 一致,这就是“舞台”,所有 res.render() 的内容都塞进它里面。
  • 用 'html5' 模式时,devServer 必须开 historyApiFallback: true,否则在 /index 直接刷新会 404;hash 模式则不需要。
  • import indexV from './views/index.ejs' 默认只认 .js,.ejs 是“不寻常的扩展名”,必须先配 ejs-loader,否则报解析错误。
  • indexV 导出的是函数,res.render(indexV) 传函数进去会渲染不出内容,必须 indexV({...}) 调用取返回值(字符串)。
  • 模板里用 data.xxx 取数据,是因为 loader 配置了 variable: 'data';把配置改成别的名字,模板里的变量名也要跟着改。
  • variable 对应的对象是整体赋给模板的,可以传数组、函数,用法和之前学的 ejs 完全一样。
  • 模板文件只写“舞台内部”的片段,不要写 <!DOCTYPE html>、<head> 这些,它们属于 public/index.html。
  • 页面用到的 css/js/图片统一放进 public,由 CopyWebpackPlugin 复制进 dist,在 public/index.html 里引入一次即可。

3. 生产模式:CSS 兼容前缀与压缩(postcss.config.js / .browserslistrc / config/webpack.prod.config.js) ​

js
// postcss.config.js(项目根目录)
module.exports = {
    plugins: [
        // autoprefixer 已随 postcss-loader 一起安装
        require('autoprefixer')
    ]
}
# .browserslistrc(项目根目录)
# 全球使用率超过 99.5% 的浏览器都要兼容
cover 99.5%
js
// config/webpack.prod.config.js
const { merge } = require('webpack-merge');
const baseConfig = require('./webpack.base.config');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = merge(baseConfig, {

    mode: 'production',

    module: {
        rules: [
            {
                test: /\.css$/,
                // 顺序从右到左:postcss-loader 要在 css-loader 之前处理
                use: ['style-loader', 'css-loader', 'postcss-loader']
            },
            // 如果是 less,postcss-loader 写在 css-loader 之后、less-loader 之前
            // use: ['style-loader', 'css-loader', 'postcss-loader', 'less-loader']
        ]
    },

    // 与 plugins 平级的一级配置项
    optimization: {
        minimizer: [
            `...`,                        // 保留默认的 JS 压缩插件
            new CssMinimizerPlugin()      // 再加上 CSS 压缩
        ]
    }
})

关键行与易错点

  • loader 执行顺序从右到左:postcss-loader 必须放在 css-loader 之后(数组里更靠右),因为要在 CSS 进入打包流程前先补私有前缀;less 项目则放在 css-loader 和 less-loader 之间。
  • 安装时是“两个包”:postcss-loader(负责接入)和 autoprefixer(真正加前缀),转写稿强调别只装一个。
  • 光有 loader 不够,根目录还必须有 postcss.config.js(引入 autoprefixer)和 .browserslistrc(告诉它兼容到什么程度),缺一个前缀都不会生成。
  • 前缀不是所有属性都会加:比如 filter: blur() 在支持的浏览器上本来就不需要私有前缀,所以“没看到 -moz-”不等于配置失败。
  • optimization.minimizer 与 plugins 平级,不要误写进 plugins 数组。
  • 一旦手写 minimizer,就会覆盖 webpack 内置的 JS 压缩,所以必须加 `...` 保留默认值,否则出现“CSS 压缩了、JS 没压缩”的现象(转写稿里正是这个坑)。
  • 生产环境才做抽离、兼容、压缩;开发模式为了打包快可以不做,配置分开写(webpack.dev.config.js / webpack.prod.config.js)用 webpack-merge 合并基础配置。

常见错误 ​

  1. 改完 webpack 配置,页面报 Module parse failed: Unexpected token 或行为完全没变

    • 现象:装完 loader、写好 rules 保存,刷新浏览器仍是老样子;控制台报解析错误,提示不认识 .ejs / 新语法。
    • 原因:devServer 启动时已把配置读进内存,配置文件的改动不会被热更新感知;改的是 webpack.base.config.js,跑的还是旧编译结果。
    • 修法:停掉 devServer 重新 npm start。养成习惯——凡改 config/*.js、.browserslistrc、postcss.config.js,一律重启;只有 src 下的业务代码才靠热更新。
  2. postcss-loader 装了、配了,产物里却一个 -webkit- 都没多

    • 现象:dist 里 CSS 和源文件一模一样,无前缀;构建过程不报任何错。
    • 原因:三个坑之一——只装了 postcss-loader 没装 autoprefixer;或项目根目录缺 postcss.config.js;或缺 .browserslistrc,autoprefixer 没有目标浏览器就"什么都不做"。
    • 修法:按 loader → postcss.config.js(plugins: { autoprefixer: {} })→ .browserslistrc 三件套补齐。补完先确认一件事:不是所有属性都该有前缀,像 filter 在目标浏览器版本里本来就无需前缀,没加前缀不等于没生效——要换一个确实需要前缀的属性(如 user-select、backdrop-filter)来验证。
  3. postcss-loader 位置放错,前缀没生效或 less 直接编译失败

    • 现象:纯 CSS 场景能加前缀,一换成 less 就报错;或反过来,less 正常但前缀丢失。
    • 原因:loader 数组是从右往左执行的,postcss-loader 必须在 css-loader 之前跑(即数组里写在 css-loader 右边),而 less-loader 还要更靠右。
    • 修法:统一按 ['style-loader', 'css-loader', 'postcss-loader', 'less-loader'] 排序;MiniCssExtractPlugin.loader 替换 style-loader 时同理,位置不动。
  4. 加了 CSS 压缩插件后,JS 反而不压缩了(或反之)

    • 现象:production 打包,CSS 压成一行了,JS 却还是带换行和注释;把 CSS 插件注释掉,JS 又自动压缩了。
    • 原因:optimization.minimizer 是整体覆盖,不是追加。手写 minimizer: [new CssMinimizerPlugin()] 把内置的 terser 默认值顶掉了。
    • 修法:写成 minimizer: ['...', new CssMinimizerPlugin()],用 '...' 保留默认项。验收方式:打包后同时打开 dist/js/*.js 和 dist/css/*.css,两个都应是单行。
  5. 模板渲染出来是 function ... 源码或一片空白

    • 现象:res.render(indexV) 之后页面显示函数体文本;或者模板里 data.title 位置是空的。
    • 原因:import 进来的 .ejs 是一个函数,不调用就只是个函数对象;调用时不传参,模板里的 data 就是 undefined。
    • 修法:res.render(indexV({ title: 'xx' }))——先调用拿字符串,再交给 render。模板里形参名默认是 data,由 ejs-loader 的 options.variable 决定,改名要两边同步。
  6. dist/index.html 里的 <title> 变成 <%= htmlWebpackPlugin.options.title %> 原文,或 favicon 404

    • 现象:打包后打开页面,标签页标题就是一串模板语法;图标不显示。
    • 原因:CopyWebpackPlugin 把 public 整个拷了过去,连 index.html 一起覆盖了 HtmlWebpackPlugin 编译产物;favicon 同理,源目录路径和产物路径没对上。
    • 修法:globOptions.ignore: ['**/*.html'],让 html 只由 HtmlWebpackPlugin 产出。favicon 用 ./favicon.ico 这类相对路径引用,并确认它在 dist 根目录下。
  7. history 模式路由,点链接正常,一刷新就 404

    • 现象:/adv 直接敲地址栏或 F5 白屏/404,从 /index 点过去却正常。
    • 原因:两处漏配之一——SMERouter 实例没传 'html5' 仍走 hash;或 devServer 没开 historyApiFallback,服务器把 /adv 当真实路径去找文件。
    • 修法:new SMERouter('app', 'html5') + devServer.historyApiFallback: true 同时到位。注意这只解决开发环境,生产环境要靠服务端 fallback 配置。

练习 ​

  1. 标题与站标闭环验证 修改 HtmlWebpackPlugin 的 title 为"XX 广告管理系统",同时保证浏览器标签页标题正确、favicon 正常显示。分别在 npm start 和 npm run build 后验证一遍,并说明:为什么 dist/index.html 里不能出现未被替换的 <%= %>?写出你为防止它出现所做的配置。

  2. browserslist 与 autoprefixer 的因果验证 在 .browserslistrc 中分别写入 last 2 versions 和 cover 99.5%,用同一个需要前缀的属性(建议 user-select: none 或 backdrop-filter: blur(2px))打包两次,对比 dist 中 CSS 的差异。回答:两次产物前缀有什么不同?为什么"没加前缀"有时是正确的?

  3. 压缩冲突修复 先按错误写法只写 minimizer: [new CssMinimizerPlugin()] 打包一次,记录 JS 是否被压缩;再改成 '...' 版本打包,对比两次产物。用一句话解释 '...' 在这个数组里代表什么,以及为什么它必须写。

  4. 带数据的模板渲染 为 /adv 路由准备一个 views/adv.ejs,路由中传入一个数组(如 3 条广告 {id, title, img}),模板里用 forEach 渲染成列表。完成后回答两个问题:(a) 模板里为什么可以直接用 data 这个名字?(b) 若把 ejs-loader 的 variable 改成 locals,需要改动哪些地方?

  5. 兜底路由与刷新验证 把 router.route('*') 从 redirect('/index') 改成渲染一个自定义的 views/404.ejs。然后分别测试:从 /index 点链接跳到不存在的路径、直接在地址栏输入不存在的路径并回车、在该路径上按 F5。记录三种情况下的表现,并解释差异来源。

原视频对照 ​

#视频时长
1生产模式-css兼容10:18
2使用 ejs 模板33:35
3生产模式-压缩css14:08
4webpack总结02:04
5项目介绍16:29
6后端项目启动09:20
7项目功能分析04:29

小结 ​

  1. 配置类文件(webpack config、postcss config、browserslist)改动后必须重启 devServer,这是排查"改了没反应"的第一动作,不是第二动作。
  2. loader 数组是执行顺序的倒序,postcss-loader 的插入位置由 css-loader 和 less-loader 夹出来——位置错了不报错,只是静默失效,比报错更难查。
  3. autoprefixer 是"三件套"缺一不可:loader、postcss.config.js、目标浏览器声明。少了 browserslist,工具会以为你没有兼容目标而什么都不做。
  4. 前缀加不加由目标浏览器决定,不由"这个属性看起来新不新"决定。判断兼容性处理是否生效,要拿确实需要前缀的属性做实验,而不是盯着某个属性看。
  5. optimization.minimizer 是覆盖语义,'...' 是保留默认值的开关;这条规律同样适用于其他"整体覆盖型"配置项,遇到"配了 A 结果 B 失效"先往这个方向想。
  6. 模板文件导入得到的是函数,调用才得到字符串;res.render 收的是字符串。传不传参决定了模板里的数据变量是否有值。
  7. public 是"原样搬运"的静态资源区,不参与模块依赖分析;src 里 import 的东西才走 loader。把两者搞混,就会出现资源 404 或 html 被覆盖的问题。
  8. 报错先读关键字:Module parse failed 指向缺 loader,Cannot find module 指向路径或依赖没装,404 在 history 模式下优先怀疑 fallback。定位方向对了,修起来都是一两行的事。