jsliang 求职系列 - 32 - Webpack 简单实现

一 目录

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

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

二 前言

返回目录node

参考文章:实现一个简单的Webpackgit

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

首先,咱们须要准备目录:shell

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

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

若是小伙伴懒得敲,那能够看上面仓库的最终代码。数组

而后,咱们 index.jsmessage.jsword.js 内容以下:babel

index.jsmarkdown

// index.js
import message from "./message.js";
console.log(message);
复制代码

message.js闭包

// message.js
import { word } from "./word.js";
const message = `say ${word}`;
export default message;
复制代码

word.js

// word.js
export const word = "hello";
复制代码

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

实现思路:

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

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

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

返回目录

这一步须要利用 babel 帮助咱们进行转换,因此先装包:

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

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.js

    console.log(_message["default"]);
`,
}
复制代码
  1. 入口 filenameindex.js
  2. 依赖 message.js
  3. 转义代码 code

因此 jsliangcode 提取到 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.js
console.log(_message["default"]);
复制代码

解读下这个文件内容:

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

因此如今这份文件的内容是能够运行的了,可是你运行的时候会报错,报错内容以下:

import { word } from "./word.js";
       ^

SyntaxError: Unexpected token {
复制代码

也就是说咱们执行到 message.js,可是它里面的内容无法运行,由于 importES6 内容嘛。

咋整,继续看下面内容。

四 第二步 生成依赖图谱

返回目录

既然咱们只生成了一份转义后的文件:

--- 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.js

    console.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 经过 Nodefs 模块将其写进了 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.js
console.log(_message["default"]);

"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports["default"] = void 0;

var _word = require("./word.js");

// message.js
var message = "say ".concat(_word.word);
var _default = message;
exports["default"] = _default;

"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.word = void 0;
// word.js
var 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 方法,最终输出 456123

如今,再回头来看:

区块一: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.js
console.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.js console.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.js var 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.js var word = "hello"; exports.word = word;', }, }); 复制代码

此时咱们 node bundle.js,就能够获取到:

say hello
复制代码

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


jsliang 的文档库由 梁峻荣 采用 知识共享 署名-非商业性使用-相同方式共享 4.0 国际 许可协议 进行许可。
基于 github.com/LiangJunron… 上的做品创做。
本许可协议受权以外的使用权限能够从 creativecommons.org/licenses/by… 处得到。