Webpack4 实战 React 和 Vue 项目

avatar
UX @京东

最近一直在参与小组内“造轮子”(具体内容另寻机会再详说)在开发的过程中,了解并且学习到 Webpack v4 的一些内容,趁记忆还深,汇总成文。

导读:本文你将 Get 到使用 Webpack 4 从零开始分别搭建 React 16 和 Vue 2 项目,同时还有基于 Webpack 4 的一些开发和生产环境配置经验,感兴趣同学可以继续阅读。

使用 Webpack 作为关键词在 Google 可以搜索到很多相关的文章,网上文章也是针对各自项目和某些情况的具体方案或者介绍说明,本文也不例外,并没有覆盖到所有项目,只是介绍分享造轮子过程中积累的 4.0 版本的个人实战经验。

PS. 前半部分较为基础,有一定经验的同学可以直接跳过阅读后半部分实战内容

以前也翻译过两篇关于 Webpack 的文章,感兴趣的同学可以点击下面链接查看:!!! 强烈推荐 !!!

Webpack 4 从“零”开始

相信提到 Webpack 无论是作为前端工程师,还是 Web 开发者都不会太陌生,它从诞生伊始就收到社区的追捧和大量的生产实践,大量的项目代码构建工具开始选择它作为主力构建工具,究竟它是什么样工具, 官网是这样描述的:

At its core, webpack is a static module bundler for modern JavaScript applications. When webpack processes your application, it internally builds a dependency graph which maps every module your project needs and generates one or more bundles.
其核心就是现代 Javascript 应用的静态模块构建器。当在应用中运行 Webpack 的时候,它就会在内部构建依赖图来映射项目中的每一个所需要的模块,并生成一个或多个 bundle 文件。

也就是说 Webpack 可以分析项目中模块之间的依赖,并将最终的结果打包成 bundle 文件,开发者只要在开发过程中做到正确的引用和正常的代码开发即可,打包的事情统统交给 Webpack 即可。

在 Webpack 逐渐进化的过程中,或多或少存在一些缺点被社区人们所诟病,比如配置繁琐、构建时间较长且占用 CPU 高、文档不完整等等问题,并且衍生出一些替代品,但 Webpack 团队和开源社区的持续不断的贡献,它在不断完善和修正,如今已经进入 v4.x.x 的时代。帮助开发者减少工作量是 4.0 的一个任务,从这个版本开始,Webpack 几乎 可以做到”零“配置或少配置地来构建项目生成 Bundle 文件,下面我们就先来看一下 Webpack 4 的 “零配置”。

首先,我们来创建一个 demo 文件夹,做一些简单的初始化信息,并本地安装 webpack,此时项目中没有 webpack 配置文件。

mkdir webpack-4-demo ## 创建目录
cd webpack-4-demo ## 进入目录
yarn init -y ## 快速 yarn 初始化
yarn add webpack --dev ## 安装 webpack
 

修改 package.json 文件:

"scripts": {
"build": "webpack"
}
 

运行

yarn build
 

过程中会提示是否安装 webpack-cli 直接敲 yes 即可。

此时控制台执行结果会有如下报错:

ERROR in Entry module not found: Error: Can't resolve './src' in '/Users/xxxx/webpack-4-demo'
npm ERR! code ELIFECYCLE
npm ERR! errno 2
npm ERR! webpack-4-demo@1.0.0 build: `webpack`
npm ERR! Exit status 2
 

注意:我们目前没有写任何配置文件,但 Webpack 仍会提示没有找到 ./src 目录下 module。自 v4.0 开始已经 Webpack 可以自动在不配置 entry 的情况下自动检索项目文件夹中 src 目录下的 js 文件作为入口文件进行编译了。

接下来我们按照错误提示,在目录下创建 src 文件夹,并且新建一个文件 index.js 并且输入内容 console.log('hello webpack 4'),再次运行 yarn build

这时可以看到编译成功,项目目录下多出一个 dist 文件夹,我们事先也并没有配置 output 输出指向,Webpack 默认将 bundle 好的内容,放在了 dist 文件夹内。

在执行成功的过程中,有一处警告提示:

WARNING in configuration
The 'mode' option has not been set, webpack will fallback to 'production' for this value. Set 'mode' option to 'development' or 'production' to enable defaults for each environment.
You can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/concepts/mode/
 

