时间:2023-06-11 15:06:02 | 来源:网站运营
时间:2023-06-11 15:06:02 来源:网站运营
手撕webpack:hello world
作为一个简单的例子,但是我将这句话拆成了几部分,放到了不同的文件里面。hello.js
,只导出一个简单的字符串:const hello = 'hello';export default hello;
然后再来一个helloWorld.js
,将hello
和world
拼成一句话,并导出拼接的这个方法:import hello from './hello';const world = 'world';const helloWorld = () => `${hello} ${world}`;export default helloWorld;
最后再来个index.js
,将拼好的hello world
插入到页面上去:import helloWorld from "./helloWorld";const helloWorldStr = helloWorld();function component() { const element = document.createElement("div"); element.innerHTML = helloWorldStr; return element;}document.body.appendChild(component());
现在如果你直接在html
里面引用index.js
是不能运行成功的,因为大部分浏览器都不支持import
这种模块导入。而webpack
就是来解决这个问题的,它会将我们模块化的代码转换成浏览器认识的普通JS来执行。webpack
这么庞大的一个体系,我们也不能一口吃个胖子,得一点一点来。webpack
自己的逻辑,编译后的文件还是有一百多行代码,所以即使我把具体逻辑折叠起来了,这个截图还是有点长,为了能够看清楚他的结构,我将它分成了4个部分,标记在了截图上,下面我们分别来看看这几个部分吧。__webpack_modules__
,这个对象里面有三个属性,属性名字是我们三个模块的文件路径,属性的值是一个函数,我们随便展开一个./src/helloWorld.js
看下:helloWorld.js
非常像:__webpack_require__.r
和__webpack_require__.d
,这两个辅助函数我们在后面会看到。import
关键字改成了__webpack_require__
函数,并用一个变量_hello__WEBPACK_IMPORTED_MODULE_0__
来接收了import
进来的内容,后面引用的地方也改成了这个,其他跟这个无关的代码,比如const world = 'world';
还是保持原样的。__webpack_modules__
对象存了所有的模块代码,其实对于模块代码的保存,在不同版本的webpack
里面实现的方式并不一样,我这个版本是5.4.0
,在4.x
的版本里面好像是作为数组存下来,然后在最外层的立即执行函数里面以参数的形式传进来的。但是不管是哪种方式,都只是转换然后保存一下模块代码而已。__webpack_require__
,:__webpack_module_cache__
作为加载了的模块的缓存__webpack_require__
其实就是用来加载模块的__webpack_modules__
将对应的模块取出来执行__webpack_modules__
就是上面第一块代码里的那个对象,取出的模块其实就是我们自己写的代码,取出执行的也是我们每个模块的代码export
的内容添加到module.exports
上,这就是前面说的__webpack_require__.d
辅助方法的作用。添加到module.exports
上其实就是添加到了__webpack_module_cache__
缓存上,后面再引用这个模块就直接从缓存拿了。__webpack_require__.d
:核心其实是Object.defineProperty
,主要是用来将我们模块导出的内容添加到全局的__webpack_module_cache__
缓存上。__webpack_require__.o
:其实就是Object.prototype.hasOwnProperty
的一个简写而已。__webpack_require__.r
:这个方法就是给每个模块添加一个属性__esModule
,来表明他是一个ES6
的模块。__webpack_require__
加载入口模块,启动执行。import
这种浏览器不认识的关键字替换成了__webpack_require__
函数调用。__webpack_require__
在实现时采用了类似CommonJS
的模块思想。export
的内容添加到这个模块对象上。import
和export
关键字,放到__webpack_modules__
对象上。__webpack_modules__
和最后启动的入口是变化的,其他代码,像__webpack_require__
,__webpack_require__.r
这些方法其实都是固定的,整个代码结构也是固定的,所以完全可以先定义好一个模板。import
这种代码转换成浏览器能识别的普通JS代码,所以我们首先要能够将代码解析出来。在解析代码的时候,可以将它读出来当成字符串替换,也可以使用更专业的AST
来解析。AST
全称叫Abstract Syntax Trees
,也就是抽象语法树
,是一个将代码用树来表示的数据结构,一个代码可以转换成AST
,AST
又可以转换成代码,而我们熟知的babel
其实就可以做这个工作。要生成AST
很复杂,涉及到编译原理,但是如果仅仅拿来用就比较简单了,本文就先不涉及复杂的编译原理,而是直接将babel
生成好的AST
拿来使用。babel
,而是使用的acorn。webpack
自己实现了一个JavascriptParser类,这个类里面用到了acorn
。本文写作时采用了babel
,这也是一个大家更熟悉的工具。babel
转换成AST
可以直接这样写:const fs = require("fs");const parser = require("@babel/parser");const config = require("../webpack.config"); // 引入配置文件// 读取入口文件const fileContent = fs.readFileSync(config.entry, "utf-8");// 使用babel parser解析ASTconst ast = parser.parse(fileContent, { sourceType: "module" });console.log(ast); // 把ast打印出来看看
上面代码可以将生成好的ast
打印在控制台:AST
,但是看起来并不清晰,关键数据其实是body
字段,这里的body
也只是展示了类型名字。所以照着这个写代码其实不好写,这里推荐一个在线工具https://astexplorer.net/,可以很清楚的看到每个节点的内容:AST
我们可以看到,body
主要有4块代码:ImportDeclaration
:就是第一行的import
定义VariableDeclaration
:第三行的一个变量申明FunctionDeclaration
:第五行的一个函数定义ExpressionStatement
:第十三行的一个普通语句VariableDeclaration
展开后,其实还有个函数调用helloWorld()
:traverse
遍历AST
AST
,我们可以使用@babel/traverse
来对他进行遍历和操作,比如我想拿到ImportDeclaration
进行操作,就直接这样写:// 使用babel traverse来遍历ast上的节点traverse(ast, { ImportDeclaration(path) { console.log(path.node); },});
上面代码可以拿到所有的import
语句:import
转换为函数调用import
:import helloWorld from "./helloWorld";
转换成普通浏览器能识别的函数调用:var _helloWorld__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/helloWorld.js");
为了实现这个功能,我们还需要引入@babel/types
,这个库可以帮我们创建新的AST
节点,所以这个转换代码写出来就是这样:const t = require("@babel/types");// 使用babel traverse来遍历ast上的节点traverse(ast, { ImportDeclaration(p) { // 获取被import的文件 const importFile = p.node.source.value; // 获取文件路径 let importFilePath = path.join(path.dirname(config.entry), importFile); importFilePath = `./${importFilePath}.js`; // 构建一个变量定义的AST节点 const variableDeclaration = t.variableDeclaration("var", [ t.variableDeclarator( t.identifier( `__${path.basename(importFile)}__WEBPACK_IMPORTED_MODULE_0__` ), t.callExpression(t.identifier("__webpack_require__"), [ t.stringLiteral(importFilePath), ]) ), ]); // 将当前节点替换为变量定义节点 p.replaceWith(variableDeclaration); },});
上面这段代码我们用了很多@babel/types
下面的API,比如t.variableDeclaration
,t.variableDeclarator
,这些都是用来创建对应的节点的,具体的API可以看这里。注意这个代码里面我有很多写死的地方,比如importFilePath
生成逻辑,还应该处理多种后缀名的,还有最终生成的变量名_${path.basename(importFile)}__WEBPACK_IMPORTED_MODULE_0__
,最后的数字我也是直接写了0
,按理来说应该是根据不同的import
顺序来生成的,但是本文主要讲webpack
的原理,这些细节上我就没花过多时间了。AST
,修改后的AST
可以用@babel/generator
又转换为代码:const generate = require('@babel/generator').default;const newCode = generate(ast).code;console.log(newCode);
这个打印结果是:import helloWorld from "./helloWorld";
已经被转换为var __helloWorld__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/helloWorld.js");
。import
进来的变量import
语句替换成了一个变量定义,变量名字也改为了__helloWorld__WEBPACK_IMPORTED_MODULE_0__
,自然要将调用的地方也改了。为了更好的管理,我们将AST
遍历,操作以及最后的生成新代码都封装成一个函数吧。function parseFile(file) { // 读取入口文件 const fileContent = fs.readFileSync(file, "utf-8"); // 使用babel parser解析AST const ast = parser.parse(fileContent, { sourceType: "module" }); let importFilePath = ""; // 使用babel traverse来遍历ast上的节点 traverse(ast, { ImportDeclaration(p) { // 跟之前一样的 }, }); const newCode = generate(ast).code; // 返回一个包含必要信息的新对象 return { file, dependcies: [importFilePath], code: newCode, };}
然后启动执行的时候就可以调这个函数了parseFile(config.entry);
拿到的结果跟之前的差不多:import
的地方也替换了,因为我们已经知道了这个地方是将它作为函数调用的,也就是要将const helloWorldStr = helloWorld();
转为这个样子:const helloWorldStr = (0,_helloWorld__WEBPACK_IMPORTED_MODULE_0__.default)();
这行代码的效果其实跟_helloWorld__WEBPACK_IMPORTED_MODULE_0__.default()
是一样的,为啥在前面包个(0, )
,我也不知道,有知道的大佬告诉下我呗。traverse
里面加一个CallExpression
:traverse(ast, { ImportDeclaration(p) { // 跟前面的差不多,省略了 }, CallExpression(p) { // 如果调用的是import进来的函数 if (p.node.callee.name === importVarName) { // 就将它替换为转换后的函数名字 p.node.callee.name = `${importCovertVarName}.default`; } }, });
这样转换后,我们再重新生成一下代码,已经像那么个样子了:parseFile
方法来解析处理入口文件,但是我们的文件其实不止一个,我们应该依据模块的依赖关系,递归的将所有的模块都解析了。要实现递归解析也不复杂,因为前面的parseFile
的依赖dependcies
已经返回了:function parseFiles(entryFile) { const entryRes = parseFile(entryFile); // 解析入口文件 const results = [entryRes]; // 将解析结果放入一个数组 // 循环结果数组,将它的依赖全部拿出来解析 for (const res of results) { const dependencies = res.dependencies; dependencies.map((dependency) => { if (dependency) { const ast = parseFile(dependency); results.push(ast); } }); } return results;}
然后就可以调用这个方法解析所有文件了:const allAst = parseFiles(config.entry);console.log(allAst);
看看解析结果吧:__webpack_modules__
已经很像了,但是还有两块没有处理:import
进来的内容作为变量使用,比如export
语句还没处理import
进来的变量(作为变量调用)CallExpression
处理过作为函数使用的import
变量了,现在要处理作为变量使用的其实用Identifier
处理下就行了,处理逻辑跟之前的CallExpression
差不多:traverse(ast, { ImportDeclaration(p) { // 跟以前一样的 }, CallExpression(p) { // 跟以前一样的 }, Identifier(p) { // 如果调用的是import进来的变量 if (p.node.name === importVarName) { // 就将它替换为转换后的变量名字 p.node.name = `${importCovertVarName}.default`; } }, });
现在再运行下,import
进来的变量名字已经变掉了:export
语句export
需要进行两个处理:export default
,需要添加一个__webpack_require__.d
的辅助方法调用,内容都是固定的,加上就行。export
语句转换为普通的变量定义。export
语句,在遍历ast
的时候添加ExportDefaultDeclaration
就行了:traverse(ast, { ImportDeclaration(p) { // 跟以前一样的 }, CallExpression(p) { // 跟以前一样的 }, Identifier(p) { // 跟以前一样的 }, ExportDefaultDeclaration(p) { hasExport = true; // 先标记是否有export // 跟前面import类似的,创建一个变量定义节点 const variableDeclaration = t.variableDeclaration("const", [ t.variableDeclarator( t.identifier("__WEBPACK_DEFAULT_EXPORT__"), t.identifier(p.node.declaration.name) ), ]); // 将当前节点替换为变量定义节点 p.replaceWith(variableDeclaration); }, });
然后再运行下就可以看到export
语句被替换了:hasExport
变量判断在AST
转换为代码的时候要不要加__webpack_require__.d
辅助函数:const EXPORT_DEFAULT_FUN = `__webpack_require__.d(__webpack_exports__, { "default": () => (__WEBPACK_DEFAULT_EXPORT__)});/n`;function parseFile(file) { // 省略其他代码 // ...... let newCode = generate(ast).code; if (hasExport) { newCode = `${EXPORT_DEFAULT_FUN} ${newCode}`; }}
最后生成的代码里面export
也就处理好了:__webpack_require__.r
的调用添上吧__webpack_require__.r
的调用__esModule
标记的,我们也给他加上吧,直接在前面export
辅助方法后面加点代码就行了:const ESMODULE_TAG_FUN = `__webpack_require__.r(__webpack_exports__);/n`;function parseFile(file) { // 省略其他代码 // ...... let newCode = generate(ast).code; if (hasExport) { newCode = `${EXPORT_DEFAULT_FUN} ${newCode}`; } // 下面添加模块标记代码 newCode = `${ESMODULE_TAG_FUN} ${newCode}`;}
再运行下看看,这个代码也加上了:ejs
模板引擎:// 模板文件,直接从webpack生成结果抄过来,改改就行/******/ (() => { // webpackBootstrap/******/ "use strict";// 需要替换的__TO_REPLACE_WEBPACK_MODULES__/******/ var __webpack_modules__ = ({ <% __TO_REPLACE_WEBPACK_MODULES__.map(item => { %> '<%- item.file %>' : ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { <%- item.code %> }), <% }) %> });// 省略中间的辅助方法 /************************************************************************/ /******/ // startup /******/ // Load entry module// 需要替换的__TO_REPLACE_WEBPACK_ENTRY /******/ __webpack_require__('<%- __TO_REPLACE_WEBPACK_ENTRY__ %>'); /******/ // This entry module used 'exports' so it can't be inlined /******/ })() ; //# sourceMappingURL=main.js.map
__TO_REPLACE_WEBPACK_MODULES__
来生成最终的__webpack_modules__
__TO_REPLACE_WEBPACK_ENTRY__
来替代动态的入口文件webpack
代码里面使用前面生成好的AST
数组来替换模板的__TO_REPLACE_WEBPACK_MODULES__
webpack
代码里面使用前面拿到的入口文件来替代模板的__TO_REPLACE_WEBPACK_ENTRY__
ejs
来生成最终的代码// 使用ejs将上面解析好的ast传递给模板// 返回最终生成的代码function generateCode(allAst, entry) { const temlateFile = fs.readFileSync( path.join(__dirname, "./template.js"), "utf-8" ); const codes = ejs.render(temlateFile, { __TO_REPLACE_WEBPACK_MODULES__: allAst, __TO_REPLACE_WEBPACK_ENTRY__: entry, }); return codes;}
ejs
生成好的代码写入配置的输出路径就行了:const codes = generateCode(allAst, config.entry);fs.writeFileSync(path.join(config.output.path, config.output.filename), codes);
然后就可以使用我们自己的webpack
来编译代码,就可以看到效果了。webpack
的基本原理,并自己手写实现了一个基本的支持import
和export
的default
的webpack
。完整代码在这里。webpack
最基本的功能其实是将JS
的高级模块化语句,import
和require
之类的转换为浏览器能认识的普通函数调用语句。AST
,也就是将代码转换为抽象语法树
。AST
是一个描述代码结构的树形数据结构,代码可以转换为AST
,AST
也可以转换为代码。babel
可以将代码转换为AST
,但是webpack
官方并没有使用babel
,而是基于acorn自己实现了一个JavascriptParser。webpack
构建的结果入手,也使用AST
自己生成了一个类似的代码。webpack
最终生成的代码其实分为动态和固定的两部分,我们将固定的部分写入一个模板,动态的部分在模板里面使用ejs
占位。babel
来生成AST
,并对其进行修改,最后再使用babel
将其生成新的代码。AST
时,我们从配置的入口文件开始,递归的解析所有文件。即解析入口文件的时候,将它的依赖记录下来,入口文件解析完后就去解析他的依赖文件,在解析他的依赖文件时,将依赖的依赖也记录下来,后面继续解析。重复这种步骤,直到所有依赖解析完。ejs
将其写入模板,以生成最终的代码。require
或者AMD
,其实思路是类似的,最终生成的代码也是差不多的,主要的差别在AST
解析那一块。关键词: