【webpack进阶】前端运行时的模块化设计与实现

你真的了解前端模块化么?

告别「webpack配置工程师」

webpack是一个强大而复杂的前端自动化工具。其中一个特色就是配置复杂,这也使得「webpack配置工程师」这种戏谑的称呼开始流行🤷可是,难道你真的只知足于玩转webpack配置么?javascript

显然不是。在学习如何使用webpack以外,咱们更须要深刻webpack内部,探索各部分的设计与实现。万变不离其宗,即便有一天webpack“过气”了,但它的某些设计与实现却仍会有学习价值与借鉴意义。所以,在学习webpack过程当中,我会总结一系列【webpack进阶】的文章和你们分享。前端

欢迎感兴趣的同窗多多交流与关注!java

1. 引言

下面进入正题。一直以来,在前端领域,开发人员日益增加的语言能力需求和落后的JavaScript规范造成了一大矛盾。例如,咱们会用babel来进行ES6到ES5的语法转换,会使用各类polyfill来兼容老式上的新特性……而咱们本文的主角 —— 模块化也是如此。node

因为JavaScript在设计之初就没有考虑这一点,加之模块化规范的迟到,致使社区中涌现出一系列前端运行时的模块化方案,例如RequireJS、seaJS等。以及与之对应的编译期模块依赖解决方案,例如browserify、rollup和本文的主角webpack。webpack

可是咱们要知道,<script type="module">还存在必定的兼容性与使用问题。git

在更通用的范围内来说,浏览器原生实际是不支持所谓的CommonJS或ESM模块化规范的。那么webpack是如何在打包出的代码中实现模块化的呢?github

2. NodeJS中的模块化

在探究webpack打包后代码的模块化实现前,咱们先来看一下Node中的模块化。web

NodeJS(如下简称为Node)在模块化上基本是遵循的CommonJS规范,而webpack打包出来的代码所实现模块化的方式,也相似于CommonJS。所以,咱们先以熟悉的Node(这里主要参考Node v10)做为引子,简单介绍它的模块化实现,帮助咱们接下来理解webpack的实现。json

Node中的模块引入会经历下面几个步骤:数组

  1. 路径分析
  2. 文件定位
  3. 编译执行

在Node中,模块以文件维度存在,而且在编译后缓存于内存中,经过require.cache能够查看模块缓存状况。在模块中添加console.log(require.cache)查看输出以下:

{ '/Users/alienzhou/programming/gitrepo/test.js':
   Module {
     id: '.',
     exports: {},
     parent: null,
     filename: '/Users/alienzhou/programming/gitrepo/test.js',
     loaded: false,
     children: [],
     paths:
      [ '/Users/alienzhou/programming/gitrepo/node_modules',
        '/Users/alienzhou/programming/node_modules',
        '/Users/alienzhou/node_modules',
        '/Users/node_modules',
        '/node_modules' ] } }

上面就是模块对象的数据结构,也能够在Node源码中找到Module类的构造方法。其中exports属性很是重要,它就是模块的导出对象。所以,下面这行语句

var test = require('./test.js');

其实就是把test.js模块的exports属性赋值给test变量。

也许你还会好奇,当咱们写一个Node(JavaScript)模块时,模块里的modulerequire__filename等这些变量是哪来的?若是你看过Node loader.js 部分源码,应该就大体能理解:

Module.wrap = function(script) {
  return Module.wrapper[0] + script + Module.wrapper[1];
};

Module.wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

Node会自动将每一个模块进行包装(wrap),将其变为一个function。例如模块test.js本来为:

console.log(require.cache);
module.exports = 'test';

包装后大体会变为:

(function (exports, require, module, __filename, __dirname) {
    console.log(require.cache);
    module.exports = 'test';
});

这下你应该明白modulerequire__filename这些变量都是哪来的了吧 —— 它们会被做为function的参数在模块编译执行时注入进来。以一个扩展名为.js的模块为例,当你require它时,一个完整的方法调用大体包括下面几个过程:

st=>start: require()引入模块
op1=>operation: 调用._load()加载模块
op2=>operation: new Module(filename, parent)建立模块对象
op3=>operation: 将模块对象存入缓存
op4=>operation: 根据文件类型调用Module._extensions
op5=>operation: 调用.compile()编译执行js模块
cond=>condition: Module._cache是否无缓存
e=>end: 返回module.exports结果
st->op1->cond
cond(yes)->op2->op3->op4->op5->e
cond(no)->e

