Webpack 环境搭建(vue):从 0 到 1

1,453 阅读5分钟

webpack 的官方教程对于 webpack 的入门的各个部分已经讲得分清楚了。但从自身来说,教程看了很多遍,知识总还是像教程一样,零散的分布在脑子里。如何将其串联起来呢?这是本篇文章的目的。

webpack 是一个工具,工具以解决问题为目的,解放生产力。这些问题从何而来,来自实际的项目。本篇文章以当前热门的 vue SPA 为导线,探究 webpack 的工作流配置。

起步

npm init -y
npm install --save vue
npm install --save-dev webpack webpack-cli

目录结构:

├── index.html
├── node_modules
├── package-lock.json
├── package.json
├── src
│   └── index.js
└── webpack.config.js
  • webpack.config.js

    const path = require('path');
    
    module.exports = {
      context: path.resolve(__dirname, '/'),
      entry: {
        app: './src/index.js',
      },
      output: {
        filename: '[name].[chunckhash].js',
        path: path.resolve(__dirname, 'dist'),
        publicPath: '/' // 如果想要在浏览器直接打开,请修改为 ./
      },
      resolve: {
        alias:{
          'vue$':'vue/dist/vue.esm.js', // 参考 https://cn.vuejs.org/v2/guide/installation.html
        }
      },
      plugins: [
        new CleanWebpackPlugin(), // 清理输出
        new HtmlWebpackPlugin({
          template: 'index.html'
        })
      ]
    };
    
  • index.js

    import Vue from 'vue';
    
    const app = new Vue({
      el: '#app',
      data: {
        text: 'Hello webpack!'
      },
      template: '<div>{{ text }}</div>'
    });
    
  • html

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta http-equiv="X-UA-Compatible" content="ie=edge">
      <title>custom-vue</title>
    </head>
    <body>
      <div id="app"></div>
    </body>
    </html>
    
  • package.json

    {
        "scripts": {
            "test": "echo \"Error: no test specified\" && exit 1",
            "build": "webpack",
        },
    }
    

运行 npm run build,会得到一个 dist 文件夹,在浏览器打开,得到如下结果:

环境分离

在上面的配置中,改完代码后每次都需要重新构建然后在本地打开它,这样的非常的麻烦。思考这样的情况,在项目中,开发环境的配置和生产环境的配置具有明显的区别。

开发环境中,我们期待这样的效果:

  • 自动构建
  • 快速看到更改后的效果
  • 能够看到源码

生产环境,我们期待另外的效果:

  • 最小的代码
  • 最大化利用浏览器并发下载
  • 最大化利用缓存
  • 更快的构建速度

或者在一些特殊场景的项目中,还需要区分其他部署环境,如预发布环境等等,不同的环境就意味着 webpack 需要不同的配置,但这些环境配置中有些是公共的部分,从程序设计的角度来讲,我们需要将这些公共的部分抽取出来。

在这里我们可以利用 webpack-merge 库来完成配置文件的合并。

调整目录结构

将 webpack 配置文件拆分:

├── build
│   ├── utils.js
│   ├── webpack.config.base.js // 公共配置
│   ├── webpack.config.dev.js // 开发配置
│   └── webpack.config.prod.js // 生产配置
├── index.html
├── node_modules
├── package-lock.json
├── package.json
├── src
│   └── index.js
└── webpack.config.js

修改公共配置文件

webpack.config.base.js 来源于 webpack.config.js

  • 修改 webpack.config.base.js

    module.exports = {
      context: path.resolve(__dirname, '../'),
      output: {
        path: resolve('dist'),
      },
    };
    
  • package.json

    {
        "scripts": {
            "test": "echo \"Error: no test specified\" && exit 1",
            "build": "webpack --inline --progress --config build/webpack.config.dev.js",
        },
    }
    
    • inline:内联热更新代码
    • progress:显示进度
    • config:使用配置文件

抽取公共配置

