跨过前端工程化建设的三座大山

2,763 阅读4分钟

前言

现如今前端构建打包工具层出不穷,有 WebpackViteRollup...,但无论是哪一个工具,还是从一个工具切换到一个新颖的工具,实际上最终都离不开 解析编译模块分包压缩优化三个阶段。本文使用 Webpack5 解释并配置这三个阶段。

三大阶段

整篇文章都会围绕以下这张图讲解。最左边是多入口的业务文件,中间是构建打包三个阶段,最右边是产物文件。

image.png

解析编译

入口

Webpack 在读取配置的时候会先读取 entry 字段,该字段就是入口文件地址。

entry: {
  'entry1': path.resolve(process.cwd(), './app/entry1/entry1.js'),
  'entry2': path.resolve(process.cwd(), './app/entry2/entry2.js')
}

输出路径

不同环境文件的输出路径有所不同。

生产环境
output: {
    // 文件名
    filename: 'js/[name]_[chunkhash:8].bundle.js',
    // 输出路径
    path: path.resolve(process.cwd(), './app/public/dist/prod/'),
    // 公共路径
    publicPath: '/dist/prod/'
},
开发环境

开发环境不需要文件落地,通过 devServer (下面会讲如何使用中间件实现)放置到内存中。

output: {
    filename: 'js/[name]_[chunkhash:8].bundle.js',
    path: path.resolve(process.cwd(), './app/public/dist/dev/'), // 输出文件存储路径
    publicPath: `http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/public/dist/dev/`, // 外部资源文件公共路径
},

模块解析

有一些模块需要通过解析器进行被更好的识别/兼容/优化。比如 .vue 结尾的文件需要转换成 .js 结尾才能被浏览器识别,.less 结尾的文件需要转换成 .css 结尾的文件,.css 结尾的文件需要转换成 style 标签 ...

模块解析需要配置在 module 字段下面。

// 模块解析配置(决定了要加载解析哪些模块, 以及用什么方式去解释)
module: {
    rules: [],
},
处理 .vue 文件
{
    test: /\.vue$/,
    use: {
      loader: 'vue-loader',
    },
},
处理 .js 文件
{
    test: /\.js$/,
    include: [
      // 只对业务代码进行 babel 处理
      path.resolve(process.cwd(), './app/pages'),
    ],
    use: {
      loader: 'babel-loader',
    },
},
处理资源文件

对小图片进行 base64 转换。

{
    test: /\.(png|jpe?g|gif|svg)(\?.+)?$/,
    use: {
      loader: 'url-loader',
      options: {
        limit: 300
      },
    },
},

在引入其他静态文件的时候,输出到 output 目录,并且修改成正确的 url。

{
    test: /\.(eot|svg|ttf|woff|woff2)(\?\S*)?$/,
    use: 'file-loader',
},
处理 css 相关文件

less -> css

css -> <style>

{
    test: /\.css$/,
    use: ['style-loader', 'css-loader'],
},

{
    test: /\.less$/,
    use: ['style-loader', 'css-loader', 'less-loader'],
},

文件扩展名和路径别名

resolve: {
    // import xxx from './xxx.vue' -> import xxx from './xxx'
    extensions: ['.js', '.vue', '.less', '.css'],
    alias: {
      // 配置别名 ./app/pages/xxx -> $pages/xxx
      $pages: path.resolve(process.cwd(), './app/pages'),
      $store: path.resolve(process.cwd(), './app/pages/store'),
    },
},

plugins

plugins: [
    // 处理 .vue 文件
    // 它的职责是将你定义过的其它规则复制并应用到 .vue 文件里。
    // 例如,如果你有一条匹配 /\.js$/ 的规则,那么它会应用到 .vue 文件里的 <script> 块。
    new VueLoaderPlugin(),
    // 把第三方库暴露到 window context 下
    new webpack.ProvidePlugin({
      Vue: 'vue',
      axios: 'axios',
      _: 'lodash',
    }),
    // 构造最终渲染的页面模板 entry1
    new HtmlWebpackPlugin({
      // 产物 (最终模板) 输出路径
      filename: path.resolve(process.cwd(), './app/public/dist/', 'entry.page1.tpl'),
      // 指定要使用的模板文件
      template: path.resolve(process.cwd(), './app/view/entry.tpl'),
      // 要注入的代码块 与入口对应
      chunks: ['entry1'],
    }),
    // 构造最终渲染的页面模板 entry2
    new HtmlWebpackPlugin({
      // 产物 (最终模板) 输出路径
      filename: path.resolve(process.cwd(), './app/public/dist/', 'entry.page2.tpl'),
      // 指定要使用的模板文件
      template: path.resolve(process.cwd(), './app/view/entry.tpl'),
      // 要注入的代码块 与入口对应
      chunks: ['entry2'],
    })
],

