webpack学习记录(5)-开发过程中的基础构建

377 阅读9分钟

前言

前面了解了一些Webpack的基础配置,那么下面就了解一些在实际开发过程中的应用。

这里会分为开发环境和生产环境来分别简述。

注:以下功能都是基于Webpack版本^4.43.0调试。

开发环境

开发过程中,每次修改后需要编译代码时,如果手动运行npm run build会显得很麻烦,Webpack中提供了几种工具来进行处理。

为了区分于生产环境,Webpack的配置文件我们命名为webpack.dev.config.js

module.exports = {
  entry: {
    main: './src/main.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].js',
  },
  mode: 'development',
  module: {
    rules: [
      ...
    ],
  },
}

文件监听(watch mode)

文件监听是在发现源码发⽣生变化时,⾃自动重新构建出新的输出⽂文件。

其原理是Webpack轮询判断文件的最后编辑时间是否变化。如果某个文件发生了变化,会先缓存起来,等一定延迟时间(aggregateTimeout)后再一起执行

如果需要开启监听模式,有两种方式:

  • 启动Webpack命令时,带上 --watch 参数
  • 在配置文件中设置watch: true
// package.json
"scripts": {
    "dev": "webpack --config webpack.dev.config.js --watch"
},

// webpack.dev.config.js
module.exports = {
    ...
    plugins: [
        // 避免在 watch 触发增量构建后删除 index.html 文件
        new CleanWebpackPlugin({ cleanStaleWebpackAssets: false }),
        new HtmlWebpackPlugin({
          title: '管理输出',
        }),
    ],
    watch: true,
    // 启动文件监听的相关配置信息
    watchOptions: {
        // 当第一个文件更改,会在重新构建前增加延迟。单位毫秒,默认值200
        aggregateTimeout: 600,
        // 可以使用正则来设置不需要监听的文件或文件夹
        ignored: /node_modules/,
        // 指定毫秒为单位进行轮询。默认为false,如果监听没生效,可以先把这个配置打开
        poll: 1000

    }
}

watchoptions的相关配置可以查看这里

文件监听的配置很简单,但是其有一个最大的缺点:每次都需要手动刷新浏览器。为了能自动刷新浏览器,下面我们了解webpack-dev-server

webpack-dev-server

webpack-dev-server为你提供了一个简单的web server,并且具有 live reloading(实时重新加载) 功能。

webpack-dev-server在编译之后不会写入到任何输出文件。而是将 bundle 文件保留在内存中,然后将它们 serve 到 server 中,就好像它们是挂载在 server 根路径上的真实文件一样。

我们需要先进行安装:

npm install -D webpack-dev-server

安装完毕后需要修改配置文件:

module.exports = {
  ...
  devServer: {
    contentBase: './dist', // 加载dist文件夹下的文件到server
  },
}

接下来,使用命令npm run dev就可以启动。

// package.json
"scripts": {
    "dev": "webpack-dev-server --open --config webpack.dev.config.js",
},

当启动后,默认打开localhost:8080

我们可以参考这里的devServer配置项来修改默认配置,下面说一些其他的重要配置。

  • inline

布尔值,表示在开发服务器的两种不同模式之间切换。默认情况下,应用程序将启用inline模式,即bundle文件插入脚本通过自动刷新页面来重新加载项目。

若设置为false,则使用iframe模式,将bundle文件插入页面的<iframe>中,通过重新加载<iframe>来重新加载项目。

  • hot 布尔值,表示是否启用热加载。可以用下面几种方式表示启用。
// package.json
"scripts": {
    "dev": "webpack-dev-server --open --config webpack.dev.config.js --hot"
},

// webpack.dev.config.js
devServer: {
     ...
     hot: true,
},

在Webpack中,也可以通过Node来启用。

在 Node.js API 中使用 webpack dev server 时,不要将 dev server 选项放在 webpack 配置对象中。而是在创建时, 将其作为第二个参数传递。

const webpackDevServer = require('webpack-dev-server');
const webpack = require('webpack');

const config = require('./webpack.config.js');
const options = {
  contentBase: './dist',
  hot: true,
  ...
};

webpackDevServer.addDevServerEntrypoints(config, options);
const compiler = webpack(config);
const server = new webpackDevServer(compiler, options);

server.listen(5000, 'localhost', () => {
  console.log('dev server listening on port 5000');
});

这里需要多说明的是,对于一般的css和img,HMR开箱可用。但是针对js,HRM没有通用方案支持,需要HotModuleReplacementPlugin提供的api进行手动的处理需要进行HRM的模块(可以参考官网上的示例)。万幸的是,我们一般使用框架进行开发,不管是Vue(使用vue-loade)也好,React(使用react-hot-loader)也好都集成了HRM解决方案,我们可以开箱可用。

  • proxy webpack-dev-server使用http-proxy-middleware去把请求代理到一个外部的服务器,我们可以在proxy进行配置。
