超通俗易懂的webpack核心原理Webpack 是一个模块打包工具,它可以分析项目的依赖关系,将这些依赖转换和打包为合
Webpack 是一个模块打包工具,它可以分析项目的依赖关系,将这些依赖转换和打包为合适的格式以供浏览器使用。
基本原理
- 入口:读取
entry
入口文件的内容,分析项目的依赖关系- 模块解析:Webpack 通过其配置的加载器(
loaders
)解析模块。- 打包:Webpack 将这些模块打包为一个或多个
bundle
。- 插件:Webpack 允许使用插件来扩展其功能,比如插件可以用来优化打包、压缩代码、生成静态资源等。
- 出口:最后,Webpack 将打包后的文件输出到配置的出口路径。
简单介绍一下webpack,我们直接来看看webpack打包后的文件是什么样?
首先进行初始化和依赖的安装
npm init
yarn add webpack webpack-cli -D
然后在package.json
增加打包命令
// package.json
"scripts": {
"build": "webpack"
}
然后新建一个 webpack.config.js
文件,进行输入文件和输出文件的配置
// webpack.config.js
const path = require('path');
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
};
然后在src
目录下创建index.js
文件
// src/index.js
console.log('aaa')
执行 yarn bulid
的命令,就可以看到在 dist
目录下生成了 bundle.js
文件,其为打包后的产物,打包后的代码如下
(() => {
var __webpack_modules__ =
({
"./src/index.js": (() => {
eval("console.log('aaa')");
})
});
var __webpack_exports__ = {};
__webpack_modules__["./src/index.js"]();
})();
可以看到打包出来的代码被一个立即执行函数包裹着,里面的模块里是一个对象,key
是入口文件的路径,value
是对应的代码,假设index
文件引入了其他文件会是怎样的?
// src/index.js
import add from './add'
console.log(add(1, 2))
// src/add.js
export default function add (a, b) {
return a + b
}
打包看看bundle
代码,放主要代码
"use strict";
var __webpack_modules__ = ({
"./src/add.js":
((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (/* binding */ add)\n/* harmony export */ });\nfunction add (a, b) {\r\n return a + b\r\n}\n\n//# sourceURL=webpack://webpack/./src/add.js?");
}),
"./src/index.js":
((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _add__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./add */ \"./src/add.js\");\n\r\nconsole.log((0,_add__WEBPACK_IMPORTED_MODULE_0__[\"default\"])(1, 2))\n\n//# sourceURL=webpack://webpack/./src/index.js?");
})
});
可以发现modules里的key是入口文件和import
的路径,可以假设modules存储的是入口文件涉及的依赖,后面会验证。
我们一起手动实现一个简易的webpack,来学习一下基本原理
实现简易的webpack
webpack的核心原理
打包主要流程如下:
- 需要读到入口文件里面的内容。
- 分析入口文件,递归的去读取模块所依赖的文件内容,生成AST语法树。
- 根据AST语法树,生成浏览器能够运行的代码
获取依赖关系
假设入口文件依赖另外一个文件的,那么webpack是怎么获取这些依赖关系的?
Webpack 分析入口文件的依赖关系主要是通过解析文件的抽象语法树(AST,Abstract Syntax Tree)来实现的。Webpack 会先将源代码转换为 AST,然后通过遍历 AST 来查找 import
和 require
语句,从而确定代码的依赖关系。
代码转ast
什么工具可以将代码转换为ast?
babel插件 @babel/parser
可以将源代码解析成 AST ,方便各个插件分析语法进行相应的处理。
我们来安装依赖并编写一个执行文件来试试
yarn add @babel/parser -D
// src/test.js
const fs = require('fs');
const path = require('path')
const parser = require('@babel/parser')
const code = fs.readFileSync(path.resolve(__dirname, './index.js'), 'utf8');
const ast = parser.parse(code,{
sourceType: 'module', // 表明我们解析的是 ES6 模块
plugins: [],
})
console.log(ast, '==============ast==============')
上面的截图就是将 index.js
文件转换为ast
结构的样子,也可以通过该网站(在线将代码转换为ast)来查看ast的结构
遍历ast
接下来我们怎么来看看怎么获取到 import
和 require
语句?我们可以通过遍历入口文件然后识别对应的 import节点,就可以获取对应的路径,其中@babel/traverse
插件可以实现遍历ast语法树的功能。
首先我们要知道 import的节点 是什么样?可以通过ast
语法树中得知。
看看上图代码转换成的ast
语法树,import语句对应的节点就是 ImportDeclaration
,所以我们只需要在该节点内获取路径即可。
我们来给index文件增加import的例子来看看
// src/index.js
import add from './add.js'
const c = add(1, 2)
// src/test.js
const content = fs.readFileSync(path.resolve(__dirname, './index.js'), 'utf8');
const ast = parser.parse(content,{
sourceType: 'module', // 表明我们解析的是 ES6 模块
plugins: [],
})
traverse(ast, {
ImportDeclaration(path) { // 访问每个 import 声明节点
const source = path.node.source.value; // 获取模块的路径
console.log(source, 'source')
}
})
确实获取到了 import
的路径,这样如果要获取所有的依赖只需要通过 递归,去遍历 import
的文件就可以获取到整一个依赖的关系了,接下来我们写一个完整的例子
// src/index.js
import add from './add.js'
import minus from './minus.js'
const c = add(1, 2)
const d = minus(3, 1)
console.log(c, d)
// src/add.js
import { defaultNum } from './const.js'
export default function add (a, b) {
return a + b + defaultNum
}
// src/minus.js
export default function minus (a, b) {
return a - b
}
// src/const.js
export const defaultNum = 5
开始实践
yarn add @babel/traverse -D
// src/test.js
const fs = require('fs');
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default;
// 获取对应模块结构
const getModule = (dPath) => {
const content = fs.readFileSync(path.resolve(__dirname, dPath), 'utf8');
const ast = parser.parse(content,{
sourceType: 'module', // 表明我们解析的是 ES6 模块
plugins: [],
})
const dependencies = []
traverse(ast, {
ImportDeclaration(path) { // 访问每个 import 声明节点
const source = path.node.source.value; // 获取模块的路径
dependencies.push(source)
}
})
return {
path: dPath,
dependencies
}
}
// 获取依赖图谱
const getGraph = (entry) => {
const entryModule = getModule(entry)
const graphArray = [entryModule]
const depsGraph = {}
for(let module of graphArray) {
if (module.dependencies) {
for (let dep of module.dependencies) {
graphArray.push(getModule(dep))
}
}
}
for (graph of graphArray) {
depsGraph[graph.path] = graph
}
return depsGraph
}
getGraph('./index.js')
当我们遍历 entry
文件,获取到 import
对应的路径时,通过递归就可以获取到所有的依赖关系,依赖图谱结构如下
这样我们又完成了一大步!
转码
接下来我们就需要对代码进行转码,即将es6转换成es5,为了兼容不支持ES6语法的旧版浏览器,babel工具的@babel/core
和@babel/preset-env
可以实现转码。
yarn add @babel/core @babel/preset-env -D
// test.js
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"]
})
下图的code里就是转码后的代码
生成代码字符串
因为浏览器识别不了 require
和 export
,我们需要根据依赖图谱,通过重写require
来获取到对应依赖所引用的东西,生成打包代码
function bundle(entry){
//要先把对象转换为字符串,不然在下面的模板字符串中会默认调取对象的toString方法,参数变成[Object object]
const graph = JSON.stringify(getGraph(entry))
return `
(function(graph) {
//require函数的本质是执行一个模块的代码,然后将相应变量挂载到exports对象上
function require(module) {
//localRequire的本质是拿到依赖包的exports变量
function localRequire(relativePath) {
return require(relativePath);
}
var exports = {};
(function(require, exports, code) {
eval(code);
})(localRequire, exports, graph[module].code);
return exports;//函数返回指向局部变量,形成闭包,exports变量在函数执行后不会被摧毁
}
require('${entry}')
})(${graph})`
}
console.log(bundle('./index.js'))
看代码肯定一脸懵逼不知道什么意思,我们一步步来分析,require('${entry}'
最开始的肯定是先执行index.js
入口文件的code,我们来看看
入口文件的code里有我们转码之后的 require
函数,会执行到我们重写的 require
函数里,然后就会执行到./add.js
的code,如下
exports(外层定义的var exports = {}
)里的default值等于function add
,然后会被return出去,即
var _add = _interopRequireDefault(require("./add.js"));
等价于======>
var _add = {'default': function add()...}
以此类推对通过依赖关系进行递归解析,最终就生成打包代码
我们将log出来的代码copy到浏览器的控制台中
哇!成功了,再次手写webpack就大功告成啦!
完整代码
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 getModule = (dPath) => {
const content = fs.readFileSync(path.resolve(__dirname, dPath), 'utf8');
const ast = parser.parse(content,{
sourceType: 'module', // 表明我们解析的是 ES6 模块
plugins: [],
})
const dependencies = []
traverse(ast, {
ImportDeclaration(path) { // 访问每个 import 声明节点
const source = path.node.source.value; // 获取模块的路径
dependencies.push(source)
}
})
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"]
})
return {
path: dPath,
dependencies,
code
}
}
const getGraph = (entry) => {
const entryModule = getModule(entry)
const graphArray = [entryModule]
const depsGraph = {}
for(let module of graphArray) {
if (module.dependencies) {
for (let dep of module.dependencies) {
graphArray.push(getModule(dep))
}
}
}
for (graph of graphArray) {
depsGraph[graph.path] = graph
}
return depsGraph
}
function bundle(entry){
//要先把对象转换为字符串,不然在下面的模板字符串中会默认调取对象的toString方法,参数变成[Object object],显然不行
const graph = JSON.stringify(getGraph(entry))
return `
(function(graph) {
//require函数的本质是执行一个模块的代码,然后将相应变量挂载到exports对象上
function require(module) {
//localRequire的本质是拿到依赖包的exports变量
function localRequire(relativePath) {
return require(relativePath);
}
var exports = {};
(function(require, exports, code) {
eval(code);
})(localRequire, exports, graph[module].code);
return exports;//函数返回指向局部变量,形成闭包,exports变量在函数执行后不会被摧毁
}
require('${entry}')
})(${graph})`
}
console.log(bundle('./index.js'))
转载自:https://juejin.cn/post/7379157261426622505