webpack工程化打包原理解析与实现

@airuikun 2019-03-21 12:04:30发表于 airuikun/blog

本文主要讲解了目前最流行的前端 webpack 工程化打包工具的内部打包原理和如何实现各个模块之间的依赖分析和代码注入,然后手把手教大家亲自实现一个简易的 webpack 打包工具,帮助大家学习和更深层次的理解 webpack 和前端界的工程化工具打包实现原理。

背景

       随着前端复杂度的不断提升,诞生出很多打包工具,比如最先的grunt,gulp。到后来的 webpack 和 Parcel 。但是目前很多脚手架工具,比如 vue-cli 已经帮我们集成了一些构建工具的使用。有的时候我们可能并不知道其内部的实现原理。其实了解这些工具的工作方式可以帮助我们更好理解和使用这些工具,也方便我们在项目开发中应用。

       弄清楚打包工具的背后原理,有利于我们实现各种神奇的自动化、工程化东西,比如自创 JavaScript 语法,又如蚂蚁金服 ant 中大名鼎鼎的 import 插件,甚至是前端文件自动扫描载入等,能够极大的提升我们工作效率。

一些基础知识

AST抽象语法树

       什么是 AST 抽象语法树?在计算机科学中,抽象语法树(abstract syntax tree 或者缩写为 AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。树上的每个节点都表示源代码中的一种结构。之所以说语法是抽象的,是因为这里的语法并不会表示出真实语法中出现的每个细节。例如下面这行代码:

const answer = 8 * 9;

转化成 AST 抽象语法树后,是如下这个样子的:

{
    "type": "Program",
    "body": [
        {
            "type": "VariableDeclaration",
            "declarations": [
                {
                    "type": "VariableDeclarator",
                    "id": {
                        "type": "Identifier",
                        "name": "answer"
                    },
                    "init": {
                        "type": "BinaryExpression",
                        "operator": "*",
                        "left": {
                            "type": "Literal",
                            "value": 8,
                            "raw": "8"
                        },
                        "right": {
                            "type": "Literal",
                            "value": 9,
                            "raw": "9"
                        }
                    }
                }
            ],
            "kind": "const"
        }
    ],
    "sourceType": "script"
}

       大家可以通过Esprima 这个网站来将代码转化成 ast。首先一段代码转化成的抽象语法树是一个对象,该对象会有一个顶级的 type 属性 Program ,第二个属性是 body 是一个数组。body 数组中存放的每一项都是一个对象,里面包含了所有的对于该语句的描述信息

type:描述该语句的类型 --变量声明语句
kind:变量声明的关键字 -- const
declaration: 声明的内容数组,里面的每一项也是一个对象
    type: 描述该语句的类型 
    id: 描述变量名称的对象
        type:定义
        name: 是变量的名字
        init: 初始化变量值得对象
        type: 类型
        value:"is tree" 不带引号
        row: "\"is tree"\" 带引号

打包原理的代码实现

       有了上面这些基础的知识,我们开始亲自实现一个简易版本的 webpack 打包工具,代码仓库地址放在里gitlab上.

首先我们定义3个文件:

// 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';

当我们实现了简易版本的 webpack 后,运行 entry.js 会输出 “hello world” 。

接下来我们参照笔者放在gitlab上实现的简易版源码,进行一一解析。我们创建 /src/minipack.js 文件,在顶部插入如下代码:

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

这是实现打包原理所需要的核心依赖,我们先考虑一下一个基础的打包编译工具可以做什么?

转换 ES6 语法成 ES5
处理模块加载依赖
生成一个可以在浏览器加载执行的 js 文件
       第一个问题,转换语法,其实我们可以通过babel来做。核心步骤就是通过 babylon 生成 AST ,通过 babel-core 的 transformFromAst 方法将 AST 重新生成源码,traverse 的作用就是帮助开发者遍历 AST 抽象语法树,帮助我们获取树节点上的需要的信息和属性。接下来看:

let ID = 0;
       全局的自增 id,记录每一个载入的模块的 id,我们将所有的模块都用唯一标识符进行标示,因此自增 id 是最有效也是最直观的,有多少个模块,一统计就出来了。

然后创建createAsset函数:

function createAsset(filename) {
  const content = fs.readFileSync(filename, 'utf-8');
  const ast = babylon.parse(content, {
    sourceType: 'module',
  });
  const dependencies = [];
  traverse(ast, {
    ImportDeclaration: ({node}) => {
      dependencies.push(node.source.value);
    },
  });
  const id = ID++;
  const {code} = transformFromAst(ast, null, {
    presets: ['env'],
  });
  const customCode = loader(filename, code)
  return {
    id,
    filename,
    dependencies,
    code,
  };
}

       我们对每一个文件进行处理。因为这只是一个简单版本的 bundler,因此,我们并不考虑如何去解析 css、md、txt 等等之类的格式,我们专心处理好 js 文件的打包,因为对于其他文件而言,处理起来过程不太一样,用文件后缀很容易将他们区分进行不同的处理,在这个版本,我们还是专注 js代码,createAsset 函数第一行:

const content = fs.readFileSync(filename, 'utf-8');

函数注入一个 filename 顾名思义,就是文件名,读取其的文件文本内容。

const ast = babylon.parse(content, {
    sourceType: 'module',
  });

       首先,我们使用 babylon 的 parse 方法去转换我们的原始代码,通过转换以后,我们的代码变成了抽象语法树( AST ),你可以通过 https://astexplorer.net/ 这个可视化的网站,看看 AST 生成的是什么。

  const dependencies = [];
  traverse(ast, {
    ImportDeclaration: ({node}) => {
      dependencies.push(node.source.value);
    },
  });

       当我们解析完以后,我们就可以提取当前文件中的 dependencies,dependencies 翻译为依赖,也就是我们文件中所有的 import xxxx from xxxx,我们将这些依赖都放在 dependencies 的数组里面,之后统一进行导出,traverse 函数是一个遍历 AST 的方法,由 babel-traverse 提供,他的遍历模式是经典的 visitor 模式,visitor 模式就是定义一系列的 visitor ,当碰到 AST 的 type === visitor 名字时,就会进入这个 visitor 的函数,类型为 ImportDeclaration 的 AST 节点,其实就是我们的 import xxx from xxxx,其中 path.node.source.value 的值,就是我们 import from xxxx 中的地址,将地址 push 到 dependencies 中。

  const id = ID++;
  const {code} = transformFromAst(ast, null, {
    presets: ['env'],
  });

       当我们完成依赖的收集以后,我们就可以把我们的代码从 AST 转换成 CommenJS 的代码,这样子兼容性更高更好,并且将 ID 自增。

const customCode = loader(filename, code)
function loader(filename, code) {
  if (/entry/.test(filename)) {
    console.log('this is loader ')
  }
  return code
} 

       还记得我们的 webpack-loader 系统吗?具体实现就是在这里可以实现,通过将文件名和代码都传入 loader 中,进行判断,甚至用户定义行为再进行转换,就可以实现 loader 的机制,当然,我们在这里,就做一个弱智版的 loader 就可以了,parcel 在这里的优化技巧是很有意思的,在 webpack 中,我们每一个 loader 之间传递的是转换好的代码,而不是 AST,那么我们必须要在每一个 loader 进行 code -> AST 的转换,这样时非常耗时的,parcel 的做法其实就是将 AST 直接传递,而不是转换好的代码,这样,速度就快起来了。
       接下来,我们对模块进行更高级的处理。我们之前已经写了一个 createAsset 函数,那么现在我们要来写一个 createGraph 函数,我们将所有文件模块组成的集合叫做 queue ,用于描述我们这个项目的所有的依赖关系,createGraph 从 entry (入口) 出发,一直到打包完所有的文件为止。createGraph 函数如下:

function createGraph(entry) {
  const mainAsset = createAsset(entry);
  const queue = [mainAsset];
  for (const asset of queue) {
    asset.mapping = {};
    const dirname = path.dirname(asset.filename);
    asset.dependencies.forEach(relativePath => {
      const absolutePath = path.join(dirname, relativePath);
      const child = createAsset(absolutePath);
      asset.mapping[relativePath] = child.id;
      queue.push(child);
    });
  }
  return queue;
}

先看前面两行代码:

const mainAsset = createAsset(entry);
const queue = [mainAsset];

从 entry 出发,首先收集 entry 文件的依赖,queue 其实是一个数组,我们将最开始的入口模块放在最开头。

for (const asset of queue) {
    asset.mapping = {};
    //从asset中获取文件对应的文件夹
    const dirname = path.dirname(asset.filename);
    //... ...
  }

       在这里我们使用 for of 循环而不是 foreach ,原因是因为我们在循环之中会不断的向queue 中,push 进东西,queue 会不断增加,用 for of 会一直持续这个循环直到 queue 不会再被推进去东西,这就意味着,所有的依赖已经解析完毕,queue 数组数量不会继续增加,但是用 foreach 是不行的,只会遍历一次, asset 代表解析好的模块,里面有 filename,code,dependencies 等东西,asset.mapping 是一个不太好理解的概念,我们每一个文件都会进行 import 操作,import 操作在之后会被转换成 require ,每一个文件中的 require 的 path 其实会对应一个数字自增 id ,这个自增 id 其实就是我们一开始的时候设置的 id ,我们通过将 path-id 利用键值对,对应起来,之后我们在文件中 require 就能够轻松的找到文件的代码,解释这么啰嗦的原因是往往模块之间的引用是错中复杂的,这恰巧是这个概念难以解释的原因。

    asset.dependencies.forEach(relativePath => {
      const absolutePath = path.join(dirname, relativePath);
      const child = createAsset(absolutePath);
      asset.mapping[relativePath] = child.id;
      queue.push(child);
    });

       每个文件都会被 parse 出一个 dependencise,他是一个数组,在之前的函数中已经讲到,因此,我们要遍历这个数组,将有用的信息全部取出来,值得关注的是 asset.mapping[dependencyPath] = denpendencyAsset.id 操作。absolutePath 获取文件中模块的绝对路径,比如 import ABC from './world',会转换成 /User/xxxx/desktop/xproject/world 这样的形式。

asset.mapping[relativePath] = child.id;

       这里是重要的点,我们解析每解析一个模块,我们就将他记录在这个文件模块 asset 下的 mapping 中,之后我们 require 的时候,能够通过这个 id 值,找到这个模块对应的代码,并进行运行,将解析的模块推入 queue 中去。最后我们得到的 queue 是如下这个样子的:

  [ 
    { 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 函数,我们通过 createGraph 完成了 queue 的收集,那么就到我们真正的代码打包了,这个 bundle 函数使用了大量的字符串处理,你们不要觉得奇怪,为什么代码和字符串可以混起来写,如果你跳出写代码的范畴,看我们的代码,实际上,代码就是字符串,只不过他通过特殊的语言形式组织起来而已,对于脚本语言 JS 来说,字符串拼接成代码,然后跑起来,这种操作在前端非常的常见,我认为,这种思维的转换,是拥有自动化、工程化的第一步。

function bundle(graph) {
  let modules = '';
  graph.forEach(mod => {
    modules += `${mod.id}: [
      function (require, module, exports) {
        ${mod.code}
      },
      ${JSON.stringify(mod.mapping)},
    ],`;
  });
  // ... ...
  return result;
}

       我们的 modules 就是一个字符串,我们将 graph 中所有的 asset 取出来,然后使用 node.js 制造模块的方法来将一份代码包起来。我们将转换好的源码,放进一个 function(require,module,exports){} 函数中,这个函数的参数就是我们随处可用的 require,module,以及 exports,这就是为什么我们可以随处使用这三个玩意的原因,因为我们每一个文件的代码终将被这样一个函数包裹起来,不过这段代码中比较奇怪的是,我们将代码封装成了 1:[...],2:[...]的形式,我们在最后导入模块的时候,会为这个字符串加上一个 {},变成 {1:[...],2:[...]},你没看错,这是一个对象,这个对象里用数字作为 key ,一个二维元组作为值,[0] 第一个就是我们被包裹的代码,[1] 第二个就是我们的 mapping 。

function bundle(graph) {
  //... ...
  const result = `
    (function(modules) {
      function require(id) {
        const [fn, mapping] = modules[id];

        function localRequire(name) {
          return require(mapping[name]);
        }

        const module = { exports : {} };

        fn(localRequire, module, module.exports);

        return module.exports;
      }

      require(0);
    })({${modules}})
  `;
  return result;
}

       这一段代码实际上才是模块引入的核心逻辑,我们制造一个顶层的 require 函数,这个函数接收一个 id 作为值,并且返回一个全新的 module 对象,我们倒入我们刚刚制作好的模块,给他加上 {},使其成为 {1:[...],2:[...]} 这样一个完整的形式,然后塞入我们的立即执行函数中(function(modules) {...})(),在 (function(modules) {...})() 中,我们先调用 require(0),理由很简单,因为我们的主模块永远是排在第一位的,紧接着,在我们的 require 函数中,我们拿到外部传进来的 modules,利用我们一直在说的全局数字 id 获取我们的模块,每个模块获取出来的就是一个二维元组,然后,我们要制造一个 子require,这么做的原因是我们在文件中使用 require 时,我们一般 require 的是地址,而顶层的 require 函数参数时 id,不要担心,我们之前的 mapping 在这里就用上了,通过用户 require 进来的地址,在 mapping 中找到 id,然后递归调用 require(id),就能够实现模块的自动倒入了,接下来制造一个 const newModule = {exports: {}};,运行我们的函数 fn(childRequire, newModule, newModule.exports);,将应该丢进去的丢进去,最后 return newModule.exports 这个模块的 exports 对象。

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

       最后,运行我们开头的demo例子三个文件的代码,node src/minipack.js,就能看到所有编译好的代码,将代码复制出来放到chrome浏览器的console里,就能看到"hello world"的输出。

结尾

       目前为止,我们已经实现了一个简易版本的 webpack 了,我们将 es6 的代码通过 entry.js 入口文件开始编译,然后根据循环递归调用把所有文件所依赖的文件都解析和加载了一遍,理解了这个简易版本的 webpack 实现原理,再去看看其他的打包工具原理,大同小异,你就会发现一切都豁然开朗,无非就是在这个简易版本的 webpack 基础上加了很多工具函数和细节的处理,限于篇幅笔者就不再一一介绍了。

        本文实现的简易版本的 webpack 的完整代码gitlab地址为:minipack

交流

欢迎关注我的微信公众号,讲述了一个前端屌丝逆袭的心路历程,共勉。

image

作者:第一名的小蝌蚪

github: 文章会第一时间分享在前端屌丝心路历程,欢迎star或者watch,感恩