# Webpack 异步加载

本文转载自掘金, 深受启发, 特此记录

在正式开始之前我们先看看几个常见的相关面试题:

  • 在 Webpack 搭建的项目中,如何达到懒加载的效果?
  • 在 Webpack 中常用的代码分割方式有哪些?
  • Webpack 中懒加载的原理是什么?

相信读完本文,你对上面的一系列问题都能够轻松解答。

# 前置知识

在正式内容开始之前,先来学一些预备小知识点,以免影响后面的学习。

懒加载或者按需加载,是一种很好的优化网页或应用的方式。这种方式实际上是先把你的代码在一些逻辑断点处分离开,然后在一些代码块中完成某些操作后,立即引用或即将引用另外一些新的代码块。这样加快了应用的初始加载速度,减轻了它的总体体积,因为某些代码块可能永远不会被加载。

懒加载的本质实际上就是代码分离。把代码分离到不同的 bundle 中,然后按需加载或并行加载这些文件

在 Webpack 中常用的代码分离方法有三种:

今天我们的核心主要是第三种方式:动态导入

当涉及到动态代码拆分时,Webpack 提供了两个类似的技术:

我们主要看看 import()语法 (opens new window) 的方式。

import() 的语法十分简单。该函数只接受一个参数,就是引用模块的地址,并且使用  promise  式的回调获取加载的模块。在代码中所有被  import() 的模块,都将打成一个单独的模块,放在  chunk  存储的目录下。在浏览器运行到这一行代码时,就会自动请求这个资源,实现异步加载。

常见使用场景:路由懒加载。

# 统一配置

为了防止出现我可以你不可以的情况,我们先统一配置:

  "webpack": "^5.73.0",
  "webpack-cli": "^4.10.0",

webpack.config.js 配置:

module.exports = {
  mode: 'development',
  devtool: false,
  entry: {
    main: './src/main.js'
  },
  output: {
    filename: 'main.js', //定义打包后的文件名称
    path: path.resolve(__dirname, './dist') //必须是绝对路径
  }
}

# import()基本使用

我们先来看看使用 import() 异步加载的效果。

在 main.js 中同步导入并使用:

const buttonEle = document.getElementById('button')

buttonEle.onclick = function () {
  import('./test').then((module) => {
    const print = module.default
    print()
  })
}

test.js:

export default () => {
  console.log('按钮点击了')
}

先看打包结果:将 main.js 和 test.js 打包成了两个文件(说明有做代码分割)。

webpack-async-load

将打包后的文件在 index.html 中引入(注意这里只引用了 main.js ,并没有引用 src_test_js.main.js ):

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button id="button">来点击我!</button>
  </body>
  <script src="./main.js"></script>
</html>

将 index.html 在浏览器中打开,查看网络请求:

jvd1c-z85av.gif

发现首次并没有加载 src_test_js.main.js 文件(也就是 test.js 模块),在点击按钮后才会加载。符合懒加载的预期,确实有帮助我们做异步加载。

# 原理分析

结合现象看本质。在上面我们主要了解了异步加载的现象,接下来我们主要来分析和实现一下其中的原理。

老规矩,我们先说整体思路:

  • 第一步:当点击按钮时,先通过 jsonp 的方式去加载 test.js 模块所对应的文件
  • 第二步:加载回来后在浏览器中执行此 JS 脚本,将请求过来的模块定义合并到 main.js 中的 modules 中去
  • 第三步:合并完后,去加载这个模块
  • 第四步:拿到该模块导出的内容

整体代码思路(这里函数命名跟源代码有出入,有优化过):

image.png

第一步:当点击按钮时,先通过 jsonp 的方式去加载 test.js 模块所对应的文件

const buttonEle = document.getElementById('button')
buttonEle.onclick = function () {
  require.e('src_test_js') //src_test_js是test.js打包后的chunkName
}

接下来就去实现require.e函数:

//接收chunkId,这里其实就是 "src_test_js"
require.e = function (chunkId) {
  let promises = [] //定义promises,这里面放的是一个个promise
  require.j(chunkId, promises) //给promises赋值
  return Promise.all(promises) //只有当promises中的所有promise都执行完成后,才能走到下一步
}

require.j函数:这一步其实就是给promises数组赋值,并通过jsonp去加载文件

//已经安装好的代码块,main.js就是对应的main代码块,0表示已经加载成功,已经就绪
var installedChunks = {
  main: 0
}

//这里传入的是 "src_test_js" , []
require.j = function (chunkId, promises) {
  var promise = new Promise((resolve, reject) => {
    installedChunks[chunkId] = [resolve, reject] //此时installedChunks={ main: 0, "src_test_js":[ resolve, reject ]}
  })
  promises.push(promise) //此时promises=[ promise ]

  var url = require.publicPath + chunkId + '.main.js' //拿到的结果就是test.js打包后输出的文件名称:src_test_js.main.js,publicPath就是我们在output中配置的publicPath,默认是空字符串
  let script = document.createElement('script')
  script.src = url
  document.head.appendChild(script) //将该脚本添加进来
}

第二步:加载回来后在浏览器中执行此 JS 脚本,将请求过来的模块定义合并到 main.js 中的 modules 中去

在第一步中我们通过jsonp的方式加载了src_test_js.main.js文件,加载后需要立即执行该文件的内容,我们先来看看该文件长什么样子:

self['webpackChunkstudy'].push([
  ['src_test_js'],
  {
    './src/test.js': (modules, exports, require) => {
      require.defineProperty(exports, {
        default: () => WEBPACK_DEFAULT_EXPORT
      })
      const WEBPACK_DEFAULT_EXPORT = () => {
        console.log('按钮点击了')
      }
    }
  }
])

这里的self其实就是windowwebpackChunkstudy就是一个名字,它是webpackChunk + 我们package.json 中的 name 字段拼接来的,我这里是 study。

翻译过来就是要执行 window.webpackChunkstudy.push([xxx])这个函数,那接下来我们就实现一下它:接受一个二维数组作为参数,二维数组中,第一项是moduleId,第二项是模块定义:

//初始化:默认情况下这里放的是同步代码块,这里的demo因为没有同步代码,所以是一个空的模块对象
var modules = {}

//这里chunkIds=["src_test_js"] moreModules={xxx} test.js文件的模块定义
function webpackJsonpCallback([chunkIds, moreModules]) {
  const resolves = []
  for (let i = 0; i < chunkIds.length; i++) {
    const chunkId = chunkIds[i] //src_test_js
    resolves.push(installedChunks[chunkId][0]) //此时installedChunks={ main: 0, "src_test_js":[ resolve, reject ]} ,将 src_test_js 的resolve放到resolves中去
    installedChunks[chunkId] = 0 //标识一下代码已经加载完成了
  }

  for (const moduleId in moreModules) {
    modules[moduleId] = moreModules[moduleId] //合并modules,此时modules中有了test.js的代码
  }

  while (resolves.length) {
    resolves.shift()() //执行promise中的resolve,当所有promises都resolve后,接下来执行第三步
  }
}

window.webpackChunkstudy.push = webpackJsonpCallback

此时 modules 已经变为:

var modules = {
  './src/test.js': (modules, exports, require) => {
    require.defineProperty(exports, {
      default: () => WEBPACK_DEFAULT_EXPORT
    })
    const WEBPACK_DEFAULT_EXPORT = () => {
      console.log('按钮点击了')
    }
  }
}

第三步:合并完后,去加载这个模块

走到这里require.e函数中的 Promise.all 已经走完,接下来走到第一个.then处:require.bind(require, "./src/test.js")

require
  .e('src_test_js') //完成第一步和第二步的工作
  .then(require.bind(require, './src/test.js')) //完成第三步

