likes
comments
collection
share

聊一聊 webpack dynamic import 原理

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

其实 webpack 实现动态 import 的核心原理很简单:就是用一个 promise 来做时间上的控制,等到其它函数去把模块通过网络加载回来的时候,再放行 promise,接着再使用 webpack 的 require 函数去加载该动态模块。

总而言之,从代码设计上的角度来说:动态 import 与 静态 import 最大区别就是在 require 之前有没有加一层 promise 控制,而 promise 从创建到决策完成的中间的过程就是通过网络去加载异步模块的过程。

关于动态导入

原生例子

我们先看这个原生的例子,如下:

<!-- test.html -->
<html lang="en">
  <body>
    <button onclick="onClick()">加载</button>
    <script>
      const onClick = async () => {
        const data = await import('./hello.js')
        alert(data.default)
      }
    </script>
  </body>
</html>
// hello.js
export default 'hello world'

点击加载按会发现出现 hello world 的弹窗,同时网络那一块又加载了 hello.js 资源。

聊一聊 webpack dynamic import 原理

这是我们原生动态加载的过程:当 JS 在运行时发现需要某个模块的时候,浏览器再发起请求去获取这个模块这种发起网络请求加载模块的能力是浏览器原生支持的,类似于我们写的 script 标签、使用 fetch、xhr API 等。

polyfill

那么如果我们的环境不支持原生的动态 import 语法,又比如像 webpack 这样的打包工具,它需要支持动态导入,那怎么办呢?

总体来说我们 polyfill 的实现思路是这样的,总而言之我们需要动态去请求模块,拿到模块结果再进行后续的处理。所以我们 polyfill 的核心就是:实现请求模块的过程。比如动态创建一个 script 标签去请求,又或者直接发起一个 Ajax 请求去获取模块内容。

接下来我们看看 webpack 为我们提供的方案。

环境搭建

接下来我们从零开始搭建一个 webpack 项目环境

初始化项目

创建文件夹 core,接下来在该文件夹下执行 yarn init -y 生成初始化 package.json。再配一个命令,方便打包:

{
  "name": "core",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "build": "npx webpack --mode=development"
  }
}

在项目目录下创建 webpack 配置文件 webpack.config.js,并简单配置一下:

const { resolve } = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: './index.js',
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: resolve(__dirname, './index.html'),
    }),
  ],
}

安装依赖

接下来需要安装 webpack 相关依赖:

yarn add webpack webpack-cli html-webpack-plugin -D

测试文件准备

我们这里准备三个测试文件:index.jshello.jsindex.html

<!-- index.html -->
<html lang="en">
  <body>
    <button onclick="window.onClick()">加载</button>
  </body>
</html>
// hello.js
export default 'hello world'
// index.js
const onClick = async () => {
  const data = await import('./hello.js')
  console.log(data.default)
}
window.onClick = onClick

值得一提的是:我们这里为了方便,就直接将 onClick 挂载在全局变量 window 上了。

打包测试

运行 yarn build 进行打包,打包完成后打开 index.html 文件,当我们点击加载按钮,发现网络会加载 hello.js 文件,控制台会输出 hello world

打包结果分析

接下来我们仔细看一下 webpack 具体的打包结果,可以发现实际上生成了三个文件:hello_js.jsindex.htmlmain.js

hello_js.jsindex.html 文件

我们可以瞄一眼 hello_js.jsindex.html 的打包结果,如下:

// hello_js.js
;(self['webpackChunkcore'] = self['webpackChunkcore'] || []).push([
  ['hello_js'],
  {
    './hello.js': (
      __unused_webpack_module,
      __webpack_exports__,
      __webpack_require__
    ) => {
      eval(`
      __webpack_require__.r(__webpack_exports__)
      __webpack_require__.d(__webpack_exports__, {
        default: () => __WEBPACK_DEFAULT_EXPORT__,
      })
      const __WEBPACK_DEFAULT_EXPORT__ = 'hello world'
      `)
    },
  },
])
<!-- index.html -->
<html lang="en">
  <head>
    <script defer src="main.js"></script>
  </head>
  <body>
    <button onclick="window.onClick()">加载</button>
  </body>
</html>

关于 hello_js.js 其中涉及两个方法:__webpack_require__.r__webpack_require__.d,他们的具体实现在附录中有,我们这里简单提一下他们的实现的功能。__webpack_require__.r 是用来标记当前文件使用的模块化规范是不是 esModule__webpack_require__.d 简单解释就是默认导出的内容混合到 exports 对象身上。

