撸一个简单的Webpack

759 阅读6分钟

前言

现在的webpack功能非常巨大,我们不可能实现所有功噢,所有本篇文章不考虑plugins,loaders,多文件打包等复杂问题,仅仅实现一个简单的bundle.js。将其中的ES6代码转为ES5代码,并将这些文件打包,生成一段能在浏览器正常运行的代码啦

本篇文章将从以下几个模块来分析如何实现一个简单的webpack

一、什么是webpack
二、webpack的构建流程
三、简单实现原理
四、准备工作
五、代码实现

一、什么是webpack

看下官方定义:

本质上,webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle

简言之,webpack是一个打包模块化 JavaScript 的工具,在 webpack里一切文件皆模块,通过 Loader 转换文件,通过 Plugin 注入钩子,最后输出由多个模块组合成的文件,专注于构建模块化项目

webpack的核心概念

  • 入口(entry)
  • 输出(output)
  • loader: 模块转换器、将所有的非JS文件转换为webpack能够有效识别的模块
  • 插件(plugins): 在webpack运行的生命周期中会广播出许多事件,plugin可以监听这些事件,在合适的时机通过webpack提供的API改变输出结果

二、webpack的构建流程

图片来源网上

  1. 初始化参数: 生成options (将webpack.config.js和shell中的参数,合并中options对象)
  2. 实例化complier对象 (webpack全局的配置对象,包含entry,output,loader,plugins等所有配置信息)
  3. 实例化Compilation对象 (compiler.run方法执行,开始编译过程,生成Compilation对象)
  4. 分析入口js文件,调用AST引擎(acorn)处理入口文件,生成抽象语法树AST,根据AST构建模块的所有依赖
  5. 通过loader处理入口文件的所有依赖,转换为js模块,生成AST,继续遍历,构建依赖的依赖,递归,直至所有依赖分析完毕
  6. 对生成的所有module进行处理,调用plugins,合并,拆分,生成chunk
  7. 将chunk生成为对应bundle文件,输出到指定目录

三、简单实现原理

  • 解析一个文件及其依赖
  • 构建一个依赖关系图
  • 将所有东西打包成一个单文件

四、准备工作

在编写构建工具之前,需要下载四个包

1.@babel/parser: 分析我们通过 fs.readFileSync 读取的文件内容,返回 AST (抽象语法树) 或者Babylon(Babel的解析器) 2.@babel/traverse: 可以遍历 AST, 并负责替换、移除和添加节点,拿到必要的数据 3.@babel/core: babel 核心模块,有个transformFromAst方法,可以将 AST 转化为浏览器可以运行的代码 4.@babel/preset-env: 将代码转化成 ES5 代码

五、代码实现

1.解析文件及其依赖

  • babylon解析代码生成AST;
  • babel-travere遍历AST,通过importDeclaration方法获取import引入的模块,计算依赖的模块集合
  • babel-coretransformFromAst属性结合babel/preset-env 将ES6->ES5

在线解析器

代码实现

const fs = require('fs');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
const { transformFromAst } = require('babel-core');

// 解析一个文件及其依赖
function createAsset(filename) {
  const content = fs.readFileSync(filename, "utf-8");
  /**
   * sourceType 可以是 "module" 或者 "script",它表示 Babylon 应该用哪种模式来解析。
   * "module" 将会在严格模式下解析并且允许模块定义,"script" 则不会
   * sourceType 的默认值是 "script" 并且在发现 import 或 export 时产生错误,使用 scourceType: "module" 来避免这些错误
   */
  const ast = babylon.parse(content, {
    sourceType: "module",
  });
  // 存储分析的依赖
  const dependencies = [];
  // 遍历AST抽象语法树
  traverse(ast, {
    // 获取通过import引入的模块
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value);
    },
  });
  const id = ID++; // 记录每一个载入的模块id,可清晰的看到当前的依赖的模块

  const { code } = transformFromAst(ast, null, {
    presets: ["env"],
  });
  return {
    id,
    filename,
    dependencies,
    code
  };
}

执行结果

{ id: 0,
  filename: './src/entry.js',
  dependencies: [ './message.js' ],
  code:
   '"use strict";\n\nvar _message = require("./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);' }

看结果就可以理解,输出的依赖是什么啦~

2.分析模块间的依赖、生成依赖图谱

从入口递归分析,生成整个项目的依赖图谱

代码实现


// 构建一个依赖关系图
function createGraph(entry) {
  const mainAssets = createAsset(entry);

  const queue = [mainAssets];

  for (const asset of queue) {
    const dirname = path.dirname(asset.filename);

    asset.mapping = {};

    asset.dependencies.forEach((relativePath) => {
      const absolutePath = path.join(dirname, relativePath);
      /**
       * src/message.js  ./message.js
       * src/name.js
       */
      const child = createAsset(absolutePath);
      asset.mapping[relativePath] = child.id;
      queue.push(child);
    });
  }
  return queue;
}

