一 目录

不折腾的前端,和咸鱼有什么区别

目录
一 目录
二 前言
三 第一步 转换代码、生成依赖
四 第二步 生成依赖图谱
五 第三步 生成代码字符串

二 前言

返回目录
参考文章:实现一个简单的Webpack

Webpack 的本质就是一个模块打包器,工作就是将每个模块打包成相应的 bundle

首先,我们需要准备目录:

  1. + 项目根路径 || 文件夹
  2. - index.js - 主入口
  3. - message.js - 主入口依赖文件
  4. - word.js - 主入口依赖文件的依赖文件
  5. - bundler.js - 打包器
  6. - bundle.js - 打包后存放代码的文件

最终的项目地址:all-for-one - 031-手写 Webpack

如果小伙伴懒得敲,那可以看上面仓库的最终代码。

然后,我们 index.jsmessage.jsword.js 内容如下:

index.js

  1. // index.js
  2. import message from "./message.js";
  3. console.log(message);

message.js

  1. // message.js
  2. import { word } from "./word.js";
  3. const message = `say ${word}`;
  4. export default message;

word.js

  1. // word.js
  2. export const word = "hello";

最后,我们实现一个 bundler.js 文件,将 index.js 当成入口,将里面牵扯的文件都转义并执行即可!

实现思路:

  1. 利用 babel 完成代码转换,并生成单个文件的依赖
  2. 生成依赖图谱
  3. 生成最后打包代码

下面分 3 章尝试这个内容。

三 第一步 转换代码、生成依赖

这一步需要利用 babel 帮助我们进行转换,所以先装包:

  1. npm i @babel/parser @babel/traverse @babel/core @babel/preset-env -D

转换代码需要:

  1. 利用 @babel/parser 生成 AST 抽象语法树
  2. 利用 @babel/traverse 进行 AST 遍历,记录依赖关系
  3. 通过 @babel/core@babel/preset-env 进行代码的转换

然后添加内容:

bundler.js

  1. const fs = require("fs");
  2. const path = require("path");
  3. const parser = require("@babel/parser");
  4. const traverse = require("@babel/traverse").default;
  5. const babel = require("@babel/core");
  6. // 第一步:转换代码、生成依赖
  7. function stepOne(filename) {
  8. // 读入文件
  9. const content = fs.readFileSync(filename, "utf-8");
  10. const ast = parser.parse(content, {
  11. sourceType: "module", // babel 官方规定必须加这个参数,不然无法识别 ES Module
  12. });
  13. const dependencies = {};
  14. // 遍历 AST 抽象语法树
  15. traverse(ast, {
  16. // 获取通过 import 引入的模块
  17. ImportDeclaration({ node }) {
  18. const dirname = path.dirname(filename);
  19. const newFile = "./" + path.join(dirname, node.source.value);
  20. // 保存所依赖的模块
  21. dependencies[node.source.value] = newFile;
  22. },
  23. });
  24. //通过 @babel/core 和 @babel/preset-env 进行代码的转换
  25. const { code } = babel.transformFromAst(ast, null, {
  26. presets: ["@babel/preset-env"],
  27. });
  28. return {
  29. filename, // 该文件名
  30. dependencies, // 该文件所依赖的模块集合(键值对存储)
  31. code, // 转换后的代码
  32. };
  33. }
  34. console.log('--- step one ---');
  35. const one = stepOne('./index.js');
  36. console.log(one);
  37. fs.writeFile('bundle.js', one.code, () => {
  38. console.log('写入成功');
  39. });

通过 Node 的方式运行这段代码:node bundler.js

  1. // --- step one ---
  2. {
  3. filename: './index.js',
  4. dependencies: { './message.js': './message.js' },
  5. code:`
  6. "use strict";
  7. var _message = _interopRequireDefault(require("./message.js"));
  8. function _interopRequireDefault(obj) {
  9. return obj && obj.__esModule ? obj : { "default": obj };
  10. }
  11. // index.js
  12. console.log(_message["default"]);
  13. `,
  14. }
  1. 入口 filenameindex.js
  2. 依赖 message.js
  3. 转义代码 code

