webpack源码学习系列之一:如何实现一个简单的webpack

@youngwind 2017-02-04 00:46:29发表于 youngwind/blog webpackwebpack 源码

前言

在上一篇 #98 中,我们通过实现requireJS,对模块化有了一些认识。今天我们更进一步,看看如何实现一个简单的webpack,实现的源码参考这里

目标

现在的webpack是一个庞然大物,我们不可能实现其所有功能。
那么,应该将目光聚焦在哪儿呢?
webpack的第一个commit可以看出,其当初最主要的目的是在浏览器端复用符合CommonJS规范的代码模块。这个目标不是很难,我们努力一把还是可以实现的。

注意:在此我们不考虑插件、loaders、多文件打包等等复杂的问题,仅仅考虑最基本的问题:如何将多个符合CommonJS规范的模块打包成一个JS文件,以供浏览器执行。

bundle.js

显然,浏览器没法直接执行CommonJS规范的模块,怎么办呢?
答案:将其转换成一个自执行表达式
注意:此处涉及到webpack构建出来的bundle.js的内部结构问题,如果不了解bundle.js具体是如何执行的,请务必搞清楚再往下阅读。可以参考 #64 或者这里

例子

我们实际要处理的例子是这个:example依赖于a、b和c,而且c位于node_modules文件夹中,我们要将所有模块构建成一个JS文件,就是这里的output.js

思路

仔细观察output.js,我们能够发现:

  1. 不管有多少个模块,头部那一块都是一样的,所以可以写成一个模板,也就是templateSingle.js
  2. 需要分析出各个模块间的依赖关系。也就是说,需要知道example依赖于a、b和c
  3. c模块位于node_modules文件夹当中,但是我们调用的时候却可以直接require('c'),这里肯定是存在某种自动查找的功能。
  4. 在生成的output.js中,每个模块的唯一标识是模块的ID,所以在拼接output.js的时候,需要将每个模块的名字替换成模块的ID。也就是说,
// 转换前
let a = require('a');
let b = require('b');
let c = require('c');

// 转换后
let a = require(/* a */1);
let b = require(/* b */2);
let c = require(/* c */3);

ok,下面我们来逐一看看这些问题。

分析模块依赖关系

CommonJS不同于AMD,是不会在一开始声明所有依赖的。CommonJS最显著的特征就是用到的时候再require,所以我们得在整个文件的范围内查找到底有多少个require
怎么办呢?
最先蹦入脑海的思路是正则。然而,用正则来匹配require,有以下两个缺点:

  1. 如果require是写在注释中,也会匹配到。
  2. 如果后期要支持require的参数是表达式的情况,如require('a'+'b'),正则很难处理。

因此,正则行不通。
一种正确的思路是:使用JS代码解析工具(如esprima或者acorn),将JS代码转换成抽象语法树(AST),再对AST进行遍历。这部分的核心代码是parse.js

在处理好了require的匹配之后,还有一个问题需要解决。那就是匹配到require之后需要干什么呢?
举个例子:

// example.js
let a = require('a');
let b = require('b');
let c = require('c');

这里有三个require,按照CommonJS的规范,在检测到第一个require的时候,根据require即执行的原则,程序应该立马去读取解析模块a。如果模块a中又require了其他模块,那么继续解析。也就是说,总体上遵循深度优先遍历算法。这部分的控制逻辑写在buildDeps.js中。

找到模块

在完成依赖分析的同时,我们需要解决另外一个问题,那就是如何找到模块?也就是模块的寻址问题。
举个例子:

// example.js
let a = require('a');
let b = require('b');
let c = require('c');

在模块example.js中,调用模块a、b、c的方式都是一样的。
但是,实际上他们所在的绝对路径层级并不一致:a和bexample同级,而c位于与example同级的node_modules。所以,程序需要有一个查找模块的算法,这部分的逻辑在resolve.js中。

目前实现的查找逻辑是:

  1. 如果给出的是绝对路径/相对路径,只查找一次。找到?返回绝对路径。找不到?返回false。
  2. 如果给出的是模块的名字,先在入口js(example.js)文件所在目录下寻找同名JS文件(可省略扩展名)。找到?返回绝对路径。找不到?走第3步。
  3. 在入口js(example.js)同级的node_modules文件夹(如果存在的话)查找。找到?返回绝对路径。找不到?返回false。

当然,此处实现的算法还比较简陋,之后有时间可以再考虑实现逐层往上的查找,就像nodejs默认的模块查找算法那样。

拼接output.js

这是最后一步了。
在解决了模块依赖模块查找的问题之后,我们将会得到一个依赖关系对象depTree,此对象完整地描述了以下信息:都有哪些模块,各个模块的内容是什么,他们之间的依赖关系又是如何等等。具体的结构如下:

{
    "modules": {
        "/Users/youngwind/www/fake-webpack/examples/simple/example.js": {
            "id": 0,
            "filename": "/Users/youngwind/www/fake-webpack/examples/simple/example.js",
            "name": "/Users/youngwind/www/fake-webpack/examples/simple/example.js",
            "requires": [
                {
                    "name": "a",
                    "nameRange": [
                        16,
                        19
                    ],
                    "id": 1
                },
                {
                    "name": "b",
                    "nameRange": [
                        38,
                        41
                    ],
                    "id": 2
                },
                {
                    "name": "c",
                    "nameRange": [
                        60,
                        63
                    ],
                    "id": 3
                }
            ],
            "source": "let a = require('a');\nlet b = require('b');\nlet c = require('c');\na();\nb();\nc();\n"
        },
        "/Users/youngwind/www/fake-webpack/examples/simple/a.js": {
            "id": 1,
            "filename": "/Users/youngwind/www/fake-webpack/examples/simple/a.js",
            "name": "a",
            "requires": [],
            "source": "// module a\n\nmodule.exports = function () {\n    console.log('a')\n};"
        },
        "/Users/youngwind/www/fake-webpack/examples/simple/b.js": {
            "id": 2,
            "filename": "/Users/youngwind/www/fake-webpack/examples/simple/b.js",
            "name": "b",
            "requires": [],
            "source": "// module b\n\nmodule.exports = function () {\n    console.log('b')\n};"
        },
        "/Users/youngwind/www/fake-webpack/examples/simple/node_modules/c.js": {
            "id": 3,
            "filename": "/Users/youngwind/www/fake-webpack/examples/simple/node_modules/c.js",
            "name": "c",
            "requires": [],
            "source": "module.exports = function () {\n    console.log('c')\n}"
        }
    },
    "mapModuleNameToId": {
        "/Users/youngwind/www/fake-webpack/examples/simple/example.js": 0,
        "a": 1,
        "b": 2,
        "c": 3
    }
}

根据这个depTree对象,我们便能完成这最后的一步:**output.js文件的拼接。**其控制逻辑无非是一层循环,写在writeChunk.js中。
但是这里有一个需要注意的地方,那就是本文思路章节提到的第4点:要把模块名转换成模块ID,这是writeSource.js所要完成的功能。

至此,我们就实现了一个非常简单的webpack了。

遗留问题

  1. 尚未支持require('a' + 'b')这种情况。
  2. 如何实现自动 watch 的功能?
  3. 其loader或者插件机制又是怎样的?
  4. ……

参考资料

  1. webpack 源码解析
  2. http://www.jianshu.com/p/01a606c97d76
  3. DDFE/DDFE-blog#12
  4. http://hao.jser.com/archive/13881/
  5. http://taobaofed.org/blog/2016/09/09/webpack-flow/

========EOF===========