【webpack 系列】进阶篇
- 2020 年 4 月 6 日
- 筆記
本文将继续引入更多的 webpack
配置,建议先阅读【webpack 系列】基础篇的内容。如果发现文中有任何错误,请在评论区指正。本文所有代码都可在 github 找到。
打包多页应用
之前我们配置的是一个单页的应用,但是我们的应用可能需要是个多页应用。下面我们来进行多页应用的 webpack
配置。
先看一下我们的目录结构
├── public │ ├── detail.html │ └── index.html ├── src │ ├── detail-entry.js │ ├── index-entry.js
public
下面有 index.html
和 detail.html
两个页面,对应 src
下面有 index-entry.js
和 detail-entry.js
两个入口文件。
在webpack.config.js
配置
// webpack.config.js const HtmlWebpackPlugin = require('html-webpack-plugin'); // ... module.exports = { entry: { index: path.resolve(__dirname, 'src/index-entry.js'), detail: path.resolve(__dirname, 'src/detail-entry.js') }, output: { path: path.resolve(__dirname, 'dist'), // 输出目录 filename: '[name].[hash:6].js', // 输出文件名 }, plugins: [ // index.html new HtmlWebpackPlugin({ template: path.resolve(__dirname, 'public/index.html'), // 指定模板文件,不指定会生成默认的 index.html 文件 filename: 'index.html', // 打包后的文件名 chunks: ['index'] // 指定引入的 js 文件,对应在 entry 配置的 chunkName }), // detail.html new HtmlWebpackPlugin({ template: path.resolve(__dirname, 'public/detail.html'), // 指定模板文件,不指定会生成默认的 index.html 文件 filename: 'detail.html', // 打包后的文件名 chunks: ['detail'] // 指定引入的 js 文件,对应在 entry 配置的 chunkName }), // 打包前自动清除dist目录 new CleanWebpackPlugin() ] }
npm run build
之后可以看到生成的 dist
目录如下
dist ├── assets │ └── author_ee489e.jpg ├── detail.dbcb15.js ├── detail.dbcb15.js.map ├── detail.html ├── index.dbcb15.js ├── index.dbcb15.js.map └── index.html
index.html
页面中已经引入了打包好的 index.dbcb15.js
文件,detail.html
文件也已经引入了 detail.dbcb15.js
文件。更多配置请查看 html-webpack-plugin。
将 CSS 样式单独抽离生成文件
webpack4
对 css
模块支持的完善以及在处理 css
文件提取的方式上也做了些调整,由 mini-css-extract-plugin
来代替之前使用的 extract-text-webpack-plugin
,使用方式很简单。
该插件将 css
提取到单独的文件中,为每个包含 css
的 js
文件创建一个 css
文件,支持 css
和 sourcemap
的按需加载。
与 extract-text-webpack-plugin
相比有如下优点
- 异步加载
- 没有重复的编译(性能)
- 更容易使用
- 特定于
css
安装 extract-text-webpack-plugin
npm i -D mini-css-extract-plugin
配置 webpack.config.js
// webpack.config.js const MiniCssExtractPlugin = require('mini-css-extract-plugin'); // ... module.exports = { // ... module: { rules: [ { test: /.(c|le)ss$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader', 'less-loader'], exclude: /node_modules/ }, { test: /.sass$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader', 'sass-loader'], exclude: /node_modules/ }, // ... ] }, plugins: [ // ... new MiniCssExtractPlugin({ filename: 'css/[name].[hash:6].css' }) ] }
npm run build
之后会发现在 dist/css
目录有了抽离出来的 css
文件了。
这时我们发现两个问题:
- 打包生成的
css
文件没有进行压缩。 - 所有文件命名的
hash
部分都是一样的,存在缓存问题。
对 css 文件进行压缩
通过 optimize-css-assets-webpack-plugin
插件压缩 css
代码
npm i -D optimize-css-assets-webpack-plugin
配置 webpack.config.js
// webpack.config.js //... const OptimizeCssPlugin = require('optimize-css-assets-webpack-plugin'); module.exports = { //... plugins: [ //... new OptimizeCssPlugin() ] }
这样就可以对 css
文件进行压缩了。
对于第二个问题,我们首先需要了解下 hash
、chunkHash
、contentHash
的区别。
hash、chunkhash、contenthash 的区别和使用
hash
hash
是基于整个 module identifier
序列计算得到的,webpack 默认为给各个模块分配一个 id
以作标识,用来处理模块之间的依赖关系,默认的 id
命名规则是根据模块引入的顺序赋予一个整数(1
、2
、3
…)。任意修改、增加、删除一个模块的依赖,都会对整个 id
序列造成影响,从而改变 hash
值。也就是每次修改或者增删任何一个文件,所有文件名的 hash
值都将改变,整个项目的文件缓存都将失效。
output: { path: path.resolve(__dirname, 'dist'), // 输出目录 filename: '[name].[hash:6].js', // 输出文件名 } new MiniCssExtractPlugin({ filename: 'css/[name].[hash:6].css' })
可以看到打包后的 js
和 css
文件的 hash
值是一样的,所以对于没有发生改变的模块而言,这样做是不合理的。
当然可以看到,对于图片等资源该 hash
还是可以生成一个唯一值的。
chunkhash
chunkhash
根据不同的入口文件进行依赖文件解析、构建对应的 chunk
,生成对应的哈希值。我们将 filename
配置成 chunkhash
来看一下打包的结果。
output: { path: path.resolve(__dirname, 'dist'), // 输出目录 filename: '[name].[chunkhash:6].js', // 输出文件名 } new MiniCssExtractPlugin({ filename: 'css/[name].[chunkhash:6].css' })
可以看到此时打包之后的 index.js
和 detail.js
的 chunkhash
是不一样的。但是会发现 index.js
和 index.css
以及 detail.js
和 detail.css
的 chunkhash
是一致的,并且任意改动 js
或者 css
都会引起对应的 css
和 js
文件的 chunkhash
的改变,这是不合理的。所以这里抽离出来的 css
文件将使用 contenthash
,来区分 css
文件和 js
文件的更新。
contenthash
contenthash
是针对文件内容级别的,只有你自己模块的内容变了,那么 hash
值才改变。
output: { path: path.resolve(__dirname, 'dist'), // 输出目录 filename: '[name].[chunkhash:6].js', // 输出文件名 } new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash:6].css' })
OK
,可以看到分离出来的 css
文件已经和入口文件的 hash
值区分开了。
如何使用
为了实现理想的缓存,我们一般这样使用他们:
JS
文件使用chunkhash
- 抽离的
CSS
样式文件使用contenthash
gif|png|jpe?g|eot|woff|ttf|svg|pdf
等使用hash
按需加载
很多时候我们并不需要在一个页面中一次性加载所有的 js
或者 css
文件,而是应该是需要用到时才去加载相应的 js
或者 css
文件。
import()
比如,现在我们需要点击一个按钮才会使用对应的 js
、css
文件,需要 import()
语法:
// index-entry.js import './index.sass'; //... const handle = () => import('./handle'); const handle2 = () => import('./handle2'); document.querySelector('#btn').onclick = () => { handle().then(module => { module.handleClick(); }); handle2().then(module => { module.default(); }); }
// handle.js import './handle.css'; export function handleClick () { console.log('handleClick'); }
// handle2.js export default function handleClick () { console.log('handleClick2'); }
npm run build
可以看到,多了这 3
个文件,并且只有在我们点击该按钮是才会去加载这 3
个文件。
webpackChunkName
这些文件可能不太好区分,我们可以通过设置 webpackChunkName
来定义生成的文件名
// index-entry.js const handle = () => import(/* webpackChunkName: "handle" */ './handle'); const handle2 = () => import(/* webpackChunkName: "handle2" */ './handle2');
我们再将这些文件的 hash
长度设置为 8
加以区分
// webpack.config.js module.exports = { output: { path: path.resolve(__dirname, 'dist'), // 输出目录 filename: '[name].[chunkhash:6].js', // 输出文件名 chunkFilename: '[name].[chunkhash:8].js' } // ... new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash:6].css', chunkFilename: 'css/[name].[contenthash:8].css' }) }
npm run build
之后查看
当然我们也可以将 handle
和 handle2
文件的 webpackChunkName
设置成一样的,这样这两个文件将会打包在一起生成一个文件,可以减少请求数量。
热更新( HMR, Hot Module Replacement )
开发过程中,我们希望在浏览器不刷新页面的情况下能够去加载我们修改的代码,来提高我们的开发效率。我们来看下如何配置:
- 打开
webpack-dev-server
的热更新开关 - 使用
HotModuleReplacementPlugin
插件
HotModuleReplacementPlugin
插件是 Webpack
自带的,在 webpack.config.js
直接配置
// webpack.config.js module.exports = { devServer: { //... hot: true }, plugins: [ //... new webpack.HotModuleReplacementPlugin() // 热更新插件 ] }
在入口文件添加
if (module && module.hot) { module.hot.accept() }
这样就完成了热更新的配置,但是此时 webpack
打包却报错了。
搜了一下相关的问题,在开发环境中我们使用了 HotModuleReplacementPlugin
此时需要使用 hash
来输出文件,使用 chunkhash
会导致 webpack
报错,而生产环境则没有问题。但是现在我们只是通过 process.env.NODE_ENV
这个变量来区分环境,这显然不是一个很好的方式。
我们最好能够需要区分一下开发环境和生产环境的配置文件。
定义不同环境的配置
我们可以给不同的环境定义不同的配置文件,但是这些文件将会有大量相似的配置,这时我们可以这样来定义文件:
webpack.base.js
:定义公共的配置webpack.dev.js
:定义开发环境的配置webpack.prod.js
:定义生产环境的配置
我们可以将一些公共的配置抽离到 webpack.base.js
,然后在 webpack.dev.js
和 webpack.prod.js
进行对应环境的配置。我们还需要通过 webpack-merge
来合并两个配置文件。
安装 webpack-merge
npm i -D webpack-merge
现在 webpack.dev.js
就是这样的
// webpack.dev.js const path = require('path'); const webpack = require('webpack'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const merge = require('webpack-merge'); const baseConfig = require('./webpack.config.base'); module.exports = merge(baseConfig, { mode: 'development', devtool: 'inline-source-map', devServer: { contentBase: path.join(__dirname, 'dist'), port: '9000', // 默认是8080 compress: true, // 是否启用 gzip 压缩 hot: true }, output: { path: path.resolve(__dirname, 'dist'), // 输出目录 filename: '[name].[hash:6].js', // 输出文件名 chunkFilename: '[name].[hash:8].js' }, plugins: [ new MiniCssExtractPlugin({ filename: 'css/[name].[hash:6].css', chunkFilename: 'css/[name].[hash:8].css' }), new webpack.HotModuleReplacementPlugin() // 热更新插件 ] });
同时需要在 package.json
中指定我们的配置文件
// package.json "scripts": { "dev": "cross-env NODE_ENV=development webpack-dev-server --config webpack.config.dev.js", "build": "cross-env NODE_ENV=production webpack --config webpack.config.pro.js" },
这时我们就很优雅的区分开不同环境的配置了。
拷贝静态资源
有时候我们需要在 html
中直接引用一个打包好的第三方插件库,这个库不需要通过 webpack
编译。比如我们 lib
目录下有个 lib-a.js
,需要在 public/index.html
中直接引用它。
<!-- public/index.html --> <script src="/lib/lib-a.js"></script>
这时 build
之后会发现 dist
下是没有 lib
目录的,这时会找不到这个文件。这时我们需要借助 CopyWebpackPlugin
这个插件来帮助我们把根目录下的 lib
目录拷贝到 dist
目录下面。
首先安装 CopyWebpackPlugin
npm i -D CopyWebpackPlugin
配置 webpack.config.js
// webpack.config.js const CopyWebpackPlugin = require('copy-webpack-plugin'); module.exports = { //... plugins: [ //... new CopyWebpackPlugin([ { from: path.resolve(__dirname, 'lib'), to: path.resolve(__dirname, 'dist/lib') } ]) ] }
这时后运行 npm run build
就会发现,dist
目录下已经有了 lib
目录及文件了。
更多的配置请查看copy-webpack-plugin。
Resolve 配置
Webpack
在启动后会从配置的入口模块出发找出所有依赖的模块,Resolve
配置 Webpack
如何寻找模块所对应的文件。 Webpack
内置 JavaScript
模块化语法解析功能,默认会采用模块化标准里约定好的规则去寻找,但你也可以根据自己的需要修改默认的规则。
alias
resolve.alias
配置项通过别名来把原导入路径映射成一个新的导入路径。
比如我们在 index-entry.js
中引入 lib/lib-b.js
,你可能需要这样引入
import '../lib/lib-b.js';
而当目录层级比较深时,这个相对路径就会变得不好辨认了。这时我们可以配置 lib
的一个别名。
// webpack.config.js module.exports = { //... resolve: { alias: { '@lib': path.resolve(__dirname, 'lib') // 为lib目录添加别名 } } }
这时无论你处于目录的哪个层级,你只需要这样引入
import '@lib/lib-b.js';
extensions
如果在导入文件时没有带后缀名,webpack
会自动带上后缀后去尝试访问文件是否存在。 resolve.extensions
用于配置在尝试过程中用到的后缀列表,默认是
extensions: ['.js', '.json']
就是说当遇到 import '@lib/lib-b';
时,webpack
会先去寻找 @lib/lib-b.js
文件,如果该文件不存在就去寻找 @lib/lib-b.json
文件, 如果还是找不到就报错。
如果你想优先使用其他后缀文件,比如 .ts
文件,可以这样配置
// webpack.config.js module.exports = { //... resolve: { alias: { '@lib': path.resolve(__dirname, 'lib'), // 为lib目录添加别名 extensions: ['.ts', '.js', '.json'] // 从左往右 } } }
这样就会先去找 .ts
了。不过一般我们会将高频的后缀放在前面,并且数组不要太长,减少尝试次数,不然会影响打包速度。
现在我们引入 js
文件时可以省略后缀名了。
modules
resolve.modules
配置 webpack
去哪些目录下寻找第三方模块,默认是只会去 node_modules
目录下寻找。如果项目中某个文件夹下的模块经常被导入,不希望写很长的路径,比如 import '../../../components/link'
,那么就可以通过配置 resolve.modules
来简化。
// webpack.config.js module.exports = { //... resolve: { modules: ['./src/components', 'node_modules'] // 从左到右查找 } }
这时,你就可以通过 import 'link'
引入了。
mainFields
有一些第三方模块会针对不同环境提供几份代码。例如分别提供采用 es5
和 es6
的 2
份代码,这 2
份代码的位置写在 package.json
文件里。
{ "jsnext:main": "es/index.js",// 采用 ES6 语法的代码入口文件 "main": "lib/index.js" // 采用 ES5 语法的代码入口文件 }
webpack
会根据 mainFields
的配置去决定优先采用那份代码, mainFields
默认配置如下:
mainFields: ['browser', 'main']
假如你想优先采用 ES6
的那份代码,可以这样配置:
mainFields: ['jsnext:main', 'browser', 'main']
enforceExtension
resolve.enforceExtension
如果配置为 true
,那么所有导入语句都必须要带文件后缀。
enforceModuleExtension
enforceModuleExtension
和 enforceExtension
作用类似,但 enforceModuleExtension
只对 node_modules
下的模块生效。 因为安装的第三方模块中大多数导入语句没带文件后缀,如果这时你配置了 enforceExtension
为 true
,那么就需要配置 enforceModuleExtension: false
来兼容第三方模块。
利用 webpack 解决跨域问题
本地开发时,前端项目的端口号是 9000
,但是服务端可能是 9001
,根据浏览器的同源策略,是不能直接请求到后端服务的。当然你可以在后端配置 CORS
相关的头部来实现跨域,其实也可以通过 webpack
的配置来解决跨域问题。
首先,我们起一个后端服务,安装 koa
、koa-router
npm i -D koa koa-router
新建 server/index.js
// server/index.js const Koa = require('koa'); const KoaRouter = require('koa-router'); const app = new Koa(); // 创建 router 实例对象 const router = new KoaRouter(); // 注册路由 router.get('/user', async (ctx, next) => { ctx.body = { code: 0, data: { name: '阿林十一' }, msg: 'success' }; }); app.use(router.routes()); // 添加路由中间件 app.use(router.allowedMethods()); // 对请求进行一些限制处理 app.listen(9001);
使用 node server/index.js
启动服务后,在 http://localhost:9001/user
可以访问结果。
之后再修改 handle.js
,在点击按钮之后会请求接口
import './handle.css'; export function handleClick () { console.log('handleClick'); fetch('/api/user') .then(r => r.json()) .then(data => console.log(data)) .catch(err => console.log(err)); }
这是会发现接口报 404
,下面我们配置一下 webpack.config.dev.js
// webpack.config.dev.js module.exports = { //... proxy: { '/api': { target: 'http://127.0.0.1:9001/', pathRewrite: { '^/api': '' } } } }
请求到 http://localhost:9000/api/user
现在会被代理到请求 http://localhost:9001/user
。点击按钮发起请求:
最后
现在,我们对 webpack
的配置有了更进一步的了解了,快动手试试吧。本文所有代码可以查看 github。
后续将会继续推出 webpack
系列的其他内容哦~
喜欢本文的话点个赞吧~
更多精彩内容,欢迎关注微信公众号~