likes
comments
collection
share

webpack import() 按需加载模块

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

前言

Webpack 被广泛应用于项目工程来编译 JavaScript 模块,理解 webpack 打包同步模块import和异步模块import()在浏览器上的运行原理,可以更好的帮助我们理解 JS 模块化理念,以及应对打包技术上的疑难问题。

阅读完本篇文章,你将收获:

  1. import() 动态导入模块;
  2. 路由懒加载 实战应用;
  3. 同步模块异步模块在浏览器上的运行机制;
  4. 理解 webpack runtime

一、ES2020 import()

通常我们都采用 ESModule import 方式去引入模块。import 命令会被 JavaScript 引擎静态分析,先于模块内的其他语句执行,这要求 import 命令只能用在模块顶层。

显然,这样的设计无法像 Node require 方法那样在运行时加载模块。

ES2020提案 中引入了 import() 函数,支持动态加载模块。不过不同于 Node require 的同步加载,它是一个异步加载,返回一个 Promise 对象,语法结构如下:

import('path/to/module') -> Promise

import() 适用场景通常是在 条件加载 (if 语句中)和 动态加载(需要的时候加载)。

而在 webpack 中,import() 语法可用作代码拆分。import() 的模块以及它引用的所有子模块,会分离到一个单独的 chunk 中。

一般我们会借助 import() 对路由页面进行动态加载,实现页面代码的拆分和按需加载,在一定程度上提升应用的首屏渲染速度。

下面,我们在 React 环境下,借助 webpack + import() 特性实现路由按需加载。

二、按需加载路由

通常,我们会在程序入口定义路由,所使用静态 import 语法大致如下:

import React from 'react';
import { HashRouter, Switch, Route } from 'react-router-dom';
import Home from '@/pages/Home';

const App = () => {
  return (
    <HashRouter basename="/">
      <Switch>
        <Route exact path='/' render={props => <Home {...props} />} />
        ...
      </Switch>
    </HashRouter>
  )
}

现在,我们通过编写一个 高阶组件,封装 import() 逻辑实现路由组件的动态加载。程序入口路由定义改造如下:

import React from 'react';
import { HashRouter, Switch, Route } from 'react-router-dom';
import lazyComponent from '@/components/HighComponents/LazyComponent';

const App = () => {
  return (
    <HashRouter basename="/">
      <Switch>
        ...
        <Route path='/' component={lazyComponent('home', () => import(/* webpackChunkName: "home" */ '@/pages/home'))} />
      </Switch>
    </HashRouter>
  )
}

lazyComponent 第一个参数是模块名称(用于 cache),第二参数是一个函数,用于执行并返回 webpakc import() Promise 对象。具体实现如下:

// src/components/HighComponents/LazyComponent.tsx
import React from 'react';

const lazyCaches: { [key: string]: React.ComponentType<any> } = {};

function lazyComponent(lazyName: string, loadComponent: () => Promise<any>) {
  lazyCaches[lazyName] = lazyCaches[lazyName] || (
    class AsyncComponent extends React.PureComponent<any, { Component: React.ComponentType<any> | null }> {
      constructor(props: any) {
        super(props);
        this.state = { Component: null };
      }
      async componentDidMount() {
        const { default: Component } = await loadComponent();
        this.setState({ Component });
      }
      render() {
        const Component = this.state.Component;
        return (
          Component ? <Component {...this.props} /> : null
        )
      }
    }
  )
  return lazyCaches[lazyName];
}

export default lazyComponent;

这样,经过打包后会生成一个 home.chunk.js 文件,在匹配到对应路由时,动态创建 script 标签来加载模块内容。

三、使用注意:

对于刚上手使用的同学,有以下几点需要注意:

3.1、自定义 chunk 名称

默认使用 import(modulePath) 一个模块时,webpack 会将模块 id 作为打包生成的 chunk 名称,在 mode=development 开发环境下可能是文件路径,如:src_pages_home.js。

import() 规范中不允许控制模块的名称或其他属性。不过,webpack 中可以通过注释作为参数来为 chunk 规定文件名称。

如上例我们通过注释 webpackChunkName 为 Home 模块定义的 chunk 名称为 home

import(
  /* webpackChunkName: "home" */ 
  '@/pages/home'
)

3.2、import() 动态引入未生效

有时候,我们使用 import() 动态导入一个模块,经过打包发现未生成单独的 chunk 文件。原因可能是这个模块被 ESModule import(静态) 和 import()(动态)两种导入方式都应用了。