webpack.config.base.js

module.exports = {
  context: path.resolve(__dirname, '../'),
  entry: {
    polyfill: './src/utils/polyfill.js',
    app: './src/index.js',
  },
  output: {
    filename: '[name].js',
    path: resolve('dist'),
    publicPath: '/'
  },
  resolve: {
    alias:{
      'vue$':'vue/dist/vue.esm.js',
    }
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: vueLoaderConfig
      },
      {
          test: /\.js$/,
          loader: 'babel-loader',
          include: [resolve('src'), resolve('test'), resolve('node_modules/webpack-dev-server/client')]
      },
      {
          test: /\.css$/i,
          loaders: [
              'style-loader',
              'css-loader',
          ]
      },
      {
          test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
          loader: 'url-loader',
          options: {
              limit: 10000,
              name: utils.assetsPath('img/[name].[hash:7].[ext]')
          }
      },
      {
          test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
          loader: 'url-loader',
          options: {
              limit: 10000,
              name: utils.assetsPath('media/[name].[hash:7].[ext]')
          }
      },
      {
          test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
          loader: 'url-loader',
          options: {
              limit: 10000,
              name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
          }
      },
      {
        test: /\.(sass|scss)$/,
        loaders:[
          "style-loader", // creates style nodes from JS strings
          "css-loader", // translates CSS into CommonJS
          {
            loader: 'postcss-loader',
            options: {
              plugins: [
                require('postcss-flexbugs-fixes'),
                require('postcss-preset-env')({
                  autoprefixer: {
                    flexbox: 'no-2009',
                  },
                  stage: 3,
                }),
              ]
            }
          },
          "sass-loader" // compiles Sass to CSS, using Node Sass by default
        ]
      },
    ]
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      template: 'index.html'
    })
  ],
};

配置开发环境

const path = require('path');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.config.base');
const webpack = require('webpack');
const utils = require('./utils');

const { resolve } = utils;

module.exports = merge(baseConfig, {
  mode: 'development', // 设置为开发模式
  devtool: 'inline-source-map', // 设置 source map 
  devServer: {
    clientLogLevel: 'warning', // 默认为 info,会打印各种信息,我们将其设置为 warning,只打印有用的信息
    contentBase: resolve('dist'),
    compress: true, // 开启 gzip
    port: 3000, // 端口号
    hot: true, // 热模块替换,更改完代码可以自动构建,刷新浏览器
    open: true // 自动打开浏览器
  },
  plugins: [
    new webpack.NoEmitOnErrorsPlugin() // 
  ]
});

上面的代码存在一个问题,当 3000 端口被占用时会报错,构建会失败。我们在开发中往往只想能够正常启动服务进行调整,并不真正关心到底使用哪一个端口,因此,如果能够在端口号在被占用的情况下自动更换端口号,这个问题就得到了解决。

但有时候我们忘记之前已经启动这个服务了,这时候再更换另一个端口启动一个服务,这样会造成额外的开销。这时候可以给用户展示提示信息,告知客户端口已占用,甚至可以提示用户暂用端口的应用程序是哪一个。

自动更换端口号

为了达到这个目的,我们使用 node-portfinder 来查找空闲的端口号,并且需要将配置导出为一个 Promise:

const path = require('path');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.config.base');
const utils = require('./utils');
const portfinder = require('portfinder');

const { resolve } = utils;

const devWebpackConfig = merge(baseConfig, {
  mode: 'development',
  devtool: 'inline-source-map',
  devServer: {
    clientLogLevel: 'warning',
    contentBase: resolve('dist'),
    compress: true,
    port: 3000,
    hot: true,
    open: true,
    quiet: true,
    host: 'localhost',
  },
});