打印

const graph = createGraph("./src/entry.js");
console.log(graph)

执行结果

[ { id: 0,
    filename: './src/entry.js',
    dependencies: [ './message.js' ],
    code:
     '"use strict";\n\nvar _message = require("./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);',
    mapping: { './message.js': 1 } },
  { id: 1,
    filename: 'src/message.js',
    dependencies: [ './name.js' ],
    code:
     '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\n\nvar _name = require("./name.js");\n\nexports.default = "hello " + _name.name + "!";',
    mapping: { './name.js': 2 } },
  { id: 2,
    filename: 'src/name.js',
    dependencies: [],
    code:
     '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nvar name = exports.name = \'watermelon\';',
    mapping: {} } ]

3.将所有东西打包成一个单文件

/// bundle.js
function bundle(graph) {
  // 生成代码字符串
  let modules = "";
  graph.forEach((mod) => {
    modules += `${mod.id}:[
      function(require,module,exports){
        ${mod.code}
      },
      ${JSON.stringify(mod.mapping)}
    ],`;
  });
 /**
   * 打印 moudles,可看出是个这样0: [...], 1: [...]形式的字符串,最后在导入模块的时候,会给这个字符串加上
   * 一个 {}, => {0: [...], 1: [...]},你没看错,这是一个对象,这个对象里用数字作为 key、
   * 一个数组作为值、[0] 第一个就是我们被包裹的代码,[1]第二个就是对应的 mapping
   */
  const result = `
   (function(modules){
     function require(id){
       const [fn, mapping] = modules[id];
       // 代码引入文件时根据相对路径,这里需要把相对路径跟id进行一个映射
       function localRequire(relativePath){
 // require(id)--递归调用require(id),实现模块的自动导入
         return require(mapping[relativePath])
       }
       const module = {exports:{}};
       fn(localRequire,module,module.exports)
       return module.exports;
     }
     require(0); // 执行入口模块
   })({${modules}})
   `;
  return result;
}

const graph = createGraph("./src/entry.js");
const result = bundle(graph);
console.log(result)

直接在命令行输入 node bundle.js

执行结果


(function (modules) {
 function require(id) {
   const [fn, mapping] = modules[id];
   // 代码引入文件时根据相对路径,这里需要把相对路径跟id进行一个映射
   function localRequire(relativePath) {
     // require(id)-- 实现模块的自动倒入
     return require(mapping[relativePath])
   }
   const module = { exports: {} };
   fn(localRequire, module, module.exports)
   return module.exports;
 }
 require(0); // 执行入口模块
})({
 0: [
   function (require, module, exports) {
     "use strict";

     var _message = require("./message.js");

     var _message2 = _interopRequireDefault(_message);

     function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

     console.log(_message2.default);
   },
   { "./message.js": 1 }
 ], 1: [
   function (require, module, exports) {
     "use strict";

     Object.defineProperty(exports, "__esModule", {
       value: true
     });

     var _name = require("./name.js");

     exports.default = "hello " + _name.name + "!";
   },
   { "./name.js": 2 }
 ], 2: [
   function (require, module, exports) {
     "use strict";

     Object.defineProperty(exports, "__esModule", {
       value: true
     });
     var name = exports.name = 'watermelon';
   },
   {}
 ],
})

可看到打包处理的代码从整体来看,就是一个立即执行函数,大致结构如下

(function(modules) {
   ...
})({
    ...
})

我们的代码被加载到页面中的时候,是需要立即执行的,所以输出的bundle.js本质上要是一个立即执行函数,

const module = { exports : {} };
fn(localRequire, module, module.exports);

return module.exports;

等价于

const newModule = {exports: {}}
fn(childRequire, newModule, newModule.exports)
return newModule.exports // 这个模块的 exports 对象

4.生成的JS文件可在浏览器运行

1.复制上述生成的字符串代码直接在浏览器的控制台执行

2.建立一个dist目录,将字符串放在main.js文件里,测试

执行结果

3.当然也可自动指定输出的文件 增加下面代码

// bundle.js
const build = file => {
  const content = bundle(createGraph(file))
  // 写入到dist/main.js
  fs.mkdirSync('./dist')
  fs.writeFileSync('./dist/main.js', content)
}

build('./src/entry.js')

结尾:

目前为止,我们已经实现了一个简单的webpack啦,当然真正意义上的webpack实现还需要考虑非常多的因素,不过通过这个简单的例子,相信你对webpack做的事情,有了比较清楚的了解了。

完整代码地址戳我

参考

Ronen Amiel - Build Your Own Webpack