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

0 阅读6分钟

一、什么是前端工程化:

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

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

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

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

1. 编译解析:

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

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

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

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

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

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

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

  • 依赖的模块路径(如 './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 配置:
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” 入口配置项,这里是引入了多页面入口,比如:项目中存在多个入口,需要将其流转成打包的页面产物,这里就会用对象的形式传入配置:

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

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

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

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 分包配置:
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 环境的需求配置。

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 开发环境配置

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 的访问地址,访问加载到内存文件。

// 本地开发启动 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 中间件 实现热模块替换

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

// 引入 hotMiddleware 中间件,支持热更新(HMR)
app.use(hotMiddleware(compiler, {
    path: `/${DEV_SERVER_CONFIG.HMR_PATH}`,
    log: (err) => {}
}));