里程碑二:基于 webpack5 完成工程化建设

一、什么是前端工程化:

前端工程化是一套贯穿开发、测试、部署、维护全生命周期的思维模式和实践体系。它的目标是将项目里的零零散散的东西用标准化的"流水线"来保证效率、质量和可维护性。

二、开发阶段的工程化过程

(一) 我认为这是前端工程化在项目中必备的一个工程化设计:

通常情况下在项目里会写一堆代码文件,我们需要把这些代码文件,通过 "编译解析 " --> "模块分包 " --> "压缩优化" 这几个过程产出浏览器可以访问识别的产物文件,比如:html、css、js 等文件。

1. 编译解析:

这里用 webpack 举例,在解析编译需要进过这几个过程:

一、将源代码转换成抽象语法树(AST)

webpack 打包工具它需要先通过解析器(Parser) (例如 acorn)将每个模块的源代码解析成一种树状结构的数据结构,即 AST 。AST 能够精确地描述代码的语法结构(比如哪些是变量声明,哪些是函数调用,哪些是 import 或 require 语句)。

类比:就像你收到一段英文文本,你需要先把它拆成单词、短语、句子(AST),才能理解它的意思。

不同的打包工具都会将其转化各种各样的语法树。

二、分析模块依赖关系(收集依赖)

有了 AST 之后,Webpack 会遍历这棵树,找出所有的 import、require、define 等语句。每找到一个依赖,它就会记录下:

  • 依赖的模块路径 (如 './a.js'、'lodash')
  • 依赖的类型(ES Module、CommonJS、AMD 等)

这样,Webpack 就能从入口文件开始,一层一层地递归解析,最终构建出整个项目的依赖关系图(Dependency Graph) 。这张图是后续打包的基础。

三、执行 Loader 转换(非 JS 资源的编译)

这是工程化的重点。你写的代码很可能不是纯 JavaScript(比如 .vue、.tsx、.scss 文件)。Webpack 本身的解析器只认识 JS 和 JSON。因此,在解析之前或解析过程中 ,Webpack 会根据配置的 module.rules 调用相应的 Loader ,把这些资源编译成标准的 JavaScript 模块。

例如:

  • sass-loader + css-loader:将 .scss 文件编译成 CSS,再转换成 JS 模块(导出 CSS 字符串或 style 对象)。
  • babel-loader:将 ES6+ 或 JSX 代码编译成 ES5 标准的 JS。
  • ts-loader:将 TypeScript 编译成 JavaScript。

这一步可以理解为"翻译",把各种非 JS 资源"翻译"成 Webpack 能够解析的 JS 模块。

四、处理模块解析规则(Resolve)

在解析依赖路径时,Webpack 需要知道如何找到真正的文件。比如你写 import 'lodash',Webpack 会根据 resolve 配置去 node_modules 里找;如果你写 import '@/utils',它需要识别 @ 这个别名。编译解析阶段会应用这些路径解析规则,定位到真实的模块文件。

五、生成模块对象(Module)

对于每个经过解析和转换的文件,Webpack 最终会生成一个模块对象,里面包含:

  • 模块 ID(通常是路径或数字)
  • 模块的最终代码(经过 Loader 处理后的 JS 字符串)
  • 该模块的依赖列表
  • 其他元信息(如是否被 sideEffects 标记等)

这些模块对象将被传递给打包(Seal) 阶段,用于生成最终的 bundle。

2. 模块分包:

模块分包(通常指代码分割,Code Splitting)是现代前端工程化中的一项关键优化手段。它的核心目的是把一个庞大的 JavaScript 文件拆分成多个更小、更合理的"块"(chunk),从而提升页面性能和用户体验。

你可能见过这样的场景:一个单页应用(SPA)的 main.js 有 5MB 大小。用户第一次访问时,浏览器需要下载、解析并执行这 5MB 代码,期间页面白屏时间很长。而通过模块分包,可以将其拆分为:

  • vendor.js:第三方库(React、Vue、lodash 等)
  • home.js:首页业务代码
  • about.js:关于页面的代码(仅当用户访问关于页时按需加载)
  • common.js:多个页面共用的组件