require函数与之前相同,不做过多的赘述,大家可以看前一篇文章:从构建产物洞悉模块化原理 (opens new window)。这里直接拷贝过来:

//已经加载过的模块
var cache = {}

//相当于在浏览器中用于加载模块的polyfill
function require(moduleId) {
  var cachedModule = cache[moduleId]
  if (cachedModule !== undefined) {
    return cachedModule.exports
  }
  var module = (cache[moduleId] = {
    exports: {}
  })
  modules[moduleId](module, module.exports, require)
  return module.exports
}

require.defineProperty = (exports, definition) => {
  for (var key in definition) {
    Object.defineProperty(exports, key, {
      enumerable: true,
      get: definition[key]
    })
  }
}

这里执行完require.bind(require, "./src/test.js")后,返回的是一个export对象:

 {
        default: () => {
            console.log("按钮点击了");
        } //因为这里是默认导出,所以是default
 }

第四步:拿到该模块导出的内容

require
  .e('src_test_js') //完成第一步和第二步的工作
  .then(require.bind(require, './src/test.js')) //完成第三步:前面代码加载并合并完后,去执行该模块代码
  .then((module) => {
    //完成第四步
    const print = module.default
    print()
  })

在第三步中导出的是一个export对象,又因为是默认导出,所以这里取值是module.default,走到这里就完全走完啦。

# 整体代码

打包后的main.js(经优化):

//初始化:默认情况下这里放的是同步代码块,这里的demo因为没有同步代码,所以是一个空的模块对象
var modules = {}

//已经加载过的模块
var cache = {}

//相当于在浏览器中用于加载模块的polyfill
function require(moduleId) {
  var cachedModule = cache[moduleId]
  if (cachedModule !== undefined) {
    return cachedModule.exports
  }
  var module = (cache[moduleId] = {
    exports: {}
  })
  modules[moduleId](module, module.exports, require)
  return module.exports
}

require.defineProperty = (exports, definition) => {
  for (var key in definition) {
    Object.defineProperty(exports, key, {
      enumerable: true,
      get: definition[key]
    })
  }
}

//已经安装好的代码块,main.js就是对应的main代码块,0表示已经加载成功,已经就绪
var installedChunks = {
  main: 0
}

require.publicPath = '' //output中的publicPath属性

require.j = function (chunkId, promises) {
  var promise = new Promise((resolve, reject) => {
    installedChunks[chunkId] = [resolve, reject]
  })
  promises.push(promise)
  var url = require.publicPath + chunkId + '.main.js'
  let script = document.createElement('script')
  script.src = url
  document.head.appendChild(script)
}

function webpackJsonpCallback([chunkIds, moreModules]) {
  const resolves = []
  for (let i = 0; i < chunkIds.length; i++) {
    const chunkId = chunkIds[i]
    resolves.push(installedChunks[chunkId][0])
    installedChunks[chunkId] = 0 //标识一下代码已经加载完成了
  }

  for (const moduleId in moreModules) {
    modules[moduleId] = moreModules[moduleId] //合并modules
  }

  while (resolves.length) {
    resolves.shift()()
  }
}
self.webpackChunkstudy = {}
self.webpackChunkstudy.push = webpackJsonpCallback

require.e = function (chunkId) {
  let promises = []
  require.j(chunkId, promises)
  return Promise.all(promises)
}

const buttonEle = document.getElementById('button')
buttonEle.onclick = function () {
  require
    .e('src_test_js')
    .then(require.bind(require, './src/test.js'))
    .then((module) => {
      const print = module.default
      print()
    })
}

打包后的test.js:

self['webpackChunkstudy'].push([
  ['src_test_js'],
  {
    './src/test.js': (modules, exports, require) => {
      require.defineProperty(exports, {
        default: () => WEBPACK_DEFAULT_EXPORT
      })
      const WEBPACK_DEFAULT_EXPORT = () => {
        console.log('按钮点击了')
      }
    }
  }
])