webpack按需加载:从实践到原理

前言

在h5首屏优化的时候,代码分割(code spitting)是一个常用的优化手段。一个页面可能会有很多功能不需要在首屏展示,有些甚至直到页面关闭都不会被用户使用到。所以我们希望页面首次渲染的时候只加载最核心的js文件,其他的模块等到有需要的时候再加载,即按需加载。

实现动态导入

使用import函数

webpack提供了几种方式帮我们实现代码分割,其中一种方式是使用import函数来动态导入模块。
假设我们有如下模块:

// src/utils.js
const utils = 'utils模块'
export default utils
复制代码

在b模块引用:

// src/b.js
import('./utils').then(data => {
    console.log(data);
});
const b = 'b模块';
export default b;
复制代码

a模块引用了b模块和utils模块:

// src/a.js
import('./utils').then((data) => {
    console.log(data);
});
import('./b').then((data) => {
    console.log(data);
});
const a = 'a模块';
export default a;
复制代码

入口文件:

// index.js
import('./src/a').then((data) => {
    console.log(data);
});
复制代码

webpack配置:

// webpack.config.js
const path = require('path');
module.exports = {
  mode: 'development',
  entry: './index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
};
复制代码

执行构建命令后,结果正如我们预期,输出了4个js文件。通过import函数导入的模块都被打包成独立的js文件。在实际使用中,我们会通过触发某些事件,再去执行import函数,这时就会在html文档上插入一个script标签,加载我们需要的模块。

使用babel/plugin-syntax-dynamic-import插件

通常我们会在项目中使用babel来编译代码,这时需要确保babel能正确解析import语法。所以需要在项目中引入
babel/plugin-syntax-dynamic-import插件:

{ 
  "plugins": ["@babel/plugin-syntax-dynamic-import"]
}
复制代码

实际应用

    在实际应用中,其实我们很少会直接使用import函数来动态导入一个模块。这是因为在所有导入该模块的地方,如果有一处忘记使用import函数,它就会被打包到最终的bundle里面,这样我们优化的目的就达不到了。此外,我们还需要判断这个模块是否真的需要分割出来,因为建立一个http请求也需要一定的开销。import函数使用不当不仅达不到性能优化的目的,可能还会降低页面的性能。

    webpack4以后内置了SplitChunksPlugin插件,通过配置optimization.splitChunk,可以定好代码分割的策略,webpack就会按照这个策略对代码进行分割。关于splitChunk的使用,以后有空再写一篇文章来讨论,这里不做展开。

    vue-router和react-router官方都提供了路由懒加载的方案。使用vue-cli和vue-router的时候,路由懒加载是开箱即用的:

// 定义异步组件
const Foo = () => import('./Foo.vue')

// 在路由配置中什么都不需要改变,只需要像往常一样使用 Foo
const router = new VueRouter({
  routes: [{ path: '/foo', component: Foo }]
})
复制代码

更详细的文档可以查看vue-router路由懒加载

使用react-router有不同的方案可以选择:

  1. 使用 React.lazy;
  2. 使用 loadable-components.

官方文档提供了很详细的使用指南

webpack打包后的代码如何实现模块化

    webpack打包输出的代码是一个自执行函数,通过全局变量modules,installedModules和函数__webpack_require__实现模块化。

查看打包后的bundle.js,为了查看整体结构,我把代码折叠起来:
微信图片_20210816171116.png

自执行函数和eval有点难看,我们可以把代码结构整理成这样:

function main(modules) {
  function webpackJsonpCallback(data){
    //...
  }
  var installedModules = {};
  function __webpack_require__(moduleId) {
    // ...
  }
}

var indexModule = {
  './index.js': function(module, exports, __webpack_require__) {
    __webpack_require__.e(1).then(
      __webpack_require__.bind(null, "./src/a.js")
    ).then(data => {
      console.log(data)
    })
  }
}

main(indexModule)