这样做带来了以下几个实实在在的好处:

  • 减少首屏幕加载的时间
  • 利用浏览器缓存,减少重复下载
  • 按需加载,减少不必要的资源浪费
  • 降低内存占用

至于具体按什么划分去模块分包,需要根据不同的项目场景考虑:

  • 分包不是分的越多越好

因为太多细小的 chunk 会导致额外的网络请求开销(尤其是 HTTP/1.1 下)。需要权衡:首屏关键请求数不宜超过 10~15 个(HTTP/2 可适当增多)。

  • 分包会增加复杂度

但现代打包工具(Webpack、Vite)已经将智能分包策略内置或提供简单配置,大部分场景下只需配置 splitChunks: { chunks: 'all' } 就能获得很好的默认优化。

3. 压缩优化:
一、减少文件体积,加快网络传输

这是最直接的作用。通过压缩,JavaScript、CSS、HTML 和图片等资源的体积可以显著减小(通常能减少 30%~70% 甚至更多)。

二、降低带宽和服务器成本

对于网站运营者,每次用户请求都会消耗服务器流量。压缩后的文件体积更小,意味着:

  • 同样的流量配额可以服务更多用户。
  • CDN 传输费用更低。
  • 用户流量消耗少(尤其对手机流量用户更友好)。
三、3. 提升页面关键性能指标
  • 减少白屏时间(FCP / LCP) :较小的主资源(如入口 JS、CSS)能被更快下载和解析,浏览器更早渲染出首屏内容。
  • 加快可交互时间(TTI) :JS 文件越小,下载、解析、编译、执行的总体开销越小,页面能响应用户操作的时间点提前。

(二) webpack 详细配置

一、文件目录:

二、webpack.base.js 配置:
javascript 复制代码
const path = require("path");
const glob = require("glob");
const { VueLoaderPlugin } = require('vue-loader');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');

/**
 * @file webpack.base.js
 * @author afeifly
 * @description webpack基础配置
 * @date 2024-06-17
 */

// 获取 app 目录下的 pages 下的所有入口文件
const pageEntrys = {};
const htmlWebpackPluginsList = [];

// 动态获取入口文件和构造最终渲染的页面模版
glob.sync(path.resolve(process.cwd(), './app/pages/**/entry.*.js')).forEach(file => {
    const entryName = path.basename(file, '.js');
    // 构造 entry
    pageEntrys[entryName] = file;
    // 构造最终渲染的页面文件
    htmlWebpackPluginsList.push({
        // 产物(最终的模版)输出路径
        filename: path.resolve(process.cwd(), './app/public/dist', `${entryName}.tpl`),
        // 指定要使用的模版文件
        template: path.resolve(process.cwd(), './app/view/entry.tpl'),
        // 要注入的代码块
        chunks: [entryName],
    });
});

module.exports = {
    // 入口配置
    entry: pageEntrys,
    // 模块解析配置(决定了要加载解析哪些模块以及用什么样的方式解析)
    module: {
        rules: [{
            test: /.vue$/,
            use: {
                loader: 'vue-loader',
            }
        }, {
            test: /.js$/,
            // 只对业务代码进行 babel ,加快 webpack 构建速度
            include: [ path.resolve(process.cwd(), './app/pages') ],
            use: {
                loader: 'babel-loader',
            }
        }, {
            test: /.(png|jpe?g|gif)(?.+)?$/, 
            use: [{
                loader: 'url-loader',
                options: {
                    limit: 300,
                    esModule: false,
                }
            }]
        }, {
            test: /.css$/,
            use: [
                'style-loader',
                'css-loader'
            ]
        }, {
            test: /.less$/,
            use: [
                'style-loader',
                'css-loader',
                'less-loader'
            ]
        }, {
            test: /.(eot|svg|ttf|woff|woff2)(?\S*)?$/,
            use: [{
                loader: 'file-loader',
            }]
        }]
    },
    // 产物输出路径 因为开发和生产环境输出不一致,所以在各自环境中自行配置
    output: {},
    // 配置模块解析的具体行为(定义 webpack 如何寻找模块所对应的文件)
    resolve: {
        extensions: ['.js', '.vue', '.less', '.css'],
        alias: {
            $pages: path.resolve(process.cwd(), './app/pages'),
            $common: path.resolve(process.cwd(), './app/pages/common'),
            $widgets: path.resolve(process.cwd(), './app/pages/widgets'),
            $store: path.resolve(process.cwd(), './app/pages/store'),
        }
    },
    // 配置 webpack 插件
    plugins: [
        // 处理 .vue 文件 它的职能是将 .vue 文件转换成 JavaScript 模块
        new VueLoaderPlugin(),
        // 把第三方库暴露到全局,供业务代码使用
        new webpack.ProvidePlugin({
            Vue: 'vue',
            axios: 'axios',
            _: 'lodash'
        }),
        // 定义全局变量
        new webpack.DefinePlugin({
            __VUE_OPTIONS_API__: 'true', // 支持 Vue 支持 Options API
            __VUE_PROD_DEVTOOLS__: 'false', // 生产环境禁用 devtools 调试工具
            __VUE_PROD_HYDRATION_MISMATCH_DETAILS_: 'false', // 生产环境禁止使用 "水合" 信息
        }),
        // 构造最终渲染的页面模版
        ...htmlWebpackPluginsList.map(options => new HtmlWebpackPlugin(options)),
    ]
};

