本文讨论的核心内容以下:javascript
webpack
进行打包的基本原理loader
和plugin
注: 本文使用的webpack
版本是v4.43.0
, webpack-cli
版本是v3.3.11
,node
版本是v12.14.1
,npm
版本v6.13.4
(若是你喜欢yarn
也是能够的),演示用的chrome
浏览器版本81.0.4044.129(正式版本) (64 位)
html
webpack的一个核心功能就是把咱们写的模块化的代码,打包以后,生成能够在浏览器中运行的代码,咱们这里也是从简单开始,一步步探索webpack的打包原理前端
咱们首先创建一个空的项目,使用npm init -y
快速初始化一个package.json
,而后安装webpack webpack-cli
java
接下来,在根目录下建立src
目录,src
目录下建立index.js
,add.js
,minus.js
,根目录下建立index.html
,其中index.html
引入index.js
,在index.js
引入add.js
,minus.js
,node
目录结构以下:webpack
文件内容以下:es6
// add.js
export default (a, b) => {
return a + b
}
// minus.js
export const minus = (a, b) => {
return a - b
}
// index.js
import add from './add.js'
import { minus } from './minus.js'
const sum = add(1, 2)
const division = minus(2, 1)
console.log('sum>>>>>', sum)
console.log('division>>>>>', division)
复制代码
<!--index.html-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>demo</title>
</head>
<body>
<script src="./src/index.js"></script>
</body>
</html>
复制代码
这样直接在index.html
引入index.js
的代码,在浏览器中显然是不能运行的,你会看到这样的错误web
Uncaught SyntaxError: Cannot use import statement outside a module
复制代码
是的,咱们不能在script
引入的js
文件里,使用es6
模块化语法正则表达式
咱们首先在项目根目录下再创建一个bundle.js,这个文件用来对咱们刚刚写的模块化js
代码文件进行打包chrome
咱们首先来看webpack官网对于其打包流程的描述:
it internally builds a dependency graph which maps every module your project needs and generates one or more bundles(webpack会在内部构建一个 依赖图(dependency graph),此依赖图会映射项目所需的每一个模块,并生成一个或多个 bundle)
在正式开始以前,结合上面webpack
官网说明进行分析,明确咱们进行打包工做的基本流程以下:
既然要读取文件内容,咱们须要用到node.js
的核心模块fs
,咱们首先来看读到的内容是什么:
// bundle.js
const fs = require('fs')
const getModuleInfo = file => {
const body = fs.readFileSync(file, 'utf-8')
console.log(body)
}
getModuleInfo('./src/index.js')
复制代码
咱们定义了一个方法getModuleInfo
,这个方法里咱们读出文件内容,打印出来,输出的结果以下图:
index.js
的全部内容都以字符串形式输出了,咱们接下来能够用正则表达式或者其它一些方法,从中提取到
import
以及
export
的内容以及相应的路径文件名,来对入口文件内容进行分析,获取有用的信息。可是若是
import
和
export
的内容很是多,这会是一个很麻烦的过程,这里咱们借助
babel
提供的功能,来完成入口文件的分析
咱们安装@babel/parser
,演示时安装的版本号为^7.9.6
这个babel模块的做用,就是把咱们js文件的代码内容,转换成js对象的形式,这种形式的js对象,称作抽象语法树(Abstract Syntax Tree, 如下简称AST)
// bundle.js
const fs = require('fs')
const parser = require('@babel/parser')
const getModuleInfo = file => {
const body = fs.readFileSync(file, 'utf-8')
const ast = parser.parse(body, {
// 表示咱们要解析的是es6模块
sourceType: 'module'
})
console.log(ast)
console.log(ast.program.body)
}
getModuleInfo('./src/index.js')
复制代码
使用@babel/parser
的parse
方法把入口文件转化称为了AST
,咱们打印出了ast
,注意文件内容是在ast.program.body
中,以下图所示:
Node
节点,咱们能够看到,每一个节点有一个
type
属性,其中前两个的
type
属性是
ImportDeclaration
,这对应了咱们入口文件的两条
import
语句,而且,每个
type
属性是
ImportDeclaration
的节点,其
source.value
属性是引入这个模块的相对路径,这样咱们就获得了入口文件中对打包有用的重要信息了。
接下来要对获得的ast作处理,返回一份结构化的数据,方便后续使用。
对ast.program.body
部分数据的获取和处理,本质上就是对这个数组的遍历,在循环中作数据处理,这里一样引入一个babel的模块@babel/traverse
来完成这项工做。
安装@babel/traverse
,演示时安装的版本号为^7.9.6
const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const getModuleInfo = file => {
const body = fs.readFileSync(file, 'utf-8')
const ast = parser.parse(body, {
sourceType: 'module'
})
const deps = {}
traverse(ast, {
ImportDeclaration({ node }) {
const dirname = path.dirname(file);
const absPath = './' + path.join(dirname, node.source.value)
deps[node.source.value] = absPath
}
})
console.log(deps)
}
getModuleInfo('./src/index.js')
复制代码
建立一个对象deps
,用来收集模块自身引入的依赖,使用traverse
遍历ast
,咱们只须要对ImportDeclaration
的节点作处理,注意咱们作的处理实际上就是把相对路径转化为绝对路径,这里我使用的是Mac
系统,若是是windows
系统,注意斜杠的区别
获取依赖以后,咱们须要对ast
作语法转换,把es6
的语法转化为es5
的语法,使用babel
核心模块@babel/core
以及@babel/preset-env
完成
安装@babel/core @babel/preset-env
,演示时安装的版本号均为^7.9.6
const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')
const getModuleInfo = file => {
const body = fs.readFileSync(file, 'utf-8')
const ast = parser.parse(body, {
sourceType: 'module'
})
const deps = {}
traverse(ast, {
ImportDeclaration({ node }) {
const dirname = path.dirname(file);
const absPath = './' + path.join(dirname, node.source.value)
deps[node.source.value] = absPath
}
})
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"]
})
const moduleInfo = { file, deps, code }
console.log(moduleInfo)
return moduleInfo
}
getModuleInfo('./src/index.js')
复制代码
以下图所示,咱们最终把一个模块的代码,转化为一个对象形式的信息,这个对象包含文件的绝对路径,文件所依赖模块的信息,以及模块内部通过babel
转化后的代码
这个过程,也就是获取依赖图(dependency graph)
的过程,这个过程就是从入口模块开始,对每一个模块以及模块的依赖模块都调用getModuleInfo
方法就行分析,最终返回一个包含全部模块信息的对象
const parseModules = file => {
// 定义依赖图
const depsGraph = {}
// 首先获取入口的信息
const entry = getModuleInfo(file)
const temp = [entry]
for (let i = 0; i < temp.length; i++) {
const item = temp[i]
const deps = item.deps
if (deps) {
// 遍历模块的依赖,递归获取模块信息
for (const key in deps) {
if (deps.hasOwnProperty(key)) {
temp.push(getModuleInfo(deps[key]))
}
}
}
}
temp.forEach(moduleInfo => {
depsGraph[moduleInfo.file] = {
deps: moduleInfo.deps,
code: moduleInfo.code
}
})
console.log(depsGraph)
return depsGraph
}
parseModules('./src/index.js')
复制代码
得到的depsGraph对象以下图:
咱们最终获得的模块分析数据如上图所示,接下来,咱们就要根据这里得到的模块分析数据,来生产最终浏览器运行的代码。
在咱们实现以前,观察上一节最终获得的依赖图,能够看到,最终的code里包含exports以及require这样的语法,因此,咱们在生成最终代码时,要对exports和require作必定的实现和处理
咱们首先调用以前说的parseModules方法,得到整个应用的依赖图对象:
const bundle = file => {
const depsGraph = JSON.stringify(parseModules(file))
}
复制代码
接下来咱们应该把依赖图对象中的内容,转换成可以执行的代码,以字符串形式输出。 咱们把整个代码放在自执行函数中,参数是依赖图对象
const bundle = file => {
const depsGraph = JSON.stringify(parseModules(file))
return `(function(graph){ function require(file) { var exports = {}; return exports } require('${file}') })(${depsGraph})`
}
复制代码
接下来内容其实很简单,就是咱们取得入口文件的code信息,去执行它就行了,使用eval函数执行,初步写出代码以下:
const bundle = file => {
const depsGraph = JSON.stringify(parseModules(file))
return `(function(graph){ function require(file) { var exports = {}; (function(code){ eval(code) })(graph[file].code) return exports } require('${file}') })(${depsGraph})`
}
复制代码
上面的写法是有问题的,咱们须要对file作绝对路径转化,不然graph[file].code
是获取不到的,定义adsRequire方法作相对路径转化为绝对路径
const bundle = file => {
const depsGraph = JSON.stringify(parseModules(file))
return `(function(graph){ function require(file) { var exports = {}; function absRequire(relPath){ return require(graph[file].deps[relPath]) } (function(require, exports, code){ eval(code) })(absRequire, exports, graph[file].code) return exports } require('${file}') })(${depsGraph})`
}
复制代码
接下来,咱们只须要执行bundle方法,而后把生成的内容写入一个JavaScript文件便可
const content = bundle('./src/index.js')
// 写入到dist/bundle.js
fs.mkdirSync('./dist')
fs.writeFileSync('./dist/bundle.js', content)
复制代码
最后,咱们在index.html引入这个./dist/bundle.js
文件,咱们能够看到控制台正确输出了咱们想要的结果
const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')
const getModuleInfo = file => {
const body = fs.readFileSync(file, 'utf-8')
console.log(body)
const ast = parser.parse(body, {
sourceType: 'module'
})
// console.log(ast.program.body)
const deps = {}
traverse(ast, {
ImportDeclaration({ node }) {
const dirname = path.dirname(file);
const absPath = './' + path.join(dirname, node.source.value)
deps[node.source.value] = absPath
}
})
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"]
})
const moduleInfo = { file, deps, code }
return moduleInfo
}
const parseModules = file => {
// 定义依赖图
const depsGraph = {}
// 首先获取入口的信息
const entry = getModuleInfo(file)
const temp = [entry]
for (let i = 0; i < temp.length; i++) {
const item = temp[i]
const deps = item.deps
if (deps) {
// 遍历模块的依赖,递归获取模块信息
for (const key in deps) {
if (deps.hasOwnProperty(key)) {
temp.push(getModuleInfo(deps[key]))
}
}
}
}
temp.forEach(moduleInfo => {
depsGraph[moduleInfo.file] = {
deps: moduleInfo.deps,
code: moduleInfo.code
}
})
// console.log(depsGraph)
return depsGraph
}
// 生成最终能够在浏览器运行的代码
const bundle = file => {
const depsGraph = JSON.stringify(parseModules(file))
return `(function(graph){ function require(file) { var exports = {}; function absRequire(relPath){ return require(graph[file].deps[relPath]) } (function(require, exports, code){ eval(code) })(absRequire, exports, graph[file].code) return exports } require('${file}') })(${depsGraph})`
}
const build = file => {
const content = bundle(file)
// 写入到dist/bundle.js
fs.mkdirSync('./dist')
fs.writeFileSync('./dist/bundle.js', content)
}
build('./src/index.js')
复制代码
loader
和plugin
loader
loader本质上就是一个函数,这个函数会在咱们在咱们加载一些文件时执行
loader
首先咱们初始化一个项目,项目结构如图所示:
// index.js
console.log('我要学好前端,由于学好前端能够: ')
// webpack.config.js
const path = require('path')
module.exports = {
mode: 'development',
entry: {
main: './src/index.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
}
}
复制代码
咱们在根目录下建立syncLoader.js
,用来实现一个同步的loader,注意这个函数必须返回一个buffer
或者string
// syncloader.ja
module.exports = function (source) {
console.log('source>>>>', source)
return source
}
复制代码
同时,咱们在webpack.config.js
中使用这个loader
,咱们这里使用resolveLoader
配置项,指定loader
查找文件路径,这样咱们使用loader
时候能够直接指定loader
的名字
const path = require('path')
module.exports = {
mode: 'development',
entry: {
main: './src/index.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
},
resolveLoader: {
// loader路径查找顺序从左往右
modules: ['node_modules', './']
},
module: {
rules: [
{
test: /\.js$/,
use: 'syncLoader'
}
]
}
}
复制代码
接下来咱们运行打包命令,能够看到命令行输出了source内容,也就是loader做用文件的内容。
module.exports = function (source) {
source += '升值加薪'
return source
}
复制代码
咱们再次运行打包命令,去观察打包后的代码:
loader
的函数里打印
this
,发现输出结果是很是长的一串内容,
this
上有不少咱们能够在
loader
中使用的有用信息,因此,对于
loader
的编写,必定不要使用箭头函数,那样会改变
this
的指向。
通常来讲,咱们会去使用官方推荐的loader-utils
包去完成更加复杂的loader
的编写
咱们继续安装loader-utils
,版本是^2.0.0
咱们首先改造webpack.config.js
:
const path = require('path')
module.exports = {
mode: 'development',
entry: {
main: './src/index.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
},
resolveLoader: {
// loader路径查找顺序从左往右
modules: ['node_modules', './']
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'syncLoader',
options: {
message: '升值加薪'
}
}
}
]
}
}
复制代码
注意到,咱们为咱们的loader
增长了options
配置项,接下来在loader函数里使用loader-utils获取配置项内容,拼接内容,咱们依然能够获得与以前同样的打包结果
// syncLoader.js
const loaderUtils = require('loader-utils')
module.exports = function (source) {
const options = loaderUtils.getOptions(this)
console.log(options)
source += options.message
// 能够传递更详细的信息
this.callback(null, source)
}
复制代码
这样,咱们就完成了一个简单的同步loader
的编写
loader
和同步loader的编写方式很是类似,咱们在根目录下创建一个asyncLoader.js的文件,内容以下:
const loaderUtils = require('loader-utils')
module.exports = function (source) {
const options = loaderUtils.getOptions(this)
const asyncfunc = this.async()
setTimeout(() => {
source += '走上人生颠覆'
asyncfunc(null, res)
}, 200)
}
复制代码
注意这里的this.async()
,用官方的话来讲就是Tells the loader-runner that the loader intends to call back asynchronously. Returns this.callback.
也就是让webpack知道这个loader是异步运行,返回的是和同步使用时一致的this.callback
接下来咱们修改webpack.config.js
const path = require('path')
module.exports = {
mode: 'development',
entry: {
main: './src/index.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
},
resolveLoader: {
// loader路径查找顺序从左往右
modules: ['node_modules', './']
},
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: 'syncLoader',
options: {
message: '走上人生巅峰'
}
},
{
loader: 'asyncLoader'
}
]
}
]
}
}
复制代码
注意loader执行顺序是从下网上的,因此首先为文本写入‘升值加薪’,而后写入‘走上人生巅峰’
到此,咱们简单介绍了如何手写一个loader
,在实际项目中,能够考虑一部分公共的简单逻辑,能够经过编写一个loader
来完成(好比国际化文本替换)
plugin
plugin
一般是在webpack
在打包的某个时间节点作一些操做,咱们使用plugin
的时候,通常都是new Plugin()
这种形式使用,因此,首先应该明确的是,plugin
应该是一个类。
咱们初始化一个与上一接实现loader时候同样的项目,根目录下建立一个demo-webpack-plugin.js
的文件,咱们首先在webpack.config.js
中使用它
const path = require('path')
const DemoWebpackPlugin = require('./plugins/demo-webpack-plugin')
module.exports = {
mode: 'development',
entry: {
main: './src/index.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js'
},
plugins: [
new DemoWebpackPlugin()
]
}
复制代码
再来看demo-webpack-plugin.js
的实现
class DemoWebpackPlugin {
constructor () {
console.log('plugin init')
}
apply (compiler) {
}
}
module.exports = DemoWebpackPlugin
复制代码
咱们在DemoWebpackPlugin
的构造函数打印一条信息,当咱们执行打包命令时,这条信息就会输出,plugin
类里面须要实现一个apply
方法,webpack
打包时候,会调用plugin
的aplly
方法来执行plugin
的逻辑,这个方法接受一个compiler
做为参数,这个compiler
是webpack
实例
plugin的核心在于,apply方法执行时,能够操做webpack本次打包的各个时间节点(hooks,也就是生命周期勾子),在不一样的时间节点作一些操做
关于webpack编译过程的各个生命周期勾子,能够参考Compiler Hooks
一样,这些hooks也有同步和异步之分,下面演示compiler hooks
的写法,一些重点内容能够参考注释:
class DemoWebpackPlugin {
constructor () {
console.log('plugin init')
}
// compiler是webpack实例
apply (compiler) {
// 一个新的编译(compilation)建立以后(同步)
// compilation表明每一次执行打包,独立的编译
compiler.hooks.compile.tap('DemoWebpackPlugin', compilation => {
console.log(compilation)
})
// 生成资源到 output 目录以前(异步)
compiler.hooks.emit.tapAsync('DemoWebpackPlugin', (compilation, fn) => {
console.log(compilation)
compilation.assets['index.md'] = {
// 文件内容
source: function () {
return 'this is a demo for plugin'
},
// 文件尺寸
size: function () {
return 25
}
}
fn()
})
}
}
module.exports = DemoWebpackPlugin
复制代码
咱们的这个plugin
的做用就是,打包时候自动生成一个md
文档,文档内容是很简单的一句话
上述异步hooks的写法也能够是如下两种:
// 第二种写法(promise)
compiler.hooks.emit.tapPromise('DemoWebpackPlugin', (compilation) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve()
}, 1000)
}).then(() => {
console.log(compilation.assets)
compilation.assets['index.md'] = {
// 文件内容
source: function () {
return 'this is a demo for plugin'
},
// 文件尺寸
size: function () {
return 25
}
}
})
})
// 第三种写法(async await)
compiler.hooks.emit.tapPromise('DemoWebpackPlugin', async (compilation) => {
await new Promise((resolve, reject) => {
setTimeout(() => {
resolve()
}, 1000)
})
console.log(compilation.assets)
compilation.assets['index.md'] = {
// 文件内容
source: function () {
return 'this is a demo for plugin'
},
// 文件尺寸
size: function () {
return 25
}
}
})
复制代码
最终的输出结果都是同样的,在每次打包时候生成一个md文档
到此为止,本文介绍了webpack打包的基本原理,以及本身实现loader和plugin的方法。但愿本文内容能对你们对webpack的学习,使用带来帮助,谢谢你们。