学习原理-手动实现小型webpack

1,755 阅读1分钟

前言

由于现在社区有太多的零配置脚手架,导致日常业务开发中基本不会关注webpack的原理,甚至一些具体配置都不会去看。

由于疫情严重被困在家,无聊中透露着寂寞,我就按照着官方40分钟教你写webpack,学着实现一个小型的webpack, 通过此次实践简单了解webpack的打包原理。

准备

因为涉及到 ES6 转 ES5,所以我们首先需要安装一些 Babel 相关的工具

yarn add babylon babel-core babel-traverse babel-preset-env

为了方便调试查看, 另外安装一些辅助包(高亮代码/格式代码)

yarn add cli-highlight js-beautify

package.json 文件中添加script命令

"bundle": "node bundler.js | js-beautify | highlight"

待打包文件

我们按照官方操作,创建三个待打包文件(entry.js -> message.js -> name.js)

// entry.js 入口文件
import message from './message.js';
console.log(message);

// message.js
import {name} from './name.js';
export default `hello ${name}!`;

// name.js
export const name = 'world';

源码文件

在根目录创建bundler.js, 编写打包代码, 这里面主要包括三个函数

// 据入口文件获取文件信息, 获取当前js文件的依赖信息
function createAsset(filename) {//代码略}
// 从入口开始分析所有依赖项,形成依赖图,采用广度遍历
function createGraph(entry) {//代码略}
// 根据生成的依赖关系图,生成浏览器可执行文件
function bundle(graph) {//代码略}

目录结构

- example
    - entry.js
    - message.js
    - name.js
- bundler.js

实现

获取依赖关系(createAsset)

如何获取依赖呢,其实思路很简单:

  1. 根据webpack的入口配置,指向一个文件, 通过这个文件的路径读取文件的信息
  2. 把读取的文件代码(字符串),转化成AST(抽象语法树)
  3. AST中找到它所依赖的模块, 获取其相对路径,组成json格式数据
const fs = require('fs')
const path = require('path')
const babylon = require('babylon')
const traverse = require('babel-traverse').default
const babel = require('babel-core')

let ID = 0

// 根据入口文件获取文件信息, 获取当前js文件的依赖信息
function createAsset(filename) {
  //获取文件,返回值是字符串
  const content = fs.readFileSync(filename, 'utf-8')
  // babylon 转换成 AST
  const ast = babylon.parse(content, {
    sourceType: 'module'
  })

  // 用来存储当前文件所依赖的文件路径
  const dependencies = []
  
  // 遍历 ast
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      // 把当前依赖的模块加入到数组中,其实这存的是字符串,
      dependencies.push(node.source.value)
    }
  })
  // 创建id, 方便之后找到依赖关系,下面会讲
  const id = ID++

  // 这边主要把ES6 的代码转成 ES5
  const { code } = babel.transformFromAst(ast, null, {
    presets: ['env']
  });

  return {
    id,
    filename,
    dependencies,
    code
  }
}

接下来在末行添加代码

console.log(createAsset('./example/entry.js'))

运行yarn bundle命令,查看生成的数据:

{
    id: 0,
    filename: './example/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);'
}

生成依赖关系图(createGraph)

从上面的代码可以看出,以入口文件为引查询到它的依赖关系(entry.js 依赖 message.js), 那之后就可以通过遍历的方式,查询message.js之后的依赖关系,形成数组数据

// 从入口开始分析所有依赖项,形成依赖图,采用广度遍历
function createGraph(entry) {
  // 如上所示,表示入口文件的依赖
  const mainAsset = createAsset(entry)
  
  // 既然要广度遍历肯定要有一个队列,第一个元素肯定是 从 "./example/entry.js" 返回的信息
  const queue = [mainAsset]

  for (const asset of queue) {
    // 获取相对路径
    const dirname = path.dirname(asset.filename)

    // 新增一个属性来保存子依赖项的数据
    // 保存类似 这样的数据结构 --->  {"./message.js" : 1}
    // 对应上面的 id,  方便找到依赖关系
    asset.mapping = {}

    // 根据依赖添加数组元素
    asset.dependencies.forEach(relativePath => {
      const absolutePath = path.join(dirname, relativePath)
      // 获得子依赖(子模块)的依赖项、代码、模块id,文件名
      const child = createAsset(absolutePath) 
      
      asset.mapping[relativePath] = child.id
      queue.push(child)
    })
  }
  return queue
}

接下来跟同样进行测试

const graph = createGraph('./example/entry.js')
console.log(graph)

得到如下数据:

[
    {
        id: 0,
        filename: './example/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: 'example/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: 'example/name.js',
        dependencies: [],
        code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nvar name = exports.name = \'world\';',
        mapping: {}
    }
]