这里的 (self['webpackChunkcore'] = self['webpackChunkcore'] || []).push 其实也是一个重点,我一开始没有关注,后面绕一点弯,我们后面再仔细讲它的作用。

main.js 文件

我们先来看一下 index.js 打包后的结果,在 main.js 中,如下:

const onClick = async () => {
  const data = await __webpack_require__
    .e('hello_js')
    .then(__webpack_require__.bind(__webpack_require__, './hello.js'))
  console.log(data.default)
}
window.onClick = onClick

这段代码可以看出 __webpack_require__.e("hello_js") 调用的返回结果是一个 promise,当这个 promise 进行决策后,才会真正去 require 模块。

我们可以把 index.js 打包结果的代码改造成这样,也是可以正常运行的,如下:

const onClick = async () => {
  await __webpack_require__.e('hello_js')
  const data = __webpack_require__('./hello.js')
  console.log(data.default)
}
window.onClick = onClick

所以由此可见我们需要关注的就是两个方法:__webpack_require____webpack_require__.e__webpack_require__ 这个方法很简单,就是按 moduleId 找到模块内容,然后运行模块函数得到运行结果并将结果缓存起来。我们主要需要看 __webpack_require__.e 这个方法。

__webpack_require__.e 方法

__webpack_require__.f = {}
// This file contains only the entry chunk.
// The chunk loading function for additional chunks
__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__.f{},所以 Object.keys(__webpack_require__.f),应该是 [],那所有代码其实不都是在空转。

此时我们需要继续往下看打包结果,后面我们会看到这段代码:

var installedChunks = {
  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) {
      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))

        // start chunk loading
        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) => { ... }
        __webpack_require__.l(url, loadingEnded, 'chunk-' + chunkId, chunkId)
      }
    }
  }
}

这段代码会在初始化过程中执行,而我们的 __webpack_require__.e 在点击事件发生时才会得到运行,所以 Object.keys(__webpack_require__.f) 的结果是 __webpack_require__.f.j,也就是上面这段代码。

接下来我们继续阅读 __webpack_require__.f.j 的具体内容。__webpack_require__.o 没有什么特别的,就是判断一个对象是否具有某个属性。由于 installedChunks 对象(已经加载的模块)并不具有 hello_js 属性,所以 installedChunkDataundefined,最终会走到 else 分支。说句实话,这个包一层 if(true) 的作用我是真没看懂。我们简化一下代码:

__webpack_require__.f.j = (chunkId, promises) => {
  // JSONP chunk loading for javascript
  var installedChunkData = undefined

  // 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))

  // start chunk loading
  var url = __webpack_require__.p + __webpack_require__.u(chunkId)
  // create error before stack unwound to get useful stacktrace later
  var loadingEnded = (event) => {}
  __webpack_require__.l(url, loadingEnded, 'chunk-' + chunkId, chunkId)
}

所以 __webpack_require__.f.j 就干了两件事,第一件事是往我们的 promises 参数里面丢了一个 promise 进去;第二件事事情就是通过 chunkId 生成了一个 url,并且调用 __webpack_require__.l 方法。

__webpack_require__.f.j 会往 promises 里面 push promise,而 __webpack_require__.e 使用 Promise.all 方法把这些 promise 统一管理,等到所有的 promise 都成功了或者失败了,__webpack_require__.e 方法返回的 promise 就决策了,接下来就可以去 require __webpack_require__('./hello.js')

我们可以发现,其实在 main.js 中是没有 ./hello.js 模块的。所以不难猜测到可能是:__webpack_require__.l 这个方法中加载了 ./hello.js 模块,并且将 ./hello.js 模块挂载在了全局对象 __webpack_module_cache__ 身上

__webpack_require__.p 的逻辑其实很简单,就是在 document 对象上找一个 script 标签,读取它的 src 属性来作为 baseUrl,接着再拼上 hello_js 文件名,就得到了 ./hello.js 文件的 url 路径了。

__webpack_require__.l 方法

这个方法内容比较多,我就不贴代码了,代码在附录中有。这个方法的主要逻辑就是创建 script 标签,去加载模块。刚开始看这个地方的时候,我猜测逻辑是这样的,我们以为是在 script 的加载事件里面去进行 promise 决策,因为在 __webpack_require__.l 方法中也确实对 onerror、onload 这些事件进行了监听。

script.onerror = onScriptComplete.bind(null, script.onerror)
script.onload = onScriptComplete.bind(null, script.onload)
needAttach && document.head.appendChild(script)

但是后面找了好一会,都没有找到 promise resolve 的逻辑。后面通过特别的手段追踪到了 promise resolve 的触发地方。这里的对 onerror、onload 事件的监听其实是一个兜底逻辑