所以将 code 提取到 bundle.js 中进行查看:

bundler.js

  1. // ...代码省略
  2. fs.writeFile('bundle.js', one.code, () => {
  3. console.log('写入成功');
  4. });

bundle.js

  1. "use strict";
  2. var _message = _interopRequireDefault(require("./message.js"));
  3. function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
  4. // index.js
  5. console.log(_message["default"]);

解读下这个文件内容:

  • use strict:使用严格模式
  • _interopRequireDefault:对不符合 babel 标准的模块添加 default 属性,并指向自身对象以避免 exports.default 出错

所以现在这份文件的内容是可以运行的了,但是你运行的时候会报错,报错内容如下:

  1. import { word } from "./word.js";
  2. ^
  3. SyntaxError: Unexpected token {

也就是说我们执行到 message.js,但是它里面的内容没法运行,因为 importES6 内容嘛。

咋整,继续看下面内容。

四 第二步 生成依赖图谱

返回目录
既然我们只生成了一份转义后的文件:

  1. --- step one ---
  2. {
  3. filename: './index.js',
  4. dependencies: { './message.js': './message.js' },
  5. code:`
  6. "use strict";
  7. var _message = _interopRequireDefault(require("./message.js"));
  8. function _interopRequireDefault(obj) {
  9. return obj && obj.__esModule ? obj : { "default": obj };
  10. }
  11. // index.js
  12. console.log(_message["default"]);
  13. `,
  14. }

那么我们可以根据其中的 dependencies 进行递归,将整个依赖图谱都找出来:

bundler.js

  1. // ...省略前面内容
  2. // 第二步:生成依赖图谱
  3. // entry 为入口文件
  4. function stepTwo(entry) {
  5. const entryModule = stepOne(entry);
  6. // 这个数组是核心,虽然现在只有一个元素,往后看你就会明白
  7. const graphArray = [entryModule];
  8. for (let i = 0; i < graphArray.length; i++) {
  9. const item = graphArray[i];
  10. const { dependencies } = item; // 拿到文件所依赖的模块集合(键值对存储)
  11. for (let j in dependencies) {
  12. graphArray.push(stepOne(dependencies[j])); // 敲黑板!关键代码,目的是将入口模块及其所有相关的模块放入数组
  13. }
  14. }
  15. // 接下来生成图谱
  16. const graph = {};
  17. graphArray.forEach((item) => {
  18. graph[item.filename] = {
  19. dependencies: item.dependencies,
  20. code: item.code,
  21. };
  22. });
  23. return graph;
  24. }
  25. console.log('--- step two ---');
  26. const two = stepTwo('./index.js');
  27. console.log(two);
  28. let word = '';
  29. for (let i in two) {
  30. word = word + two[i].code + '\n\n';
  31. }
  32. fs.writeFile('bundle.js', word, () => {
  33. console.log('写入成功');
  34. });

所以当我们 node bundler.js 的时候,会打印内容出来:

  1. --- step two ---
  2. {
  3. './index.js': {
  4. dependencies: { './message.js': './message.js' },
  5. 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"]);'
  6. },
  7. './message.js': {
  8. dependencies: { './word.js': './word.js' },
  9. code:
  10. '"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;'
  11. },
  12. './word.js': {
  13. dependencies: {},
  14. code:
  15. '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nexports.word = void 0;\n// word.js\nvar word = "hello";\nexports.word = word;'
  16. }
  17. }

可以看到我们将整个依赖关系中的文件都搜索出来,并通过 babel 进行了转换,然后 jsliang 通过 Nodefs 模块将其写进了 bundle.js 中:

bundler.js

  1. let word = '';
  2. for (let i in two) {
  3. word = word + two[i].code + '\n\n';
  4. }
  5. fs.writeFile('bundle.js', word, () => {
  6. console.log('写入成功');
  7. });

再来看 bundle.js 内容:

bundle.js

  1. "use strict";
  2. var _message = _interopRequireDefault(require("./message.js"));
  3. function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
  4. // index.js
  5. console.log(_message["default"]);
  6. "use strict";
  7. Object.defineProperty(exports, "__esModule", {
  8. value: true
  9. });
  10. exports["default"] = void 0;
  11. var _word = require("./word.js");
  12. // message.js
  13. var message = "say ".concat(_word.word);
  14. var _default = message;
  15. exports["default"] = _default;
  16. "use strict";
  17. Object.defineProperty(exports, "__esModule", {
  18. value: true
  19. });
  20. exports.word = void 0;
  21. // word.js
  22. var word = "hello";
  23. exports.word = word;

跟步骤一的解析差不多,不过这样子的内容是没法运行的,毕竟我们塞到同一个文件中了,所以需要步骤三咯。

五 第三步 生成代码字符串

返回目录
最后一步我们实现下面代码:

bundler.js

  1. // 下面是生成代码字符串的操作
  2. function stepThree(entry){
  3. // 要先把对象转换为字符串,不然在下面的模板字符串中会默认调取对象的 toString 方法,参数变成 [Object object],显然不行
  4. const graph = JSON.stringify(stepTwo(entry))
  5. return `(function(graph) {
  6. // require 函数的本质是执行一个模块的代码,然后将相应变量挂载到 exports 对象上
  7. function require(module) {
  8. // localRequire 的本质是拿到依赖包的 exports 变量
  9. function localRequire(relativePath) {
  10. return require(graph[module].dependencies[relativePath]);
  11. }
  12. var exports = {};
  13. (function(require, exports, code) {
  14. eval(code);
  15. })(localRequire, exports, graph[module].code);
  16. return exports; // 函数返回指向局部变量,形成闭包,exports 变量在函数执行后不会被摧毁
  17. }
  18. require('${entry}')
  19. })(${graph})
  20. `;
  21. };
  22. console.log('--- step three ---');
  23. const three = stepThree('./index.js');
  24. console.log(three);
  25. fs.writeFile('bundle.js', three, () => {
  26. console.log('写入成功');
  27. });

可以看到,stepThree 返回的是一个立即执行函数,需要传递 graph

  1. (function(graph) {
  2. // 具体内容
  3. })(graph)

那么图谱(graph)怎么来?需要通过 stepTwo(entry) 拿到了依赖图谱。

但是,因为步骤二返回的是对象啊,如果直接传进去对象,那么就会被转义,所以需要 JSON.stringify()

  1. const graph = JSON.stringify(stepTwo(entry));
  2. (function(graph) {
  3. // 具体内容
  4. })(graph)

那为什么这个函数(stepThree)需要传递 entry?原因在于我们需要一个主入口,就好比 Webpack 单入口形式:

转变前后

  1. // 转变前
  2. const graph = JSON.stringify(stepTwo(entry));
  3. (function(graph) {
  4. function require(module) {
  5. // ...具体内容
  6. }
  7. require('${entry}')
  8. })(graph)
  9. /* --- 分界线 --- */
  10. // 转变后
  11. const graph = JSON.stringify(stepTwo(entry));
  12. (function(graph) {
  13. function require(module) {
  14. // ...具体内容
  15. }
  16. require('./index.js')
  17. })(graph)

这样我们就清楚了,从 index.js 入手,然后再看里面具体内容:

  1. function require(module) {
  2. // localRequire 的本质是拿到依赖包的 exports 变量
  3. function localRequire(relativePath) {
  4. return require(graph[module].dependencies[relativePath]);
  5. }
  6. var exports = {};
  7. (function(require, exports, code) {
  8. eval(code);
  9. })(localRequire, exports, graph[module].code);
  10. return exports; // 函数返回指向局部变量,形成闭包,exports 变量在函数执行后不会被摧毁
  11. }
  12. require('./index.js')

eval 是指 JavaScript 可以运行里面的字符串代码,eval('2 + 2') 会出来结果 4,所以 eval(code) 就跟我们第一步的时候,node bundle.js 一样,执行 code 里面的代码。

所以我们执行 require(module) 里面的代码,先走:

  1. (function(require, exports, code) {
  2. eval(code);
  3. })(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) 里面

有的小伙伴会好奇这代码怎么走的,我们可以先看下面一段代码:

  1. const localRequire = (abc) => {
  2. console.log(abc);
  3. };
  4. const code = `
  5. console.log(456);
  6. doRequire(123)
  7. `;
  8. (function(doRequire, code) {
  9. eval(code);
  10. })(localRequire, code);

这段代码中,执行的 doRequire 其实就是传入进来的 localRequire 方法,最终输出 456123

现在,再回头来看:

区块一:bundle.js

  1. function require(module) {
  2. // localRequire 的本质是拿到依赖包的 exports 变量
  3. function localRequire(relativePath) {
  4. return require(graph[module].dependencies[relativePath]);
  5. }
  6. var exports = {};
  7. (function (require, exports, code) {
  8. eval(code);
  9. })(localRequire, exports, graph[module].code);
  10. return exports; // 函数返回指向局部变量,形成闭包,exports 变量在函数执行后不会被摧毁
  11. }
  12. require("./index.js");

它先执行 立即执行函数 (function (require, exports, code) {})(),再到 eval(code),从而执行下面代码:

区块二:graph['./index.js'].code

  1. "use strict";
  2. var _message = _interopRequireDefault(require("./message.js"));
  3. function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
  4. // index.js
  5. console.log(_message["default"]);

在碰到 require("./message.js") 的时候,继续进去上面【区块一】的代码,因为此刻的 require 是:

  1. function localRequire(relativePath) {
  2. return require(graph[module].dependencies[relativePath]);
  3. }

所以我们再调用自己的 require() 方法,将内容传递进去,变成:require('./message.js')

……以此类推,直到 './word.js' 里面没有 require() 方法体了,我们再执行下面内容,将 exports 导出去。

这就是这段内容的运行流程。

至于其中细节我们就不一一赘述了,小伙伴们如果还没看懂可以自行断点调试,这里面的代码口头描述的话 jsliang 讲得不是清楚。

最后我们看看输出整理后的 bundle.js

bundle.js

  1. (function (graph) {
  2. // require 函数的本质是执行一个模块的代码,然后将相应变量挂载到 exports 对象上
  3. function require(module) {
  4. // localRequire 的本质是拿到依赖包的 exports 变量
  5. function localRequire(relativePath) {
  6. return require(graph[module].dependencies[relativePath]);
  7. }
  8. var exports = {};
  9. (function (require, exports, code) {
  10. eval(code);
  11. })(localRequire, exports, graph[module].code);
  12. return exports; // 函数返回指向局部变量,形成闭包,exports 变量在函数执行后不会被摧毁
  13. }
  14. require("./index.js");
  15. })({
  16. "./index.js": {
  17. dependencies: { "./message.js": "./message.js" },
  18. code: `
  19. "use strict";
  20. var _message = _interopRequireDefault(require("./message.js"));
  21. function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
  22. // index.js
  23. console.log(_message["default"]);
  24. `,
  25. },
  26. "./message.js": {
  27. dependencies: { "./word.js": "./word.js" },
  28. code: `
  29. "use strict";
  30. Object.defineProperty(exports, "__esModule", {
  31. value: true
  32. });
  33. exports["default"] = void 0;
  34. var _word = require("./word.js");
  35. // message.js
  36. var message = "say ".concat(_word.word);
  37. var _default = message;
  38. exports["default"] = _default;
  39. `,
  40. },
  41. "./word.js": {
  42. dependencies: {},
  43. code: `
  44. "use strict";
  45. Object.defineProperty(exports, "__esModule", {
  46. value: true
  47. });
  48. exports.word = void 0;
  49. // word.js
  50. var word = "hello";
  51. exports.word = word;',
  52. },
  53. });

此时我们 node bundle.js,就可以获取到:

  1. say hello

这样我们就手撸完成了单入口的 Webpack 简单实现。