记一次 webpack 异步加载引发的思考

1,079 阅读5分钟

起因

原因是监控到 android 应用我们这面的业务异步 chunk 加载失败率相较其他业务高了许多,上面的大佬不满意,所以老大安排我去排(shuai)查(guo)问题

说实话,咱之前也没干过这种事情,完全没有经验,所以一开始为了排查这个问题,我一头扎进了 native 端的日志里,然而看着茫茫多的日志,根本就无从下手。

所以不得不转战方向,比如失败文件会不会有什么规律呢,于是我就去从日志系统爬数据,根据数据分析出来有一个 css 文件的失败率奇高

然而到这里,还是看不出什么,然后我就想既然是异步加载失败了,那就了解一下 webpack 异步加载的流程吧,没想到还真的发现了 css 加载失败奇高的原因

但是并没有定位到根本问题

001EE750.jpg

不过也算是有点收获,学习到了 webpack 异步加载的机制

准备阶段

首先我们使用 create-react-app 创建一个 react 项目,然后我们创建两个用于异步加载的组件,项目的结构如下

image.png

可以看到我们创建了两个非常简单组件,如名字所示,Parent 引用了 Child 组件,代码如下

// Parent
import React from 'react';
import Child from './Child';
import './parent.css';

export default function Parent() {
  return (
    <div>
      <div className='parent'>Parent</div>
      <Child />
    </div>
  );
}
// Child
import React from 'react';
import './child.css';

export default function Child() {
  return (
    <div className='child'>Child</div>
  );
}

然后我们在 App 组件里写一下我们的异步加载的逻辑,代码如下,也是比较简单

// App
import { useState } from 'react';
import './App.css';

function App() {
  const [Comp, setComp] = useState(null);

  function onClick() {
    if (Comp) {
      return;
    }

    import('./components/Parent.js').then(module => {
      setComp(() => module.default);
    });
  }

  return (
    <div className="App">
      <h1 onClick={onClick}>App</h1>
      {
        Comp ? <Comp /> : null
      }
    </div>
  );
}

export default App;

发现问题

然后可以把项目跑起来看下是否运行正常,我这里就不贴运行效果的图了

为了模拟效果,我们在 index 里挂载一个全局的异步加载方法

window.loadDynamic = function() {
  import('./components/Parent.js').then(() => {
    console.log('load success');
  }).catch(e => {
    console.log('load fail: ', e);
  })
}

然后 build 生成大致如下结构

image.png

然后我们起一个 server 看下效果,正如我们预期所想,点击 h1 的时候,异步加载了组件

1626877093(1).jpg

还记得我们之前挂载了一个方法在 window 上吗,我们试试直接调用该函数是否能够加载文件

image.png

image.png

可以看到也是可以成功的,这时候我们来个骚操作,删除 js chunk 调用一下试试

image.png

image.png

可以看到我们调用两次函数,就加载了 js 两次,符合我们的直觉,那么 css 呢,直觉上感觉应该表现和 js 加载是一致的

然而...

image.png

image.png

为什么,为什么这里只加载了一次 css,难道 webpack 区别对待 css,抱着为 css 鸣不平的态度,我们深(biao)入(mian)了解一下 webpack 的异步加载的处理

进入主题

话不多说,我们直接上代码,我将代码进行了分割,并加上了相应的注释,代码本身不是很绕,应该比较好看懂

改混淆过的代码好难,花费了不少时间,本就不好的视力又加深了 100 度

004BA4CE.jpg

注意,前方大量代码出没,请系好安全带,带好头盔,我们直接发车冲锋

前置代码

var installedCss = {};
var installChunks = {};

// __webpack_require__
function c() {}

// 异步加载函数
// __webpack_require__.ensure
function ensure(chunkId) {
  var promises = [
    loadCss(chunkId),
    loadScript(chunkId),
  ];

  return Promise.all(promises);
}

加载 css