比如,一个路由模块在注册 <Route /> 时采用动态 import() 导入,但在这个模块对外暴露了一些变量方法供其他子模块使用,在这些子模块中使用了同步 ESModule import 方式引入,这就造成了 动态 import() 的失效。

所以,如果想要动态导入 import() 一个模块,这个模块就不能再出现被其他模块使用 同步 import 方式导入。

3.3、chunk 在动态加载时出现 404

从上面我们知道,每个路由模块经过 webpack + import() 打包处理后,生成一个独立的 chunk 文件。

chunk 文件的加载方式是通过动态创建 script 标签,资源地址 src 将指向打包目录 build

这部分的实现逻辑可以通过分析打包生成的 runtime.js 文件中看到。

runtime.js 中,会看到有这样一行代码来定义动态加载 chunk 的路径地址:

var url = __webpack_require__.p + __webpack_require__.u(chunkId);
  • __webpack_require__.u 执行后得到了 chunk 文件相对于 build 目录所在的相对路径(home.js)
  • __webpack_require__.p 是什么呢?其实它就是 __webpack_public_path__,即 webpack 的 publicPath 全局变量。

当你发现动态导入 chunk 时资源 404 了,可能是由于你在程序入口改写了 __webpack_public_path__ 的值。

// src/index.js
let { origin, pathname } = window.location;
__webpack_public_path__ = origin + pathname;

经过上面分析,我们大致清楚 webpack 对 import() 的模块生成 chunk 文件后,通过动态创建 script 标签添加到 html 中加载并执行资源(在资源加载成功后,会将 script 标签从 DOM 树上移除)。

下面我们看看在源码中的具体实现。

四、原理分析

接下来我们准备一个简单打包环境,根据打包后的产物分析 importimport() 两种方式的实现和差异。

4.1、环境准备

  1. 依赖安装:
mkdir webpack-import && cd webpack-import && npm init -y && npm install webpack webpack-cli -D
  1. 入口模块文件:
// src/index.js
import sync from './sync';

console.log('sync module: ', sync);
const button = document.createElement('button');
button.innerText = '动态加载模块';
button.onclick = () => import('./async').then(res => {
  console.log('async module: ', res.default)
});
document.body.appendChild(button);

// src/sync.js
console.log('load sync module.');
module.exports = 'sync';

// src/async.js
console.log('load async module.');
module.exports = 'async';
  1. 添加打包配置:
// webpack.config.js
const path = require('path');
module.exports = () => {
  return {
    mode: 'development',
    devtool: false,
    entry: path.resolve(__dirname, 'src/index.js'),
    output: {
      path: path.resolve(__dirname, 'build'),
      filename: '[name].js',
      chunkFilename: '[name].chunk.js',
    },
  }
}
  1. 添加执行脚本:
// package.json
"scripts": {
  "build": "webpack"
},

在示例中,我们在入口文件分别采用 importimport() 引入 sync.jsasync.js 两个模块,执行 npm run build,打包输出的 assets 如下:

build/main.js
build/src_async_js.chunk.js

其中,src_async_js.chunk.js 是 webpack 对 import('src/async.js') 按需加载模块所生成的 chunk 文件:

// build/src_async_js.chunk.js
// self 在浏览器上代表 Window 全局对象
(self["webpackChunkwebpack_import"] = self["webpackChunkwebpack_import"] || []).push([["src_async_js"], {
  "./src/async.js": ((module) => {
    console.log('load async module.');
    module.exports = 'async';
  })
}]);

4.2、同步模块(静态)

对于同步方式引入的模块 sync.js,和 webpack 运行模块代码 一起打包在了 main.js 中,你看到的代码结构应该如下:

(() => {
  // 1、入口文件依赖的同步模块集合对象
  var __webpack_modules__ = ({
    "./src/sync.js": ((module) => {
      console.log('load sync module.');
      module.exports = 'sync';
    })
  });

  var __webpack_module_cache__ = {};

  // 2、webpack 实现的 **静态** 模块查找方法 - require()
  function __webpack_require__(moduleId) {
    var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    // Create a new module (and put it into the cache)
    var module = __webpack_module_cache__[moduleId] = {
      exports: {}
    };
    // Execute the module function
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
    // Return the exports of the module
    return module.exports;
  }
  
  // ... __webpack_require__ 上扩展的一系列方法

  // 3、入口文件的代码
  (() => {
    var _sync__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/sync.js");
    var _sync__WEBPACK_IMPORTED_MODULE_0___default = __webpack_require__.n(_sync__WEBPACK_IMPORTED_MODULE_0__);

    console.log('sync module: ', (_sync__WEBPACK_IMPORTED_MODULE_0___default()));
    const button = document.createElement('button');
    button.innerText = '动态加载模块';
    button.onclick = () => __webpack_require__.e("src_async_js").then(__webpack_require__.t.bind(__webpack_require__, "./src/async.js", 23)).then(res => {
      console.log('async module: ', res.default)
    });
    document.body.appendChild(button);
  })();
})();

