webpack早就已经在前端领域大放异彩,会使用和优化webpack也已是中、高级工程师必备技能,在此基础之上再对webpack的原理进行理解和掌握,一定会在将来的开发中事半功倍。如果对于webpack不熟悉能够查看以前的文章进行学习和了解。javascript
因为本人能力通常、水平有限,因此会在本篇文章编写过程当中对一些内容进行又臭又长的赘述,就是为了能让一些基础比较薄弱的同窗阅读起来能够更加省心点,接下来即将开始正题了,但愿此文章能对你有些许帮助。css
新建一个文件夹 webpack-theory
html
是以后插件的名字,能够理解为webpack的别名,能够直接 wepack-theory
进行使用。前端
新建 bin
目录,在此目录下建立webpack-theory.js
文件, 将打包工具主程序放入其中vue
主程序的顶部应当有: #!/usr/bin/env node
标识,指定程序执行环境为 nodejava
#!/usr/bin/env node
// log的内容修改直接,能够直接生效
console.log('当经过npm link连接以后,经过webpack-theory指令能够直接打出');
复制代码
在package.json中配置 bin 脚本,与scripts平级node
{
"bin": "./bin/webpack-theory.js"
}
复制代码
经过 npm link
将本地的项目webpack-theory 连接到全局包中,连接以后即可以直接在本地使用,供本地测试使用,具体参考 npm linkreact
cd /usr/local/lib/node_modules
查看全部安装的包进入目录后,能够看到webpack-theory
,webpack-theory就是npm link时,在全局的node_modules中生成一个符号连接,指向模块(webpack-theory)的本地目录,当本地的文件(bin/webpack-theory)修改时会自动连接到全局,由于全局的node_modules只是本地的引用webpack
webpack-theory
, 会直接将 bin/webpack-theory.js
的console.log内容输出>>> webpack-theory
>>> 当经过npm link连接以后,经过webpack-theory指令能够直接打出
复制代码
在深刻接触webpack
原理以前,须要知道其打包生成的文件结果是什么样,经过打包生成的文件能够从总体了解webpack在对文件处理过程当中作了哪些事情,经过结果反推其原理。git
自行建立一个简单的weback项目,建立三个js文件,分别是index.js,parent.js 和 child.js,并将其经过webpack进行打包
const parent = require('./parent.js')
console.log(parent)
复制代码
const child = require('./child.js')
module.exports = {
msg: '我是parent的信息',
child: child.msg
}
复制代码
module.exports = {
msg: '我是child的信息'
}
复制代码
经过 npx webpack
进行打包,将打包文件进行简单的删除和整理以后
(function (modules) { // 将全部的模块组成一个modules对象传递进来, 键就是模块的路径,值就是模块内部的代码
// 模块缓存对象, 已经解析过的路径都会放进来,能够判断当前须要解析的模块是否已经解析过
var installedModules = {};
// 定义一个 webpack 本身的的 require polyfill
function __webpack_require__(moduleId) {
// 检测 moduleId 是否已经存在缓存中了,如果已经存在则不须要在进行依赖解析
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// 建立一个新的 module, 并将其push至缓存中,方便在后续递归遍历解析依赖时,检测是否已经解析过
var module = installedModules[moduleId] = {
i: moduleId, // moduleId 是自执行函数的参数 modules 对象的键,根本是模块的路径
exports: {}
};
// 执行 modules[moduleId] 函数
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// 将 exports 返回
return module.exports;
}
// 将 webpack.config.js 配置中的 entry 做为 moduleId 进行传递
return __webpack_require__("./src/index.js");
})
/*** 将项目中的几个模块做为自执行函数的参数传递 ***/
({
// webpack.config.js 配置中 entry 的值,会将其做为递归解析依赖的入口
"./src/index.js": (function (module, exports, __webpack_require__) {
eval("const parent = __webpack_require__(/*! ./parent.js */ \"./src/parent.js\")\n\nconsole.log(parent)\n\n//# sourceURL=webpack:///./src/index.js?");
}),
"./src/parent.js": (function (module, exports, __webpack_require__) {
eval("const child = __webpack_require__(/*! ./child.js */ \"./src/child.js\")\n\nmodule.exports = {\n msg: '我是parent的信息',\n child: child.msg\n}\n\n\n\n//# sourceURL=webpack:///./src/parent.js?");
}),
"./src/child.js": (function (module, exports) {
eval("\nmodule.exports = {\n msg: '我是child的信息'\n}\n\n//# sourceURL=webpack:///./src/child.js?");
})
});
复制代码
根据生成的bundle.js
能够梳理webpack的总体打包思路,就是利用一个自执行函数建立一个闭包,在这个独立的做用域中,将模块的路径做为modules的键、模块的内容放在一个函数中做为值做为自执行函数的形参传递进来,经过自定义的函数 __webpack_require__
进行递归解析。
简单分析一下bundle的总体执行过程
__webpack_require__
函数,并将入口文件的路径./src/index.js
做为形参moduleId
传递__webpack_require__
执行过程当中
moduleId
是否已经存在缓存installedModules
中,如果存在则直接返回,不须要再继续解析其依赖。如果不存在,则会构造一个对象并将其同时存到installedModules
中和module
中。第一次执行时installedModules
为空对象,moduleId为./src/index.js
。modules[moduleId]
函数,即执行modules['./src/index.js']
,会经过call改变其做用域并传递module, module.exports, __webpack_require__
三个形参,执行的内容就是入口文件模块./src/index.js
中的js代码。
module.exports
,因为module.exports
此时为空对象,则index.js
中的做用域就是指向它,这也是典型的使用闭包来解决做用域的问题。module, module.exports
的做用就是用于模块内抛出对象使用的,做用是一个的,能够参考require.js
进行这块的理解__webpack_require__
的做用就很巧妙了,此时入口index.js
中使用的require('./parent.js')
已经被替换成__webpack_require__("./src/parent.js\")
,执行modules[moduleId]
函数时便会在此调用__webpack_require__
函数进行递归调用,会再次回到第二步,直到child.js
执行完毕,整个bundle才算执行结束。分析完bundle以后,会发现对于webpack的打包结果,除了形参modules
会跟着代码的业务逻辑修改而变化以外,自执行函数中的代码始终是固定不变的,所以想要编写一个属于本身的webpack时,重点关注和须要解决的就是modules这个对象是如何生成的。
分析完webpack打包完成以后的bundle文件,以结果为导向反推实现过程便会简单许多,如果让咱们本身动手实现一个简单版的webpack,便会有了些思路。
首先须要一个简单的wbepack配置
const path = require('path')
module.exports = {
entry: './src/index.js',
output:{
path: path.join(__dirname, 'dist'),
filename: 'bundle.js'
}
}
复制代码
简单版本的webpack实现思路
有了思路,接下来就是循序渐进的实现
const config = require(path.resolve('webpack.config.js'))
复制代码
const Compiler = require('../lib/Compiler')
new Compiler(config).start()
复制代码
const path = require('path')
const fs = require('fs')
class Compiler {
constructor(config){
this.config = config
const { entry } = config // 配置文件
this.entry = entry // 入口文件
this.root = process.cwd() // 输入 webpack-theory 的路径
this.modules = {} // 初始化一个控对象,存放全部的模块
}
/** * 开始打包 * 打包最主要的就是依赖的分析 */
start(){
this.depAnalyse(path.resolve(this.root, this.entry))
}
/** * 依赖分析 * 须要根据入口entry进行开始分析 */
depAnalyse(modulePath){
// 获取 index.js 的内容
let source = this.getSource(modulePath)
}
// 读取文件
getSource(path){
return fs.readFileSync(path, 'utf-8')
}
}
module.exports = Compiler
复制代码
index.js
的文件内容以后,并不能直接使用,须要经过将其解析成抽象语法树进行处理,须要使用一个插件@babel/parser
将模块代码解析成AST,而后插件@babel/traverse
配合着使用,将AST的节点进行替换,替换完成以后,使用插件@babel/generator
将AST转换成模块的原有代码,改变的只是将require
变成__webpack_require__
,须要注意的是须要将路径处理一下,由于此时的路径是相对于src
下面的。处理完index
以后须要递归调用处理所有的模块,并声称bundle
中自执行函数的参数modules
此时index的模块代码通过处理以后,变成了须要的代码
const parent = __webpack_require__("./src/parent.js");
console.log(parent);
复制代码
在函数depAnalyse
中添加以下处理
// 获取 index.js 的内容
let source = this.getSource(modulePath)
// -------
// 准备一个依赖数组,用于存储当前模块
let dependenceArr = []
// 将js代码 解析成AST
let ast = parser.parse(source)
// 将AST中的 require 替换为 __webpack_require__
traverse(ast, {
// p 是抽象语法树的节点
CallExpression(p) {
if (p.node.callee.name === 'require') {
// 将代码中的 require 替换为 __webpack_require__
p.node.callee.name = '__webpack_require__'
const oldValue = p.node.arguments[0].value
// 修改路径,避免windows出现反斜杠 \
p.node.arguments[0].value = ('./' + path.join('src', oldValue)).replace(/\\+/g, '/')
// 每找到一个require调用,就将其中的路径修改完毕后加入到依赖数组中
dependenceArr.push(p.node.arguments[0].value)
}
}
})
// 构建modules对象
const sourceCode = generator(ast).code
const modulePathRelative = './' + (path.relative(this.root, modulePath)).replace(/\\+/g, '/')
this.modules[modulePathRelative] = sourceCode
// 递归调用加载全部依赖
dependenceArr.forEach(dep => this.depAnalyse(path.resolve(this.root, dep)))
复制代码
至此已经完成了modules
的处理,接下来须要处理的就是怎么生成bundle.js
,分析bundle
时已经指出咱们须要关注的地方只有modules
的拼接,至于自执行函数中的内容都是基本都是固定的,不须要额外的处理
使用模版引擎ejs建立模版,模版的内容就是webpack
打包生成的内容,只须要根据Compiler
中modules
进行遍历便可,还须要关注的是return __webpack_require__(__webpack_require__.s = "<%-entry%>")
,这里传入的是配置文件的入口,也是自执行函数第一次执行时的参数
ejs
的模板文件output.ejs
,须要关注的只有两个地方,其它地方使用默认的代码// 第一次执行的参数就是配置的entry
return __webpack_require__(__webpack_require__.s = "<%-entry%>");
// 拼接函数须要的形参 modules
{
<% for (let k in modules) {%>
"<%-k%>": (function (module, exports, __webpack_require__) {
eval(`<%-modules[k]%>`);
}),
<%}%>
}
复制代码
为Compiler
增长一个emitFile
方法,用于根据模板生成打包的bundle
文件,在start
函数中的depAnalyse
以后进行调用
/** * 根据写好的模板 建立文件 */
emitFile(){
// 已经建立好的 ejs 模版
const template = this.getSource(path.join(__dirname, '../template/output.ejs'))
// 使用 ejs 进行编译
const result = ejs.render(template, {
entry: this.entry,
modules: this.modules
})
// 获取输出路径和文件名
const {
path: filePath,
filename
} = this.output
const outputPath = path.join(filePath, filename)
// 打包生成bundle 并放在指定的目录下
fs.writeFile(outputPath, result, (err) => {
console.log(err ? err : '打包生成bundle完成');
})
}
复制代码
到目前为止,已经能够进行简单的模块打包,能够将index.js、parent.js和child.js进行简单的打包,这里仅仅是支持最简单的webpack用法打包
loader是webpack的重要核心功能之一,也是使用频率很是高的,主要功能就是将代码按照预期的结果进行加工处理,生成最终的代码后输出,所以掌握loade的基本机制是颇有必要的。loader的使用也是很是简单的,其基本配置和用法这里再也不赘述,接下来一块儿看看如何在本身的webpack-theory中添加解析loader和如何编写一个本身的loader。
在为webpack-theory
添加处理loader的能力以前,先看看如何在webpack
中实现一个本身的loader
webpack中loader,主要步骤以下
当想要在webpack中增长处理cass文件能力的时候,会进行loader的配置
{
test:/\.scss$/,
use:['style-loader', 'css-loader', 'sass-loader']
}
复制代码
sass-loader
其实就是一个函数,根据test
的匹配规则,将以.scss
结束的文件内容读取出来,而后将匹配到的文件内容做为参数传递给一个函数,这个函数将sass
文件的内容按照规则进行加工处理成浏览器可以识别的css
并输出,因此loader
的本质就是一个函数,接受一个参数,这个参数就是匹配到的文件里面的代码。同理,css-loader
和style-loader
也是这样的处理流程,只是内部作的事情不一样。
function handlerScss(sourceCode){
// 这里就是将scss文件的内容,按照规则进行加工、处理,结果就是浏览器可以识别解析的css,并将其返回
return newSourceCode
}
复制代码
parent.js
和child.js
中的信息使用loader处理为msg
// 将js文件中的 信息 换成 msg
module.exports = function (source) {
return source.replace(/信息/g, 'msg')
}
复制代码
在webpack中配置loader
{
test:/\.js/,
use:['./loader/handlerLoader1.js']
}
复制代码
使用npx webpack
打包以后,能够看到打包的代码中已经将原有代码中的信息更换为msg
handlerLoader1
的loader中替换的内容经过配置自定义处理呢?就像是url-loader
那样传递一个配置选项options
,而后在loader中进行接受并处理。能够经过loader-utils
的getOptions
提取loader中的options
进行处理,老版本是经过thus.query
来进行处理修改loader文件handlerLoader1
const loaderUtils = require('loader-utils')
// 将js文件中的 信息 换成 经过options传递的name
module.exports = function (source) {
const optionsName = loaderUtils.getOptions(this).name || ''
return source.replace(/信息/g, optionsName)
}
复制代码
修改webpack的loader
{
test:/\.js/,
use:{
loader: './loader/loader1.js',
options:{
name:'新的信息'
}
}
}
复制代码
使用npx webpack
打包以后,即可以经过options
配置进行替换
handlerLoader1
处理完的东西还须要交给下一个loader进行处理以后,这样就会牵扯到多个同级loader的状况,将handlerLoader1
拷贝两份,分别命名为handlerLoader11
和handlerLoader12
,内容可保持原有的,只是在原有的函数中分别打印其对应的loader的文件名称,由于只是为了看看loader的加载。handlerLoader1
的内容为
// 将js文件中的 信息 换成 msg
module.exports = function (source) {
console.log('我是 handlerLoader1'); // 其他两loader 的log分别为 handlerLoader2 handlerLoader3
return source.replace(/信息/g, 'msg')
}
复制代码
webpack配置loader
{
test:/\.js/,
use:[
'./loader/handlerLoader1.js',
'./loader/handlerLoader2.js',
'./loader/handlerLoader3.js'
]
}
复制代码
执行webpack打包,输出结果,能够得出loader的默认顺序是由右到左
>>> 我是 handlerLoader3
>>> 我是 handlerLoader2
>>> 我是 handlerLoader1
复制代码
{
test:/\.js/,
use:['./loader/loader1.js']
},{
test:/\.js/,
use:['./loader/loader2.js']
},{
test:/\.js/,
use:['./loader/loader3.js']
},
复制代码
执行webpack打包,输出结果,能够得出loader的默认顺序是由下到上的
>>> 我是 handlerLoader3
>>> 我是 handlerLoader2
>>> 我是 handlerLoader1
复制代码
经过自制一个loader以后,能够总结获得webpack支持loader的功能,主要就是4步
webpack.config.js
的module.rules
loader配置项,进行倒序迭代在webpack-theory
中增长处理loader
的能力,无非就是在加载每一个模块的时候,根据配置的rules
的正则进行匹配须要的资源,知足条件以后就会加载并使用对应的loader
进行处理并迭代调用
须要注意的是,在何时去执行loader
呢,在每次获取模块依赖的时候,都须要进行loader
的test
匹配,如果匹配到就加载对应的loader
进行处理。例如本文的案例代码存在三个js文件,首先会加载index.js
,在加载解析index的依赖以前就须要对其进行倒序便利所有的loader
,如果匹配到对应的loader
就会加载对应的loader
对index.js
的内容进行处理,由于index引入了parent.js
,接下来便会在递归调用depAnalyse
方法解析parnet以前进行判断和处理,child.js
同理。
在depAnalyse
方法中每次解析以来以前添加以下代码:
// 内部定义一个处理loader的函数
const _handleLoader = (usePath, _this) => {
const loaderPath = path.join(this.root, usePath)
const loader = require(loaderPath)
source = loader.call(_this, source)
}
// 读取 rules 规则, 进行倒序遍历
const rules = this.rules
for (let i = rules.length - 1; i >= 0; i--) {
const {
test,
use
} = rules[i]
// 匹配 modulePath 是否符合规则,如果符合规则就须要倒序遍历获取全部的loader
// 获取每一条规则,和当前的 modulePath 进行匹配
if (test.test(modulePath)) {
// use 多是 数组、对象、字符串
console.log(use);
if (Array.isArray(use)) {
// array
for (let j = use.length - 1; j >= 0; j--) {
// const loaderPath = path.join(this.root, use[j])
// const loader = require(loaderPath)
// source = loader(source)
_handleLoader(use[j])
}
} else if (typeof use === 'string') {
// string
_handleLoader(use)
} else if (use instanceof Object) {
// object
_handleLoader(use.loader, {
query: use.options
})
}
}
}
复制代码
loader基础的相关编写到此为止,可是仍是须要多加练习的思考,这里仅仅是演示了最简单的,你们能够参考官方文档进行loader的enforce
、异步loader
等知识点的深刻学习和查看babel
、sass-loader
等社区优秀loader
进行深刻的理解和练习。
插件是 webpack 生态系统的重要组成部分,为社区用户提供了一种强大方式来直接触及 webpack 的编译过程(compilation process)。插件可以 钩入(hook) 到在每一个编译(compilation)中触发的全部关键事件。在编译的每一步,插件都具有彻底访问
compiler
对象的能力,若是状况合适,还能够访问当前compilation
对象。
自定义插件本质就是在webpack的编译过程的提供的生命周期钩子中,进行编码开发实现一些功能,在适当的时间节点作该作的事情,例如clean-webpack-plugin
插件,就是在编译以前执行插件,将打包目录清空。
在实现自制插件以前,先了解一下webpack插件组成
一个JavaScript命名函数
在插件函数的prototype上定义一个apply方法
指定一个绑定到webpack自身的事件钩子
处理webpack内部实例的特定数据
功能完成后调用webpack提供的回调
webpack的生命周期钩子 生命周期钩子
Compiler
模块是 webpack 的支柱引擎,它经过 CLI 或 Node API 传递的全部选项,建立出一个 compilation 实例。它扩展(extend)自Tapable
类,以便注册和调用插件。大多数面向用户的插件首,会先在Compiler
上注册。
根据官方文档实现一个hello word插件,能够简单的了解到plugin
// 1. 一个JavaScript命名函数
// 2. 在插件函数的 prototype 上定义一个apply方法
class HelloWordPlugin {
// 3. apply 中有一个 compiler 形参
apply(compiler){
console.log('插件执行了');
// 4. 经过compiler对象能够注册对应的事件,所有的钩子均可以使用
// 注册一个编译完成的钩子, 通常须要将插件名做为事件名便可
compiler.hooks.done.tap('HelloWordPlugin', (stats) => {
console.log('整个webpack打包结束了');
})
compiler.hooks.emit.tap('HelloWordPlugin', (compilation) => {
console.log('触发emit方法');
})
}
}
module.exports = HelloWordPlugin
复制代码
在webpack.config.js
引入并使用
const HelloWordPlugin = require('./plugins/HelloWordPlugin')
{
// ...
plugins:[
new HelloWordPlugin()
]
}
复制代码
npx webpack
打包,能够查看插件的触发
>>> 插件执行了
>>> 触发emit方法
>>> 整个webpack打包结束了
复制代码
模仿实现HtmlWebpackPlugin插件的功能
html-webpack-plugin 能够将制定的html模板复制一份输出到dist目录下,自动引入bundle.js
const path = require('path')
const fs = require('fs')
const cheerio = require('cheerio')
class HTMLPlugin {
constructor(options){
// 插件的参数,filename、template等
this.options = options
}
apply(compiler){
// 1. 注册 afterEmit 钩子
// 若是使用done钩子,则须要使用stats.compilation.assets获取,并且会比 afterEmit 晚一些
compiler.hooks.afterEmit.tap('HTMLPlugin', (compilation) => {
// 2. 根据模板读取html文件内容
const result = fs.readFileSync(this.options.template, 'utf-8')
// 3. 使用 cheerio 来分析 HTML
let $ = cheerio.load(result)
// 4. 建立 script 标签后插入HTML中
Object.keys(compilation.assets).forEach(item => {
$(`<script src="/${item}"></script>`).appendTo('body')
})
// 5. 转换成新的HTML并写入到 dist 目录中
fs.writeFileSync(path.join(process.cwd(), 'dist', this.options.filename), $.html())
})
}
}
module.exports = HTMLPlugin
复制代码
为webpack-theory
添加plugin
功能,只需在Compiler构造时,建立对应的钩子便可,webpack-theory
只是负责定义钩子,并在适当的时间节点去触发,至于钩子的事件注册都是各个plugin
本身内部去实现。
// tapable 的构造函数内部定义的钩子
this.hooks = {
afterPlugins: new SyncHook(),
beforeRun: new SyncHook(),
run: new SyncHook(),
make: new SyncHook(),
afterCompiler: new SyncHook(),
shouldEmit: new SyncHook(),
emit: new SyncHook(),
afterEmit: new SyncHook(['compilation']),
done: new SyncHook(),
}
// 触发plugins中全部插件的apply方法, 并传入Compiler对象
if(Array.isArray(this.config.plugins)){
this.config.plugins.forEach(plugin => {
plugin.apply(this)
});
}
复制代码
在合适的时机调用对应钩子的call方法便可,如须要传入参数,能够在对应的钩子中定义好须要传入的参数,call时直接传入
在 start
中调用定义的钩子
start() {
this.hooks.compiler.call() // 开始编译
this.depAnalyse(path.resolve(this.root, this.entry))
this.hooks.afterCompiler.call() //编译完成了
this.hooks.emit.call() // 开始发射文件
this.emitFile()
this.hooks.done.call() // 完成
}
复制代码
就是将一行代码解析成对象的格式,可使用在线工具生成ast语法树 astexplorer 进行查看
npm i -S @babel/parser
复制代码
const parser = require('@babel/parser')
//source是须要生成ast语法树的代码片断
const ast = parser.parse(source)
复制代码
源码
const news = require('./news')
console.log(news.content)
复制代码
生成的ast语法树
Node {
type: 'File',
start: 0,
end: 57,
loc:
SourceLocation {
start: Position { line: 1, column: 0 },
end: Position { line: 3, column: 25 } },
program:
Node {
type: 'Program',
start: 0,
end: 57,
loc: SourceLocation { start: [Position], end: [Position] },
sourceType: 'script',
interpreter: null,
body: [ [Node], [Node] ],
directives: [] },
comments: [] }
复制代码
在webpack内部实现事件流机制的核心就在于tapable,有了它就能够经过事件流的形式,将各个插件串联起来,tapable相似于node中的events库,核心原理就是一个订阅发布模式
基本用法
const { SyncHook } = require('tapable')
/** * 学习前端 * 学习过程 1.准备 2.学html 3.学css 4.学js 5.学框架 * 学习的每一个过程就相似于生命周期 */
class Frontend{
constructor(){
// 1. 定义生命周期钩子
this.hooks = {
beforeStudy: new SyncHook(),
afterHtml: new SyncHook(),
afterCss: new SyncHook(),
afterJs: new SyncHook(),
// 须要传递的参数 须要在 new SyncHook() 的时候定义好
afterFrame: new SyncHook(['name']),
}
}
study(){
// 3. 在合适的时候 调用
console.log('准备');
this.hooks.beforeStudy.call()
console.log('准备学html');
this.hooks.afterHtml.call()
console.log('准备学css');
this.hooks.afterCss.call()
console.log('准备学js');
this.hooks.afterJs.call()
console.log('准备学框架');
this.hooks.afterFrame.call('vue、react')
}
}
const f = new Frontend()
// 2. 注册事件
f.hooks.afterHtml.tap('afterHtml', () => {
console.log('学完html,完成了一部分前端知识');
})
f.hooks.afterJs.tap('afterJs', () => {
console.log('学完js,完成了一部分前端知识');
})
f.hooks.afterFrame.tap('afterFrame', (name) => {
console.log(`学完了${name}框架,天下无敌....`);
})
f.study()
复制代码