module.exports = () => {
  return new Promise((resolve, reject) => {
    // portfinder.basePort = process.env.PORT || config.dev.port;

    portfinder.basePort = 3000; // 这里已经出现两次 3000,因此,这里可以将其抽取到一个单独的配置文件中了
    // 获取端口号
    portfinder.getPort(function (err, port) {
      if (err) {
        reject(err);
      } else {
        // 使用取得的端口号
        process.env.PORT = port;
        devWebpackConfig.devServer.port = port;

        resolve(devWebpackConfig);
      }
    });
  })
}

交互式端口选择

修改 webpack.config.dev.js:

module.exports = () => {
  return new Promise((resolve, reject) => {
    utils.choosePort(3000).then((port) => {
      process.env.PORT = port;
      devWebpackConfig.devServer.port = port;

      resolve(devWebpackConfig);
    }, (err) => {
      reject(err);
    });
  });
}

我们把选择端口的功能抽取到 utils 里面:

新引入几个库和工具:

const chalk = require('chalk'); // 样式化终端输出
const inquirer = require('inquirer'); // 终端人机交互
const portfinder = require('portfinder'); // 查找端口
const clearConsole = require('./clearConsole'); // 清理控制台
const getProcessForPort = require('./getProcessForPort'); // 获取在目标端口运行的线程
exports.choosePort = (defaultPort) => new Promise((resolve, reject) => {
  portfinder.basePort = defaultPort;
  portfinder.getPort(function (err, port) {
    if (err) {
      return reject(err);
    }
    
    // 如果相同,直接返回
    if (port === defaultPort) {
      return resolve(port);
    }
    
    clearConsole();

    const message =
      process.platform !== 'win32' && defaultPort < 1024 && !isRoot()
        ? `Admin permissions are required to run a server on a port below 1024.`
        : `Something is already running on port ${defaultPort}.`;

    const existingProcess = getProcessForPort(defaultPort);

    const question = {
      type: 'confirm',
      name: 'shouldChangePort',
      message:
        chalk.yellow(
          message +
            `${existingProcess ? ` Probably:\n  ${existingProcess}` : ''}`
        ) + '\n\nWould you like to run the app on another port instead?',
      default: true,
    };
    
    inquirer.prompt(question).then(answer => {
      if (answer.shouldChangePort) {
        resolve(port);
      } else {
        reject(null);
      }
    });
  })
});

样式化控制台信息

在项目中,我们的控制台常常会输出很多信息,比如代码检查、模块解析、编译等等的。但默认情况下的输出很难去辨别到底哪些是自己想要的,因此需要一个东西来格式化输出这些信息。

我们采用 friendly-errors-webpack-plugin 来实现这个功能:

module.exports = () => {
  return new Promise((resolve, reject) => {
    utils.choosePort(3000).then((port) => {
      process.env.PORT = port;
      devWebpackConfig.devServer.port = port;

      devWebpackConfig.plugins.push(new FriendlyErrorsPlugin({
        compilationSuccessInfo: {
          messages: [`Your application is running here: http://${devWebpackConfig.devServer.host}:${port}`],
        },
        onErrors: true
        ? utils.createNotifierCallback()
        : undefined
      }));

      resolve(devWebpackConfig);
    }, (err) => {
      reject(err);
    });
  });
}

结果:

更多信息请参考 friendly-errors-webpack-plugin 文档

生产环境

首先将 webpack.config.prod.jsmode 选项设置为 production:

{
    mode: 'production'
}

再来看看生产环境的需求:

  • 最小的代码
  • 最大化利用缓存
  • 更快的构建速度

针对每个目标,我们都进行相应的配置:

最小的代码

HTML

const HtmlWebpackPlugin = require('html-webpack-plugin');

new HtmlWebpackPlugin({
  filename: 'index.html',
  template: 'index.html',
  inject: true,
  minify: {
    removeComments: true,
    collapseWhitespace: true,
    removeRedundantAttributes: true,
    useShortDoctype: true,
    removeEmptyAttributes: true,
    removeStyleLinkTypeAttributes: true,
    keepClosingSlash: true,
    minifyJS: true,
    minifyCSS: true,
    minifyURLs: true,
  }
}),