从上面代码中,我们可以得到三点重要信息:

  1. 对于使用 import 同步引入的模块,Webapck 将其存放在了一个叫 __webpack_modules__ 的 Map 对象中,key 为文件路径,value 为可执行函数,函数体内容就是模块中导出的代码;
  2. webpack 实现了一个类似于 Node require() 模块查找方法 __webpack_require__,查找来源其实就是存储 import 模块的 __webpack_modules__ 对象;
  3. 在最下方会直接执行入口文件 src/index.js 中的内容,调用 __webpack_require__ 方法读取到 sync.js 模块内容并进行输出。

__webpack_require__.n 是一个封装模块默认导出的一个方法,此外,webpack 为 __webpack_require__ 函数身上扩展了许多类似功能方法,称之为 Webpack 运行时代码

(() => {
  __webpack_require__.n = (module) => {
    return module && module.__esModule ?
      () => (module['default']) :
      () => (module);
  };
})();

此时,你将代码运行在 html 文件,会看到输出打印:sync module: sync,同步模块的实现原理如此简单。

4.3、异步模块(动态)

当我们点击按钮时,会执行加载异步模块 async.js。从打包后的产物可以看出:我们代码中的 import('./async') 被编译成了:

__webpack_require__.e("src_async_js").then(__webpack_require__.t.bind(__webpack_require__, "./src/async.js", 23))

接下来我们从 __webpack_require__.e 作为突破口看一看 webpack 对动态模块的处理实现。

(() => {
  var installedChunks = {
    "main": 0
  };
  __webpack_require__.f = {};
  __webpack_require__.f.j = (chunkId, promises) => { ... }
  __webpack_require__.e = (chunkId) => {
    return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
      __webpack_require__.f[key](chunkId, promises);
      return promises;
    }, []));
  };
})();

__webpack_require__.e 返回一个 Promise.all,其目的是为后续能够调用 .then 做铺垫。

从上面代码可以看出:__webpack_require__.f 下只有一个 j 方法(JSONP),用于动态加载 js 脚本,我们来看看具体实现。

__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop));

var installedChunks = {
  "main": 0
};
__webpack_require__.f.j = (chunkId, promises) => {
  var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
  // 1、避免重复加载。0 说明加载完成,避免重复创建 HTTP 请求加载
  if (installedChunkData !== 0) {
    // 2、installedChunkData 存在(为一个 promise),说明正在加载中
    if (installedChunkData) {
      promises.push(installedChunkData[2]);
    } else {
      // 3、初次加载,创建一个 promise 实例
      var promise = new Promise((resolve, reject) => (installedChunkData = installedChunks[chunkId] = [resolve, reject]));
      promises.push(installedChunkData[2] = promise);
      // 根据 publicPath 和 chunk 输入路径拼接得到 chunk 的加载 url
      var url = __webpack_require__.p + __webpack_require__.u(chunkId);
      var error = new Error();
      var loadingEnded = (event) => {
        if (__webpack_require__.o(installedChunks, chunkId)) {
          // 通常,如果 chunk 加载成功,installedChunkData 将得到一个 0
          installedChunkData = installedChunks[chunkId];
          if (installedChunkData !== 0) installedChunks[chunkId] = undefined;
          if (installedChunkData) {
            // ... error 属性信息设置
            installedChunkData[1](error); // 执行 reject 更新 Promise 状态
          }
        }
      };
      __webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
    }
  }
}

installedChunks 记录了所有 chunk 是否被成功加载,keychunk 名称,value === 0 说明加载(installed)完成,默认记录了入口文件 main。

从代码可以看出,__webpack_require__.f.j 主要是为 chunk 的加载操作创建一个 Promise,待 chunk 加载完成之后更新 Promise 状态(resolve、reject)以便后续调用 .then

  1. 如果 chunk 处于加载中,直接返回上次创建的 Promise
  2. 如果初次加载,为 chunk 创建一个 Promise 实例并将 resolvereject 一起存储在 installedChunks 中;
  3. 接着根据 publicPathchunk 相对于打包目录的路径拼接得到完整的 url 地址;
  4. 最后执行 __webpack_require__.l 去动态创建 script 标签去加载和执行 chunk 文件中的代码。