promise 决策的主流程逻辑其实是这样的:由于使用 script 加载 js 文件,文件加载回来之后会自动执行,又由于所有的 js 会加载到同一个全局环境下执行,所以不同的 js 文件可以通过全局变量进行信息交互。而 webpack 就是利用 window.self['webpackChunkcore'] 这个全局变量实现模块间的信息传递。所以 hello_js.js 通过全局变量将模块信息传递给 main.jsmain.js 收到模块信息之后,就可以进行 promise 决策,进而去 require 模块。

源代码如下:

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]
    ) {
      installedChunks[chunkId][0]()
    }
    installedChunks[chunkId] = 0
  }
}

var chunkLoadingGlobal = (self['webpackChunkcore'] =
  self['webpackChunkcore'] || [])
chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0))
chunkLoadingGlobal.push = webpackJsonpCallback.bind(
  null,
  chunkLoadingGlobal.push.bind(chunkLoadingGlobal)
)

就是重写了 self['webpackChunkcore'] 的 push 方法,在 push 方法中将模块函数加到 __webpack_require__.m 上。__webpack_require__.m 其实就是 __webpack_modules__,也就是存储模块函数的对象。__webpack_modules____webpack_module_cache__ 的区别在于:前者存储模块函数,后者存储模块函数的执行结果。

接下来再在 hello_js 中去触发 push:

// hello_js.js
;(self['webpackChunkcore'] = self['webpackChunkcore'] || []).push([
  ['hello_js'],
  {
    './hello.js': (
      __unused_webpack_module,
      __webpack_exports__,
      __webpack_require__
    ) => {
      eval(`
      __webpack_require__.r(__webpack_exports__)
      __webpack_require__.d(__webpack_exports__, {
        default: () => __WEBPACK_DEFAULT_EXPORT__,
      })
      const __WEBPACK_DEFAULT_EXPORT__ = 'hello world'
      `)
    },
  },
])

自此我们动态 import 就完成了逻辑闭环,这也就是动态 import 的全部过程。接下来我们可以思考三个小问题:二次点击、多个动态 import 以及先静态 import 再动态 import 相同的模块,看看这些情况下 webpack 又会如何处理。

追踪 promise 调用小技巧

简单来说就是在 resolve 函数外层再包一层函数,在这个函数里面打一个 debugger,然后通过函数调用栈就可以找到这个 promise 决策的位置了。

关于第二次点击

关于第二次点击,我们关心的肯定是缓存的问题,也就是我们肯定不希望第二次点击的时候,又去加载一遍 ./hello.js 模块。那么我们想了解的也是 webpack 如何实现缓存的。

其实它的实现思路很简单,就是在 promise 创建之前加一层缓存,如果缓存命中了,就直接不再创建 promise,这个时候相当于 promise 控制直接放行,那么就可以直接去加载模块了。

var installedChunks = {
  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) {
    // ...
  }
}

加载过的模块会记录在 installedChunks 中。

动态 import 多个

测试代码如下:

const onClick = async () => {
  const { default: hello } = await import('./hello.js')
  const { default: world } = await import('./world.js')
  console.log(`${hello}-${world}`)
}
window.onClick = onClick

我们看一眼打包结果,发现有一个 hello_js.js 和 一个 world_js.js 这两个文件,这没有问题。接下来我们主要看一下我们测试代码打包后的结果有没有什么变化,代码如下:

const onClick = async () => {
  const { default: hello } = await __webpack_require__
    .e('hello_js')
    .then(__webpack_require__.bind(__webpack_require__, './hello.js'))
  const { default: world } = await __webpack_require__
    .e('world_js')
    .then(__webpack_require__.bind(__webpack_require__, './world.js'))
  console.log(`${hello}-${world}`)
}
window.onClick = onClick

所以没有什么特殊处理,结合前面的逻辑,这一段应该很容易理解。

先静态 import,再动态 import

测试代码如下:

import hello from './hello.js'

const onClick = async () => {
  const data = await import('./hello.js')
  console.log(data.default)
}
window.onClick = onClick

我们看一眼打包结果,发现并没有生成 hello_js.js 文件。再运行一下,发现运行结果是正常的,网络也没有去加载 hello_js.js。这样的结果是符合我们的预期的。接下来我们看一眼测试代码的打包结果,如下:

__webpack_require__.r(__webpack_exports__)
var _hello_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__('./hello.js')
const onClick = async () => {
  const data = await Promise.resolve().then(
    __webpack_require__.bind(__webpack_require__, './hello.js')
  )
  console.log(data.default)
}
window.onClick = onClick