Node源码中能看到,模块执行时,包装定义的几个变量被注入了:

if (inspectorWrapper) {
    result = inspectorWrapper(compiledWrapper, this.exports, this.exports,
                              require, this, filename, dirname);

} else {
    result = compiledWrapper.call(this.exports, this.exports, require, this,
                                  filename, dirname);
}
题外话,从这里你也能够看出,在模块内使用 module.exportsexports的区别

3. webpack实现的前端模块化

之因此在介绍「webpack是如何在打包出的代码中实现模块化」以前,先用必定篇幅介绍了Node中的模块化,是由于二者在同步依赖的设计与实现上有殊途同归之处。理解Node的模块化对学习webpack颇有帮助。固然,因为运行环境的不一样(webpack打包出的代码运行在客户端,而Node是在服务端),实现上也有必定的差别。

下面就来看一下,webpack是如何在打包出的代码中实现前端(客户端)模块化的。

3.1. 模块对象

和Node的模块化实现相似,在webpack打包出的代码中,每一个模块也有一个对应的模块对象。在__webpack_require__()方法中,有这么一段代码:

function __webpack_require__(moduleId) {
    // …… other code
    
    var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {},
        parents: null,
        children: []
    };
    
    // …… other code
}

相似于Node,在webpack中各个模块的也有对应的模块对象,其数据结构基本遵循CommonJS规范;其中installedModules则是模块缓存对象,相似于Node中的require.cache/Module._cache

2.2. 模块的require:__webpack_require__

__webpack_require__是webpack前端运行时模块化中很是重要的一个方法,至关于CommonJS规范中的require

根据第一部分的流程图:在Node中,当咱们require一个模块时,会先判断该模块是否在缓存之中,若是存在则直接返回该模块的exports属性;不然会加载并执行该模块。webpack中的实现也相似:

function __webpack_require__(moduleId) {
    // 1.首先会检查模块缓存
    if(installedModules[moduleId]) {
        return installedModules[moduleId].exports;
    }
    
    // 2. 缓存不存在时,建立并缓存一个新的模块对象,相似Node中的new Module操做
    var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {},
        children: []
    };

    // 3. 执行模块,相似于Node中的:
    // result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

    module.l = true;

    // 4. 返回该module的输出
    return module.exports;
}

若是你仔细对比webpack与Node,你会发如今__webpack_require__中有一个重要的区别:

在webpack中不存在像Node同样调用._compile()这种方法的过程。即不会像Node那样,对一个未载入缓存的模块,经过「读取模块路径 -> 编译模块代码 -> 执行模块」来载入模块。为何呢?

这是由于,Node做为服务端语言,模块都是本地文件,加载时延低,可同步阻塞进行模块文件寻址、读取、编译和执行,这些过程在模块require的时候再“按需”执行便可;而webpack运行在客户端(浏览器),显然不能在须要时(即执行__webpack_require__时)再经过网络加载js文件,并同步地等待加载完成后再返回__webpack_require__。这种网络时延,显然不能知足“同步依赖”的要求。

那么webpack是如何解决这个问题的呢?

3.2. 如何解决前端的同步依赖

咱们仍是回来看下Node:

Node(v10)中加载、编译与执行(js)模块的代码主要集中在Module._extensions['.js']Module.prototype._compile中。首先会经过fs.readFileSync读取文件内容,而后经过vm.runInThisContext来编译和执行JavaScript代码。

The vm module provides APIs for compiling and running code within V8 Virtual Machine contexts.

可是,根据上面的分析,在前端runtime中确定不能经过网络去同步获取JavaScript脚本文件;那么就须要咱们换一个思路:有没有什么地方可以预先放置咱们“以后”可能会须要的模块,让咱们可以在require时不须要同步等待过长的时间(固然,这里的“以后”多是几秒、几分钟后,也多是此次事件循环task的下几行代码)。

内存就是一个不错的选择。咱们能够把同步依赖的模块先“注册”到内存中(模块暂存),等到require时,再执行该模块、缓存模块对象、返回对应的exports。而webpack中,这个所谓的内存就是modules对象。

注意这里指的模块暂存和模块缓存概念彻底不一样。暂存能够粗略类比为将编译好的模块代码先放到内存中,实际并无引入该模块。基于这个目的,咱们也能够把“模块暂存”理解为“模块注册”,所以后文中“模块暂存”与“模块注册”具备相等的概念。