复制代码

    下面我们分析一下模块的加载,为了宏观把握代码结构,这里我们会忽略一些执行细节,只讲主要流程。

    webpack用一个全局对象modules来存放所有的模块,key和value对应moduleId和模块函数,从入口模块./index.js开始加载其他模块。__webpack_require__.e动态插入script标签,在chunk加载完成后会把chunk里面所有的模块和对应的模块函数加入到modules对象里面,__webpack_require__负责执行模块函数。在这个实例中,__webpack_require__.e(1)加载了1.bundle.js, 完成后modules会变成这样:

{
  './index.js': function(module, exports, __webpack_require__) {
    __webpack_require__.e(1).then(
      __webpack_require__.bind(null, "./src/a.js")
    ).then(data => {
      console.log(data)
    })
  },
  './src/a.js': function(module, __webpack_exports__, __webpack_require__) {
    __webpack_require__.r(__webpack_exports__);
    __webpack_require__.e(0)
      .then(__webpack_require__.bind(null,  "./src/utils.js"))
      .then((data) => {   console.log(data);});
    __webpack_require__.e(2)
      .then(__webpack_require__.bind(null, "./src/b.js"))
      .then((data) => {console.log(data);});
    const a = 'a模块'; 
    __webpack_exports__["default"] = (a);
  }
}
复制代码

    代码通过__webpack_require__来引用模块./src/a.js,判断到installedModules里面没有这个模块,就会去执行对应的模块加载函数,调用__webpack_require__.e动态加载./src/utils.js./src/b.js

    因为a模块的导出值不依赖于其他模块,所以可以看到它的模块执行函数没有等异步模块加载完,直接赋值:__webpack_exports__["default"] = (a)

    模块加载函数执行完了,会把结果缓存到installedModules中,然后返回导出值。下一次有其他地方加载a模块,就直接从installedModules拿,不需要执行模块加载函数了。

上面讲的,正是__webpack_require__函数的源码:

function __webpack_require__(moduleId) {
  if(installedModules[moduleId]) {
    // 有缓存,直接返回模块导出值
    return installedModules[moduleId].exports;
  }
  // 构造一个新模块
  var module = installedModules[moduleId] = {
    i: moduleId,
    l: false,
 	exports: {}
  };
  // 执行模块加载函数。前面分析过,module.exports会在模块执行函数中赋值
  modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
  // 模块加载完成标志
  module.l = true;
  // 返回导出值
  return module.exports;
}
复制代码

动态导入(dynamic import)的原理

    上一小节分析webpack的模块化方案时,已经有提到动态加载是通过__webpack_require__.e来实现的,与之相关的还有一个全局变量installedChunks和挂载在window对象上的window["webpackJsonp"]

在分析源码前先思考一个问题,通过动态插入script标签来实现动态加载,如何解决这样一个问题:

多次引用同一个模块,只生成一个script标签

    在我们的实例中,a模块和b模块都引用了utils.js,而a模块又引用了b模块。utils模块被引用了两次,我们希望只生成一个script标签。且模块加载完成后,能够通知到所有正在加载utils的模块。

全局变量installedChunks用来缓存异步加载的chunks,形式如下:

{
    chunkId: installedChunkData
}
复制代码

installedChunkData有四种可能的值:

installedChunkData meaning
undefined chunk not loaded
null chunk preloaded/prefetched
resolve, reject, promise chunk loading
0 chunk loaded

首次加载到一个chunk的时候:

  1. installedChunkData = [resolve, reject, promise],script onloaded之后installedChunkData = undefined;
  2. 添加script标签,异步js加载完成后开始执行,首先会执行一个全局定义的webpackJsonpCallback函数,调用installedChunkData的resolve方法,然后installedChunkData = 0。

经过上面的分析之后,我们就可以回答前面的问题了:

  1. __webpack_require__.e加载chunk的时候,会判断installedChunkData的值,如果值为0就不会去加载了。所以加载同一个chunk不会多次添加script标签。
  2. 如果script还没loaded,__webpack_require__.e就会返回首次加载时创建的promise对象,因为所有地方引用的都是同一个promise,所以只要有一个地方调用了resolve方法,promise状态就会改变了。
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享