回过头查看刚刚编译好的 main.js 文件,该文件已经直接被压缩好的,且可在生产模式下运行。

警告中提到的mode 就是 Webpack 4 新增的一个配置,具体内容可以见下面链接,以及笔者之前的翻译文章。

当然,也可以通过 --mode 选项来手动选择 bundle 的模式,比如 webpack --mode development

自此,如果你的项目 src 目录下的内容需要 Webpack 帮你编译,输出在 dist 目录,Webpack 几乎零配置就可以直接“胜任”了。

Webpack 4.x & React 16.x

上一节简单介绍了 V4.x “零”配置的基础应用。当然,实际工作中我们的项目都会比较复杂,上面的内容远不能满足我们的需求,下面我们就以一个 React 16 & Webpack 4 DEMO 项目为例,还原从零开始搭建基于 Webpack 打包编译项目的整个过程。

Facebook 官方推出的 create-react-app 工具已经非常好用,但仍然需要做一些修改才可以满足实际项目上线的需求,同时我们仍希望有更多所谓个性化设置来支持项目,且截至到今天 cra 使用 webpack 3.8 与我们本文介绍 webpack 4 有出入,所以下面内容不再提及,对 cra 感兴趣的同学也可以自行搜索查看了解。

React 项目初始化

首先,重复上面介绍的步骤:(创建目录、安装 react、安装 webpack、安装 babel)

mkdir react-webpack-demo
cd react-webpack-demo
## 初始化 package.json
yarn init -y
## 安装 react
yarn add react react-dom
## 安装 webpack
yarn add webpack webpack-cli --dev
## 安装 babel
yarn add @babel/core @babel/preset-env --dev
## 安装 babel-react
yarn add @babel/preset-react "babel-loader@^8.0.0-beta" --dev
 

注意:这里使用 babel 转义,此处既可以在项目根目录下创建 .babelrc 文件,也可以稍后在 webpack.config.js 中配置,这里我们选择在后者统一配置。

现在我们新建一个配置文件,webpack.config.js 代码:

module.exports = {
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: { // babel 转义的配置选项
babelrc: false,
presets: [
require.resolve('@babel/preset-react'),
[require.resolve('@babel/preset-env'), { modules: false }],
],
cacheDirectory: true,
},
},
},
]
}
};
 

src 目录下创建 App.jsx

import React from "react";
import ReactDOM from "react-dom";
const App = () => {
return (
<div>
 
Hello React and Webpack
 
</div>
);
};
export default App;
ReactDOM.render(, document.getElementById("app"));
 

src 下新建 index.jsx 内容如下:

import App from './App';
 

执行 yarn build 等待打包结果,此时目录 dist 下已经打包好 bundle。

我们接着创建 html 文件,在 src 下创建 index.html :

<div id="app"></div>
 

修改 build 的配置,拷贝 html

yarn add html-webpack-plugin
 

修改上面的 config:

const HtmlWebPackPlugin = require("html-webpack-plugin");
 
module.exports = {
mode: 'production',
resolve: {
extensions: ['.js', '.jsx'],
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
babelrc: false,
presets: [
require.resolve('@babel/preset-react'),
[require.resolve('@babel/preset-env'), { modules: false }],
],
cacheDirectory: true,
},
},
},
]
},
plugins: [
+ new HtmlWebPackPlugin({
+ template: "src/index.html",
+ filename: "index.html"
})
]
};
 

执行 yarn build 就可以看到已经打包好的 index.html 和 bundle js 。

进入开发阶段

dev-server

通过 webpack-dev-server 搭建本地 server 服务,目前是通用的解决办法

安装依赖:

yarn add webpack-dev-server --dev
 

方便起见,我们在 package.json 的 scripts 中增加内容:

"scripts": {
"start": "webpack-dev-server --mode development --open",
}
 

运行 yarn start 此时会运行一个 dev-server 服务,这样我们就能方便地在本地进行开发了。

在开发中我们还有一些其他的需求,比如 sourceMap 、修改 dev-server 配置等,所以我们可以新建一个配置 webpack.config.dev.js

const path = require('path');
 