因此,过程大体是这样的:

当咱们已经获取了模块内容后(但模块还未执行),咱们就将其暂存在modules对象中,键就是webpack的moduleId;等到须要使用__webpack_require__引用模块时,发现缓存中没有,则从modules对象中取出暂存的模块并执行。

3.3. 如何”暂存“模块

思路已经清晰了,那么咱们就来看看,webpack是如何将模块“暂存”在modules对象上的。在实际上,webpack打包出来的代码能够简单分为两类:

  • 一类是webpack模块化的前端runtime,你能够简单类比为RequireJS这样的前端模块化类库所实现的功能。它会控制模块的加载、缓存,提供诸如__webpack_require__这样的require方法等。
  • 另外一类则是模块注册与运行的代码,包含了源码中的模块代码。为了进一步理解,咱们先来看一下这部分的代码是怎样的。
为了便于学习与代码阅读,建议你能够在webpack(v4)配置中加入 optimization:{runtimeChunk: {name: 'runtime'}},这样会让webpack将runtime与模块注册代码分开打包。
// webpack module chunk
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["home-0"],{

/***/ "module-home-0":
/***/ (function(module, exports, __webpack_require__) {

const myalert = __webpack_require__("module-home-1");

myalert('test');

/***/ }),

/***/ "module-home-1":
/***/ (function(module, exports) {

module.exports = function (a) {
    alert('hi:' + a);
};

/***/ })

},[["module-home-0","home-1"]]]);

上面这是一个不包含runtime的chunk,咱们不妨将其称为module chunk(下面会沿用这个叫法)。简化一下这部分代码,大体结构以下:

// webpack module chunk
window["webpackJsonp"].push([
    ["home-0"], // chunkIds
    {
        "module-home-0": (function(module, exports, __webpack_require__){ /* some logic */ }),
        "module-home-1": (function(module, exports, __webpack_require__){ /* some logic */ })
    },
    [["module-home-0","home-1"]]
])

这里,.push()方法参数为一个数组,包含三个元素:

  • 第一个元素是一个数组,["home-0"]表示该js文件所包含的全部chunk的id(能够粗略理解为,webpack中module组成chunk,chunk又组成file);
  • 第二个元素是一个对象,键是各个模块的id,值则是一个被function包装后的模块;
  • 第三个元素也是一个数组,其又是由多个数组组成。具体做用咱们先按下不表,最后再说。

来看下参数数组的第二个元素 —— 包含模块代码的对象,你会发现这里方法签名是否是很像Node中的经过Module.wrap()进行的模块代码包装?没错,webpack源码中也有相似,会像Node那样,将每一个模块的代码用一个function包装起来。

而当webpack配置了runtime分离后,打包出的文件中会出现一个“纯净”的、不包含任何模块代码的runtime,其主要是一个自执行方法,其中暴露了一个全局变量webpackJsonp

// webpack runtime chunk
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
jsonpArray.push = webpackJsonpCallback;
webpackJsonp变量名能够经过 output.jsonpFunction进行配置

能够看到,window["webpackJsonp"]上的.push()方法已经被修改成了webpackJsonpCallback()方法。该方法以下:

// webpack runtime chunk
function webpackJsonpCallback(data) {
    var chunkIds = data[0];
    var moreModules = data[1];
    var executeModules = data[2];

    var moduleId, chunkId, i = 0, resolves = [];
    // webpack会在installChunks中存储chunk的载入状态,据此判断chunk是否加载完毕
    for(;i < chunkIds.length; i++) {
        chunkId = chunkIds[i];
        if(installedChunks[chunkId]) {
            resolves.push(installedChunks[chunkId][0]);
        }
        installedChunks[chunkId] = 0;
    }
    
    // 注意,这里会进行“注册”,将模块暂存入内存中
    // 将module chunk中第二个数组元素包含的 module 方法注册到 modules 对象里
    for(moduleId in moreModules) {
        if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
            modules[moduleId] = moreModules[moduleId];
        }
    }

    if(parentJsonpFunction) parentJsonpFunction(data);

    while(resolves.length) {
        resolves.shift()();
    }

    deferredModules.push.apply(deferredModules, executeModules || []);

    return checkDeferredModules();
};