CSS

  • 提取

    1. 添加 loader

      {
          test: /\.css$/i,
          loaders: [
            {
              loader: MiniCssExtractPlugin.loader,
              options: {
                publicPath: '../',
              },
            },
            'css-loader',
            {
              loader: 'postcss-loader',
              options: {
                plugins: [
                  require('postcss-flexbugs-fixes'),
                  require('postcss-preset-env')({
                    autoprefixer: {
                      flexbox: 'no-2009',
                    },
                    stage: 3,
                  }),
                ]
              }
            },
          ],
        },
      

      这里,不仅添加了提取 CSS 的 loader,还增加了 postcss-loader,用来对 CSS 进行额外的处理,比如添加厂商前缀。

      注意: postcss-loader 我们采用 options 属性来配置所需的插件,而没有采用配置文件的方式,因为在实际应用中,我发现配置文件并未起效。

      使用 postcss 值得注意的一点是,package.jsonbrowserlist 的值会影响 autoprefixer 的结果。

    2. 添加插件:

      const MiniCssExtractPlugin = require('mini-css-extract-plugin');
      
      plugins: [
          new MiniCssExtractPlugin({
            filename: 'css/[name].[contenthash:8].css',
            chunkFilename: 'css/[name].[contenthash:8].chunk.css',
          }),
      ]
      
  • 压缩

    const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
    
    optimization: {
        minimizer: [
          new OptimizeCSSAssetsPlugin({
            cssProcessorOptions: {
              parser: postCssSafeParser,
              map: {
                inline: false,
                annotation: true,
              }
            },
          })
        ]
    },
    

JS

  • 压缩

    在之前的 webpack 的教程中,常常使用 UglifyJsPlugin 插件对 js 代码进行压缩,但这在 webpack 4.x 中是不必要的,因为 modeproduction 的时候会默认开启 TerserPlugin 插件,并使用其对 JS 进行压缩。

    我们可以对 TerserPlugin 插件进行配置:

    optimization: {
        minimizer: [
          new TerserPlugin({
            terserOptions: {
              parse: {
                ecma: 8,
              },
              compress: {
                ecma: 5,
                warnings: false,
                comparisons: false,
                inline: 2,
              },
              mangle: {
                safari10: true,
              },
              output: {
                ecma: 5,
                comments: false,
                ascii_only: true,
              },
            },
            parallel: !isWsl,
            // Enable file caching
            cache: true,
            sourceMap: shouldUseSourceMap,
          }),
        ]
    }
    

图片处理

可以使用 image-webpack-loader 或者 imagemin-webpack-plugin 对图片进行压缩

最大化利用缓存

web 应用的缓存是基于资源的路径(URL—— Uniform Resource Location)的,实现方式和规则由 HTTP 协议所规定。

所谓缓存,简单来讲就是将获取的资源存放在本地,当资源变化时再更新。因此这里需要解决的问题就出现了:

  • 如何定义资源的路径
  • 如何在资源更改时对其路径进行更新

当资源部署到固定的服务器时,当前资源的基础路径就已经固定了,比如 https://www.example.com/static/img/...。我们能够决定的,就是后面的部分:

  • 附加路径
  • 文件名
  • 查询参数

附加路径会使文件的目录结构复杂化,不是一个好方案。那就剩下了文件名和查询参数这两项,这两项都可以达到目的。

那如何在资源更改时能够更新这个 URL 呢?如何统一名称的资源内容更新后唯一区分它们呢?那肯定就需要有一个跟他们内容一一对应的东西,这个东西就是根据他们的内容计算出来的散列值(hash 值)。

计算出内容的散列之后,我们将其赋予文件名或者查询参数,那这个资源就永远都是唯一的,并且与其内容息息相关。

需要缓存的资源:

  • JS
  • CSS
  • 图片

代码分离