module.exports = {
mode: 'development',
+ devtool: 'cheap-module-source-map',
resolve: {
extensions: ['.js', '.jsx'],
},
+ devServer: {
+ contentBase: path.join(__dirname, "./src/"),
+ publicPath: '/',
+ host: '127.0.0.1',
+ port: 3000,
+ stats: {
+ colors: true,
+ },
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: { // babel 转义的配置选项
babelrc: false,
presets: [
require.resolve('@babel/preset-react'),
[require.resolve('@babel/preset-env'), { modules: false }],
],
cacheDirectory: true,
},
},
},
],
},
plugins: [
new HtmlWebPackPlugin({
template: 'src/index.html',
filename: 'index.html',
+ inject: true,
})
]
};
 

修改 package.json 中 scripts 执行所需要执行的配置文件:

"scripts": {
"start": "webpack-dev-server --config './webpack.config.dev.js'"
}
 

重新执行 yarn start 可以看到修改配置后的 dev-server

热更新 HMR

配置热更新就可以让我们在开发过程中,将修改后代码整页面无刷新且保持原有 state 的情况下直接反应到页面,下面我们继续修改 config.dev.js 并在 App.jsx 增加内容:

// webpack.config.dev.js config 部分
devServer: {
...
+ hot: true,
...
},
 
// webpack.config.dev.js plugins 部分
...
plugins: [
+ new webpack.HotModuleReplacementPlugin(),
...
]
...
 
// app.jsx 页面底部新增
...
+ if (module.hot) {
+ module.hot.accept((err) => {
+ if (err) {
+ console.error('Cannot apply HMR update.', err);
+ }
+ });
+ }
 

执行 yarn start 重新启动 server,每次修改代码后保存就可以看到控制台里重新编译的信息,浏览器中变化实际修改的内容了。

Webpack 4.x & Vue 2.x

Vue 官方也推出 vue-cli 来帮助使用者快速创建项目,同时也是使用 webpack 4 来进行构建项目,通过阅读使用文档和源码,需要满足现有复杂项目的需求,我们可能也是不仅需要使用 –options 的方式同时还需要做一些定制的开发才可以,因此下面不再提及。

Vue 项目初始化

与 React 项目初始化一致(创建目录、安装 vue、安装 webpack、安装 babel)

mkdir vue-webpack-demo
cd vue-webpack-demo
## 初始化 package.json
yarn init -y
## 安装 webpack
yarn add webpack webpack-cli --dev
## 安装 babel
yarn add @babel/core @babel/preset-env --dev
## 安装 vue
yarn add vue
## 安装 vue-loader
yarn add vue-loader vue-template-compiler --dev
 

在 vue-webpack-demo 文件夹下,创建 index.html

<div id="app"></div>
 

新建 src 目录,并新建 app.js 和 app.vue 文件:

// app.js
import Vue from 'vue'
import App from './app.vue'
 
Vue.config.productionTip = false
 
new Vue({
render: h => h(App)
}).$mount('#app')
 

// app.vue
<div id="app">Hello Vue & Webpack</div>
 
 
<script>
export default {
}
</script>
 
<style>
</style>
 
 

下面来增加配置文件,webpack.config.js

const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
 
module.exports = {
resolve: {
alias: {
'vue$': 'vue/dist/vue.esm.js'
},
extensions: ['*', '.js', '.vue', '.json']
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.vue$/,
include: '/src/',
loader: 'vue-loader'
}
]
},
plugins: [
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'index.html',
chunks: ['app', 'manifest', 'vendor'],
});
]
}
 

完成后执行 webpack 就可以看到 dist 下已经构建完成的项目。

注意:vue-loader 已经更新到 v15.x ,与之前 14.x 配置方式有差异。

用于开发 dev-server 配置与 React 基本一致,这里不重复。

实战内容经验积累

下面的内容是在笔者积累的项目优化实战经验汇总。

提取公共依赖

为了减少一次请求文件体积过大,同时修改业务代码时不必要重复重新下载公共依赖代码,我们通常将公共依赖模块如 reactreact-domvueaxio 等文件抽取出来独立打包。

注意:CommonsChunkPlugin 在 4.0 中已经被 optimization 选项取代,不需要安装该插件就可以实现相同效果,见下面配置。

mode: 'production',
output: {
...
+ chunkFilename: 'js/[name].[chunkhash:8].js',
...
},
 