其实也就是将 promise 直接放行了。

附录

__webpack_require__.r

__webpack_require__.r = (exports) => {
  if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
    Object.defineProperty(exports, Symbol.toStringTag, {
      value: 'Module',
    })
  }
  Object.defineProperty(exports, '__esModule', { value: true })
}

其实就标记该模块是不是 esModule 模块。

__webpack_require__.d

__webpack_require__.d = (exports, definition) => {
  for (var key in definition) {
    if (
      __webpack_require__.o(definition, key) &&
      !__webpack_require__.o(exports, key)
    ) {
      Object.defineProperty(exports, key, {
        enumerable: true,
        get: definition[key],
      })
    }
  }
}

其实就给 exports 对象上挂载一些属性。

__webpack_require__

var __webpack_module_cache__ = {}

function __webpack_require__(moduleId) {
  // Check if module is in cache
  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] = {
    // no module.id needed
    // no module.loaded needed
    exports: {},
  })

  // Execute the module function
  __webpack_modules__[moduleId](module, module.exports, __webpack_require__)

  // Return the exports of the module
  return module.exports
}

我们知道,其实 webpack 打包的结果是就是一个 IIFE(立即执行函数)。而 __webpack_module_cache__ 在这个立即执行函数的顶层,那么其实就相当于就是一个全局变量。

__webpack_require__ 这个方法很简单,就是按 moduleId 找到模块内容,然后运行模块函数得到运行结果并将结果缓存起来。

__webpack_require__.f.j

__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) {
      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))

        // start chunk loading
        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)
      }
    }
  }
}

__webpack_require__.o

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

__webpack_require__.e

__webpack_require__.f = {}
// This file contains only the entry chunk.
// The chunk loading function for additional chunks
__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__.p

var scriptUrl
if (__webpack_require__.g.importScripts)
  scriptUrl = __webpack_require__.g.location + ''
var document = __webpack_require__.g.document
if (!scriptUrl && document) {
  if (document.currentScript) scriptUrl = document.currentScript.src
  if (!scriptUrl) {
    var scripts = document.getElementsByTagName('script')
    if (scripts.length) {
      var i = scripts.length - 1
      while (i > -1 && (!scriptUrl || !/^http(s?):/.test(scriptUrl)))
        scriptUrl = scripts[i--].src
    }
  }
}
// When supporting browsers where an automatic publicPath is not supported you must specify an output.publicPath manually via configuration
// or pass an empty string ("") and set the __webpack_public_path__ variable from your code to use your own logic.
if (!scriptUrl)
  throw new Error('Automatic publicPath is not supported in this browser')
scriptUrl = scriptUrl
  .replace(/#.*$/, '')
  .replace(/\?.*$/, '')
  .replace(/\/[^\/]+$/, '/')
__webpack_require__.p = scriptUrl

找到资源的 baseUrl

__webpack_require__.g

__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_require__.u

__webpack_require__.u = (chunkId) => {
  // return url for filenames based on template
  return '' + chunkId + '.js'
}

将 chunkId 转换为文件名

__webpack_require__.l

var inProgress = {}
var dataWebpackPrefix = 'core:'
__webpack_require__.l = (url, done, key, chunkId) => {
  if (inProgress[url]) {
    inProgress[url].push(done)
    return
  }
  var script, needAttach
  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
      }
    }
  }
  if (!script) {
    needAttach = true
    script = document.createElement('script')

    script.charset = 'utf-8'
    script.timeout = 120
    if (__webpack_require__.nc) {
      script.setAttribute('nonce', __webpack_require__.nc)
    }
    script.setAttribute('data-webpack', dataWebpackPrefix + key)

    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)
    doneFns && doneFns.forEach((fn) => fn(event))
    if (prev) return prev(event)
  }
  var timeout = setTimeout(
    onScriptComplete.bind(null, undefined, { type: 'timeout', target: script }),
    120000
  )
  script.onerror = onScriptComplete.bind(null, script.onerror)
  script.onload = onScriptComplete.bind(null, script.onload)
  needAttach && document.head.appendChild(script)
}

webpackJsonpCallback

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]
    ) {
      installedChunks[chunkId][0]()
    }
    installedChunks[chunkId] = 0
  }
}

var chunkLoadingGlobal = (self['webpackChunkcore'] =
  self['webpackChunkcore'] || [])
chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0))
chunkLoadingGlobal.push = webpackJsonpCallback.bind(
  null,
  chunkLoadingGlobal.push.bind(chunkLoadingGlobal)
)
转载自:https://juejin.cn/post/7343862045123313716
评论
请登录