其中 "entry" 入口配置项,这里是引入了多页面入口,比如:项目中存在多个入口,需要将其流转成打包的页面产物,这里就会用对象的形式传入配置:

arduino 复制代码
// 构造 entry
pageEntrys[entryName] = file;
// 执行结果:
const pageEntrys = {
  home: './src/pages/home.js',
  about: './src/pages/about.js'
};

得到多个页面之后,传入配置就实现了多入口配置:

渲染页面模版时也需要配置多个:

arduino 复制代码
module.exports =  {
  plugins: {
    ...
    new HtmlWebpackPlugin({
        filename: './app/public/dist/home.tpl',
        template: './app/view/entry.tpl',
        // 要注入的代码块
        chunks: ['home'],
    }),
    ...
    new HtmlWebpackPlugin({
        filename: './app/public/dist/about.tpl',
        template: './app/view/entry.tpl',
        // 要注入的代码块
        chunks: ['about'],
    })
    ...
  };
};
三、optimization 分包配置:
javascript 复制代码
module.exports = {
  // 配置打包输出优化(代码分割、模块合并、缓存,tree-shaking、压缩等)
  optimization: {
    /**
     * 分割代码块
     * 把 js 文件打包成3种类型:
     * 1. vendor:第三方 lib 库,基本不会改动,除非依赖版本升级
     * 2. comon:业务组件代码的公共部分抽取出来,改动比较少
     * 3. entry.{page}:不用页面 entry 里的业务组件代码的差异部分,会进场改动
     * 目的:把改动和引用频率不一样的 js 区分出来,以达到更好利用浏览器缓存的效果
     */
    splitChunks: {
        chunks: 'all', // 对同步和异步模块进行分割
        maxAsyncRequests: 10, // 每次异步加载的最大并行请求数
        maxInitialRequests: 10, // 入口点的最大并行请求数
        // 代码块分割的最小尺寸,单位为字节
        cacheGroups: { 
            // 第三方依赖库
            vendor: {
                test: /[\/]node_modules[\/]/,
                name: 'vendor', // 模块名称
                priority: 20, // 优先级,数值越大优先级越高
                enforce: true, // 强制执行
                reuseExistingChunk: true, // 复用已有的公共 chunk
            },
            common: {
                name: 'common',
                minChunks: 2, // 被两处引用即被归为公共模块
                minSize: 1, // 最小分割文件大小为 1 字节
                priority: 10, // 优先级,数值越大优先级越高
                reuseExistingChunk: true, // 复用已有的公共 chunk
            }
        }
    },
    // 将 webpack 运行时生成的代码打包到 runtime.js
    runtimeChunk: true,
  }
}

需要根据项目需求,进行配置分包,chunks 不一定非要配置 "all" 所有模块,也可以是 "async"、"initial"。