module.exports = {
  //...
  devServer: {
    proxy: {
      '/api': 'http://localhost:3000'
    }
  }
};

上面的例子中,对/api/users的请求会将请求代理到http://localhost:3000/api/users。(更多信息可以查看这里

webpack-dev-middleware

webpack-dev-middleware是一个封装器(wrapper),它可以把webpack处理过的文件发送到一个server。webpack-dev-server在内部使用了它,然而它也可以作为一个单独的package来使用,以便根据需求进行更多自定义设置。

下面按照官网的示例进行说明。我们先安装相关npm:

npm install --save-dev express webpack-dev-middleware

然后,调整配置文件。

module.exports = {
  //...
  output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
     publicPath: '/',
  },
};

在添加server.js,用于webpack-dev-middleware相关配置。

// server.js

const express = require('express');
const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');

const app = express();
const config = require('./webpack.config.js');
const compiler = webpack(config);

// 告知 express 使用 webpack-dev-middleware,
// 以及将 webpack.config.js 配置文件作为基础配置。
app.use(
  webpackDevMiddleware(compiler, {
    publicPath: config.output.publicPath,
  })
);

// 将文件 serve 到 port 3000。
app.listen(3000, function () {
  console.log('Example app listening on port 3000!\n');
});

最后,添加npm script,就可以正常启动了。

// package.json
"scripts": {
    "server": "node server.js",
}

当执行npm run server,就可以在http://localhost:3000中看见项目初始页。这里需要注意一个配置属性output.publicPath,官网上表示此选项指定在浏览器中所引用的「此输出目录对应的公开 URL」

webpack-dev-server 也会默认从publicPath为基准,使用它来决定在哪个目录下启用服务,来访问 webpack 输出的文件。

所以,webpackDevMiddleware方法中的publicPath需要和output.publicPath保持一致,且打开初始页面链接也需要添加该信息。比如若设置output.publicPath/assets/,则初始页面URL为http://localhost:3000/assets/

source map

在开发过程中,除了需要修改代码后进行热更新,我们也需要能进行代码错误的排查。但是Webpack会将多个模块打包为一个bundle文件中,而一个模块的错误会指向该bundle文件。若想准确知道是哪个模块哪一行的错误,则需要source map功能。

source map使用devtool进行配置,相关详细说明可以参考这篇文章【[webpack] devtool里的7种SourceMap模式是什么鬼?】,这里就不在过多描述。

在开发环境中,倾向于使用eval-cheap-module-source-map配置。

  • 使用cheap模式可以大幅提高souremap生成的效率。大多数情况下,我们调试时仅需知道行数就行,不需要列数信息。
  • 使用eval方式可大幅提高持续构建效率。
  • 使用module可支持babel这种预编译工具(在Webpack里做为loader使用)。我们需要定位debug到最原始的资源,比如定位错误到jsx,ts的原始代码,而不是经编译后的js代码。
  • 使用eval-source-map模式可以减少网络请求。DataURL内联在文件中,可以稍微提高点网络请求效率。

生产环境

在开发环境中,我们需要实时重新加载或热模块替换能力的server,相对完整的source map。但是在生产环境中,我们更加关注更小的bundle(压缩输出), 更轻量的source map, 还有更优化的资源等。

所以,为了遵循逻辑分离原则, 我们需要将开发环境和生产环境编写彼此独立的配置文件;为了遵守不重复原则,我们保留一个通用配置,通过webpack-merge将其和不同环境的配置文件结合起来。

首先,进行安装:

npm install --save-dev webpack-merge

配置文件:

// webpack.base.config.js
module.exports = {
  entry: {
    main: './src/main.js',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].js',
  },
  ...
}

// webpack.pord.config.js
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base.config')

module.exports = merge(baseConfig, {
  mode: 'production',
  output: {
    filename: '[name].[contenthash].js',
  },
  ...
})

mode模式

Webpack中有mode配置选项,告知Webpack使用相应模式的内置优化,默认值为production

当设置modeproduction时,

会将 process.env.NODE_ENV 的值设为 production。启用 FlagDependencyUsagePlugin, FlagIncludedChunksPlugin, ModuleConcatenationPlugin, NoEmitOnErrorsPlugin, OccurrenceOrderPlugin, SideEffectsFlagPlugin 和 UglifyJsPlugin.

详细内容可以查看之前的一篇文章【webpack学习记录(1)-mode模式】

process.env.NODE_ENV

process.env.NODE_ENV的作用主要是帮我们判断是开发环境(development)还是生产环境(production)。

