因为文章篇幅较长,为了更好的阅读体验,本文分为上、中、下三篇:javascript
上篇介绍了什么是 webpack,为何须要 webpack,webpack 的文件输入和输出css
中篇介绍了 webpack 在输入和输出这段中间所作的事情,也就是 loader 和 pluginshtml
下篇介绍了 webpack 的优化,以及在开发环境和生产环境的不一样用法前端
用过上面两篇较长的篇幅,咱们终于了解了 webpack 的总体工做流程。在最后一篇,将会介绍一些零碎但也很重要的知识点。vue
tree-shaking 这个技术在 webpack2 就已经被添加进来了,做用是在打包过程当中,将模块内没有使用的代码删除,从而减少打包后的文件体积。java
这个单词表面的意思是,有一棵小树,你去抖动这棵树,那么树上多余没用的树叶就会掉落,那在代码中具体是什么样子呢。假设咱们如今将一些经常使用的方法都封装在了 util.js 这个文件中:node
// util.js function add(...args) { return args.reduce((prev, currrent, index) => { return prev + currrent; }, 0); } function multiply(...args) { return args.reduce((prev, currrent, index) => { return prev * currrent; }, 1); } export { add, multiply }
而后咱们在 index.js 中须要用到 add 这方法:jquery
// index.js import { add } from './util.js'; add(1, 2); add(1, 2, 3);
这样打包后的代码是不含有 multiply 这个函数的,这就是 tree-shaking 的做用。webpack
如今咱们再回顾一下 webpack 打包过程git
若是只有一个入口文件,最终也只会打包出一个文件(下文用 chunk
表示,每打包出的一个文件就叫一个 chunk)(排除动态加载的状况(import()
))。这里有一个很明显的缺陷,就是将全部的模块打包成一个文件,打包后的体积必定会很大。同时,若是咱们使用了 chunkhash 作文件缓存的话,每次项目修改的时候,不管修改哪一个文件,即便是修改了一个换行,chunkhash 的值都会发生改变,那么每次改动上线以后,用户都要从新加载这个巨大的文件,这样用户体验很是糟糕。若是你说我不作文件缓存,那么因为浏览器缓存的缘由,用户首次加载的文件会被缓存到本地,下次即便你更新了代码,用户执行的仍是首次加载的文件,这样老板会找你聊天的。
为了解决这个问题,咱们能够考虑设置多个入口文件,就像在介绍 entry 的例子代码中那样:
// webpack.config.js module.exports = { entry: { login: 'src/login.js', logout: 'src/logout.js', } }
经过这样的配置,咱们就能够将 login.js 和 logout.js 打包成两个文件,并且修改其中一个文件不会影响到另外一个的 chunkhash。看起来好像已经解决了上面的问题,可是咱们再结合实际的项目深刻的分析一下,咱们一般会在项目中引入一些类库,好比常见的 lodash ,假设 login.js 和 logout.js 中都用到了 lodash ,这就须要在这个两个文件中显式的 import _ from 'lodash';
这样一来,打包出来的两个文件都包含了 lodash ,这就属于重复引用了,另外若是咱们的项目是单页应用,理应只有一个入口,在须要的时候再去加载 login.js 或 logout.js 的代码。
因此咱们要解决咱们一开始的问题,应该从下面两个点出发:
针对第一点,咱们可使用 webpack 提供的 SplitChunksPlugin 插件,这个插件和上面介绍的 minimize 同样,须要在 optimization.splitChunks
中配置。在 production 模式下 webpack 会默认作一下代码分离的工做,可是没多大的卵用,因此仍是须要咱们本身动手配置。
第一步先未来自 node_modules 中的包分离出来,由于这些都是项目所依赖的第三方库,咱们是不会改动的(除非升级版本),这些能够作经过 chunkhash
作长期缓存,咱们把这写代码打包为 chunk-vendors
// webpack.config.js module.exports = { entry, output: { path: './dist', filename: '[name].[chunkhash:8].js' // 只取chunkhash的前8位 }, optimization: { splitChunks: { cacheGroups: { vendors: { name: `chunk-vendors`, test: /[\\/]node_modules[\\/]/, priority: -10, chunks: 'initial' } } } } }
在上面的配置中,咱们用 cacheGroups
将 node_modules 的代码所有分离出来。 cacheGroups 直译成中文就是缓存组,其实就是存放分离代码块规则的对象,第一个对象的 key 值是 vendors ,这个 key 值没什么用,主要仍是看对应的 val 。
test: /[\\/]node_modules[\\/]/
表示全部来自 node_modules 下面的代码,能够填写具体的路径若是打包出的 chunk-vendors 体积很大,并且包含一些常常升级的依赖,那么咱们能够继续作拆分
// webpack.config.js module.exports = { entry, output: { path: './dist', filename: '[name].[chunkhash:8].js' }, optimization: { splitChunks: { cacheGroups: { vendors: { name: `chunk-vendors`, test: /[\\/]node_modules[\\/]/, priority: -10, chunks: 'initial' }, vue: { name: 'chuank-vue', test: /[\\/]node_modules[\\/]vue[\\/]/, priority: 10, chunks: 'initial' } } } } }
这样我就将 vue 分离成单独一个 chunk 了,不只减少了 chunk-vendors 的体积 ,当咱们升级 vue 版本的时候,也不会影响 chunk-vendors 的 chunkhash 。注意:不要忘了设置 priority 。
除了将 node_modules 中的类库分离出来,咱们本身写的代码中也有些公共的部分,好比在讲 tree-shaking 提到了 util.js ,做为一个工具方法,跟定会在项目中好多处用到,那么咱们也能够将会这个公共代码分离出来:
// webpack.config.js module.exports = { entry, output: { path: './dist', filename: '[name].[chunkhash:8].js' }, optimization: { splitChunks: { cacheGroups: { common: { name: `chunk-common`, minChunks: 2, priority: -20, chunks: 'initial', reuseExistingChunk: true } } } } }
在上面的配置中,咱们把被依赖超过两次(minChunks: 2
)的 chunk 都分离到了 chunk-common.f4786e34.js 中。
在解决了对公共代码的分离,下一步即便处理动态加载的代码,之一部分相对简单一些,就像在介绍 babel 时提到的那样,经过 import()
来切分动态加载的代码。
webpack 在将咱们的代码打包后,也会生成一些在运行时所必须的代码,这些代码默认会打包进主文件中,咱们也能够将它分离出来单独打包成一个文件,这须要在 optimization.runtimeChunk
中单独配置:
// webpack.config.js module.exports = { entry, output, optimization: { runtimeChunk: { name: 'manifest' } } }
这样就能够将运行时的代码也分里出来,打包为 manifest.js。
其实代码拆分是须要反复尝试的,通常状况下咱们只会将 node_modules 里的包分离成一份(chunk-vendors.js
), 业务中公共的代码分离成一分(chunk-common.js
),剩下的都放在了主模块(main.js) 和动态加载的 chunk 中了。可是因为项目的不一样,这种方式未必是最好的,因此这须要咱们反复的去尝试一各类分离的方式,为了让咱们对打包后的代码有更为直观的认识,咱们能够借助 webpack-bundle-analyzer 来帮咱们很直观的看到打包后每个 chunk 的大小。
在上面的介绍中,都是面向打包的,也就是说咱们默认代码是无误能够直接打包上线运行,固然这是不可能滴,实际开中须要配合 Google 和 fuck 来 debug 代码,若是用上面的方法来 debug 我相信无论是谁,都会想砸电脑的,由于每次 debug 都要从新的打包,而后再想办法再本地启动一个web服务,用来托管咱们打包出的静态文件。那么 webpack 可不能够帮我作到这两点呢:
为了解决上面两点,webpack 提供了 webpack-dev-server 这个包,它能够轻松的帮助咱们实现上面两功能,这个包须要单独安装一下
npm i webpack-dev-server -D
而后在 npm script 中添加一行 :
// package.json { "scripts": { "dev": "webpack-dev-server --mode development", "build": "webpack --mode production" } }
这时候在命令行中执行 npm run dev
,便会在本地启动一个Web服务,当命令行中出现 Compiled successfully
便表示服务启动成功,而后打开浏览器,输入 localhost:8080
即可以直接访问项目了。当源代码发生变化时,便会自动从新编译,而后刷新浏览器。
webpack-dev-server 一样也提供了一些配置选项,能够在配置文件的 devServer
中进行配置:
// webpack.config.js module.exports = { entry, output, devServer: { port: 8080, // 设置端口为8080,默认就是8080 open: true, // 编译完成后自动打开浏览器 historyApiFallback: true, // 若是你的项目使用了 HTML5 history API ,开启此项能够将全部的跳转将指向index.html } }
这些配置也能够以参数的形式添加在加命令行后面,可是有的配置只能以参数的形式使用,好比咱们想查看编译的进度,就须要加上 --progress
:
// package.json { "scripts": { "dev": "webpack-dev-server --mode development --progress", "build": "webpack --mode production" } }
学会了如何使用,在简单的介绍一下 webpack-dev-server 的工做原理,webpack-dev-server 是一个基于 express 封装的 Web 服务,当咱们在执行 webpack-dev-server 时候,虽然能够看到打包以后的运行效果,可是实际上并无生成打包后的文件,这是由于 webpack-dev-server 将打包后的内容放在了内存中,当某一个源代码文件发生变动的时候,它也不会从新的再将全部的文件打包一遍,而是只更新了一部分文件,这样的好处是能够加快从新编译的速度,加大程度的减小了开发模式下的编译时间。
讲到这里,你可能也意识到了,若是是开发模式下,有许多事情都不须要作。好比不须要设置 output ,不须要对代码压缩,不须要分离 css 和 js 等等,若是省去这些工做,首次编译的速度又会有大幅度的提高,这是一个优化点,会在后面讲到。
HMR (hot module replace) 模块热替换,在不刷新页面的状况下更新代码。
在引入了 webpack-dev-server 以后,咱们能够作到监听源代码变化,而后刷新浏览器及时看到修改效果。可是在前端开发中,每一步操做每每都伴随着状态和 dom 的变化,好比咱们开发一个定外卖的网站,此时正在调试购物车功能,先加了一份煲仔饭,为了满减,再加一份荷包蛋,可是这时候后出现了bug,加了荷包蛋仍是没有满减,原来是计算满减的方法写错了,修复这个bug以后,咱们发现页面刷新了,回到最开始的样子,因而又要从选择店铺开始在走一遍流程。那可不能够在修复计算满减的方法以后,不要刷新页面也能看到正确的效果呢?这就是 HMR 实现的功能了。
开启 HMR 须要将 devServer.hot 设置为 true ,而后在 plugins 中添加 HotModuleReplacementPlugin 插件,该插件是 webpack 自带的一个插件:
// webpack.config.js module.exports = { entry, output, plugins: [ new webpack.HotModuleReplacementPlugin() ] devServer: { hot: true, /* 其余配置 */ /* ... */ } }
还有一种更简便的方法来开启 HRM ,那就是在命令行中添加参数 --hot
,而后在执行 npm run dev
的时候也会自动添加 HotModuleReplacementPlugin 插件。
如今咱们在 webpack 中开启了 HMR 功能,webpack 能够将老模块替换为编译后的新模块,可是从浏览器层面考虑,浏览拿到新模块以后,并不知道要作什么处理,就像咱们前面举的例子中提到,在修改计算满减方法以后,咱们但愿从新执行一遍这个方法,很明显这个需求不太现实,浏览名没那么聪明。全部这就须要咱们显式的用代码来告诉浏览器来作哪些事情。
咱们能够在项目代码中经过 module.hot
来判断是否启用了 HMR ,经过 module.hot.accept
来处理模块更新后的要作的事情,如今假设咱们的项目入口文件是 index.js ,还有一个 util.js 里面封装了 add 方法:
project ├── src │ ├── index.js │ ├── util.js │ └── index.html └── webpack.config.js
// util.js function add(...args) { return args.reduce((prev, currrent, index) => { return prev + currrent; }, 0); } export { add }
而后咱们在 index.js 中导入 add 方法,而且将计算结果显示在页面上:
// index.js import { add } from './util.js'; const h2 = document.createElement('h2'); h2.innerHTML = add('1', '2'); document.body.appendChild(h2);
将项目跑起来以后,发现 add 方法计算的结果错了,经排查发现原来 add 方法忽略了对 string 类型的转换,只要修改一下 util.js 中的 add 函数就行了:
// util.js function add(...args) { return args.reduce((prev, currrent, index) => { return prev + currrent * 1; }, 0); } export { add }
这时候能够发现,页面中虽然显示了正确的结果,可是页面刷新了,而咱们但愿的是在页面不刷新的状况下显示正确结果,这时候就要在 index.js 添加热更新后须要执行的代码了:
// index.js import { add } from './util.js'; const h2 = document.createElement('h2'); h2.innerHTML = add('1', '2'); document.body.appendChild(h2); if (module.hot) { module.hot.accept('./util.js', () => { h2.innerHTML = add('1', '2'); }); }
这样再去修改 add 方法的时候,h2 显示的内容会发生变化,可是页面却不会刷新,这才是咱们想要的热更新。
讲到这里你可能已经发现,实现一个完美的热更新,难点不是在 webpack 的配置,而是在咱们的项目代码中,咱们要针对全部须要热更新的模块加上热更新以后的回调( module.hot.accept
),不过社区中已经提供了一些 loader 使 HMR 与各类框架平滑地进行交互 https://webpack.js.org/guides/hot-module-replacement/#other-code-and-frameworks ,
若是须要样式热更新的话,咱们须要判断当前的环境变量是否为 development ,而后将 MiniCssExtractPlugin.loader 换成 style-loader ,由于 MiniCssExtractPlugin 还不支持 HMR :
// webpack.config.js const styleLoader = process.env.NODE_ENV === 'development' ? 'style-loader' : MiniCssExtractPlugin.loader; module.exports = { entry, output, module: { rules: [{ test: /\.css$/, use: [styleLoader, 'css-loader'] }] } }
在 plugins 这一章节中,提到了 copy-webpack-plugin 这个插件,它是用来将一些静态资源拷贝到打包后的目录,可是在开发环境下,咱们是经过 webpack-dev-server 建立一个 Web 服务,它的根目录默认是配置文件所在的目录,因此在开发模式下,若是须要请求一些静态资源,那么咱们就须要设置一下 contentBase
。
假设咱们的静态资源放在了项目根目录下的 static 文件夹下面,并且配置文件 webpack.config.js 也放在了项目根目录下,那么我么就能够将 devServer.contentBase
设置为 static
:
// webpack.config.js module.exports = { entry, output, devServer: { contentBase: 'static' } }
假设 static 下面有一个图片 logo.png ,咱们就能够经过 localhost:8080/logo.png 来访问这张图片了。
在介绍 output.publicPath 的时候提到,这个值并不会影响打包后输出的文件路径,他只是设置在线上运行的时候,所请求的资源路径,当咱们在 webpack-dev-server 这个 Web 服务下调试咱们的代码的时候,可能也会出现和类型的状况,这时候就须要设置一下 devServer.publicPath 了。它 output.publicPath 的区别的是 一个做用于线上环境,一个做用于咱们调试的开发环境。
在开发过程,常常须要调用后端提供的接口,通常状况会把接口部署在测试环境,好比 http://test.api.com 而后咱们在项目中经过 ajax 的方式去调用。因为同源策略,咱们在开发的时候经过 webpack-dev-server 启动的 Web 服务的域是 localhost:8080 ,很明显跨域了,接口没法调用了。这个时候有两种办法解决,一是在测试上环境上配置 cors ,将咱们的 localhost 加入容许跨域的名单;二是咱们在本地利用 node 去请求这个接口,而后再将请求内容发送给前端,在整个过程当中 node 扮演的角色就是一个可靠的跑腿子,你去把请求交给它,它把请求送给测试环境,测试环境把响应交给它,它再把响应送到你这边。
在 webpack-dev-server 集成了中间件 (http-proxy-middleware)[https://github.com/chimurai/http-proxy-middleware] 能够很轻松的完成接口转发,好比咱们想将全部的以 /api 开头的请求都转发到 http://test.api.com 只要在 devServer.proxy
像下面这样配置便可:
// webpack.config.js module.exports = { entry, output, devServer: { proxy: { '/api': 'http://test.api.com' } } }
devServer.proxy
暴露出的配置项和 (http-proxy-middleware)[https://github.com/chimurai/http-proxy-middleware] 的配置项彻底同样,具体能够点击连接查看。
后端已经写好的接口咱们咱们能够用转发的方式调用,而对于尚未写好的接口咱们能够经过 mock 的方式来调用,这样能够解决由于接口调用通而致使咱们开发不顺畅的问题。由于 webpacl-dev-server 是基于 express 封装了,而且将 express 的实例暴露在了 devServer.before
和 devServer.after
这两个配置项下面,因此咱们彻底能够将后端没有写好的接口在 devServer.before 经过 express 去 mock 。假设咱们如今须要调用 /api/user/creation
这个接口来建立用户,咱们能够这样 mock
// webpack.config.js module.exports = { entry, output, devServer: { befor(app) { app.post('/api/user/creation', (req, res) => { // some code res.json({success: true}); }); } } }
若是你须要 mock 的接口后端,那你彻底能够像写 express 那样去写接口,固然有些经常使用的中间件须要咱们本身去安装。
webpack 打包压缩以后的代码可读性几乎为零,同时也不方便调试,这时候能够经过设置 devtool
选项来帮助咱们在开发环境调试,具体效果是:在 chrome 中(其它高级浏览器一样支持)打开控制台,咱们能够在 Sources
中看到一个以 webpack://
开头的资源,里面的内容和咱们编写代码大体相同(这取决于 devtool
的值)。
因为 devtool 会影响打包的速度和打包后的代码质量,因此在生产环境的构建中,不建议开启此项(默认为none
),只要在开环境设置为 eval-source-map
便可。其它配置和打包速度能够参考 官网 。
当项目的目录结构愈来愈深,模块变得愈来愈多的时候,模块间的引用会变得很混乱,时常会看到下面这样的代码:
import ComponentA from '../../../../../components/a.component.js'; import ServiceA from '../../../../../service/a.service.js';
有没有想骂人的冲动?这时候可使用 webpack 的 alias
选项来解决这个问题,配置文件的内容以下:
// webpack.config.js module.exports = { entry, output, resolve: { alias: { '@': path.resolve(__dirname, "src"), 'components': path.resolve(__dirname, "src/components"), 'services': path.resolve(__dirname, "src/services"), } } }
上面的配置表示为 src
、src/components
、src/services
分别设置一个别名,咱们就能够在代码中用 @
表示相对路径 src
而没必要再使用 ../../
一层一层的向上查找了。假设咱们如今的项目结构是下面这样子:
project ├── src │ ├── components │ └── services └── webpack.config.js
这样咱们能够在任意文件夹下的代码内使用 @
来表示根目录 src/
,使用 components
来表示路径 src/components/
,因此上面例子中的代码能够在简化为:
import ComponentA from '@/components/a.component.js'; import ServiceA from 'services/a.service.js';
这样配置以后,webpack 在打包编译的时候能识别简化以后的路径,可是编辑器却未必能识别,这又给咱们开发带来了一些困扰,若是你是 vscode
用户的话,这个问题能够很好的解决。只要在项目的根目录添加一份配置文件 jsconfig.json
便可,配置文件的内容以下:
{ "compilerOptions": { "baseUrl": ".", // 根目录 "paths": { "@/*": [ "./src/*" ], "components/*": [ "./src/components/*" ], "services/*": [ "./src/services/*" ], } } }
这个配置文件和 webpack 是没有关系的,它是给 vscode
用的,想请能够查看这里:https://code.visualstudio.com/docs/languages/jsconfig
在原生的 JavaScript 中,使用 import
加载一个模块是能够不用写文件的扩展名的,nodejs 中的 require 也是同样,就像这样:import ModuleA from 'a'
,如今有了 loader 咱们也但愿 import
其它类型文件的时候也不写扩展名,好比
import styles from '@/styles/common'; import html from '@/tpl/login';
只需在 webpack 中配置 extensions 便可,具体代码以下:
// webpack.config.js module.exports = { entry, output, resolve: { extensions: ['.js', '.json', '.css', '.html'] } }
该选项的值是一个数组,默认值为 ['.js', '.json']
,当咱们手动配置以后,默认值会被覆盖,因此为了避免影响以前的写法,要在配置中将 .js
和 .json
也加上。
我的建议不要配置此项,尽可能把文件的扩展名写全,这样不只能够知道引入的文件是什么类型,并且在打包的时候速度也相对快一些。
开发一个 Web 项目确定会用到第三方的类库好比 jQuery
、lodash
等,有人会选从 npm 下载,有人会选择从 cdn 加载。这两种方式使用起来都很简单:
import
就好了:import _ from 'lodash'
script
引入以后(注意引用顺序),即可以在任何地方使用可是从 cdn 引入的资源在开发过程有一个很很差的地方:既然已是模块化开发了,忽然冒出一个全局变量会让人以为很莫名其妙,并且这个变量也不能类型提示。
那可不能够这样子呢:
import
的方式来引入资源(代码模块清晰)答案是能够的,配置一下 externals 就能够轻松实现,以 jQuery
为例,具体代码以下:
// webpack.config.js module.exports = { entry, output, externals: { jquery: 'jQuery' } }
在代码中就能够这样使用 jQuery
:
import $ from 'jquery'; $(() => { console.log('hello jQuery'); });
并且打包的时候会自动的把 jquery
排除掉。
从上面的配置中能够看出,externals
是一个对象,它的 key (jquery
) 对应的是代码中引入的包名,也就是 from
后面的字符串,它的 val (jQuery
) 就是暴露在全局的变量名,jQuery 暴露在全局的变量名为 jQuery
和 $
,因此这里换成 $
一样是能够的。
因此上面的代码能够理解为是下面这种写法:
const $ = window.jQuery; $(() => { console.log('hello jQuery'); });
若是想对这个选项有更多的理解,能够参考这里:https://github.com/zhengweikeng/blog/issues/10
上面的配置中,有的是适用于生产环境的,有的是适用于开发环境的,因此咱们要将配置文件作一下分离。在项目中建立 build
文件夹,用来存放咱们的构建脚本,在 build 中建立 webpack.common.js
咱们能够将一些通用的配置写在这里面,好比 entry、output、loader 等等。而后咱们在建立 webpack.prod.js
和 webpack.dev.js
两份配置文件,分别用来编写打包和开发是的脚本,已经在webpack.common.js
中写好的配置,就不须要在写了。而后咱们利用 webpack-merge 将通用的配置分别和 dev、prod 的配置合并:
// build/webpack.prod.js const merge = require('webpack-merge'); const webpackCommonConfig = require('./webpack.common'); module.exports = merge(webpackCommonConfig, { /** 针对打包到生产环境的配置 */ });
最后再利用 npm script
设置不一样的脚本
{ "scripts": { "dev": "webpack-dev-server --mode development --color --progress --config build/webpack.dev.js", "build": "node build/build.js" } }
这里我已经写好一份能够直接使用的配置,你们能够参考一下 webpack-workbench https://github.com/onlymisaky/webpack-workbench