它们的主要区别在于,对三种模块加载/定义方式进行优化的范围不同:

  • 动态导入 :运行时按需加载,比如使用 import() 语法。
  • 同步导入 :编译时确定依赖,在文件开头使用 import ... from ... 或 require()。
  • 入口起点 :在 webpack.config.js 的 entry 配置中定义的模块。
配置值 作用的加载方式 行为描述 典型场景
'async'(默认值) 动态导入 只对动态导入的模块进行打包优化。 按需加载的路由页面,不增加首屏加载压力。
'initial' 同步导入 + 入口起点 对同步导入的模块和入口起点进行优化,可抽取公共代码。 多入口应用,消除重复代码。
'all'(推荐) 所有模块 最强大的模式,会优化所有类型的模块。能抽取动态和同步模块之间的共享代码。 大部分现代应用,最大化代码复用效果。

分包策略也可以按照业务划分:比如 公共组件库、第三方 node_modules 库....之类的进行分包划分。

四、webpack.dev.js 配置:

webpack.dev.js 开发环境的配置,是基于 webpack.base.js 基类配置扩展出来的配置,利用 webpack-merge 的 smart 方法扩展 dev 环境的需求配置。

javascript 复制代码
const { smart } = require('webpack-merge');
const webpack = require('webpack');
const path = require('path');

// 基类配置
const baseConfig = require('./webpack.base');

// devServer 的配置
const DEV_SERVER_CONFIG = {
    HOST: '127.0.0.1',
    PORT: 9002,
    HMR_PATH: '__webpack_hmr',
    TIMEOUT: 2000
};
const { HOST, PORT, HMR_PATH, TIMEOUT } = DEV_SERVER_CONFIG;

// 开发阶段的 entry 配置需要加入 hmr
Object.keys(baseConfig.entry).forEach(v => {
    // 第三方包 不用热更新, 不作为 hmr 入口
    if (v === 'vendor') {
        baseConfig.entry[v] = [
            // 主入口文件
            baseConfig.entry[v],
            // hmr 更新入口
            `webpack-hot-middleware/client?path=http://${HOST}:${PORT}/${HMR_PATH}&timeout=${TIMEOUT}&reload=true`,
        ]
    }
})
/**
 * @file webpack.dev.js
 * @author afeifly
 * @description webpack 开发环境配置
 * @date 2024-06-17
 */
const webpackDevConfig = smart(baseConfig, {
    // 指定开发环境
    mode: 'development',
    devtool: 'eval-cheap-module-source-map', // 呈现代码的映射关系,方便调试代码
    // 开发环境的 output 配置
    output: {
        filename: 'js/[name]_[chunkhash:8].bundle.js',
        path: path.resolve(process.cwd(), './app/public/dist/dev/'), // 输出文件存储的位置
        publicPath: `http://${HOST}:${PORT}/public/dist/dev/`, // 外部资源引用路径
        globalObject: 'this',
    },
    // 开发阶段插件
    plugins: [
        // 用于热模块替换(HMR)的插件
        // 极大的提升开发阶段的构建性能和开发体验
        new webpack.HotModuleReplacementPlugin({
            multiStep: true, // 多步骤构建,提升性能
        }), 
    ]
});

module.exports = {
    // webpack 的配置
    webpackDevConfig,
    // devServer的配置暴露给 dev.js 使用
    DEV_SERVER_CONFIG
}

开发环境会用到 hmr 热更新,因为在开发过程中,需要实时编译实时生效。这样在就能避免开发是反复打包然后运行的问题。

如何做到热更新?

  • 监控文件改动:

通过配置某个插件,然后监控文件的改动,将这个改动的文件,注入到内存里。

webpack.dev.js 开发环境配置

javascript 复制代码
const webpack = require('webpack');

// devServer 的配置
const DEV_SERVER_CONFIG = {
    HOST: '127.0.0.1',
    PORT: 9002,
    HMR_PATH: '__webpack_hmr',
    TIMEOUT: 2000
};
const { HOST, PORT, HMR_PATH, TIMEOUT } = DEV_SERVER_CONFIG;