模块分包

把 js 文件打包成三个类型:

  1. vendor: 第三方库,基本不会改,除非依赖升级。
  2. common: 业务组件的公共部分抽取出来,改动较少。
  3. entry.{page}: 不同页面 entry 里的业务组件代码的差异部分,会经常改动。

目的:把改动和引用频率不一样的 js 区分出来,以达到更好利用浏览器缓存的效果

// 配置打包输出优化 (代码分割, 模块合并 等优化策略)
optimization: {
    splitChunks: {
      chunks: 'all', // 对同步和异步模块都进行分割
      maxAsyncRequests: 10, // 每个异步加载模块最多的并行请求数
      maxInitialRequests: 10, // 一个入口的最大并行请求数
      cacheGroups: {
        vendor: {
          // 第三方依赖库
          test: /[\\/]node_modules[\\/]/, // 匹配 node_modules 目录下的模块
          name: 'vendor', // 模块名称
          priority: 20, // 优先级 数字越大优先级越高
          enforce: true, // 强制执行
          reuseExistingChunk: true, // 复用已经存在的 chunk
        },
        common: {
          // 公共模块
          test: /[\\/]common|widgets[\\/]/,
          name: 'common',
          minChunks: 2, // 最少引用次数
          minSize: 1, // 最小分割文件大小 字节为单位
          priority: 10,
          reuseExistingChunk: true,
        },
      },
    },
    // 将 webpack 运行时的代码单独抽离出来 runtime.js
    runtimeChunk: true,
}

压缩/优化

生产环境

css

多线程处理 happypack

抽离公共部分 MiniCssExtractPlugin

压缩 CSSMinimizerPlugin

// 多线程 build 设置
const happypackCommonConifig = {
  debug: false,
  threadPool: HappyPack.ThreadPool({ size: os.cpus().length }),
};

module: {
    rules: [{
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'happypack/loader?id=css'],
    }]
},

plugins: [{
    // 提取 css 的公共部分
    new MiniCssExtractPlugin({
      chunkFilename: 'css/[name]_[chunkhash:8].bundle.css', // 非入口 chunk 的名称
    }),
    // 优化并压缩 css 资源
    new CSSMinimizerPlugin(),
    // 多线程打包 CSS,加快打包速度
    new HappyPack({
      ...happypackCommonConifig,
      id: 'css',
      loaders: [
        {
          path: 'css-loader',
          options: {
            importLoaders: 1,
          },
        },
      ],
    }),
}]
js

多线程处理 loader happypack

并行压缩 TerserWebpackPlugin

module: {
    rules: [{
        test: /\.js$/,
        include: [
          // 只对业务代码进行 babel 处理,加快 webpack 打包速度
          path.resolve(process.cwd(), './app/pages'),
        ],
        use: ['happypack/loader?id=js'],
    }]
},
plugins: [
    // 多线程打包 JS,加快打包速度
    new HappyPack({
      ...happypackCommonConifig,
      id: 'js',
      loaders: [
        `babel-loader?${JSON.stringify({
          presets: ['@babel/preset-env'],
          plugins: [
            '@babel/plugin-transform-runtime'
          ],
        })}`,
      ],
    }),
]
optimization: {
    // 使用 TerserPlugin 的并发和缓存,提升压缩阶段性能
    // 清除 console.log
    minimize: true,
    minimizer: [
      new TerserWebpackPlugin({
        parallel: true, // 利用多核 CPU 进行压缩
        cache: true, // 启动缓存来加速构建过程
        terserOptions: {
          compress: {
            drop_console: true, // 删除所有的 `console` 语句
          },
        },
      }),
    ],
}
其他优化

打包前清空目录

