本文讨论的核心内容如下:
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 位)
1. webpack打包基本原理
webpack的一个核心功能就是把我们写的模块化的代码,打包之后,生成可以在浏览器中运行的代码,我们这里也是从简单开始,一步步探索webpack的打包原理
1.1 一个简单的需求
我们首先建立一个空的项目,使用 npm init -y
快速初始化一个 package.json
,然后安装 webpack webpack-cli
接下来,在根目录下创建 src
目录, src
目录下创建 index.js
, add.js
, minus.js
,根目录下创建 index.html
,其中 index.html
引入 index.js
,在 index.js
引入 add.js
, minus.js
,
目录结构如下:
文件内容如下:
// 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="/UploadFiles/2021-04-02/index.js">这样直接在
index.html
引入index.js
的代码,在浏览器中显然是不能运行的,你会看到这样的错误Uncaught SyntaxError: Cannot use import statement outside a module
是的,我们不能在
script
引入的js
文件里,使用es6
模块化语法1.2 实现webpack打包核心功能
我们首先在项目根目录下再建立一个bundle.js,这个文件用来对我们刚刚写的模块化
js
代码文件进行打包我们首先来看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
官网说明进行分析,明确我们进行打包工作的基本流程如下:首先,我们需要读到入口文件里的内容(也就是index.js的内容) 其次,分析入口文件,递归的去读取模块所依赖的文件内容,生成依赖图 最后,根据依赖图,生成浏览器能够运行的最终代码 1. 处理单个模块(以入口为例) 1.1 获取模块内容
既然要读取文件内容,我们需要用到
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
提供的功能,来完成入口文件的分析
1.2 分析模块内容
我们安装
@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做处理,返回一份结构化的数据,方便后续使用。
1.3 对模块内容做处理
对
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
转化后的代码2. 递归的获取所有模块的信息
这个过程,也就是获取
依赖图(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对象如下图:
我们最终得到的模块分析数据如上图所示,接下来,我们就要根据这里获得的模块分析数据,来生产最终浏览器运行的代码。
3. 生成最终代码
在我们实现之前,观察上一节最终得到的依赖图,可以看到,最终的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
文件,我们可以看到控制台正确输出了我们想要的结果4. 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')2. 手写 loader 和 plugin
2.1 如何自己实现一个
loader
loader本质上就是一个函数,这个函数会在我们在我们加载一些文件时执行
2.1.1 如何实现一个同步
loader
首先我们初始化一个项目,项目结构如图所示:
其中index.js和webpack.config.js的文件内容如下:
// 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作用文件的内容。
接着我们改造我们的loader:
module.exports = function (source) { source += '升值加薪' return source }我们再次运行打包命令,去观察打包后的代码:
这样,我们就实现了一个简单的loader,为我们的文件增加一条信息。 我们可以尝试在
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
的编写2.1.2 如何实现一个异步
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
来完成(比如国际化文本替换)2.2 如何自己实现一个
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的学习,使用带来帮助。更多相关webpack打包loader和plugin内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!
免责声明:本站文章均来自网站采集或用户投稿,网站不提供任何软件下载或自行开发的软件! 如有用户或公司发现本站内容信息存在侵权行为,请邮件告知! 858582#qq.com
稳了!魔兽国服回归的3条重磅消息!官宣时间再确认!
昨天有一位朋友在大神群里分享,自己亚服账号被封号之后居然弹出了国服的封号信息对话框。
这里面让他访问的是一个国服的战网网址,com.cn和后面的zh都非常明白地表明这就是国服战网。
而他在复制这个网址并且进行登录之后,确实是网易的网址,也就是我们熟悉的停服之后国服发布的暴雪游戏产品运营到期开放退款的说明。这是一件比较奇怪的事情,因为以前都没有出现这样的情况,现在突然提示跳转到国服战网的网址,是不是说明了简体中文客户端已经开始进行更新了呢?