// 开发阶段的 entry 配置需要加入 hmr
Object.keys(baseConfig.entry).forEach(v => {
    // 第三方包 不用热更新, 不作为 hmr 入口
    if (v === 'vendor') {
        baseConfig.entry[v] = [
            // 主入口文件
            baseConfig.entry[v],
            // hmr 更新入口
            `webpack-hot-middleware/client?path=http://${HOST}:${PORT}/${HMR_PATH}&timeout=${TIMEOUT}&reload=true`,
        ]
    }
})

const webpackDevConfig = smart(baseConfig, {
    // 开发阶段插件
    plugins: [
        // 用于热模块替换(HMR)的插件
        // 极大的提升开发阶段的构建性能和开发体验
        new webpack.HotModuleReplacementPlugin({
            multiStep: true, // 多步骤构建,提升性能
        }), 
    ]
});

dev.js 文件配置,利用 webpack-dev-middleware 中间件,实现将代码文件打包加载到内存中,然后配合 publicPath 的访问地址,访问加载到内存文件。

javascript 复制代码
// 本地开发启动 decServer
const express = require('express');
const path = require('path');
const webpack = require('webpack');

const devMiddleware = require('webpack-dev-middleware');

// 从 webpack.dev.js 中获取 webpack 配置和 devServer 配置
const { webpackDevConfig, DEV_SERVER_CONFIG } = require('./config/webpack.dev');
const app = express();

const compiler = webpack(webpackDevConfig);
// 引入静态目录
app.use(express.static(path.join(__dirname, '../public/dist')));

// 引入 devMiddleware 中间件,监控文件改动
app.use(devMiddleware(compiler, {
    // 落地文件
    writeToDisk: (filePath) => filePath.endsWith('.tpl'),
    // 资源路径
    publicPath: webpackDevConfig.output.publicPath,
    // headers 配置 
    headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
        'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization'
    },
    stats: {
        colors: true
    }
}));

// 启动 devServer 服务
const port = DEV_SERVER_CONFIG.PORT || 9002;
app.listen(port, () => {
    console.log(`开发服务器已启动,访问地址:http://${DEV_SERVER_CONFIG.HOST}:${port}`);
}); 
  • 通知浏览器更新:

通知产物文件更新配置在内存里内容,被改动的区域更新,然后再自动刷新浏览器展示最新的修改内容。webpack-hot-middleware 中间件 实现热模块替换

yaml 复制代码
// devServer 的配置
const DEV_SERVER_CONFIG = {
    HOST: '127.0.0.1',
    PORT: 9002,
    HMR_PATH: '__webpack_hmr',
    TIMEOUT: 2000
};
javascript 复制代码
// webpack-hot-middleware 中间件
const hotMiddleware = require('webpack-hot-middleware');

// 引入 hotMiddleware 中间件,支持热更新(HMR)
app.use(hotMiddleware(compiler, {
    path: `/${DEV_SERVER_CONFIG.HMR_PATH}`,
    log: (err) => {}
}));
相关推荐
liangshanbo121515 小时前
Webpack 文件指纹面试题整理
前端·webpack·node.js
liangshanbo121517 小时前
Webpack 常见 Plugin 面试题
前端·webpack·node.js
liangshanbo121517 小时前
Webpack Tree Shaking 面试题整理
前端·webpack·node.js
陪我一起学编程19 小时前
vue3笔记
前端·webpack·vue3·vite·脚手架·vue3笔记·组件式
liangshanbo121521 小时前
Webpack的分包策略面试题
前端·webpack·node.js
liangshanbo12152 天前
面试题:如何优化 Webpack 的打包速度?
前端·webpack·node.js
晚风醉蝶4 天前
webpack 模块提取
前端·webpack·node.js·ast·逆向分析
晚风醉蝶14 天前
保姆级教程:用 AST 自动提取 webpack / rspack 打包后的模块
webpack·web逆向
guwentian20 天前
Rolldown vs esbuild vs Webpack:Rust 打包器大战我们该怎么选
前端·webpack·rust·esbuild
飞翔的熊blabla22 天前
# Webpack 5 + Jenkins 持久化缓存实战:前端构建从 288 秒降到 30 秒>
前端·webpack·jenkins