打包代码(bundle)

接下来就是最重要的一步,以上已经生成的依赖关系数据,并且都有对应的code, 那现在要做的就是把这些code整合起来,让它可以在浏览器端运行。

首先看一下代码的大致结构:

function bundle(graph) {
  let modules = "";

  //循环依赖关系,并把每个模块中的代码存在function作用域里
  graph.forEach(mod => {
    modules += `${mod.id}:[
      function (require, module, exports){
        ${mod.code}
      },
      ${JSON.stringify(mod.mapping)},
    ],`;
  });

  const result = `
    (function(modules) {
       // 代码略
    })({${modules}})
  `;

  return result;
}

这块稍微有些复杂,一步一步来。

首先我们把所有的代码都转换成了ES5,并且生成了依赖关系图,现在要做的第一步就是把代码整合在一起,而做到这一步的整体思路就是:创建匿名函数,遍历整个依赖关系数组,生成一个函数对象当作参数传进去

生成的结构如图所示

(function(modules) {
    
})({
    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);
        },
        // 导入模块对应的 id
        {
            "./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 = 'world';
        },
        {},
    ],
})

由上图可以看到entry.js代码经过Babel转码后是什么样子

// 原代码
import message from './message.js';
console.log(message)

// 转换成 ES5
"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);

这代码放在浏览器中肯定是无法运行的

VM689:1 Uncaught ReferenceError: require is not defined
    at <anonymous>:1:16

Babel将我们 ES6 的模块化代码转换为了 CommonJS, 而现在需要在浏览器端运行CommonJS代码就需要一些操作,这也就是匿名函数内部所要做的事情, 具体实现就是模拟创建require方法,返回值是导入模块的export

例如entry.jsvar _message = require("./message.js");, 如何获取message.js的内容获取它的返回值?

(function(modules) {
    function require(id) {
        // 根据id获取 function 和 mapping
        const [fn, mapping] = modules[id];
        
        function localRequire(relativePath){
          //根据模块的路径在mapping中找到对应的模块id
          return require(mapping[relativePath]);
        }
        const module = {exports:{}};
        //执行每个模块的代码。
        //对应上方的 function(require, module, exports)
        fn(localRequire,module,module.exports);
        return module.exports;
    }
    // 导入entry.js 代码
    require(0)
})(modules)

乍一看可能有点蒙圈,请对照上方每块转换成ES5的代码,我简单解释一下具体的流程:

  1. require(0)执行entry.js代码,也就是执行fn(localRequire,module,module.exports);
  2. 走到 require("./message.js")这里,这里的require就是上方传入的localRequire,传入了"./message.js",根据mapping获取ID也就是1, 相当于执行了一次require(1),但此时还没有获取到message的值
  3. 走到了name.js中,又执行了require("./name.js");,如出一辙,也就是require(2)
  4. 此时执行了var name = exports.name = 'world';, 此时传入的exports终于有了值,require方法返回了module.exports的值,也就是说var _name = require("./name.js");, name的值是'world'
  5. 之后执行exports.default = "hello " + _name.name + "!";也获取了require("./message.js")的返回值

就我浅薄的理解而言,就是模拟创建并递归调用require方法,外部暴露module.export并作为返回值

完整的bundle函数如下所示:

function bundle(graph) {
  let modules = "";

  //循环依赖关系,并把每个模块中的代码存在function作用域里
  graph.forEach(mod => {
    modules += `${mod.id}:[
      function (require, module, exports){
        ${mod.code}
      },
      ${JSON.stringify(mod.mapping)},
    ],`;
  });

  //require, module, exports 是 cjs的标准不能再浏览器中直接使用,所以这里模拟cjs模块加载,执行,导出操作。
  const result = `
    (function(modules){
      //创建require函数, 它接受一个模块ID(这个模块id是数字0,1,2) ,它会在我们上面定义 modules 中找到对应是模块.
      function require(id){
        const [fn, mapping] = modules[id];
        function localRequire(relativePath){
          //根据模块的路径在mapping中找到对应的模块id
          return require(mapping[relativePath]);
        }
        const module = {exports:{}};
        //执行每个模块的代码。
        fn(localRequire,module,module.exports);
        return module.exports;
      }
      //执行入口文件,
      require(0);
    })({${modules}})
  `;

  return result;
}

打包

const graph = createGraph("./example/entry.js");
const result = bundle(graph);

// 打包生成文件
fs.writeFileSync("./bundle.js", result);

浏览器端完美运行

参考文献

官方40分钟教你写webpack

理解webpack原理, 手写一个100行的webpack