注意以上方法的这几行,就是咱们以前所说的「将模块“暂存”在modules对象上」

// webpackJsonpCallback
for(moduleId in moreModules) {
    if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
    modules[moduleId] = moreModules[moduleId];
    }
}

配合__webpack_require__()中下面这一行代码,就实现了在须要引入模块时,同步地将模块从暂存区取出来执行,避免使用网络请求致使过长的同步等待时间。

// __webpack_require__
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

3.4. 模块的自动执行

到目前为止,对于webpack的同步依赖实现已经介绍的差很少了,但还遗留一个小问题:webpack中的全部js源文件都是模块,但若是都是不会自动执行的模块,那咱们只是在前端引入了一堆“死”代码,怎么让代码“活”起来呢?

不少时候,咱们引入一个script标签加载脚本文件,至少但愿其中一个模块的代码会自动执行,而不只仅是注册在modules对象上。通常来讲,这就是webpack中所谓的入口模块。

webpack是如何让这些入口模块自动执行的呢?不知道你是否还记得module chunk中那个按下不表的第三个参数:这个参数是一个数组,而数组里面每一个元素又是一个数组

[["module-home-0","home-1"], ["module-home-2","home-3","home-5"]]

对照上面这个例子,咱们能够具体解释下参数的含义。第一个元素["module-home-0","home-1"]表示,我但愿自动执行moduleId为module-home-0的这个模块,可是该模块须要chunkId为home-1的chunk已经加载后才能执行;同理,["module-home-2","home-3","home-5"]表示自动执行module-home-2模块,可是须要检查chunkhome-3home-5已经加载。

执行某些模块须要保证一些chunk已经加载是由于,该模块所依赖的其余模块可能并不在当前chunk中,而webpack在编译期会经过依赖分析自动将依赖模块的所属chunkId注入到此处。

这个模块“自动”执行的功能在runtime chunk的代码中主要是由checkDeferredModules()方法实现:

function checkDeferredModules() {
    var result;
    for(var i = 0; i < deferredModules.length; i++) {
        var deferredModule = deferredModules[i];
        var fulfilled = true;
        // 第一个元素是模块id,后面是其所需的chunk
        for(var j = 1; j < deferredModule.length; j++) {
            var depId = deferredModule[j];
            // 这里会首先判断模块所需chunk是否已经加载完毕
            if(installedChunks[depId] !== 0) fulfilled = false;
        }
        // 只有模块所需的chunk都加载完毕,该模块才会被执行(__webpack_require__)
        if(fulfilled) {
            deferredModules.splice(i--, 1);
            result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
        }
    }
    return result;
}

4. 异步依赖

若是你只是想学习webpack前端runtime中同步依赖的设计与实现,那么到这里主要内容基本已经结束了。不过咱们知道,webpack支持使用动态模块引入的语法(代码拆分),例如:dynamic import和早期的require.ensure,这种方式与使用CommonJS的require和ESM的import最重要的区别在于,该类方法会异步(或者说按需)加载依赖。

4.1. 代码转换

就像在源码中使用require会在webpack打包时被替换为__webpack_require__同样,在源码中使用的异步依赖语法也会被webpack修改。以dynamic import为例,下面的代码

import('./test.js').then(mod => {
    console.log(mod);
});

在产出后会被转换为

__webpack_require__.e(/* import() */ "home-1")
    .then(__webpack_require__.bind(null, "module-home-3"))
    .then(mod => {
        console.log(mod);
    });

上面代码是什么意思呢?咱们知道,webpack打包后会将一些module合并为一个chunk,所以上面的"home-1"就表示:包含./test.js模块的chunk的chunkId为"home-1"

webpack首先经过__webpack_require__.e加载指定chunk的script文件(module chunk),该方法返回一个promise,当script加载并执行完成后resolve该promise。webpack打包时会保证异步依赖的全部模块都已包含在该module chunk或当前上下文中。

既然module chunk已经执行,那么代表异步依赖已经就绪,因而在then方法中执行__webpack_require__引用test.js模块(webpack编译后moduleId为module-home-3)并返回。这样在第二个then方法中就能够正常使用该模块了。

4.2. __webpack_require__.e

异步依赖的核心方法就是__webpack_require__.e。下面来分析一下该方法:

__webpack_require__.e = function requireEnsure(chunkId) {
    var promises = [];
    var installedChunkData = installedChunks[chunkId];
    
    // 判断该chunk是否已经被加载,0表示已加载。installChunk中的状态:
    // undefined:chunk未进行加载,
    // null:chunk preloaded/prefetched
    // Promise:chunk正在加载中
    // 0:chunk加载完毕
    if(installedChunkData !== 0) {
        // chunk不为null和undefined,则为Promise,表示加载中,继续等待
        if(installedChunkData) {
            promises.push(installedChunkData[2]);
        } else {
            // 注意这里installChunk的数据格式
            // 从左到右三个元素分别为resolve、reject、promise
            var promise = new Promise(function(resolve, reject) {
                installedChunkData = installedChunks[chunkId] = [resolve, reject];
            });
            promises.push(installedChunkData[2] = promise);

            // 下面代码主要是根据chunkId加载对应的script脚本
            var head = document.getElementsByTagName('head')[0];
            var script = document.createElement('script');
            var onScriptComplete;

            script.charset = 'utf-8';
            script.timeout = 120;
            if (__webpack_require__.nc) {
                script.setAttribute("nonce", __webpack_require__.nc);
            }
            
            // jsonpScriptSrc方法会根据传入的chunkId返回对应的文件路径
            script.src = jsonpScriptSrc(chunkId);

            onScriptComplete = function (event) {
                script.onerror = script.onload = null;
                clearTimeout(timeout);
                var chunk = installedChunks[chunkId];
                if(chunk !== 0) {
                    if(chunk) {
                        var errorType = event && (event.type === 'load' ? 'missing' : event.type);
                        var realSrc = event && event.target && event.target.src;
                        var error = new Error('Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')');
                        error.type = errorType;
                        error.request = realSrc;
                        chunk[1](error);
                    }
                    installedChunks[chunkId] = undefined;
                }
            };
            var timeout = setTimeout(function(){
                onScriptComplete({ type: 'timeout', target: script });
            }, 120000);
            script.onerror = script.onload = onScriptComplete;
            head.appendChild(script);
        }
    }
    return Promise.all(promises);
};

该方法首先会根据chunkId在installChunks中判断该chunk是否正在加载或已经被加载;若是没有则会建立一个promise,将其保存在installChunks中,并经过jsonpScriptSrc()方法获取文件路径,经过sciript标签加载,最后返回该promise。

jsonpScriptSrc()则能够理解为一个包含chunk map的方法,例如这个例子中:

function jsonpScriptSrc(chunkId) {
    return __webpack_require__.p + "" + ({}[chunkId]||chunkId) + "." + {"home-1":"0b49ae3b"}[chunkId] + ".js"
}

其中包含一个map —— {"home-1":"0b49ae3b"},会根据home-1这个chunkId返回home-1.0b49ae3b.js这个文件名。

4.3. 更新chunk加载状态

最后,你会发现,在onload中,并无调用promise的resolve方法。那么是什么时候resolve的呢?

你还记得在介绍同步require时用于注册module的webpackJsonpCallback()方法么?咱们以前说过,该方法参数数组中的第一个元素是一个chunkId的数组,表明了该脚本所包含的chunk。

p.s. 当一个普通的脚本被浏览器下载完毕后,会先执行该脚本,而后触发onload事件。

所以,在webpackJsonpCallback()方法中,有一段代码就是根据chunkIds的数组,检查并更新chunk的加载状态:

// webpackJsonpCallback()
var moduleId, chunkId, i = 0, resolves = [];
for(;i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    if(installedChunks[chunkId]) {
        resolves.push(installedChunks[chunkId][0]);
    }
    installedChunks[chunkId] = 0;
}

// ……

while(resolves.length) {
    resolves.shift()();
}

上面的代码先根据模块注册时的chunkId,取出installedChunks对应的全部loading中的chunk,最后将这些chunk的promise进行resolve操做。

5. 写在最后

至此,对于「webpack打包后是如何实现前端模块化」这个问题就差很少结束了。本文经过Node中的模块化为引子,介绍了webpack中的同步与异步模块加载的设计与实现。

为了方便你们对照文中内容查看webpack运行时源码,我把基础的webpack runtime chunk和module chunk放在了这里,有兴趣的朋友能够对照着看。

最后仍是欢迎对webpack感兴趣的朋友可以相互交流,关注个人系列文章。

参考资料

相关文章
相关标签/搜索