optimization: {
nodeEnv: 'production',
...
runtimeChunk: {
name: 'manifest', // 运行时文件
},
+ splitChunks: {
+ cacheGroups: {
+ commons: {
+ test: /[\\/]node_modules[\\/]/,
+ name: 'vendor', // 依赖第三方库要提取成名字是的 vendor 的文件
+ chunks: 'all', // 提取所有 chunks
+ },
+ },
},
...
},
 

通过上面的配置,可以将公共依赖和业务代码隔离开来。但是,也会存在一些隐患

  • 随着项目复杂度增加,依赖库增多,vendor.js 的体积会越来越臃肿
  • 多页面应用项目中,不同页面仍然会加载到在本页面根本无用的公共依赖的冗余代码

所以具体项目需要通过具体的需求来抽离出不同的 chunks 来分别引用,按需引用。

文件压缩

配置 mode: 'production' Webpack 会使用默认插件 [UglifyJs](https://github.com/webpack-contrib/uglifyjs-webpack-plugin) 来进行压缩代码。

官网提到在 Webpack v4 以前使用内置的 webpack.optimize.UglifyJsPlugin 插件,在 Webpack 4 以后,开始使用 ^1.0.0 独立的版本。

+ const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
+ const os = require('os');
...
// webpack config
optimization: {
nodeEnv: 'production',
+ minimizer: [
+ new UglifyJsPlugin({
+ cache: true, // node_modules/.cache/uglifyjs-webpack-plugin
+ parallel: os.cpus().length, // 并行 default:true os.cpus().length - 1
+ uglifyOptions: {
+ ecma: 5,
+ mangle: true,
+ },
+ sourceMap: false,
+ }),
],
},
 

注意:4.0 版本压缩的代码已经放在 optimizationminimizer 节点下。
备注:关于 parallel 选项,新版的 uglifyjs 已经支持多核 CPU 并行执行,所以已经不需要 webpack-parallel-uglify-plugin 插件。

proxy 接口代理

配合 dev-server 对代理本地启动的 server 某一域名进行代理,解决服务端接口暂时满足要求、本地请求跨域等问题。

devServer: {
...
+ proxy: {
+ "/api": "https://localhost:3000",
+ changeOrigin: true, // 支持跨域请求
+ secure: true, // 支持 https
+ }
}
 

文档见dev-server-proxy doc

支持多入口

虽然多数情况下,我们都在开发并且维护单页面的应用,但是当遇到需要多页面的时候,我们也希望在一个项目内进行构建,目前解决办法比较粗暴,当前有 n 个入口 html 就创建 n 个 HtmlWebpackPlugin 插件实例。

const fs = require('fs');
const path = require('path');
// 先找到项目指定目录下的所有 html 此处假设我们把入口 html 放在 src/html 下 app1.html、app2.html、app3.html
const appHtmlEntries = fs.readdirSync(resolveToAppRoot('./src/html/'))
.filter(f =&gt; f.match(/\.html?$/))
.reduce((acc, p) =&gt; Object.assign(acc, { [path.basename(p).replace(/\.html?$/, '')]: path.join(resolveToAppRoot('./src/html/'), p) }), {});
...
// 每一个 html 创建一个 HtmlWebpackPlugin 实例
Object.keys(appHtmlEntries).forEach((name) =&gt; {
const pluginHtml = new HtmlWebpackPlugin({
filename: `${name}.html`,
template: `src/html/${name}.html`,
chunks: [`${name}`, 'manifest', 'vendor'],
inject: true,
});
webpackConfig.plugins.push(pluginHtml);
});
 

预编译 sass、引入 postcss、处理 css 压缩 和 文件分离

这里要注意 rules 中 loader 数组的顺序,由于 webpack 执行 rules 是从最后一个开始倒叙执行,所以我们配置的顺序也是:

预编译 sass => 处理 postcss => 处理 css => 压缩并独立 css 文件

升级到 4.0,已经不再使用 extract-text-webpack-plugin 插件来进行文件抽取,改用 MiniCssExtractPlugin 插件,配合 OptimizeCSSAssetsPlugin 插件来压缩 css 文件。

javascript
const autoprefixer = require('autoprefixer');
const postcssFlexbugsFixes = require('postcss-flexbugs-fixes');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

...
// rules 配置
{
test: /.(css|sass|scss)$/,
use: [
{
loader: MiniCssExtractPlugin.loader, // 这个 loader 放在最后一个执行,将编译好的 css 独立
},
require.resolve('css-loader'),
{
loader: require.resolve('postcss-loader'),
options: {
ident: 'postcss',
plugins: () => [
postcssFlexbugsFixes,
autoprefixer({
browsers: [
'>1%',
'last 4 versions',
'Firefox ESR',
'not ie < 9', ], flexbox: 'no-2009', }), ], }, }, require.resolve('sass-loader'), ], }, ... // optimization 配置 const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); const cssnano = require('cssnano'); ... optimization: { nodeEnv: 'production', minimizer: [ new OptimizeCSSAssetsPlugin({ assetNameRegExp: /.css$/g, cssProcessor: cssnano, // 默认使用 cssnano 处理 css cssProcessorOptions: { reduceIdents: false, // 禁止将 keyframes 自动更名 mergeIdents: false, // 禁止自动合并 keyframes discardUnused: false, // 禁止移除掉未使用的 keyframes autoprefixer: false, // 禁止默认删除掉一些前缀,以减少兼容性的问题 zindex: false, // 禁止自动转换 z-index map: false, }, }), ], }, ... // 独立 css 文件 plugins: [ new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash:8].css', }), ], <code>### 支持 CDN 路径替换 可以将 html 中 css 和 js 的相对引用路径自动替换成配置的前缀路径,用来支持静态资源上线到具体指定的 CDN 路径来增加 app 内静态资源的下载速度。</code> diff output: { ... + publicPath: "https://...cdnpath.../assets/" // CDN 资源 URL 前缀 ... }, <code>### 生产环境的 source-map 有些时候我们会遇到在生产环境中代码出现问题的情况,而本地开发却不重现,这个时候 source-map 就成了辅助解决问题的一个有利的工具,因为详细查看生产环境打包输出的内容,到底问题出在哪里,下面见配置:</code> diff mode: 'production', + devtool: 'source-map', optimization: { nodeEnv: 'production', minimizer: [ new UglifyJsPlugin({ ... + sourceMap: true, }), new OptimizeCSSAssetsPlugin({ assetNameRegExp: /.css$/g, cssProcessor: cssnano, // 默认使用 cssnano 处理 css,另外 clean-css 也提供相应的方案,但需要 4.2 版本才可用且质量和效率都没有得到验证,暂且不提 cssProcessorOptions: { ... + map: { inline: false } , }, }), ], }, ### 处理图片资源 下面是关键 loaders 但不会列出所有配置,内容略有差异,可以根据配置 limit 大小来控制图片转 base64 或压缩等。 – file-loader 文件无处理,直接拷贝 – url-loader 可以增加 base64 处理 – svg-url-loader 处理 svg 文件,也同样支持 base64 – image-webpack-loader 图片文件降质压缩 ### 其他插件 – HashedModuleIdsPlugin 使用更稳定的 moudle id 生成方式 – webpack.optimize.ModuleConcatenationPlugin 插件已经不需要单独配置,Webpack 4 已经默认在生产模式下打包时内置开启优化 ## 小结 > 并不是每一个人都想成为 Webpack 配置工程师!

上面引用一句我们“造轮子”时使用的一句 slogan。

虽然 Webpack 4 还没有达到开箱即用的程度(当然,开箱即用也就意味着可配置的内容有所减少,这一定是一把双刃剑),况且开箱即用也并不是它的被创造出来的初衷,简单的配置无法满足项目中的实际需求,各种各样的配置和插件配合着形成解决各种问题的不同方案,只在很多次尝试后才能达到针对某一个项目项目最优的配置。

然而,它只是一个工具,也许再过一段会有新的工具来取代 Webpack,But 既然你现在用到它,还是有必要花时间了解一下如何更好地让它为你、你的团队和你的项目来服务。

关于 Webpack 4 的配置经验笔者也在摸着石头过这条小河,网上也有诸多优化和解决方案,我们造的轮子也需要更多的项目和时间来帮助其成长,文中并没有面面俱到地将所有配置详细说明,或者并不是所有配置都是最优选择,也欢迎私信留言讨论。

有兴趣欢迎关注我们的公众号:全栈探索。欢迎交流。