共计 10872 个字符,预计需要花费 28 分钟才能阅读完成。
一、前言
现在随着前端开发的复杂度和规模越来越大,鹰不能抛开工程化来独立开发,比如: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/do…
2、https://webpack.js.org/api/co…
3、https://webpack.js.org/concep…
4、https://webpack.js.org/concep…
5、手把手教你撸一个简易的 webpack