webpack系列--浅析webpack的原理
一、前言
现在随着前端开发的复杂度和规模越来越大,鹰不能抛开工程化来独立开发,比如:react的jsx代码必须编译后才能在浏览器中使用,比如sass和less代码浏览器是不支持的。如果摒弃这些开发框架,开发效率会大幅下降。
在众多前端工程化工具中,webpack脱颖而出成为了当今最流行的前端构建工具。
二、webpack的原理
知其然知其所以然。
1、核心概念
(1)entry:一个可执行模块或者库的入口。
(2)chunk:多个文件组成一个代码块。可以将可执行的模块和他所依赖的模块组合成一个chunk,这是打包。
(3)loader:文件转换器。例如把es6转为es5,scss转为css等
(4)plugin:扩展webpack功能的插件。在webpack构建的生命周期节点上加入扩展hook,添加功能。
2、webpack构建流程(原理)
从启动构建到输出结果一系列过程:
(1)初始化参数:解析webpack配置参数,合并shell传入和webpack.config.js文件配置的参数,形成最后的配置结果。
(2)开始编译:上一步得到的参数初始化compiler对象,注册所有配置的插件,插件监听webpack构建生命周期的事件节点,做出相应的反应,执行对象的 run 方法开始执行编译。
(3)确定入口:从配置的entry入口,开始解析文件构建AST语法树,找出依赖,递归下去。
(4)编译模块:递归中根据文件类型和loader配置,调用所有配置的loader对文件进行转换,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。
(5)完成模块编译并输出:递归完事后,得到每个文件结果,包含每个模块以及他们之间的依赖关系,根据entry配置生成代码块chunk。
(6)输出完成:输出所有的chunk到文件系统。
注意:在构建生命周期中有一系列插件在做合适的时机做合适事情,比如UglifyPlugin会在loader转换递归完对结果使用UglifyJs压缩覆盖之前的结果。
三、业务场景和对应解决方案
1、单页应用
一个单页应用需要配置一个entry指明执行入口,web-webpack-plugin里的WebPlugin
可以自动的完成这些工作:webpack会为entry生成一个包含这个入口的所有依赖文件的chunk,但是还需要一个html来加载chunk生成的js,如果还提取出css需要HTML文件中引入提取的css。
一个简单的webpack配置文件栗子
const { WebPlugin } = require('web-webpack-plugin');
module.exports = {
entry: {
app: './src/doc/index.js',
home: './src/doc/home.js'
},
plugins: [
// 一个WebPlugin对应生成一个html文件
new WebPlugin({
//输出的html文件名称
filename: 'index.html',
//这个html依赖的`entry`
requires: ['app','home'],
}),
],
};
说明:require: ['app', 'home']指明这个html依赖哪些entry,entry生成的js和css会自动注入到html中。
还支持配置这些资源注入方式,支持如下属性:
(1)_dist只有在生产环境中才引入的资源;
(2)_dev只有在开发环境中才引入的资源;
(3)_inline把资源的内容潜入到html中;
(4)_ie只有IE浏览器才需要引入的资源。
这些属性可以通过在js里配置,看个简单例子:
new WebPlugin({
filename: 'index.html',
requires: {
app:{
_dist:true,
_inline:false,
}
},
}),
这些属性还可以在模板中设置,使用模板好处就是可以灵活的控制资源的注入点。
new WebPlugin({
filename: 'index.html',
template: './template.html',
}),
//template模板
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<link rel="stylesheet" href="app?_inline">
<script src="ie-polyfill?_ie"></script>
</head>
<body>
<div id="react-body"></div>
<script src="app"></script>
</body>
</html>
WebPlugin
插件借鉴了fis3
的思想,补足了webpack缺失的以HTML为入口的功能。想了解WebPlugin
的更多功能,见文档。
2、一个项目管理多个单页面
一个项目中会包含多个单页应用,虽然多个单页面应用可以合成一个,但是这样做会导致用户没有访问的部分也加载了,如果项目中有很多的单页应用。为每一个单页应用配置一个entry和WebPlugin?如果又新增,又要新增webpack配置,这样做麻烦,这时候有一个插件web-webpack-plugin里的AutoWebPlugin方法可以解决这些问题。
module.exports = {
plugins: [
// 所有页面的入口目录
new AutoWebPlugin('./src/'),
]
};
分析:1、AutoWebPlugin
会把./src/
目录下所有每个文件夹作为一个单页页面的入口,自动为所有的页面入口配置一个WebPlugin输出对应的html。
2、要新增一个页面就在./src/
下新建一个文件夹包含这个单页应用所依赖的代码,AutoWebPlugin
自动生成一个名叫文件夹名称的html文件。
3、代码分隔优化
一个好的代码分割对浏览器首屏效果提升很大。
最常见的react体系:
(1)先抽出基础库react
react-dom
redux
react-redux
到一个单独的文件而不是和其它文件放在一起打包为一个文件,这样做的好处是只要你不升级他们的版本这个文件永远不会被刷新。如果你把这些基础库和业务代码打包在一个文件里每次改动业务代码都会导致文件hash值变化从而导致缓存失效浏览器重复下载这些包含基础库的代码。所以把基础库打包成一个文件。
// vender.js 文件抽离基础库到单独的一个文件里防止跟随业务代码被刷新
// 所有页面都依赖的第三方库
// react基础
import 'react';
import 'react-dom';
import 'react-redux';
// redux基础
import 'redux';
import 'redux-thunk';
// webpack配置
{
entry: {
vendor: './path/to/vendor.js',
},
}
(2)通过CommonsChunkPlugin可以提取出多个代码块都依赖的代码形成一个单独的chunk
。在应用有多个页面的场景下提取出所有页面公共的代码减少单个页面的代码,在不同页面之间切换时所有页面公共的代码之前被加载过而不必重新加载。所以通过CommonsChunkPlugin可以提取出多个代码块都依赖的代码形成一个单独的chunk。
4、构建服务端渲染
服务端渲染的代码要运行在nodejs环境,和浏览器不同的是,服务端渲染代码需要采用commonjs规范同时不应该包含除js之外的文件比如css。
webpack配置如下:
module.exports = {
target: 'node',
entry: {
'server_render': './src/server_render',
},
output: {
filename: './dist/server/[name].js',
libraryTarget: 'commonjs2',
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
},
{
test: /\.(scss|css|pdf)$/,
loader: 'ignore-loader',
},
]
},
};
分析一下:
(1)target: 'node'指明构建出代码要运行在node环境中。
(2)libraryTarget: 'commonjs2' 指明输出的代码要是commonjs规范。
(3){test: /\.(scss|css|pdf)$/,loader: 'ignore-loader'} 是为了防止不能在node里执行服务端渲染也用不上的文件被打包进去。
5、fis3迁移到webpack
fis3和webpack有很多相似地方也有不同的地方,相似地方:都采用commonjs规范,不同地方:导入css这些非js资源的方式。
fis3通过@require './index.scss',而webpack是通过require('./index.scss')。
如果想把fis3平滑迁移到webpack,可以使用comment-require-loader。
比如:你想在webpack构建是使用采用了fis3方式的imui
模块
loaders:[{
test: /\.js$/,
loaders: ['comment-require-loader'],
include: [path.resolve(__dirname, 'node_modules/imui'),]
}]
四、自定义webpack扩展
如果你在社区找不到你的应用场景的解决方案,那就需要自己动手了写loader或者plugin了。
在你编写自定义webpack扩展前你需要想明白到底是要做一个loader
还是plugin
呢?可以这样判断:
如果你的扩展是想对一个个单独的文件进行转换那么就编写loader剩下的都是plugin。
其中对文件进行转换可以是像:
1、babel-loader把es6转为es5;
2、file-loader把文件替换成对应的url;
3、raw-loader注入文本文件内容到代码中。
1、编写webpack loader
编写loader
非常简单,以comment-require-loader为例:
module.exports = function (content) {
return replace(content);
};
loader
的入口需要导出一个函数,这个函数要干的事情就是转换一个文件的内容。
函数接收的参数content
是一个文件在转换前的字符串形式内容,需要返回一个新的字符串形式内容作为转换后的结果,所有通过模块化倒入的文件都会经过loader
。从这里可以看出loader
只能处理一个个单独的文件而不能处理代码块。可以参考官方文档
2、编写webpack plugin
plugin
应用场景广泛,所以稍微复杂点。以end-webpack-plugin为例:
class EndWebpackPlugin {
constructor(doneCallback, failCallback) {
this.doneCallback = doneCallback;
this.failCallback = failCallback;
}
apply(compiler) {
// 监听webpack生命周期里的事件,做相应的处理
compiler.plugin('done', (stats) => {
this.doneCallback(stats);
});
compiler.plugin('failed', (err) => {
this.failCallback(err);
});
}
}
module.exports = EndWebpackPlugin;
loader的入口需要导出一个class,在new EndWebpackPlugin()的时候通过构造函数传入这个插件需要的参数,在webpack启动的时候会先实例化plugin,再调用plugin的apply方法,插件在apply函数里监听webpack生命周期里的事件,做相应的处理。
webpack plugin的两个核心概念:
(1)compiler:从webpack启动到退出只存在一个Compiler,compiler存放着webpack的配置。
(2)compilation:由于webpack的监听文件变化自动编译机制,compilation代表一次编译。
Compiler
和 Compilation
都会广播一系列事件。webpack生命周期里有非常多的事件
以上只是一个最简单的demo,更复杂的可以查看 how to write a plugin或参考web-webpack-plugin。
五、总结
webpack其实比较简单,用一句话概括本质:
webpack是一个打包模块化js的工具,可以通过loader转换文件,通过plugin扩展功能。
如果webpack让你感到复杂,一定是各种loader和plugin的原因。
六、一些问题
1、webpack与grunt、gulp的不同?
三者都是前端构建工具,grunt和gulp在早期比较流行,现在webpack相对来说比较主流,不过一些轻量化的任务还是会用gulp来处理,比如单独打包CSS文件等。
grunt和gulp是基于任务和流(Task、Stream)的。类似jQuery,找到一个(或一类)文件,对其做一系列链式操作,更新流上的数据, 整条链式操作构成了一个任务,多个任务就构成了整个web的构建流程。
webpack是基于入口的。webpack会自动地递归解析入口所需要加载的所有资源文件,然后用不同的Loader来处理不同的文件,用Plugin来扩展webpack功能。
总结:(1)从构建思路来说:gulp和grunt需要开发者将整个前端构建过程拆分成多个`Task`,并合理控制所有`Task`的调用关系 webpack需要开发者找到入口,并需要清楚对于不同的资源应该使用什么Loader做何种解析和加工;
(2)对于知识背景:gulp更像后端开发者的思路,需要对于整个流程了如指掌 webpack更倾向于前端开发者的思路。
2、 与webpack类似的工具还有哪些?谈谈你为什么最终选择(或放弃)使用webpack?
同样是基于入口的打包工具还有以下几个主流的:webpack,rollup,parcel。
从应用场景上来看:(1)webpack适合大型复杂的前端站点构建;(2)rollup适合基础库的打包,比如vue,react;(3)parcel适用于简单的实验室项目,但是打包出错很难调试。
3、有哪些常见的Loader?他们是解决什么问题的?
(1)babel-loader:把es6转成es5;
(2)css-loader:加载css,支持模块化,压缩,文件导入等特性;
(3)style-loader:把css代码注入到js中,通过dom操作去加载css;
(4)eslint-loader:通过Eslint检查js代码;
(5)image-loader:加载并且压缩图片晚间;
(6)file-loader:文件输出到一个文件夹中,在代码中通过相对url去引用输出的文件;
(7)url-loader:和file-loader类似,文件很小的时候可以base64方式吧文件内容注入到代码中。
(8)source-map-loader:加载额外的source map文件,方便调试。
4、有哪些常见的Plugin?他们是解决什么问题的?
(1)uglifyjs-webpack-plugin:通过UglifyJS去压缩js代码;
(2)commons-chunk-plugin:提取公共代码;
(3)define-plugin:定义环境变量。
5、loader和plugin的不同
作用不同:(1)loader让webpack有加载和解析非js的能力;(2)plugin可以扩展webpack功能,在webpack运行周期中会广播很多事件,Plugin可以监听一些事件,通过webpack的api改变结果。
用法不同:(1)loader在module.rule中配置。类型为数组,每一项都是Object;(2)plugin是单独配置的,类型为数组,每一项都是plugin实例,参数通过构造函数传入。
6、webpack的构建流程是什么?从读取配置到输出文件这个过程尽量说全
Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:
(1)初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
(2)开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
(3)确定入口:根据配置中的 entry 找出所有的入口文件;
(4)编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
(5)完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
(6)输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
(7)输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。
在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。
7、是否写过Loader和Plugin?描述一下编写loader或plugin的思路?
编写Loader时要遵循单一原则,每个Loader只做一种"转义"工作。 每个Loader的拿到的是源文件内容(source),可以通过返回值的方式将处理后的内容输出,也可以调用this.callback()方法,将内容返回给webpack。 还可以通过 this.async()生成一个callback函数,再用这个callback将处理后的内容输出出去。
Plugin的编写就灵活了许多。 webpack在运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。
8、webpack的热更新是如何做到的?说明其原理?
webpack的热更新又称热替换(Hot Module Replacement),缩写为HMR。 这个机制可以做到不用刷新浏览器而将新变更的模块替换掉旧的模块。
原理:
分析:
(1)第一步,在 webpack 的 watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,并将打包后的代码通过简单的 JavaScript 对象保存在内存中。
(2)第二步是 webpack-dev-server 和 webpack 之间的接口交互,而在这一步,主要是 dev-server 的中间件 webpack-dev-middleware 和 webpack 之间的交互,webpack-dev-middleware 调用 webpack 暴露的 API对代码变化进行监控,并且告诉 webpack,将代码打包到内存中。
(3)第三步是 webpack-dev-server 对文件变化的一个监控,这一步不同于第一步,并不是监控代码变化重新打包。当我们在配置文件中配置了devServer.watchContentBase 为 true 的时候,Server 会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。注意,这儿是浏览器刷新,和 HMR 是两个概念。
(4)第四步也是 webpack-dev-server 代码的工作,该步骤主要是通过 sockjs(webpack-dev-server 的依赖)在浏览器端和服务端之间建立一个 websocket 长连接,将 webpack 编译打包的各个阶段的状态信息告知浏览器端,同时也包括第三步中 Server 监听静态文件变化的信息。浏览器端根据这些 socket 消息进行不同的操作。当然服务端传递的最主要信息还是新模块的 hash 值,后面的步骤根据这一 hash 值来进行模块热替换。
(5)webpack-dev-server/client 端并不能够请求更新的代码,也不会执行热更模块操作,而把这些工作又交回给了 webpack,webpack/hot/dev-server 的工作就是根据 webpack-dev-server/client 传给它的信息以及 dev-server 的配置决定是刷新浏览器呢还是进行模块热更新。当然如果仅仅是刷新浏览器,也就没有后面那些步骤了。
(6)HotModuleReplacement.runtime 是客户端 HMR 的中枢,它接收到上一步传递给他的新模块的 hash 值,它通过 JsonpMainTemplate.runtime 向 server 端发送 Ajax 请求,服务端返回一个 json,该 json 包含了所有要更新的模块的 hash 值,获取到更新列表后,该模块再次通过 jsonp 请求,获取到最新的模块代码。这就是上图中 7、8、9 步骤。
(7)而第 10 步是决定 HMR 成功与否的关键步骤,在该步骤中,HotModulePlugin 将会对新旧模块进行对比,决定是否更新模块,在决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用。
(8)最后一步,当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。
9、如何利用webpack来优化前端性能?(提高性能和体验)
用webpack优化前端性能是指优化webpack的输出结果,让打包的最终结果在浏览器运行快速高效。
(1)压缩代码。删除多余的代码、注释、简化代码的写法等等方式。可以利用webpack的UglifyJsPlugin和ParallelUglifyPlugin来压缩JS文件, 利用cssnano(css-loader?minimize)来压缩css。使用webpack4,打包项目使用production模式,会自动开启代码压缩。
(2)利用CDN加速。在构建过程中,将引用的静态资源路径修改为CDN上对应的路径。可以利用webpack对于output参数和各loader的publicPath参数来修改资源路径
(3)删除死代码(Tree Shaking)。将代码中永远不会走到的片段删除掉。可以通过在启动webpack时追加参数--optimize-minimize来实现或者使用es6模块开启删除死代码。
(4)优化图片,对于小图可以使用 base64 的方式写入文件中
(5)按照路由拆分代码,实现按需加载,提取公共代码。
(6)给打包出来的文件名添加哈希,实现浏览器缓存文件
10、如何提高webpack的构建速度?
(1)多入口的情况下,使用commonsChunkPlugin来提取公共代码;
(2)通过externals配置来提取常用库;
(3)使用happypack实现多线程加速编译;
(4)使用webpack-uglify-parallel来提升uglifyPlugin的压缩速度。原理上webpack-uglify-parallel采用多核并行压缩来提升压缩速度;
(5)使用tree-shaking和scope hoisting来剔除多余代码。
11、怎么配置单页应用?怎么配置多页应用?
单页应用可以理解为webpack的标准模式,直接在entry中指定单页应用的入口即可。
多页应用的话,可以使用webpack的 AutoWebPlugin来完成简单自动化的构建,但是前提是项目的目录结构必须遵守他预设的规范。
12、npm打包时需要注意哪些?如何利用webpack来更好的构建?
NPM模块需要注意以下问题:
(1)要支持CommonJS模块化规范,所以要求打包后的最后结果也遵守该规则
(2)Npm模块使用者的环境是不确定的,很有可能并不支持ES6,所以打包的最后结果应该是采用ES5编写的。并且如果ES5是经过转换的,请最好连同SourceMap一同上传。
(3)Npm包大小应该是尽量小(有些仓库会限制包大小)
(4)发布的模块不能将依赖的模块也一同打包,应该让用户选择性的去自行安装。这样可以避免模块应用者再次打包时出现底层模块被重复打包的情况。
(5)UI组件类的模块应该将依赖的其它资源文件,例如.css文件也需要包含在发布的模块里。
基于以上需要注意的问题,我们可以对于webpack配置做以下扩展和优化:
(1)CommonJS模块化规范的解决方案: 设置output.libraryTarget='commonjs2'使输出的代码符合CommonJS2 模块化规范,以供给其它模块导入使用;
(2)输出ES5代码的解决方案:使用babel-loader把 ES6 代码转换成 ES5 的代码。再通过开启devtool: 'source-map'输出SourceMap以发布调试。
(3)Npm包大小尽量小的解决方案:Babel 在把 ES6 代码转换成 ES5 代码时会注入一些辅助函数,最终导致每个输出的文件中都包含这段辅助函数的代码,造成了代码的冗余。解决方法是修改.babelrc文件,为其加入transform-runtime插件
(4)不能将依赖模块打包到NPM模块中的解决方案:使用externals配置项来告诉webpack哪些模块不需要打包。
(5)对于依赖的资源文件打包的解决方案:通过css-loader和extract-text-webpack-plugin来实现,配置如下:
13、如何在vue项目中实现按需加载?
经常会引入现成的UI组件库如ElementUI、iView等,但是他们的体积和他们所提供的功能一样,是很庞大的。
不过很多组件库已经提供了现成的解决方案,如Element出品的babel-plugin-component和AntDesign出品的babel-plugin-import 安装以上插件后,在.babelrc配置中或babel-loader的参数中进行设置,即可实现组件按需加载了。
单页应用的按需加载 现在很多前端项目都是通过单页应用的方式开发的,但是随着业务的不断扩展,会面临一个严峻的问题——首次加载的代码量会越来越多,影响用户的体验。
七、参考
1、https://github.com/webpack/docs/wiki/how-to-write-a-plugin
2、https://webpack.js.org/api/compiler-hooks/
3、https://webpack.js.org/concepts/loaders
4、https://webpack.js.org/concepts/plugins
【谢谢关注和阅读,后续新的文章首发:sau交流学习社区:https://www.mwcxs.top/】