需要注意的是,process.env.NODE_ENV可以在src/*下的所有本地代码中进行访问,webpack.*.config.js中无法正常获取,仅能获取到undefined

上面我们了解到,通过设置mode可以设置process.env.NODE_ENV。我们也可以自己使用Webpack内置的DefinePlugin插件来修改这个变量。

从 webpack v4 开始, 指定mode会自动地配置DefinePlugin

// webpack.pord.config.js
const webpack = require('webpack');

module.exports = merge(baseConfig, {
  ...
  plugins: [
    new webpack.DefinePlugin({
      'process.env': {
        NODE_ENV: '"production"' // 也可以写为:JSON.stringify('production')
      }
    }),
    ...
   ]
})

注:DefinePlugin插件修改后的process.env.NODE_ENV,优先级比mode设置的process.env.NODE_ENV高。

此外,在webpack.*.config.js中,除了通过process.env.NODE_ENV判断,我们可以通过命令行中传入变量NODE_ENV的方式来进行判断当前环境。

// package.json
"scripts": {
    "combine": "webpack --config webpack.combine.config.js --env.NODE_ENV=production"
  },
  
// webpack.combine.config.js
const prodWebpackConfig = require('./webpack.prod.config');

module.exports = env => {
    return merge(prodWebpackConfig, {
        output: {
            filename: env.NODE_ENV === 'production' ? '[name].[hash].bundle.combine.js' : '[name].bundle.js',
        },
    })
}

文件指纹

为了优化体验,浏览器中存在缓存机制。我们一般会对文件添加后缀值,当文件资源变化时,修改其后缀值。

Webpack中,我们可以通过配置来实现自动给资源文件添加后缀值,即文件指纹。

之前针对文件指纹有过文章【webpack学习记录(3)-文件指纹】总结,这里我说下配置方案。

  • 针对js文件 可直接在output.filename中使用[chunkhash]占位符。这样当代码修改时,仅其所在模块对应的bundle文件的指纹会变化。
// webpack.prod.config.js
output: {
    filename: '[name].[chunkhash].js',
}
  • 针对css文件 由于css和js会一起打包到bundle文件中,所以为了避免css文件的修改而引起bundle文件指纹变化,我们使用插件mini-css-extract-plugin将css文件抽取出来,再设置css的文件指纹。
// webpack.prod.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = merge(baseConfig, {
  ...
  module: {
     rules: [
        {
            test: /.css$/,
            use: [
              MiniCssExtractPlugin.loader,
              'css-loader'
            ],
        }
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
        filename: '[name].[contenthash].css',
    }),
    ...
   ]
})

注:使用mini-css-extract-plugin时,需要在loaderplugins都进行配置。

  • 针对图片&字体 我们使用url-loader来处理图片&字体,可以根据文件内容生成hash。
{
  loader: 'file-loader',
  options: {
    name: '[path][name][hash].[ext]',
  }
}

压缩

  • js压缩 当mode的值为production,Webpack会默认使用TerserPlugin来进行代码压缩。

注:之前压缩使用的UglifyjsWebpackPlugin,但其不支持ES6+语法,所以替换为TerserPlugin

我们也可以通过设置optimization.minimizer来覆盖默认的压缩方式。

const TerserPlugin = require("terser-webpack-plugin");

module.exports = {
  optimization: {
    minimizer: [
      new TerserPlugin({
        cache: true,
        parallel: true, // 多进程并发运行
        sourceMap: true, // 如果在生产环境中使用 source-maps,必须设置为 true
        ...
      }),
    ],
  }
};
  • css压缩

css压缩需要使用插件CssMinimizerWebpack

const TerserPlugin = require("terser-webpack-plugin");
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  ...
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        cache: true,
        parallel: true, // 多进程并发运行
        sourceMap: true, // 如果在生产环境中使用 source-maps,必须设置为 true
        ...
      }),
      new CssMinimizerPlugin(),
    ],
  },
};

注:这里需要在optimization.minimizer中添加压缩方案,需要把TerserPlugin添加上,不然会覆盖掉TerserPlugin对js的压缩。

此外,也可以使用插件optimize-css-assets-webpack-plugin来进行css压缩。

const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');

module.exports = {
  ...
  plugins: [
    ...
    new OptimizeCssAssetsPlugin({
      assetNameRegExp: /\.optimize\.css$/g,
      cssProcessor: require('cssnano'),
      cssProcessorPluginOptions: {
        preset: ['default', { discardComments: { removeAll: true } }],
      },
      canPrint: true
    })
  ],
};
  • html压缩 当我们使用插件html-webpack-plugin来生成HTML文件时,若mode的值为production,会自动压缩HTML文件。也可以通过设置minify来控制是否压缩。

source map

在生产环境中,最好不要生成source map文件。

若为了方便调试,可使用模式cheap-module-source-map,并且使用Sentry来保存调试source map文件(可以参考这篇文章)或者通过nginx设置将.map文件只对白名单开放(公司内网)。

总结

  • 开发环境,基础构建需要重新加载,热更新功能,需要尽可能完整的source map
  • 生产环境,基础构建需要压缩文件,文件指纹功能,需要尽可能小的source map

参考