likes
comments
collection
share

你知道webpack是如何处理代码分割的吗?

作者站长头像
站长
· 阅读数 33

我们知道,如果写import xxx from 'xxx.js'所有的资源都会被打包进一个资源文件里面。

可以看👉这里,index.js 和 sum.js 最后被打包出来成为一个main.js 建议没看过 webpack打包cjs与esm产物分析 的先看这篇(没看也影响不大

这样做的优点就是能减少最终页面的HTTP请求数,但是缺点也同样存在:

  1. 随着项目的不断增大,最终形成的代码量过大,影响首屏渲染
  2. 因为所有的产物都在一起,就无法有效的利用上webpack的缓存构建及浏览器的缓存加载的机制

想要让某个模块在自己需要的时候再加载,我们可以利用import(module)来动态导入模块,这就是code splitting。但是一般来讲webpack打包后并不会直接使用import(module)

我们经常在优化项目时会配置webpackoptimization.splitChunks,它的本质其实就是利用import(module)的这一点完成的代码分割。我们下面来看下code splitting的运行时是怎样的。

实验代码

业务代码如下👇

// index.js 内容
import('./sum').then(m => {
  console.log(m.default(3, 4))
})

// sum.js 内容
const sum = (...args) => args.reduce((x, y) => x + y, 0)

export default sum

编写webpack 配置文件 如下👇

// build.js
const webpack = require('webpack')
const path = require('path')

function f1() {
    return webpack({
        entry: './index.js',
        mode: 'none',
        output: {
            filename: 'main.[contenthash].js', // 主文件名规则
            chunkFilename: '[name].chunk.[contenthash].js', // 模块文件名规则
            path: path.resolve(__dirname, 'dist/contenthash'), // 打包出口
            clean: true
        }
    })
}


f1().run((err, stat) => {
    console.log('🚀构建完成')
})

进行打包

node build.js

打包结果如下👇:

$ ls -lh dist/contenthash 
total 32
-rw-r--r--  1 jerry  staff   523B  9  9 16:42 1.chunk.f64a38ddcbde85380cf2.js
-rw-r--r--  1 jerry  staff    11K  9  9 16:42 main.c09a77a27e9d6bdf91d7.js

调试

调试之前需要新建一个index.html,引入main.xxx.js。通过 serve 开启静态资源服务,在浏览器中打开进行调试。

$ npx serve .

chunk 代码分析

回顾一下 webpack打包cjs与esm产物分析中几个重要的数据结构及方法:

  • 模块函数数组__webpack_modules__:用于存放模块内容的数组,其数组下标表示对应的模块函数。
  • 模块缓存对象__webpack_module_cache__:通过模块函数数组的下标缓存模块的对象。
  • 模块加载函数__webpack_require__:该函数接收一个模块id(对应的就是模块函数数组的下标),如果该模块在缓存中存在则直接返回module.exports。否则将要加载的模块缓存到模块缓存对象 __webpack_module_cache__中,并返回module.exports
  • __webpack_require__.d:该方法在模块加载时通过Object.definePropertygetter定义exports导出的值。
  • __webpack_require__.oObject.prototype.hasOwnProperty的缩写。
  • __webpack_require__.r:用来标记是ESM。

main.xxx.js的最后几行其实就是我们写的import('./sum').then ...可以对比一下:

// 编译之前
import('./sum').then(m => {
    console.log(m.default(1, 2))
})

// 编译之后
__webpack_require__.e(/* import() */ 1)
  .then(__webpack_require__.bind(__webpack_require__, 1))
  .then(m => {
    console.log(m.default(1, 2))
  })

其中__webpack_require__.e(1)中的1为chunkId,加载的对应的文件就是1.chunk.xxx.js,内容如下👇

"use strict";

// JSONP callback, 该script文件加载后执行 self.webpackChunk.push 
// 将模块内容收集到 __webpack_modules__ 中
// self["webpackChunk"].push:push 方法并不是Array的push,属于自定义方法
(self["webpackChunk"] = self["webpackChunk"] || []).push([[1], [
    /* 0 */,
    /* 1 */
    ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

        __webpack_require__.r(__webpack_exports__);
        __webpack_require__.d(__webpack_exports__, {
                "default": () => (__WEBPACK_DEFAULT_EXPORT__)
        });
        const sum = (...args) => args.reduce((x, y) => x + y, 0)

        const __WEBPACK_DEFAULT_EXPORT__ = (sum);

    })
]]);

