学习资料:拉勾课程《大前端高薪训练营》
阅读建议:搭配文章的侧边栏目录进行食用,体验会更佳哦
内容说明:本文不做知识点的搬运工,技术详情请查看官方文档
一:认识webpack打包
以下文字和截图出自:webpack中文文档
本质上,webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。
图中示例很形象,前端的各种资源在经过webpack打包之后,变成了一些打包构建好的bundle。其实webpack也属于前端工程化的构建工具,与gulp等构建工具相比,它将前端的模块化打包也集成进了前端的工程化处理之中,表现形式就是webpack的构建任务流中包含模块打包相关的任务。
对于前端工程化的自动化构建以及gulp等构建工具相关内容的介绍,可以参考我的另一篇博客前端工程化之自动化构建。接下来的行文中对webpack构建工具的探讨,我们主要关注它作为构建工具的特色功能:模块打包。
1.webpack模块打包
在叙述webpack模块打包之前,我觉得有必要先探讨一波webpack的模块概念。对于webpack来说,它的模块概念并不仅仅只包含JavaScript模块,如上图所示,webpack把在打包过程中的所有被引入的资源文件都视为一个模块来进行处理。
理解了webpack的模块之后,就可以讨论webpack的模块打包功能了。这里始终要记住一点,对于模块打包这个动作而言,打包后的代码必须是模块依赖正常并能够正常运行,否则模块打包就没有意义。所以个人觉得,模块打包功能的一个最关键的问题就是打包前模块到打包后bundle之间的模块聚合问题。而这里面又包含了:
- 如何将不同类型模块聚合在一起
- 如何保证模块依赖关系不变
- 如何避免模块聚合后的命名空间合并问题
在模块打包的这些问题,另一个主流的用于JavaScript模块打包的工具rollup也有它自己的解决方案(个人相关博客:JavaScript模块打包器:rollup)。
接下来我们就从一个模块打包示例为切入点,而后分析webpack模块打包后结果的运行原理,进而探讨一波webpack是如何解决上述模块打包问题从而保证打包后的结果模块依赖正常并能够正常运行的。
注:此篇文章仅关注webpack是如何解决前端资源模块的打包问题的,而不会研究webpack源码并分析具体实现方式。
2.webpack模块打包示例
(1): 需要打包的模块文件
- 入口模块(main.js)
import createHeading from './heading.js'
const heading = createHeading()
document.body.append(heading)
- 被引入的模块(heading.js)
export default () => {
const element = document.createElement('h2')
element.textContent = 'Hello world'
element.addEventListener('click', () => {
alert('Hello webpack')
})
return element
}
(2): webpack打包
- 安装webpack
// 示例中用到的版本为4.40.2
yarn add webpack --dev
- 编写配置文件
const path = require('path')
module.exports = {
// mode属性有三种取值,分别是 production、development 和 none。
// 1. 生产模式下,Webpack 会自动优化打包结果;
// 2. 开发模式下,Webpack 会自动优化打包速度,添加一些调试过程中的辅助;
// 3. None 模式下,Webpack 就是运行最原始的打包,不做任何额外处理;
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
}
}
- 执行命令打包
yarn webpack
- 输出打包结果
打包后,可以在dist文件夹下看到bundle.js。对于具体的文件内容,会在接下来的模块打包结果运行原理中贴出并分析。
3.模块打包结果运行原理
(1): 立即执行函数
对于上述包含两个模块的打包结果,折叠后的bundle.js代码如下:
如此可见打包后的bundle.js就是一个立即函数(webpack boostrap)调用。这个立即执行函数参数为一个模块方法数组,在调用时把所有的模块传递给它。
(2): 立即执行函数的函数体
上述立即执行函数的函数体内容如下:
/******/ function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {// ... require函数体}
/******/ // ...其它的一些工具变量
/******/ return __webpack_require__(__webpack_require__.s = 0);
/******/ })
从这个webpack bootstrap立即执行函数的函数体我们可以看到,它内部:
- 定义了一个用于加载模块的require方法
- 定义了其它的一些工具变量
- 调用require方法加载入口模块并返回
而对于 __webpack_require__函数,其内部实现如下:
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
从这个模块导入函数__webpack_require__的函数定义中我们可以得到它加载模块的逻辑:
- 判断installModules缓存对象得知该模块是否已经加载过
- 如果加载过就直接从installModules导出该模块的数据对象
- 如果没有加载过就从模块数组modules(闭包)中取moduleId获得对应的模块函数执行,并放入installModules缓存,在这个过程中传入三个函数参数以备导入模块内部使用:即放入缓存中的当前模块信息对象module、module的导出数据属性exports以及用于模块导入函数__webpack_require__以便它内部再导入其它模块
- 把该模块的导出数据返回
总的来说,立即执行函数体定义了一些工具变量并完成了入口模块的加载。
(3): 入口模块
入口模块原来的模样:
import createHeading from './heading.js'
const heading = createHeading()
document.body.append(heading)
以none模式打包后,入口模块被打包成如下模样:
function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony import */ var _heading_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);
const heading = Object(_heading_js__WEBPACK_IMPORTED_MODULE_0__["default"])()
document.body.append(heading)
/***/ }
如上打包结果我们可以得知,对于import语句加载的模块,webpack会通过__webpack_require__函数来从模块数组中加载模块,并从该模块中拿到当前模块所需要的的变量标识符数据。
(4): 被导入的模块
被导入的模块原来的模样:
export default () => {
const element = document.createElement('h2')
element.textContent = 'Hello world'
element.addEventListener('click', () => {
alert('Hello webpack')
})
return element
}
以none模式打包后,被导入的模块被打包成如下模样:
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony default export */ __webpack_exports__["default"] = (() => {
const element = document.createElement('h2')
element.textContent = 'Hello world'
element.addEventListener('click', () => {
alert('Hello webpack')
})
return element
}
如上打包结果我们可以得知,对于模块的export导出数据,webpack会把导出数据挂载在该模块信息的exports属性的属性上(该模块被导入时exports属性会作为返回值以便读取)。
(5):总结
在经过上述分析之后,我们总结一下整个模块打包的过程中:
- webpack把每个模块文件会转换为一个模块函数
- webpack提供了一个立即执行函数webpack bootstrap,以所有模块的数组作为参数
- webpack bootstrap定义了一个一些工具变量,尤其是用于导入模块的__webpack_require__函数
- webpack bootstrap使用__webpack_require__函数执行入口模块并返回入口模块导出的数据
- 入口模块使用__webpack_require__导入其依赖的模块
- 入口函数依赖的模块使用__webpack_require__导入其依赖的模块,以此反复,直到加载运行所有的模块
总结之后,我们回到原来的问题,webpack是如何保证打包后的结果模块依赖正常并能够正常运行的,也就是如下这些问题:
- 如何将不同类型模块聚合在一起
- 如何保证模块依赖关系不变
- 如何避免模块聚合后的命名空间合并问题
- …
对于不同类型模块此处案例暂没有体现(除js、json外需额外配置loader),而对于webpack能保证模块依赖顺序不变的原因则是因为webpack会在解析的模块import其它模块时才去加载其依赖的模块。
而对于模块聚合后的命名空间合并问题则是因为webpack对每个模块的命名空间都用一个模块函数的命名空间一对一替代,所以在模块聚合后并不会冲突。
二:理解webpack核心概念和执行流程
本节内容从以下四个部分来探讨:
- webpack核心概念
- webpack执行流程
- 理解webpack的loader
- 理解webpack的plugin
以下内容中核心概念和执行流程部分均出自书籍《深入浅出webpack》
1.核心概念
- Entry:入口,Webpack执行构建的第一步将从Entry开始,可抽象成输入。
- Module:模块,在Webpack里一切皆模块,一个模块对应一个文件。Webpack会从配置的Entry开始递归找出所有依赖的模块。
- Chunk:代码块,一个Chunk由多个模块组合而成,用于代码合并与分割。
- Loader:模块转换器,用于将模块的原内容按照需求转换成新内容。
- Plugin:扩展插件,在Webpack构建流程中的特定时机注入扩展逻辑,来改变构建结果或做我们想要的事情。
- Output:输出结果,在Webpack经过一系列处理并得出最终想要的代码后输出结果。
除了loader和plugin需要额外说明之外,大部分的概念都比较通俗易懂。而对于loader和plugin,后面会单独拎出来探讨。
2.执行流程
- Webpack在启动后会从Entry里配置的Module开始,递归解析Entry依赖的所有Module。
- 每找到一个Module,就会根据配置的Loader去找出相应的转换规则,对Module进行转换后,再解析出当前Module依赖的Module。
- 这些模块会以Entry单位进行分组,一个Entry及其所有依赖的Module被分到一个组也就是一个Chunk。
- 在整个流程中,Webpack会在恰当的时机执行Plugin里定义的逻辑。
3.理解loader
官方loader文档:https://webpack.js.org/concepts/loaders/
模块转换器loader是webpack实现前端模块化的核心,用于将模块的原内容按照需求转换成新内容,借助于loader就可以加载任何类型的资源。
接下来会从以下四个方面循序渐进的理解loader概念:
- (1): loader配置、使用示例
- (2): 自定义loader
- (3): 打包过程中何时被认为需要loader
- (4): 常用loader及其分类
(1): loader配置、使用示例
需求:使webpack具备加载样式模块的能力
- 样式资源:main.css
body {
margin: 0 auto;
padding: 0 20px;
max-width: 800px;
background: #f4f8fb;
}
- 配置样式相关loader
const path = require('path')
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist')
},
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader'
]
}
]
}
}
- 引入css模块:main.js
import createHeading from './heading.js'
import './main.css'
const heading = createHeading()
document.body.append(heading)
- css模块打包后的模块函数
function(module, exports, __webpack_require__) {
// Imports
var ___CSS_LOADER_API_IMPORT___ = __webpack_require__(5);
exports = ___CSS_LOADER_API_IMPORT___(false);
// Module
exports.push([module.i, "body {\n margin: 0 auto;\n padding: 0 20px;\n max-width: 800px;\n background: #f4f8fb;\n}\n", ""]);
// Exports
module.exports = exports;
/***/ }
整个样式模块的加载过程中,使用了两个loader:
- css-loader把css模块文件转换为了一个js模块函数。在模块函数中,会把模块文件内容放入一个数组中。
- style-loader也会有对应的一个模块(太长未贴出),负责把存放css模块文件的数组中内容渲染成style标签(这样一来,spa页内的元素内必然能够访问到样式)。
至此就完成了样式模块打包流程的探讨,对于其它模块类型的loader也是类似,本文不再过多举例。在使用webpack的实际过程中,需要加载什么类型的模块就去搜索、安装、配置什么样的loader即可。而对于定制化的loader需求,我们则可以通过自定义loader实现,接下来探讨一波自定义loader。
(2): 自定义loader
loader负责将模块的原内容按照需求转换成新内容。从这一句话中,我们也就可以得知自定义loader的逻辑了,用函数式编程的思想来说就是:
- 这个loader接收需要转换的文件内容
- loader内部对输入的文件内容做一定的逻辑处理
- 这个loader需要输出转换的结果
除此之外,为了webpack能够使用上这个loader打包后的结果,webpack要求在使用loader对模块文件的转换过程中,不管其中经历了多少个loader,转换过程如何,最终只需要也要求返回一段语法正确的JavaScript代码。
搞清楚上面的逻辑之后,接下来我们就实现一个自定义loader示例,需求是:定义一个markdown-loader,让webpack能够支持打包markdown文件。
定义markdown-loader
const marked = require('marked')
module.exports = source => {
const html = marked(source) // md文件内容转html
// 方式一:返回一个用JavaScript语法包裹的html字符串
// return `export default ${JSON.stringify(html)}`
// 方式二:直接返回html字符串,而后使用html-loader把html字符串转为JavaScript语法的字符串
return html
}
使用示例
- 需要转换的markdown模块文件
# 关于我
我是清心,一个沙雕博主,欢迎关注~
- 配置markdown-loader
const path = require('path')
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
publicPath: 'dist/'
},
module: {
rules: [
{
test: /.md$/,
use: [
'html-loader',
'./markdown-loader'
]
}
]
}
}
- 模块打包结果后对应的打包函数
function(module, exports) {
module.exports = "<h1 id=\"关于我\">关于我</h1>\n<p>我是清心,一个沙雕博主,欢迎关注~</p>\n";
/***/ }
至此,一个简单的markdown-loader的定义和测试使用都已经叙述完毕。由此我们可以感觉到,自定义一个webpack的loader还是很简单的,也说明webpack本身真的是有很好的灵活性和扩展性。
(3): 打包过程中何时被认为需要loader
上面探讨过了如何配置使用loader以及如何自定义loader来实现对模块文件进行加载,但是我们还没有探讨一个最关键的问题,就是webpack在打包过程中,什么情况下会认为需要对一个模块文件进行加载,毕竟如果webpack不知道要加载模块文件的话,用什么类型loader来加载当前模块就无从谈起了。
对于上述问题,webpack在打包过程中,它会进行语法分析,在解析到到以下几种情况时就会被判定为需要加载模块:
- 遵循ES Modules标准的import声明
- 遵循CommonJS标准的require函数
- 遵循AMD标准的define函数和require函数
- 样式代码中的@import指令和Url函数
- HTML代码中图片标签的src属性
在具体的探讨loader之前,有必要先理解一波何时会触发loader
(4): 常用loader及其分类
参考官方文档:https://webpack.js.org/loaders/
参考博客:https://www.cnblogs.com/hughes5135/p/6891784.htmll
模板类型:
loader名称 | 作用 |
---|---|
html-loader | 将HTML文件导出编译为字符串,可供js识别的其中一个模块 |
pug-loader | 加载pug模板 |
jade-loader | 加载jade模板(是pug的前身,由于商标问题改名为pug) |
ejs-loader | 加载ejs模板 |
handlebars-loader | 将Handlebars模板转移为HTML |
样式类型
loader名称 | 作用 |
---|---|
css-loader | 解析css文件中代码 |
style-loader | 将css模块作为样式导出到DOM中 |
less-loader | 加载和转义less文件 |
sass-loader | 加载和转义sass/scss文件 |
postcss-loader | 使用postcss加载和转义css/sss文件 |
脚本转换编译类型
loader名称 | 作用 |
---|---|
script-loader | 在全局上下文中执行一次javascript文件,不需要解析 |
babel-loader | 加载ES6+ 代码后使用Babel转义为ES5后浏览器才能解析 |
typescript-loader | 加载Typescript脚本文件 |
coffee-loader | 加载Coffeescript脚本文件 |
JSON加载类型
loader名称 | 作用 |
---|---|
json-loader | 加载json文件(默认包含) |
json5-loader | 加载和转义JSON5文件 |
Files文件类型
loader名称 | 作用 |
---|---|
raw-loader | 加载文件原始内容(utf-8格式) |
url-loader | 多数用于加载图片资源,超过文件大小显示则返回data URL |
file-loader | 将文件发送到输出的文件夹并返回URL(相对路径) |
eslint-loader | 检查代码格式错误 |
加载框架:
loader名称 | 作用 |
---|---|
vue-loader | 加载和转义vue组件 |
angualr2-template–loader | 加载和转义angular组件 |
react-hot-loader | 动态刷新和转义react组件中修改的部分,基于webpack-dev-server插件需先安装,然后在webpack.config.js中引用react-hot-loader |
4.理解Plugin
webpack是一个构建工具,而不仅仅是一个模块打包工具。与gulp等构建不同的是,webpack把构建任务分成了两个概念,一个为负责实现资源模块加载的loader,另一个则为用于增强webpack自动化能力的plugin。
接下来会从以下四个方面循序渐进的理解webpack的plugin概念:
- (1): Plugin配置、使用示例
- (2): 自定义Plugin
(1): Plugin配置示例
需求:
- 实现打包前自动化清除上一次打包结果
- 实现打包时自动根据模板生成一份引入打包结果的dist/index.html文件
- 实现自动把静态资源拷贝到dis文件夹下
打包资源
- 打包目录结构
- html模板:index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Webpack</title>
</head>
<body>
<div class="container">
<h1><%= htmlWebpackPlugin.options.title %></h1>
</div>
</body>
</html>
配置示例
const path = require('path')
const { CleanWebpackPlugin } = require('clean-webpack-plugin') // v3.0.0
const HtmlWebpackPlugin = require('html-webpack-plugin') // v5.0.4
const CopyWebpackPlugin = require('copy-webpack-plugin') // v3.2.0
module.exports = {
mode: 'none',
entry: './src/main.js',
output: {
filename: 'bundle.js',
path: path.join(__dirname, 'dist'),
// publicPath: 'dist/'
},
module: {
rules: []
},
plugins: [
new CleanWebpackPlugin(),
// 用于生成 index.html
new HtmlWebpackPlugin({
title: 'Webpack Plugin Sample',
meta: {
viewport: 'width=device-width'
},
template: './src/index.html'
}),
// 用于生成 about.html
new HtmlWebpackPlugin({
filename: 'about.html'
}),
new CopyWebpackPlugin([
// 'public/**'
'public'
])
]
}
打包结果
- 目录结构
- index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Webpack</title>
<meta name="viewport" content="width=device-width"></head>
<body>
<div class="container">
<h1>Webpack Plugin Sample</h1>
</div>
<script type="text/javascript" src="bundle.js"></script></body>
</html>
更多的plugin使用示例不再多做赘述,在实际需要配置使用时,参考官方文档即可:https://webpack.js.org/plugins/
(2): 自定义Plugin
plugin是通过在webpack打包过程中的生命周期钩子中挂载函数实现的(具体钩子可以参考webpack plugin钩子官方文档),图示理解如下:
接下来我们实现一个自动移除打包后的bundle.js中的开头注释的需求,实现思路如下:
- 先从官网中找到合适的钩子,以保证插件能够在webpack打包流程中的合适步骤起作用
- 模仿官方文档示例注册钩子事件完成自定义plugin。
经过上面的需求以及实现分析,自定义plugin实现如下:
class MyPlugin {
apply (compiler) {
console.log('MyPlugin 启动')
compiler.hooks.emit.tap('MyPlugin', compilation => {
// compilation => 可以理解为此次打包的上下文
for (const name in compilation.assets) {
// console.log(name)
// console.log(compilation.assets[name].source())
if (name.endsWith('.js')) {
const contents = compilation.assets[name].source()
const withoutComments = contents.replace(/\/\*\*+\*\//g, '')
compilation.assets[name] = {
source: () => withoutComments,
size: () => withoutComments.length
}
}
}
})
}
}
而后在webpack配置文件中的plugins选项中配置该自定义plugin,即可实现移除打包后的bundle.js中的开头注释的需求。
总结来说,使用webpack来实现前端的构建任务时,除了模块打包相关的任务交由loader处理之外,基本上其它所有的构建任务都可以通过plugin来完成。官方文档以及github中,也提供了非常多的插件,使用时按需查找配置使用即可,对于定制化的插件需求,则可以通过查找webpack hooks自定义plugin实现。
最后来说,webpack通过loader和plugin机制,让它本身非常灵活也非常强大,也使得webpack可以称得上是一个大而全的构建工具。在下一篇文章中,博主会紧扣webpack实现开发环境构建和生产环境构建两点来再次展开对webpack的学习,毕竟我们始终不能忘记我们使用webpack的初衷是实现前端工程的构建需求。
本文结束,谢谢观看。
如若认可,点赞收藏。