plugins: [
    // 每次 build 前,清空 public/dist 目录
    new CleanWebpackPlugin(['public/dist'], {
      root: path.resolve(process.cwd(), './app/'),
      exclude: [],
      verbose: true,
      dry: false,
    }),
]

开发环境

热更新 HMR

开启 devServer 去热更新,需要具备两种能力,一种是监控文件变化的能力,一种是通知页面可以去更新代码的能力。

启动 devServer 的时候,可以将打包构建的其他代码放入内存,将双向通信的代码片段注入模板文件,当启动模块页的服务时就能建立与 devServer 双向通信的桥梁。

image.png

devServer

前置配置

const DEV_SERVER_CONFIG = {
  HOST: '127.0.0.1',
  PORT: 9002,
  HMR_PATH: '__webpack_hmr', // 官方规定
  TIMEOUT: 20000,
};

这里使用 express 启动服务器,然后使用下面两个中间件:

监听文件改动:webpack-dev-middleware

通知文件更新:webpack-hot-middleware

// 本地开发启动 devServer 配置
const express = require('express');
const path = require('path');
const webpack = require('webpack');
const consoler = require('consoler');
const devMiddleware = require('webpack-dev-middleware');
const hotMiddleware = require('webpack-hot-middleware');
const webpackDevConfig = require('./config/webpack.dev.js');
const app = express();

// 从 webpack.dev.js 获取 webpack 配置 和 devServer 配置
const { webpackConfig, DEV_SERVER_CONFIG } = webpackDevConfig;

const compiler = webpack(webpackConfig);

// 指定静态文件目录
app.use(express.static(path.join(__dirname, '../public/dist')));
// 引用 webpack-dev-middleware 中间件  (监控文件改动)
app.use(
  devMiddleware(compiler, {
    // 落地文件: 生成的 tpl
    writeToDisk: (filePath) => filePath.endsWith('.tpl'),
    // 资源路径
    publicPath: webpackConfig.output.publicPath,

    // headers 配置
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Method': 'GET,POST,PUT,DELETE,OPTIONS,PATCH',
      'Access-Control-Allow-Headers':
        'X-Requested-With,content-type,Authorization',
    },
    // 控制台输出
    stats: {
      colors: true,
    },
  }),
);
// 引用 webpack-hot-middleware 中间件  (热更新通讯)
app.use(
  hotMiddleware(compiler, {
    log: () => {},
    path: `/${DEV_SERVER_CONFIG.HMR_PATH}`,
  }),
);

consoler.info('请等待 webpack 初次构建完成提示...');

// 启动 devServer
const port = DEV_SERVER_CONFIG.PORT;
app.listen(port, () => {
  console.log(`app listening on port ${port}`);
});

HMR 相关的 Webpack 配置

entry: { 
    // 注入代码
    'entry1': [
        path.resolve(process.cwd(), './app/entry1/entry1.js'),
        `webpack-hot-middleware/client?path=http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/${DEV_SERVER_CONFIG.HMR_PATH}?timeout=${DEV_SERVER_CONFIG.TIMEOUT}&reload=true`,
    ],
    'entry2': [
        path.resolve(process.cwd(), './app/entry2/entry2.js'),
        `webpack-hot-middleware/client?path=http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/${DEV_SERVER_CONFIG.HMR_PATH}?timeout=${DEV_SERVER_CONFIG.TIMEOUT}&reload=true`,
    ]
},
output: {
    filename: 'js/[name]_[chunkhash:8].bundle.js',
    path: path.resolve(process.cwd(), './app/public/dist/dev/'), // 输出文件存储路径
    publicPath: `http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/public/dist/dev/`, // 外部资源文件公共路径
},
// 开发阶段插件
plugins: [
    // 实现热模块替换
    // 模块热替换允许在运行时更新各种模块
    new webpack.HotModuleReplacementPlugin({
      multiStep: false,
    }),
]
其他优化

开启 sourceMap,呈现代码的映射关系,便于在开发过程中调试代码

devtool: 'eval-cheap-module-source-map',

总结

以上就是使用 Webpack5 实现前端构建打包过程中的三个阶段。这里无论是哪种构建工具都可以实现这三个阶段,工具可以变,使用方式可以变,但核心原理核心阶段都是不变的