整个过程如下:

  1. 调用__webpack_require__.e,加载chunk。new 一个Promise,通过script标签加载chunk
  2. 加载 script,执行 JSONP callbackself["webpackChunk"].push,将传参中的模块收集到__webpack_modules__中,并将__webpack_require__.e的 Promise 进行 resolve
  3. __webpack_require__.e的 Promise resolve 后就会执行then中的回调函数——__webpack_require_函数,这个过程与之前分析esm一致。

在 script 上还有一个 onload 事件(宏任务),触发 onload 时会在 document 中将这个 script 标签删除

运行时分析

main.js 运行时分析

// 将 __webpack_modules__ 放到 __webpack_require__ 上
__webpack_require__.m = __webpack_modules__;


__webpack_require__.f = {};

// 用以加载 code spliting 的 chunk
__webpack_require__.e = (chunkId) => {
  return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
    // 实际上是 __webpack_require__.f.j
    __webpack_require__.f[key](chunkId, promises);
    return promises;
  }, []));
};

// 得到 chunk 地址,用以加载
__webpack_require__.u = (chunkId) => {
    // 这里的 chunk 脚本的地址被写死,意味着每当 chunk 的文件名发生改变,运行时代码也会发生改变
    return "" + chunkId + "." + chunkId + ".chunk." + "f64a38ddcbde85380cf2" + ".js";
};

// 封装全局变量
__webpack_require__.g = (function () {
    if (typeof globalThis === 'object') return globalThis;
    try {
        return this || new Function('return this')();
    } catch (e) {
        if (typeof window === 'object') return window;
    }
})();

以下是 webpack 实现加载 chunk 的 加载脚本

var inProgress = {};
__webpack_require__.l = (url, done, key, chunkId) => {
    // done 为脚本加载完时的回调函数
    if (inProgress[url]) { inProgress[url].push(done); return; }

// script:script 标签,用以加载 chunk,如 <script src="xxx.chunk.js">
    var script, needAttach;
    if (key !== undefined) {
        // 判断该 script 标签是否已存在在 document 中
        var scripts = document.getElementsByTagName("script");
        for (var i = 0; i < scripts.length; i++) {
            var s = scripts[i];
            if (s.getAttribute("src") == url) { script = s; break; }
        }
    }

    // 如果 script 标签不存在在 document 中,则新建 script 标签,加载脚本
    if (!script) {
        needAttach = true;
        script = document.createElement('script');

        script.charset = 'utf-8';
        script.timeout = 120;

        // nonce,用以配置 CSP 策略,见 https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
        if (__webpack_require__.nc) {
            script.setAttribute("nonce", __webpack_require__.nc);
        }

        script.src = url;
    }
    inProgress[url] = [done];
    var onScriptComplete = (prev, event) => {
        // avoid mem leaks in IE.
        script.onerror = script.onload = null;
        clearTimeout(timeout);
        var doneFns = inProgress[url];
        delete inProgress[url];
        script.parentNode && script.parentNode.removeChild(script);

        // 脚本加载结束后,回调 done 函数,并传递 event 参数s
        doneFns && doneFns.forEach((fn) => (fn(event)));

        if (prev) return prev(event);
    }
    var timeout = setTimeout(onScriptComplete.bind(null, undefined, { type: 'timeout', target: script }), 120000);

    // script.onload 事件,脚本加载完成时,执行 onScriptComplete(script.onerror)
    script.onerror = onScriptComplete.bind(null, script.onerror);

    // script.onload 事件,脚本加载完成时,执行 onScriptComplete(script.onload)
    script.onload = onScriptComplete.bind(null, script.onload);

    // 1. 将 script 附在 DOM 中,加载脚本
    // 2. chunk 脚本加载后执行 chunk 脚本中的 self["webpackChunk"].push 函数,即下边的 webpackJsonpCallback 函数
    needAttach && document.head.appendChild(script);
};

维护模块加载状态的方法及JSONP Callback的方法

// key 为 chunkId,value 为加载状态
// 0: 代表 chunk 加载成功
// [resolve, reject, Promise]: 代表 chunk 正在加载
// undefined: 代表 chunk 尚未加载
// null: chunk 为 preloaded/refetched
var installedChunks = {
  0: 0 // main 默认是 0
};