在项目中,我们使用一个文件作为入口构建了整个依赖树,所有的代码,资源都被打包到了一个文件中。这样不仅导致文件过大加载太慢,也使缓存无从谈起,因此需要将这些代码按照一定的规则进行分割。

对于缓存来讲,我们将经常变化和不经常变化地分离开。不常变化的部分就可以最大化地利用缓存。

具体分割请参照 webpack 的官方教程

webpack 4.x 配置代码分离很简单:

optimization: {
    splitChunks: { // 该选项将用于配置 SplitChunksPlugin 插件
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'false',
          chunks: 'all'
        }
      }
    },
    runtimeChunk: true
},

文件名

  • JS

    在webpack 中使用内容 hash 的文件名很简单,只需要在 ouput 选项中设置即可:

    output: {
        path: resolve('dist/js'),
        filename: '[name].[chunkhash].js'), // 使用 chunkhash
        chunkFilename: utils.assetsPath('[id].[chunkhash].js'),
        publicPath: "https://cdn.example.com/assets/"
    },
    
  • CSS

    由于 CSS 需要单独从文件中抽离出来,单独进行压缩,这部分已经在上面的代码中实现了

  • 图片

更快的构建速度

函数化

在实际使用的过程中会发现,webpack-merge 并不像想象中的智能,它只能在一定程度上进行浅合并。因此,不能从细粒度上去区分环境。

比如:

  • 开发:
module: {
    rules: [
        {
            test: /\.css$/i,
            loaders: [
                'style-loader',
                'css-loader',
                {
                    loader: 'postcss-loader',
                    options: {
                        plugins: [
                            require('postcss-flexbugs-fixes'),
                            require('postcss-preset-env')({
                                autoprefixer: {
                                    flexbox: 'no-2009',
                                },
                                stage: 3,
                            })
                        ]
                    }
                }
            ]
        },
    ]
}
  • 生产:
module: {
    rules: [
        {
          test: /\.css$/i,
          loaders: [
                {
                    loader: MiniCssExtractPlugin.loader,
                    options: {
                        publicPath: '../',
                    },
                },
                'css-loader',
                {
                    loader: 'postcss-loader',
                    options: {
                        plugins: [
                            require('postcss-flexbugs-fixes'),
                            require('postcss-preset-env')({
                                autoprefixer: {
                                    flexbox: 'no-2009',
                                },
                                stage: 3,
                            })
                        ]
                    }
                }
            ]
        },
    ]
}

在上面的代码中,加载 CSS 时在开发环境中需要使用 style-loader 将其内联到 JS 文件中,在生产环境中我们使用 MiniCssExtractPlugin.loader 将其分离到单独的文件中,但这部分代码大部分都是相同的,产生了很多冗余。

从另一个角度来讲,在不同环境进行分开配置的时候可能会应该疏忽少配置了某一项,或者忘了区分环境,这也会导致各种问题。

因此我们可以采用将 webpack 配置导出为函数的形式:

module.exports = function(env, args) {
    return {
        mode: env.production ? 'production' : 'development',
        devtool: env.production ? 'source-maps' : 'eval',
        module: {
            rules: [
                {
                    test: /\.css$/i,
                    loaders: [
                        env.production ? ({
                            loader: MiniCssExtractPlugin.loader,
                            options: {
                                publicPath: '../',
                            },
                        }) : 'style-loader',
                        'css-loader',
                        {
                            loader: 'postcss-loader',
                            options: {
                                plugins: [
                                    require('postcss-flexbugs-fixes'),
                                    require('postcss-preset-env')({
                                        autoprefixer: {
                                            flexbox: 'no-2009',
                                        },
                                        stage: 3,
                                    })
                                ]
                            }
                        }
                    ]
                },
            ]
        } 
    }
}

其他