function loadCss(chunkId) {
  // 已经加载过了
  if (installedCss[chunkId] === 0) {
    return Promise.resolve();
  }

  // 正在加载中
  if (installedCss[chunkId]) {
    return installedCss[chunkId];
  }

  // 加载 css chunk
  const p = new Promise(function (resolve, reject) {
    // 由于我们没给 chunk 命名,所以是个空对象
    const chunkName = {}[chunkId] || chunkId;
    const chunkHash = { 0: 'c337e405', 4: '31d6cfe0' }[chunkId];
    var src = 'static/css/' + chunkName + '.' + chunkHash + '.chunk.css';
    // c.p -> publicPath
    var finalPath = c.p + src;
    var links = document.getElementsByTagName('link');

    // 如果 link 有加载该 css 则直接 resolve
    for (var i = 0; i < links.length; i++) {
      var href = (f = links[i]).getAttribute('data-href') || f.getAttribute('href');
      if ('stylesheet' === f.rel && (href === src || href === finalPath)) {
        return resolve();
      }
    }

    // 如果 style 是该 css chunk 对应的 css,直接 resolve
    var styles = document.getElementsByTagName('style');
    for (i = 0; i < styles.length; i++) {
      var f;
      if (
        (href = (f = styles[i]).getAttribute('data-href')) === src ||
        href === finalPath
      ) {
        return resolve();
      }
    }

    // 否则创建 link 加载资源
    var link = document.createElement('link');
    link.rel = 'stylesheet';
    link.type = 'text/css';
    link.onload = resolve;
    link.onerror = function (t) {
      var n = (t && t.target && t.target.src) || finalPath;
      var err = new Error('Loading CSS chunk ' + chunkId + ' failed.\n(' + n + ')');
      err.code = 'CSS_CHUNK_LOAD_FAILED';
      err.request = n;
      delete installedCss[chunkId];
      link.parentNode.removeChild(link);
      reject(err);
    };
    link.href = finalPath;
    document.getElementsByTagName('head')[0].appendChild(link);
  }).then(function () {
    // 加载成功标识一下
    installedCss[chunkId] = 0;
  });

  installedCss[chunkId] = p;

  return p;
}

加载 javascript

function loadScript(chunkId) {
  var chunk = installChunks[chunkId];

  // 已经加载了
  if (chunk === 0) {
    return Promise.resolve();
  }

  // 已经在加载了
  if (chunk) {
    // [resolve, reject, promise]
    return chunk[2];
  }

  var p = new Promise(function (resolve, reject) {
    chunk = installChunks[chunkId] = [resolve, reject];
  });
  chunk[2] = p;

  var script = document.createElement('script');
  script.timeout = 120;
  const chunkName = {}[chunkId] || chunkId;
  const chunkHash = { 0: 'c30eaf27', 4: '13cdfc3d' }[chunkId];
  script.src = c.p + 'static/js/' + chunkName + '.' + chunkHash + '.chunk.js';

  var err = new Error();
  function callback(e) {
    (script.onerror = script.onload = null);
    clearTimeout(timer);
    var chunk = installChunks[chunkId];

    // 加载成功
    // 因为加载完会执行 script,期间会将该 chunk 的标识置为 0
    if (chunk === 0) {
      return;
    }

    // 标识位未被置 0,认为加载错误
    if (chunk) {
      var errorType = e && ('load' === e.type ? 'missing' : e.type);
      var src = e && e.target && e.target.src;
      err.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + src + ')';
      err.name = 'ChunkLoadError';
      err.type = errorType;
      err.request = src;
      chunk[1](err);
    }

    // 重置标识位,防止再次加载时读取旧的状态
    installChunks[chunkId] = void 0;
  };
  var timer = setTimeout(function () {
    callback({ type: 'timeout', target: script });
  }, 12e4);
  script.onerror = script.onload = callback; 
  document.head.appendChild(script);

  return p;
}

后记

相信大家看了刚才的代码,并没有发现加载 css 的逻辑有任何的问题,它应该是能够和 js 加载的表现是一致的才对

然而...

它确实没有任何问题,我在写注释的时候就发现了,后面我偷偷去和项目里的产物代码比较了一下,发现我们项目里打包出来的代码里,少了如下两行

delete installedCss[chunkId];
link.parentNode.removeChild(link);

这两行发生在 link 元素的 error 事件里,作用就是重置加载状态,让重复加载不会读取到之前加载失败的状态

本来想开开心心写篇文章吹(pian)个(dian)水(zan),没想到人家已经把这个问题修复了,宝宝心里苦呀

更新:css chunk 的加载代码是由 mini-css-extract-plugin 生成的,与 webpack 本身无关

005CB6DD.jpg

什么,你问我之前的问题找到原因了吗?

005D7AE8.jpg