__webpack_require__.f.j = (chunkId, promises) => {
  // JSONP chunk loading for javascript
  var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
  if (installedChunkData !== 0) { // 0 means "already installed".

    // a Promise means "currently loading".
    if (installedChunkData) {
      // chunk 正在加载
      promises.push(installedChunkData[2]);
    } else {
      if (true) { // all chunks have JS
        // setup Promise in chunk cache
        var promise = new Promise((resolve, reject) => (installedChunkData = installedChunks[chunkId] = [resolve, reject]));
        promises.push(installedChunkData[2] = promise);

        // 获取 chunk 的脚本地址
        var url = __webpack_require__.p + __webpack_require__.u(chunkId);
        // create error before stack unwound to get useful stacktrace later
        var error = new Error();
        var loadingEnded = (event) => {
          if (__webpack_require__.o(installedChunks, chunkId)) {
            installedChunkData = installedChunks[chunkId];
            if (installedChunkData !== 0) installedChunks[chunkId] = undefined;
            if (installedChunkData) {
              var errorType = event && (event.type === 'load' ? 'missing' : event.type);
              var realSrc = event && event.target && event.target.src;
              error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
              error.name = 'ChunkLoadError';
              error.type = errorType;
              error.request = realSrc;
              installedChunkData[1](error);
            }
          }
        };
        // 加载脚本
        __webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
      } else installedChunks[chunkId] = 0;
    }
  }
};

// JSONP callback,也就是 self.webpackChunk.push 方法
var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
  var [chunkIds, moreModules, runtime] = data;
  // add "moreModules" to the modules object,
  // then flag all "chunkIds" as loaded and fire callback
  var moduleId, chunkId, i = 0;
  if (chunkIds.some((id) => (installedChunks[id] !== 0))) {
    for (moduleId in moreModules) {
      if (__webpack_require__.o(moreModules, moduleId)) {
        __webpack_require__.m[moduleId] = moreModules[moduleId];
      }
    }
    if (runtime) var result = runtime(__webpack_require__);
  }
  if (parentChunkLoadingFunction) parentChunkLoadingFunction(data);
  for (; i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
      // 加载成功,回调 resolve(),Promise 被 resolve 掉,成功回调
      installedChunks[chunkId][0]();
      // resolve 以后就会执行 __webpack_require__ 加载模块了
      // 到这里也就是说模块的代码拿到了,后面就可以执行使用了
    }
    installedChunks[chunkId] = 0;
  }

}

var chunkLoadingGlobal = self["webpackChunk"] = self["webpackChunk"] || [];
chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));

当使用code splitting后,会存在以下变量:

  1. __webpack_require__.m:维护所有模块的数组
  2. __webpack_require__.g:全局变量,一般为 globalThis
  3. __webpack_require__.p:publicPath
  4. __webpack_require__.u:获取某个 chunk 的脚本路径,注意,这里的 chunk 脚本的地址被写死,意味着每当 chunk 的文件名发生改变,运行时代码也会发生改变,这将破坏缓存
  5. __webpack_require__.e:加载某个 chunk
  6. __webpack_require__.f:储存加载模块函数的对象
  7. __webpack_require__.l:加载某个 chunk 的脚本,加载成功后进行 JSONP Callback
  8. __webpack_require__.nc:script 脚本的 nonce 属性,用以配置 scp

总结

可以看到,webpack 处理 code splitting 使用的是 JSONP + Promise 的方式实现的。 当然,对于支持模块化的现代浏览器,可以不使用 JSONP 的方式加载,webpack 配置如下👇

function f2() {
    return webpack({
        entry: './index.js',
        mode: 'none',
        output: {
            filename: 'main.[contenthash].js',
            chunkFilename: '[name].chunk.[contenthash].js',
            path: path.resolve(__dirname, 'dist/import'),
            clean: true,
            chunkLoading: 'import',
            chunkFormat: 'module'
        }
    })
}

f2().run((err, stat) => {
    console.log('🚀 构建完成~')
})

构建出来的产物基本上与JSONP 的形式差不多,区别是直接使用的import()进行动态加载chunk文件。如果你觉得JSONP的形式比较难以理解,也可以尝试使用这种形式来调试产物代码。

参考文章: q.shanyue.tech/engineering…

参与山月哥哥的训练营涨姿势多多~

转载自:https://juejin.cn/post/7142815443630178335
评论
请登录