__webpack_require__.p 默认取自当前脚本的运行目录(如:http://localhost:52330/webpack-import/build/):

/* webpack/runtime/publicPath */
(() => {
  var scriptUrl = document.currentScript.src;
  scriptUrl = scriptUrl.replace(/#.*$/, "").replace(/\?.*$/, "").replace(/\/[^\/]+$/, "/");
  __webpack_require__.p = scriptUrl;
})();

__webpack_require__.u 用于获取 chunk 文件名称:

/* webpack/runtime/get javascript chunk filename */
(() => {
  __webpack_require__.u = (chunkId) => {
    return "" + chunkId + ".chunk.js";
  };
})();

有了 chunk 文件完整的 url 地址,我们来看 __webpack_require__.l(load script)是如何加载的:

/* webpack/runtime/load script */
(() => {
  var inProgress = {};
  var dataWebpackPrefix = "webpack-import:";
  __webpack_require__.l = (url, done, key, chunkId) => {
    var script, needAttach;
    // 1、如果 DOM 树上提供了加载此 chunk 的 script 标签,则无需再创建
    if (key !== undefined) {
      var scripts = document.getElementsByTagName("script");
      for (var i = 0; i < scripts.length; i++) {
        var s = scripts[i];
        if (s.getAttribute("src") == url || s.getAttribute("data-webpack") == dataWebpackPrefix + key) { script = s; break; }
      }
    }
    // 2、创建 script 标签
    if (!script) {
      needAttach = true;
      script = document.createElement('script');
      script.setAttribute("data-webpack", dataWebpackPrefix + key);
      script.src = url;
    }
    inProgress[url] = [done];
    var onScriptComplete = (prev, event) => {
      var doneFns = inProgress[url];
      delete inProgress[url];
      script.parentNode && script.parentNode.removeChild(script); // 加载完成,从 DOM 树上进行 remove
      doneFns && doneFns.forEach((fn) => (fn(event)));
      if (prev) return prev(event);
    }
    script.onerror = onScriptComplete.bind(null, script.onerror);
    script.onload = onScriptComplete.bind(null, script.onload);

    // 3、添加到 DOM 树上
    needAttach && document.head.appendChild(script);
  };
})();

这样一看,__webpack_require__.l 就是创建 script 标签并添加到 DOM 树上,然后代码线路就结束中断了。但是,为 chunk 所创建的 Promise 实例 resolve 状态何时被执行呢?

我们猜想 resolve 的执行时机应该是在 script 标签加载完成之后,不过在上面的 loadingEnded 方法中却只看到了 reject 的执行时机。

其实,执行 resolve 的时机发生在:加载 script 标签成功后浏览器去执行 chunk 里面的文件内容,我们来分析 src_async_js.chunk.js

(self["webpackChunkwebpack_import"] = self["webpackChunkwebpack_import"] || []).push([["src_async_js"], {
  "./src/async.js": ((module) => {
    console.log('load async module.');
    module.exports = 'async';
  })
}]);

self 代表 Window 对象,Window 上有个属性 webpackChunkwebpack_import(数组)调用了 push 方法添加一个 Map 对象(第二个元素),这里和同步模块 Map 对象 __webpack_modules__ 结构一致。

不过,这就是一个常见的数组调用 push 方法,没发现有啥不同呀,其实关键的一步就出在 push 方法上。

在执行 main.js 过程中,会加载这样一段代码来重写 webpackChunkwebpack_import.push 方法:

/* webpack/runtime/jsonp chunk loading */
(() => {
  var installedChunks = {
    "main": 0
  };

  __webpack_require__.f.j = (chunkId, promises) => { ... };
  
  var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
    ...
  }

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

webpackChunkwebpack_importpush 方法原来被改写成了 webpackJsonpCallback,而这个方法所接收的 data 属性正是 src_async_js.chunk.js 文件中看到的 push 所添加的内容。

var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
  var [chunkIds, moreModules, runtime] = data;
  var moduleId, chunkId, i = 0;
  // 1、判断模块是第一次被加载,将 moduleId 作为 key,模块内容作为 value 赋值给了 __webpack_require__.m
  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__);
  }

  // 2、调用数组原生 push 方法,向 webpackChunkwebpack_import 中添加数据
  if (parentChunkLoadingFunction) parentChunkLoadingFunction(data);

  // 3、执行 chunk promise 的 resolve,并标记 chunk 加载完成
  for (; i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
      installedChunks[chunkId][0](); // 执行 resolve 更改状态
    }
    installedChunks[chunkId] = 0; // chunk 加载完成
  }
}

这里有很关键的一行代码:__webpack_require__.m[moduleId] = moreModules[moduleId],其中 moreModules[moduleId] 就是 async.js 的模块内容:

{ // moreModules
  "./src/async.js": ((module) => {
    console.log('load async module.');
    module.exports = 'async';
  })
}

那么 __webpack_require__.m 又是什么呢?为什么要将 异步模块 信息存储在它之中呢?

其实它就是 同步模块 的 Map 对象 __webpack_modules__,在 main.js 中可以看到这样一行代码:

// expose the modules object (__webpack_modules__)
__webpack_require__.m = __webpack_modules__;

可见,到这一步,异步模块虽然通过 script 标签动态加载,但模块里的内容其实并未执行,只是存放在了 __webpack_modules__ 集合之中,最后,调用了 resolve 来更改 Promise 的状态。

installedChunks[chunkId][0](); // 执行 resolve 更改状态

更改了 chunk Promise 的状态,我们最终将模块的执行定位在了 .then 之后的 __webpack_require__.t 之中:

__webpack_require__.e("src_async_js").then(__webpack_require__.t.bind(__webpack_require__, "./src/async.js", 23))

__webpack_require__.t = function (value, mode) {
  if (mode & 1) value = this(value);
  if (mode & 8) return value;
  if (typeof value === 'object' && value) {
    if ((mode & 4) && value.__esModule) return value;
    if ((mode & 16) && typeof value.then === 'function') return value;
  }
  var ns = Object.create(null);
  Object.defineProperty(exports, '__esModule', { value: true });
  Object.defineProperty(ns, 'default', { enumerable: true, get: () => (value)});
  return ns;
};
  1. 首先是 bind __webpack_require__,那在函数之中 this 就指向了 __webpack_require__ 方法;
  2. 在函数内看到 this(value) 其实就是执行 __webpack_require__(./src/async.js)__webpack_modules__ 中查找模块并执行代码;
  3. 最后创建一个 esModule 对象并设置 default 属性来返回模块导出内容。

这样,我们就可以通过 .then 拿到返回的 default 模块导出内容:

.then(res => {
  console.log('async module: ', res.default)
});

4.4、小结

到这里,异步模块的加载流程就走完了,我们总结一下:

  1. 既然是异步加载模块,肯定需要一个 Promise 在等待加载到模块内容后,执行 resolve 来进入后续逻辑;
  2. 根据 publicPath 拼接得到一个完整的 chunk url,动态创建 script 标签并加载这个 chunk 文件;
  3. 加载成功后执行 chunk 文件中的代码,进入 webpackJsonpCallbackchunk 文件中存储的模块内容添加到 __webpack_modules__ 对象中;
  4. 最后采用同步模块的加载方式,通过 __webpack_require__ 方法去 __webpack_modules__ 中读取并执行模块代码。

我们知道,浏览器不能像 Node Fs 模块那样直接读取文件内容,我想这里也是借助 script 标签在浏览器加载到文件内容,最终将模块内容添加到内存对象 __webpack_modules__ 中提供使用。

4.5、webpack runtime

另外在这里介绍一下什么是 webpack runtime

我们改造一下 webpack 配置:

module.exports = () => {
  return {
    mode: 'development',
    ...
    optimization: {
      runtimeChunk: { // 运行时代码(webpack执行时所需的代码)从主文件中抽离
        name: entrypoint => `runtime-${entrypoint.name}`,
      },
    }
  }
}

执行 npm run build 打包后会得到 runtime-main.js 文件,可以看到,__webpack_require__ 相关的代码实现,都打包在了这个文件中。

所以,runtime 其实就是 webpack 所实现的一套属于自己的模块化规则的 运行代码,用于串联各个模块之间的引入关系,使其能够正常运行在浏览器环境中。

五、使用总结

1. 合并多个 import() 按需加载模块

我们知道,在真实业务场景中使用 import() 最多的场景是路由懒加载。

但有时我们对路由拆分太细了会造成体验感降低。

比如某个列表页和编辑页它们之间存在相互跳转,如果对它们拆分成两个 import() js 资源加载模块,在跳转过程中视图会出现白屏切换过程。

因为在跳转期间,浏览器会动态创建 script 标签来加载这个 chunk 文件,在这期间,页面是没有任何内容的。

基于此场景,我们可以将它们生成在一个 chunk 文件中,即将 webpackChunkName 绑定为同名,使用如下:

const ManageConsole = lazyComponent('list', () => import(/* webpackChunkName: "list" */ '@/pages/list'));
const TaskFlow = lazyComponent('edit', () => import(/* webpackChunkName: "list" */ '@/pages/edit'));
... 其他

最后

感谢阅读,如有不足之处,欢迎指正👏。

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