一个项目的 webpack 配置可看作一个独立的模块,其中还有值得优化的地方:

  • 抽取公共的工具

    在配置过程中避免不了要使用相同功能的代码,这些代码应该被提取出来,至于是分配到单独的文件中,还是作为某个模块(文件)的内部工具方法,那就需要再看这个方法的通用性。如果这个方法是一个通用方法,可以在几个模块间使用,那就应该提取到一个单独的文件中,如果只有某个模块使用,那就作为该模块的内部方法就可以了。

  • 独立环境相关的项目配置文件

    这里所的配置文件与上面写的配置文件(webpack.config.base.js)概念是不同的。上面的配置我们将其称为 webpack 配置。这里所说的配置文件是跟具体项目的使用有关的,在不同项目间会频繁变的,将其称为项目配置。

    比如,在使用框架等基础设置相同的情况下的 project_1project_2,它们在使用的 loader,需要的插件,基本的开发和打包输出上的都基本是一致的。但 project_1project_2 可能需要不同的开发代理,要使用的开发端口,也可能具有不同的部署目录(如一个根目录,一个相对目录)等等,并且相同的选项在不同的环境下可能不同。

    从关注点分离的角度来讲,我们应该将这些常需要变化的和不常变化的开来,将变化的部分抽离到单独的项目配置文件中。

    如 vue-cli 的配置:

    'use strict'
    // Template version: 1.3.1
    // see http://vuejs-templates.github.io/webpack for documentation.
    
    const path = require('path')
    
    module.exports = {
      dev: {
    
        // Paths
        assetsSubDirectory: 'static',
        assetsPublicPath: '/',
        proxyTable: {},
    
        // Various Dev Server settings
        host: 'localhost', // can be overwritten by process.env.HOST
        port: 8080, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined
        autoOpenBrowser: false,
        errorOverlay: true,
        notifyOnErrors: true,
        poll: false, // https://webpack.js.org/configuration/dev-server/#devserver-watchoptions-
    
        // Use Eslint Loader?
        // If true, your code will be linted during bundling and
        // linting errors and warnings will be shown in the console.
        useEslint: true,
        // If true, eslint errors and warnings will also be shown in the error overlay
        // in the browser.
        showEslintErrorsInOverlay: false,
    
        /**
         * Source Maps
         */
    
        // https://webpack.js.org/configuration/devtool/#development
        devtool: 'cheap-module-eval-source-map',
    
        // If you have problems debugging vue-files in devtools,
        // set this to false - it *may* help
        // https://vue-loader.vuejs.org/en/options.html#cachebusting
        cacheBusting: true,
    
        cssSourceMap: true
      },
    
      build: {
        // Template for index.html
        index: path.resolve(__dirname, '../dist/index.html'),
    
        // Paths
        assetsRoot: path.resolve(__dirname, '../dist'),
        assetsSubDirectory: 'static',
        assetsPublicPath: '/',
    
        /**
         * Source Maps
         */
    
        productionSourceMap: true,
        // https://webpack.js.org/configuration/devtool/#production
        devtool: '#source-map',
    
        // Gzip off by default as many popular static hosts such as
        // Surge or Netlify already gzip all static assets for you.
        // Before setting to `true`, make sure to:
        // npm install --save-dev compression-webpack-plugin
        productionGzip: false,
        productionGzipExtensions: ['js', 'css'],
    
        // Run the build command with an extra argument to
        // View the bundle analyzer report after build finishes:
        // `npm run build --report`
        // Set to `true` or `false` to always turn it on or off
        bundleAnalyzerReport: process.env.npm_config_report
      }
    }
    

问题

模块热替换

  • 错误:

    ERROR in chunk app [entry]
    [name].[chunkhash].bundle.js
    Cannot use [chunkhash] or [contenthash] for chunk in '[name].[chunkhash].bundle.js' (use [hash] instead)
    

    原因:模块热替换和 [chunkhash] or [contenthash] 不能共存

    解决方法:修改 webpack 配置,删除掉 [chunkhash] or [contenthash],或者替换为 [hash]