浅析webpack的按需加载(源码解读)

605 阅读6分钟

最近看了吴浩麟的《深入浅出webpack》有了一些收获,在这里和大家分享一下里面的按需加载章节和webpack按需加载的源码实现。由于书出的比较早,书中webpack版本为3.4.0,故本文的所有代码都是在3.4.0版本下打包构建的~

1.什么是按需加载

我们知道webpack默认会把我们项目的所有依赖打包到一个js文件中,如果没有其他优化手段,我们的这个依赖包会很大,页面加载时间会变长,用户可忍不了(:狗头保命。再加上有些文件如果用户不进行某些操作的时候可能永远都用不上,白白的浪费了用户的带宽,便宜了运营商,虽然现在好像也不贵(: 所以我们需要将这些可能未来才会用上的文件利用一个手段在用户在这个页面进行操作需要这个文件的时候才去加载这个页面,这个和我们单页应用下跳转到不同的路由再去获取该路由所需的js文件是一样的。后面会再和大家分享下结合vue-router的路由按需加载。

2.如何实现按需加载

以下内容参考《深入浅出webpack》4-12章节 Webpack 内置了强大的分割代码的功能去实现按需加载,实现起来非常简单。 我们只需要按照下面的语法引入我们需要按需加载的文件就可以实现该功能,这段代码会在按钮点击的时候加载执行show.js,加载完成后执行show('Webpack')方法,该方法弹出“Hello Webpack” image.png

image.png 其中关键的代码是

import(/* webpackChunkName: "show" */ './show')

我们都知道es6里面的import()代表着动态引用,那么在这里webpack自己实现了import动态引用的功能。那么它是怎么做到的呢

3.按需加载源码分析

webpack在遇到import()这种语法时做以下处理

  • 以 ./show.js 为入口新生成一个 Chunk;
  • 当代码执行到 import 所在语句时才会去加载由 Chunk 对应生成的文件。
  • import 返回一个 Promise,当文件加载成功时可以在 Promise 的 then 方法中获取到 show.js 导出的内容。 结合源码我们来理解下这三个步骤 按照课程中的写法,我对文件进行了打包,打包后发现在dist目录下多了一个0.js的文件,这个就是我们按需加载的包,这个包默认不会被加载,除非我们点击了按钮。 我们上面的代码被webpack打包后会被转换成下面的代码
window.document.getElementById('btn').addEventListener('click', function () {
  // 当按钮被点击后才去加载 show.js 文件,文件加载成功后执行文件导出的函数
 __webpack_require__.e(0).then(__webpack_require__.bind(null, 1))
 .then((show) => {
    show('Webpack');
  })
});

按需加载的关键就在__webpack_require__.e这个函数中。其中的0就是我们前面多出来的0.js这个chunk。 下面我们找到这个函数的源码如下,我会在源码上加入注释方便大家理解。

__webpack_require__.e = function requireEnsure(chunkId) {
//installedChunks是webpack内部的一个全局变量,它保存了所有已经下载解析完的chunks
    var installedChunkData = installedChunks[chunkId];
   //installedChunkData 找到我们要加载的模块,如果为0 代表chunk已经被成功加载啦,直接返回一个resolve的promise
   //防止重复加载,这里为什么是0 后面大家会看到,本质就是webpack在chunk加载完后保存的一个标志位
    if (installedChunkData === 0) {
        return new Promise(function (resolve) { resolve(); });
    }
//如果模块正在加载,返回这个模块数组的promise对象,后面会看到这个installedChunkData是怎么来的,就会明白为什么是installedChunkData[2]
    // a Promise means "currently loading".
    if (installedChunkData) {
        return installedChunkData[2];
    }
//接下来就是正常加载chunk,新建了一个promise对象,installedChunks[chunkId]按照数组保留了这个对象的resolve
//和reject和promise本身,所以installedChunkData[2]代表了加载这个chunk的promise对象
//因为我们加载一个js是一个异步的过程,我们需要保留这个promise来知道这个js什么时候加载完成
    // setup Promise in chunk cache
    var promise = new Promise(function (resolve, reject) {
        installedChunkData = installedChunks[chunkId] = [resolve, reject];
    });
    installedChunkData[2] = promise;
//接下来就是我们去加载这个js啦~~
//这个和jsonp是一个道理,创建一个script标签,type是‘text/javascript’,设置120000的一个timeout
    // start chunk loading
    var head = document.getElementsByTagName('head')[0];
    var script = document.createElement('script');
    script.type = "text/javascript";
    script.charset = 'utf-8';
    script.async = true;
    script.timeout = 120000;
//这行可以忽略,暂时对我们的按需加载没有影响
    if (__webpack_require__.nc) {
        script.setAttribute("nonce", __webpack_require__.nc);
    }
    //设置我们这个scipt的src为项目发布的路径(__webpack_require__.p)+chunkId.js,这里是0.js
    script.src = __webpack_require__.p + "" + ({}[chunkId] || chunkId) + ".js";
    //手动设置定时器,在120000后执行onScriptComplete这个回调函数
    var timeout = setTimeout(onScriptComplete, 120000);
    //添加脚本加载错误和加载执行完成的回调函数(注:onload是代表脚步加载和执行完成)
    script.onerror = script.onload = onScriptComplete;
    
    function onScriptComplete() {
        // avoid mem leaks in IE.
        //为了避免内存泄漏 把回调函数置为null
        script.onerror = script.onload = null;
        //清除定时器
        clearTimeout(timeout);
        //拿到安装的chunk
        var chunk = installedChunks[chunkId];
        //chunk不为0 代表脚本没有完成加载
        if (chunk !== 0) {
            if (chunk) {
                chunk[1](new Error('Loading chunk ' + chunkId + ' failed.'));
            }
            installedChunks[chunkId] = undefined;
        }
    };
    //将脚本添加到head,开始下载脚本
    head.appendChild(script);
    //返回这个promise,后序的操作都是基于这个promise进行的,
    return promise;
};

那么我们加载的这个脚本的内容又是什么呢,这里我把打包后的js源码贴在下面和大家一起分析下~

//[0] chunk id为0 
webpackJsonp([0], [
//0 就是一个占位,是我们webpack加载的模块的的一个顺序。因为对于我们的打包后的主入口文件
//默认是main.js来说第一个加载的模块文件是0,我们0.js是我们要加载的第二个模块,所以在数组中是第二项
/* 0 */,
/* 1 */
/***/ (function (module, exports) {
        module.exports = function (content) {
            window.alert('Hello ' + content);
        };
    })
]);

那么webpackJsonp又做了什么事呢,我们把在源码中找到这个函数的定义

//这个函数是挂载在全局window对象上的,方便我们上面的js加载后执行的时候可以找到这个函数
//针对我们的场景,传入的参数是[0],[,(function(module, exports){...})]
window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
    // add "moreModules" to the modules object,
    // then flag all "chunkIds" as loaded and fire callback
    var moduleId, chunkId, i = 0, resolves = [], result;
    //  循环我们要加载的chunkids
    for (; i < chunkIds.length; i++) {
        chunkId = chunkIds[i];
        //判断installedChunks中是否有这个id,还记得我们在第一步的时候已经在installedChunks加入我们的chunk了么
        //是一个[resolve,reject,promise对象]这样的数组,就是在这里用到这个数组的
        if (installedChunks[chunkId]) {
        //如果有,就在把那个promise对象的resolve推入resolves数组
            resolves.push(installedChunks[chunkId][0]);

        }
        将我们installedChunks的这个chunk设置为0,代表这个chunk已经加载完成
        installedChunks[chunkId] = 0;

    }
    
    for (moduleId in moreModules) {
        if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
        //这个是我们真正chunk加载的内容,它作为我们全局变量modules数组的第二项
            modules[moduleId] = moreModules[moduleId];

        }

    }
    //忽略
    if (parentJsonpFunction) parentJsonpFunction(chunkIds, moreModules, executeModules);
    执行我们那个promise对象的resolve函数,完成这个promise
    while (resolves.length) {
        resolves.shift()();
    }
};

在这个promise被resolve后,我们.then(webpack_require.bind(null, 1)) 利用__webpack_require__去加载我们的模块1,这个函数return module.exports(大家应该熟悉这个函数把) 然后再.then((show) => { show('Webpack'); })会拿到show函数,执行这个函数。 至此大功告成~