一 目录
不折腾的前端,和咸鱼有什么区别
| 目录 |
|---|
| 一 目录 |
| 二 前言 |
| 三 第一步 转换代码、生成依赖 |
| 四 第二步 生成依赖图谱 |
| 五 第三步 生成代码字符串 |
二 前言
返回目录
参考文章:实现一个简单的Webpack
Webpack 的本质就是一个模块打包器,工作就是将每个模块打包成相应的 bundle。
首先,我们需要准备目录:
+ 项目根路径 || 文件夹- index.js - 主入口- message.js - 主入口依赖文件- word.js - 主入口依赖文件的依赖文件- bundler.js - 打包器- bundle.js - 打包后存放代码的文件
最终的项目地址:all-for-one - 031-手写 Webpack
如果小伙伴懒得敲,那可以看上面仓库的最终代码。
然后,我们 index.js、message.js、word.js 内容如下:
index.js
// index.jsimport message from "./message.js";console.log(message);
message.js
// message.jsimport { word } from "./word.js";const message = `say ${word}`;export default message;
word.js
// word.jsexport const word = "hello";
最后,我们实现一个 bundler.js 文件,将 index.js 当成入口,将里面牵扯的文件都转义并执行即可!
实现思路:
- 利用
babel完成代码转换,并生成单个文件的依赖 - 生成依赖图谱
- 生成最后打包代码
下面分 3 章尝试这个内容。
三 第一步 转换代码、生成依赖
这一步需要利用
babel帮助我们进行转换,所以先装包:
npm i @babel/parser @babel/traverse @babel/core @babel/preset-env -D
转换代码需要:
- 利用
@babel/parser生成 AST 抽象语法树 - 利用
@babel/traverse进行 AST 遍历,记录依赖关系 - 通过
@babel/core和@babel/preset-env进行代码的转换
然后添加内容:
bundler.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");// 第一步:转换代码、生成依赖function stepOne(filename) {// 读入文件const content = fs.readFileSync(filename, "utf-8");const ast = parser.parse(content, {sourceType: "module", // babel 官方规定必须加这个参数,不然无法识别 ES Module});const dependencies = {};// 遍历 AST 抽象语法树traverse(ast, {// 获取通过 import 引入的模块ImportDeclaration({ node }) {const dirname = path.dirname(filename);const newFile = "./" + path.join(dirname, node.source.value);// 保存所依赖的模块dependencies[node.source.value] = newFile;},});//通过 @babel/core 和 @babel/preset-env 进行代码的转换const { code } = babel.transformFromAst(ast, null, {presets: ["@babel/preset-env"],});return {filename, // 该文件名dependencies, // 该文件所依赖的模块集合(键值对存储)code, // 转换后的代码};}console.log('--- step one ---');const one = stepOne('./index.js');console.log(one);fs.writeFile('bundle.js', one.code, () => {console.log('写入成功');});
通过 Node 的方式运行这段代码:node bundler.js:
// --- step one ---{filename: './index.js',dependencies: { './message.js': './message.js' },code:`"use strict";var _message = _interopRequireDefault(require("./message.js"));function _interopRequireDefault(obj) {return obj && obj.__esModule ? obj : { "default": obj };}// index.jsconsole.log(_message["default"]);`,}
- 入口
filename:index.js - 依赖
message.js - 转义代码
code
所以将 code 提取到 bundle.js 中进行查看:
bundler.js
// ...代码省略fs.writeFile('bundle.js', one.code, () => {console.log('写入成功');});
bundle.js
"use strict";var _message = _interopRequireDefault(require("./message.js"));function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }// index.jsconsole.log(_message["default"]);
解读下这个文件内容:
use strict:使用严格模式_interopRequireDefault:对不符合babel标准的模块添加default属性,并指向自身对象以避免exports.default出错
所以现在这份文件的内容是可以运行的了,但是你运行的时候会报错,报错内容如下:
import { word } from "./word.js";^SyntaxError: Unexpected token {
也就是说我们执行到 message.js,但是它里面的内容没法运行,因为 import 是 ES6 内容嘛。
咋整,继续看下面内容。
四 第二步 生成依赖图谱
返回目录
既然我们只生成了一份转义后的文件:
--- step one ---{filename: './index.js',dependencies: { './message.js': './message.js' },code:`"use strict";var _message = _interopRequireDefault(require("./message.js"));function _interopRequireDefault(obj) {return obj && obj.__esModule ? obj : { "default": obj };}// index.jsconsole.log(_message["default"]);`,}
那么我们可以根据其中的 dependencies 进行递归,将整个依赖图谱都找出来:
bundler.js
// ...省略前面内容// 第二步:生成依赖图谱// entry 为入口文件function stepTwo(entry) {const entryModule = stepOne(entry);// 这个数组是核心,虽然现在只有一个元素,往后看你就会明白const graphArray = [entryModule];for (let i = 0; i < graphArray.length; i++) {const item = graphArray[i];const { dependencies } = item; // 拿到文件所依赖的模块集合(键值对存储)for (let j in dependencies) {graphArray.push(stepOne(dependencies[j])); // 敲黑板!关键代码,目的是将入口模块及其所有相关的模块放入数组}}// 接下来生成图谱const graph = {};graphArray.forEach((item) => {graph[item.filename] = {dependencies: item.dependencies,code: item.code,};});return graph;}console.log('--- step two ---');const two = stepTwo('./index.js');console.log(two);let word = '';for (let i in two) {word = word + two[i].code + '\n\n';}fs.writeFile('bundle.js', word, () => {console.log('写入成功');});
所以当我们 node bundler.js 的时候,会打印内容出来:
--- step two ---{'./index.js': {dependencies: { './message.js': './message.js' },code: '"use strict";\n\nvar _message = _interopRequireDefault(require("./message.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\n// index.js\nconsole.log(_message["default"]);'},'./message.js': {dependencies: { './word.js': './word.js' },code:'"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nexports["default"] = void 0;\n\nvar _word = require("./word.js");\n\n// message.js\nvar message = "say ".concat(_word.word);\nvar _default = message;\nexports["default"] = _default;'},'./word.js': {dependencies: {},code:'"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nexports.word = void 0;\n// word.js\nvar word = "hello";\nexports.word = word;'}}
可以看到我们将整个依赖关系中的文件都搜索出来,并通过 babel 进行了转换,然后 jsliang 通过 Node 的 fs 模块将其写进了 bundle.js 中:
bundler.js
let word = '';for (let i in two) {word = word + two[i].code + '\n\n';}fs.writeFile('bundle.js', word, () => {console.log('写入成功');});
再来看 bundle.js 内容:
bundle.js
"use strict";var _message = _interopRequireDefault(require("./message.js"));function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }// index.jsconsole.log(_message["default"]);"use strict";Object.defineProperty(exports, "__esModule", {value: true});exports["default"] = void 0;var _word = require("./word.js");// message.jsvar message = "say ".concat(_word.word);var _default = message;exports["default"] = _default;"use strict";Object.defineProperty(exports, "__esModule", {value: true});exports.word = void 0;// word.jsvar word = "hello";exports.word = word;
跟步骤一的解析差不多,不过这样子的内容是没法运行的,毕竟我们塞到同一个文件中了,所以需要步骤三咯。
五 第三步 生成代码字符串
返回目录
最后一步我们实现下面代码:bundler.js
// 下面是生成代码字符串的操作function stepThree(entry){// 要先把对象转换为字符串,不然在下面的模板字符串中会默认调取对象的 toString 方法,参数变成 [Object object],显然不行const graph = JSON.stringify(stepTwo(entry))return `(function(graph) {// require 函数的本质是执行一个模块的代码,然后将相应变量挂载到 exports 对象上function require(module) {// localRequire 的本质是拿到依赖包的 exports 变量function localRequire(relativePath) {return require(graph[module].dependencies[relativePath]);}var exports = {};(function(require, exports, code) {eval(code);})(localRequire, exports, graph[module].code);return exports; // 函数返回指向局部变量,形成闭包,exports 变量在函数执行后不会被摧毁}require('${entry}')})(${graph})`;};console.log('--- step three ---');const three = stepThree('./index.js');console.log(three);fs.writeFile('bundle.js', three, () => {console.log('写入成功');});
可以看到,stepThree 返回的是一个立即执行函数,需要传递 graph:
(function(graph) {// 具体内容})(graph)
那么图谱(graph)怎么来?需要通过 stepTwo(entry) 拿到了依赖图谱。
但是,因为步骤二返回的是对象啊,如果直接传进去对象,那么就会被转义,所以需要 JSON.stringify():
const graph = JSON.stringify(stepTwo(entry));(function(graph) {// 具体内容})(graph)
那为什么这个函数(stepThree)需要传递 entry?原因在于我们需要一个主入口,就好比 Webpack 单入口形式:
转变前后
// 转变前const graph = JSON.stringify(stepTwo(entry));(function(graph) {function require(module) {// ...具体内容}require('${entry}')})(graph)/* --- 分界线 --- */// 转变后const graph = JSON.stringify(stepTwo(entry));(function(graph) {function require(module) {// ...具体内容}require('./index.js')})(graph)
这样我们就清楚了,从 index.js 入手,然后再看里面具体内容:
function require(module) {// localRequire 的本质是拿到依赖包的 exports 变量function localRequire(relativePath) {return require(graph[module].dependencies[relativePath]);}var exports = {};(function(require, exports, code) {eval(code);})(localRequire, exports, graph[module].code);return exports; // 函数返回指向局部变量,形成闭包,exports 变量在函数执行后不会被摧毁}require('./index.js')
eval 是指 JavaScript 可以运行里面的字符串代码,eval('2 + 2') 会出来结果 4,所以 eval(code) 就跟我们第一步的时候,node bundle.js 一样,执行 code 里面的代码。
所以我们执行 require(module) 里面的代码,先走:
(function(require, exports, code) {eval(code);})(localRequire, exports, graph[module].code);
此刻这个代码中,传递的参数有 3 个:
require:如果在eval(code)执行代码期间,碰到require就调用localRequire方法exports:如果在eval(code)执行代码期间,碰到exports就将里面内容设置到对象exports中graph[module].code:一开始module是'./index.js',所以查找graph中'./index.js'对应的code,将其传递进eval(code)里面
有的小伙伴会好奇这代码怎么走的,我们可以先看下面一段代码:
const localRequire = (abc) => {console.log(abc);};const code = `console.log(456);doRequire(123)`;(function(doRequire, code) {eval(code);})(localRequire, code);
这段代码中,执行的 doRequire 其实就是传入进来的 localRequire 方法,最终输出 456 和 123。
现在,再回头来看:
区块一:
bundle.js
function require(module) {// localRequire 的本质是拿到依赖包的 exports 变量function localRequire(relativePath) {return require(graph[module].dependencies[relativePath]);}var exports = {};(function (require, exports, code) {eval(code);})(localRequire, exports, graph[module].code);return exports; // 函数返回指向局部变量,形成闭包,exports 变量在函数执行后不会被摧毁}require("./index.js");
它先执行 立即执行函数 (function (require, exports, code) {})(),再到 eval(code),从而执行下面代码:
区块二:
graph['./index.js'].code
"use strict";var _message = _interopRequireDefault(require("./message.js"));function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }// index.jsconsole.log(_message["default"]);
在碰到 require("./message.js") 的时候,继续进去上面【区块一】的代码,因为此刻的 require 是:
function localRequire(relativePath) {return require(graph[module].dependencies[relativePath]);}
所以我们再调用自己的 require() 方法,将内容传递进去,变成:require('./message.js')。
……以此类推,直到 './word.js' 里面没有 require() 方法体了,我们再执行下面内容,将 exports 导出去。
这就是这段内容的运行流程。
至于其中细节我们就不一一赘述了,小伙伴们如果还没看懂可以自行断点调试,这里面的代码口头描述的话 jsliang 讲得不是清楚。
最后我们看看输出整理后的 bundle.js:
bundle.js
(function (graph) {// require 函数的本质是执行一个模块的代码,然后将相应变量挂载到 exports 对象上function require(module) {// localRequire 的本质是拿到依赖包的 exports 变量function localRequire(relativePath) {return require(graph[module].dependencies[relativePath]);}var exports = {};(function (require, exports, code) {eval(code);})(localRequire, exports, graph[module].code);return exports; // 函数返回指向局部变量,形成闭包,exports 变量在函数执行后不会被摧毁}require("./index.js");})({"./index.js": {dependencies: { "./message.js": "./message.js" },code: `"use strict";var _message = _interopRequireDefault(require("./message.js"));function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }// index.jsconsole.log(_message["default"]);`,},"./message.js": {dependencies: { "./word.js": "./word.js" },code: `"use strict";Object.defineProperty(exports, "__esModule", {value: true});exports["default"] = void 0;var _word = require("./word.js");// message.jsvar message = "say ".concat(_word.word);var _default = message;exports["default"] = _default;`,},"./word.js": {dependencies: {},code: `"use strict";Object.defineProperty(exports, "__esModule", {value: true});exports.word = void 0;// word.jsvar word = "hello";exports.word = word;',},});
此时我们 node bundle.js,就可以获取到:
say hello
这样我们就手撸完成了单入口的 Webpack 简单实现。
