webpack 打包JS 的运行原理

Webpack自动化构建实践指南 - 掘金

一、打包原理

最近一直在学习 webpack 的相关知识,当清晰地领悟到 webpack 就是不同 loaderplugin 组合起来打包之后,只作为工具使用而言,算是入门了。当然,在过程中碰到数之不尽的坑,也产生了想要深入一点了解 webpack 的原理(主要是掉进坑能靠自己爬出来)。因而就从简单的入手,先看看使用 webpack 打包后的 JS 文件是如何加载吧。

友情提示,本文简单易懂,就算没用过 webpack 问题都不大。如果已经了解过相关知识的朋友,不妨快速阅读一下,算是温故知新 。

简单配置

既然需要用到 webpack,还是需要简单配置一下的,这里就简单贴一下代码,首先是 webpack.config.js:

const path = require('path');
const webpack = require('webpack');
//用于插入html模板
const HtmlWebpackPlugin = require('html-webpack-plugin');
//清除输出目录,免得每次手动删除
const CleanWebpackPlugin = require('clean-webpack-plugin');

module.exports = {
  entry: {
    index: path.join(__dirname, 'index.js'),
  },
  output: {
    path: path.join(__dirname, '/dist'),
    filename: 'js/[name].[chunkhash:4].js'
  },
  module: {},
  plugins: [
    new CleanWebpackPlugin(['dist']),
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'index.html',
    }),
    //持久化moduleId,主要是为了之后研究加载代码好看一点。
    new webpack.HashedModuleIdsPlugin(),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest',
    })
  ]
};

这是我能想到近乎最简单的配置,用到的两个额外下载的插件都是十分常用的,也已经在注释中简单说明了。

之后是两个简单的 js 文件:

// test.js
const str = 'test is loaded';
module.exports = str;

// index.js
const test = require('./src/js/test');
console.log(test);

这个就不解释了,贴一下打包后,项目的目录结构应该是这样的:



至此,我们的配置就完成了。

index.js 开始看代码

先从打包后的 index.html 文件看看两个 JS 文件的加载顺序:

<body>
	<script type="text/javascript" src="js/manifest.2730.js"></script>
	<script type="text/javascript" src="js/index.5f4f.js"></script>
</body>

可以看到,打包后 js 文件的加载顺序是先 manifest.js,之后才是 index.js,按理说应该先看 manifest.js 的内容的。然而这里先卖个关子,我们先看看 index.js 的内容是什么,这样可以带着问题去了解 manifest.js,也就是主流程的逻辑到底是怎样的,为何能做到模块化。

// index.js

webpackJsonp([0], {
  "JkW7": (function(module, exports, __webpack_require__) {
    const test = __webpack_require__("zFrx");
    console.log(test);
  }),
  "zFrx": (function(module, exports) {
    const str = 'test is loaded';
    module.exports = str;
  })
}, ["JkW7"]);

删去各种奇怪的注释后剩下这么点内容,首先应该关注到的是 webpackJsonp 这个函数,可以看见是不在任何命名空间下的,也就是 manifest.js 应该定义了一个挂在 window 下的全局函数,index.js 往这个函数传入三个参数并调用。

第一个参数是数组,现在暂时还不清楚这个数组有什么作用。

第二个参数是一个对象,对象内都是方法,这些方法看起来至少接受两个参数(名为 zFrx 的方法只有两个形参)。看一眼这两个方法的内部,其实看见了十分熟悉的东西, module.exports,尽管看不见 require, 但有一个样子类似的 __webpack_require__,这两个应该是模块化的关键,先记下这两个函数。

第三个参数也是一个数组,也不清楚是有何作用的,但我们观察到它的值是 JkW7,与参数2中的某个方法的键是一致的,这可能存在某种逻辑关联。

至此,index.js 的内容算是过了一遍,接下来应当带着问题在 manifest.js 中寻找答案。

manifest.js 代码阅读

由于没有配置任何压缩 js 的选项,因此 manifest.js 的源码大约在 150 行左右,简化后为 28 行(已经跑过代码,实测没问题)。鉴于精简后的代码真的不多,因而先贴代码,大家带着刚才提出的问题,先看看能找到几个答案:

(function(modules) {
  window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
    var moduleId, result;
    for (moduleId in moreModules) {
      if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
        modules[moduleId] = moreModules[moduleId];
      }
    }
    if (executeModules) {
      for (i = 0; i < executeModules.length; i++) {
        result = __webpack_require__(executeModules[i]);
      }
    }
    return result;
  };
  var installedModules = {};

  function __webpack_require__(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    var module = installedModules[moduleId] = {
      exports: {}
    };
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    return module.exports;
  }
})([]);

首先应该看到的是,manifest.js 内部是一个 IIFE,就是自执行函数咯,这个函数会接受一个空数组作为参数,该数组被命名为 modules。之后看到我们在 index.js 中的猜想,果然在 window 上挂了一个名为 webpackJsonp 的函数。它接受的三个参数,分别名为chunkIds, moreModules, executeModules。对应了 index.js 中调用 webpackJsonp 时传入的三个参数。而 webpackJsonp 内究竟是有怎样的逻辑呢?

先不管定义的参数,webpackJsonp 先是 for in 遍历了一次 moreModules,将 moreModules 内的所有方法都存在 modules, 也就是自执行函数执行时传入的数组。

之后是一个条件判断:

if (executeModules) {
  for (i = 0; i < executeModules.length; i++) {
    result = __webpack_require__(executeModules[i]);
  }
}

判断 executeModules, 也就是第三个参数是否存在,如存在即执行 __webpack_require__ 方法。在 index.js 调用 webpackJsonp 方法时,这个参数当然是存在的,因而要看看 __webpack_require__ 方法是什么了。

__webpack_require__ 接受一个名为 moduleId 的参数。方法内部首先是一个条件判断,先不管。接下来看到赋值逻辑

var module = installedModules[moduleId] = {
  exports: {}
};

结合刚才的条件判断,可以推测出 installedModules 是一个缓存的容器,那么前面的代码意思就是如果缓存中有对应的 moduleId,那么直接返回它的 exports,不然就定义并赋值一个吧。接着先偷看一下 __webpack_require__ 的最后的返回值,可以看到函数返回的是 module.exports,那么 module.exports 又是如何被赋值的呢? 看看之后的代码:

modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

刚才我们知道 modules[moduleId] 就是 moreModules 中的方法,此处就是将 this 指定为 module.exports,再把module, module.exports, __webpack_require__ 传入去作为参数调用。这三个参数是不是很熟悉?之前我们看 index.js 里面代码时,有一个疑问就是模块化是如何实现的。这里我们已经看出了眉目。

其实 webpack 就是将每一个 js 文件封装成一个函数,每个文件中的 require 方法对应的就是 __webpack_require____webpack_require__ 会根据传入的 moduleId 再去加载对应的代码。而当我们想导出 js 文件的值时,要么用 module.exports,要么用 exports,这就对应了module, module.exports两个参数。少接触这块的童鞋,应该就能理解为何导出值时,直接使用 exports = xxx 会导出失败了。简单举个例子:

const module = {
  exports: {}
};

function demo1(module) {
  module.exports = 1;
}

demo1(module);
console.log(module.exports); // 1

function demo2(exports) {
  exports = 2;
}

demo2(module.exports);
console.log(module.exports); // 1

粘贴这段代码去浏览器跑一下,可以发现两次打印出来都是1。这和 wenpack 打包逻辑是一模一样的。

梳理一下打包后代码执行的流程,首先 minifest.js 会定义一个 webpackJsonp 方法,待其他打包后的文件(也可称为 chunk)调用。当调用 chunk 时,会先将该 chunk 中所有的 moreModules, 也就是每一个依赖的文件也可称为 module (如 test.js)存起来。之后通过 executeModules 判断这个文件是不是入口文件,决定是否执行第一次 __webpack_require__。而 __webpack_require__ 的作用,就是根据这个 modulerequire 的东西,不断递归调用 __webpack_require____webpack_require__函数返回值后供 require 使用。当然,模块是不会重复加载的,因为 installedModules 记录着 module 调用后的 exports 的值,只要命中缓存,就返回对应的值而不会再次调用 modulewebpack 打包后的文件,就是通过一个个函数隔离 module 的作用域,以达到不互相污染的目的。



二、异步加载

简单配置

webpack 的配置就不贴出来了,就是确定一下入口,提取 webpack 运行时需要用到的 minifest.js 而已。这里简单贴一下 html 模板与需要的两个 js 文件:

<!--index.html-->
<!doctype html>
<html lang="en">
<body>
    <p class="p">Nothing yet.</p>
    <button class="btn">click</button>
</body>
</html>


//index.js
const p = document.querySelector('.p');
const btn = document.querySelector('.btn');
btn.addEventListener('click', function() {
  //只有触发事件才回家再对应的js 也就是异步加载 
  require.ensure([], function() {
    const data = require('./src/js/test');
    p.innerHTML = data;
  })
})

//test.js
const data = 'success!';
module.exports = data;

这样配置示例配置就完成了。可能有小伙伴不太熟悉 require.ensure,简单地说,就是告诉 webpack,请懒加载 test.js,别一打开页面就给我下载下来。相关的知识不妨看这里

打包完的目录架构画风是这样的:



至此,配置就完成啦~

index.js 开始探索

先用浏览器打开 index.html,查看资源加载情况,能发现只加载了 index.jsminifest.js



之后点击按钮,会再加多一个 0.7f0a.js



可以说明代码是被分割了的,只要当对应的条件触发时,浏览器才会去加载指定的资源。而无论之后我们点击多少次,0.7f0a.js 文件都不会重复加载,此时小本本应记下第一个问题:如何做到不重复加载。

按照加载顺序,其实是应该先砍 minifest.js 的,但不妨先看看 index.js 的代码,带着问题有助于寻找答案。代码如下:

webpackJsonp([1], {
  "JkW7":
    (function(module, exports, __webpack_require__) {
      const p = document.querySelector('.p');
      const btn = document.querySelector('.btn');

      btn.addEventListener('click', function() {
        __webpack_require__.e(0).then((function() {
          const data = __webpack_require__("zFrx");
          p.innerHTML = data;
        }).bind(null, __webpack_require__)).catch(__webpack_require__.oe)
      })
    })
}, ["JkW7"]);

可能有些小伙伴已经忘记了上一篇文章的内容,__webpack_require__ 作用是加载对应 module 的内容。这里提一句, module 其实就是打包前,import 或者 require 的一个个 js 文件,如test.jsindex.js。后文说到的 chunk 是打包后的文件,即 index.ad23.jsmanifest.473d.js0.7f0a.js文件。一个 chunk 可能包含若干 module

回忆起相关知识后,我们看看异步加载到底有什么不同。index.js 中最引入注目的应该是 __webpack_require__.e 这个方法了,传入一个数值之后返回一个 promise。这方法当 promise 决议成功后执行切换文本的逻辑,失败则执行 __webpack_require__.oe。因而小本本整理一下,算上刚才的问题,需要为这些问题找到答案:

  • 如何做到不重复加载。
  • __webpack_require__.e 方法的逻辑。
  • __webpack_require__.oe 方法的逻辑。

minifest.js 中寻找答案

我们先查看一下 __webpack_require__.e 方法,为方法查看起见,贴一下对应的代码,大家不妨先试着自己寻找一下刚才问题的答案。

var installedChunks = {
  2: 0
};

__webpack_require__.e = function requireEnsure(chunkId) {
  var installedChunkData = installedChunks[chunkId];
  if (installedChunkData === 0) {
    return new Promise(function(resolve) {
      resolve();
    });

  }
  if (installedChunkData) {
    return installedChunkData[2];
  }

  var promise = new Promise(function(resolve, reject) {
    installedChunkData = installedChunks[chunkId] = [resolve, reject];
  });
  installedChunkData[2] = promise;
  var head = document.getElementsByTagName('head')[0];
  var script = document.createElement('script');
  script.src = "js/" + chunkId + "." + {
    "0": "7f0a",
    "1": "ad23"
  }[chunkId] + ".js";
  script.onerror = script.onload = onScriptComplete;

  function onScriptComplete() {
    script.onerror = script.onload = null;
    var chunk = installedChunks[chunkId];
    if (chunk !== 0) {
      if (chunk) {
        chunk[1](new Error('Loading chunk ' + chunkId + ' failed.'));
      }
      installedChunks[chunkId] = undefined;
    }
  };
  head.appendChild(script);
  return promise;
};

该方法中接受一个名为 chunkId 的参数,返回一个 promise,印证了我们阅读 index.js 时的猜想,也确认了传入的数字是 chunkId。之后变量 installedChunkData 被赋值为对象 installedChunks 中键为 chunkId 的值,可以推想出 installedChunks 对象其实就是记录已加载 chunk 的地方。此时我们尚未加载对应模块,理所当然是 undefined

之后我们想跳过两个判断,查看一下 __webpack_require__.e 方法返回值的 promise 是怎样的:

var promise = new Promise(function(resolve, reject) {
    installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
installedChunkData[2] = promise;

可以看到 installedChunkDatainstalledChunks[chunkId] 被重新赋值为一个数组,存放着返回值 promiseresolvereject,而令人不解的是,为何将数组的第三项赋值为这个 promise呢?

其实此前有一个条件判断:

if (installedChunkData) {
    return installedChunkData[2];
}

那你明白为什么了吗?在此例中1,假设网络很差的情况下,我们疯狂点击按钮,为避免浏览器发出若干个请求,通过条件判断都返回同一个 promise,当它决议后,所有挂载在它之上的 then 方法都能得到结果运行下去,相当于构造了一个队列,返回结果后按顺序执行对应方法,此处还是十分巧妙的。

之后就是创造一个 script 标签插入头部,加载指定的 js 了。值得关注的是 onScriptComplete 方法中的判断:

var chunk = installedChunks[chunkId];
if (chunk !== 0) {
    ...
}

明明 installedChunks[chunkId] 被赋值为数组,它肯定不可能为0啊,这不是铁定失败了么?先别急,要知道 js 文件下载成功之后,先执行内容,再执行 onload 方法的,那么它的内容是什么呢?

webpackJsonp([0], {
  "zFrx":
    (function(module, exports) {
      const data = 'success!';
      module.exports = data;
    })
});

可以看到,和 index.js 还是很像的。这个 js 文件的 chunkId 是0。它的内容很简单,只不过是 module.exports 出去了一些东西。关键还是 webpackJsonp 方法,此处截取关键部分:

var resolves = [];

for (; i < chunkIds.length; i++) {
  chunkId = chunkIds[i];
  if (installedChunks[chunkId]) {
    resolves.push(installedChunks[chunkId][0]);
  }
  installedChunks[chunkId] = 0;
}

while (resolves.length) {
  resolves.shift()();
}

当它执行的时候,会判断 installedChunks[chunkId] 是否存在,若存在则往数组中 push(installedChunks[chunkId][0]) 并将 installedChunks[chunkId] 赋值为0; 。还得记得数组的首项是什么吗?是 __webpack_require__.e 返回 promiseresolve!之后执行这个 resolve。当然, webpackJsonp 方法会将下载下来文件所有的 module 存起来,当 __webpack_require__ 对应 modulIde 时,返回对应的值。

让我们目光返回 __webpack_require__.e 方法。
已知对应的 js 文件下载成功后,installedChunks[chunkId] 被赋值为0。文件执行完或下载失败后都会触发 onScriptComplete 方法,在该方法中,如若 installedChunks[chunkId] !== 0,这是下载失败的情况,那么此时 installedChunks[chunkId] 的第二项是返回 promisereject,执行这个 reject 以抛出错误:

if (chunk !== 0) {
  if (chunk) {
    chunk[1](new Error('Loading chunk ' + chunkId + ' failed.'));
  }
  installedChunks[chunkId] = undefined;
}

当再次请求同一文件时,由于对应的 module 已经被加载,因而直接返回一个成功的 promise 即可,对应的逻辑如下:

var installedChunkData = installedChunks[chunkId];
if (installedChunkData === 0) {
  return new Promise(function(resolve) {
    resolve();
  });
}

最后看一下 __webpack_require__.oe 方法:

__webpack_require__.oe = function(err) { console.error(err); throw err; };

特别简单对吧?最后整理一下流程:当异步请求文件发起时,先判断该 chunk 是否已被加载,是的话直接返回一个成功的 promise,让 then 执行的函数 require 对应的 module 即可。不然则构造一个 script 标签加载对应的 chunk,下载成功后挂载该 chunk 内所有的 module。下载失败则打印错误。



三、代码打包优化

基础配置

CommonsChunkPlugin 插件,是一个可选的用于建立一个独立文件(又称作 chunk)的功能,这个文件包括多个入口 chunk 的公共模块。通过将公共模块拆出来,最终合成的文件能够在最开始的时候加载一次,便存起来到缓存中供后续使用。这个带来速度上的提升,因为浏览器会迅速将公共的代码从缓存中取出来,而不是每次访问一个新页面时,再去加载一个更大的文件。

简单来说,这有点像封装函数。把不变的与变化的分开,使得不变的可以高效复用,变化的灵活配置。接下来会根据这个原则优化我们的项目,现在先看看虚拟的项目长成什么样吧~

新建一个 index.html 模板与入口 index.js文件,简单配置如下:

index.html :

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    <div id="app">
        <p>{{ vue_test }}</p>
    </div>
    <div class="jq_test"></div>
</body>
</html>

index.js:

import Vue from 'vue';
import $ from 'jquery';

new Vue({
  el: '#app',
  data: {
    vue_test: 'vue is loaded!'
  }
})

$(function() {
  $('.jq_test').html('jquery is loaded!')
})

为演示起见,代码十分简单,相信不用多加解释。接下来先简单配置一下 webpack.config.js,代码如下:

const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  entry: {
    index: path.join(__dirname, 'index.js')
  },
  output: {
    path: path.join(__dirname, '/dist'),
    filename: 'js/[name].[chunkhash].js'
  },
  resolve: { alias: { 'vue': 'vue/dist/vue.js' } },
  plugins: [
    new CleanWebpackPlugin(['./dist']),
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: 'index.html',
      inject: true
    }),
    new BundleAnalyzerPlugin(),
  ]
};

CleanWebpackPlugin 主要用于清除 dist 目录下的文件,这样每次打包就不必手动清除了。HtmlWebpackPlugin 则是为了在 dist 目录下新建 html 模板并自动插入依赖的 jsBundleAnalyzerPlugin 主要是为了生成打包后的 js 文件包含的依赖,如此时进行打包,则生成:



可以看到生成的 index.js 文件包含了 vuejquery

首次优化

一般而言,我们项目中的类库变化较少,业务代码倒是多变的。需要想办法把类库抽离出来,把业务代码单独打包。这样加伤 hash 后浏览器就能缓存类库的 js 文件,优化用户体验。此时我们的主角 CommonsChunkPlugin 就正式登场了。我们在 webpack.config.js 文件的 plugins 中添加 CommonsChunkPlugin,配置如下:

plugins: [
    //...此前的代码
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: function(module) {
        return (
          module.resource &&
          /\.js$/.test(module.resource) &&
          module.resource.indexOf(
            path.join(__dirname, './node_modules')
          ) === 0
        )
      }
    }),
]

上述配置,是通过 CommonsChunkPlugin 生成一个名为 vendorjs 文件,它抽取入口文件也就是 index.js 中来源于 node_modules 的依赖组成。此例中就是 vuejquery。打包出来画风是这样的:


此时看上去解决了我们的问题,将依赖的类库抽取抽来独立打包,加上缓存就能被浏览器缓存了。然而事情没那么简单,不行你随意改一下入口的 index.js 代码,再次打包:



绝望地发现 vendor.js 文件的 hash 改变了。简单说,这是因为模块标识产生了变化所导致的,更具体的原因可以查看相关的中文文档~修正的方法其实也挺简单,就是再使用 CommonsChunkPlugin 抽取一次模块,将不变的类库沉淀下来,将变化的抽离出去。因而添如下代码:

plugins: [
    //...此前的代码
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: function(module) {
        return (
          module.resource &&
          /\.js$/.test(module.resource) &&
          module.resource.indexOf(
            path.join(__dirname, './node_modules')
          ) === 0
        )
      }
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest',
      chunks: ['vendor', 'index']
    })
]

打包后, dist/js 目录下多出一个名为 manifestjs 文件,此时你无论如何改变 index.js 的代码,打包后的 vendor.jshash 都不再会改变了。

然而稍等,当你想拍拍手收工的时候,思考一下这样的场景:随着项目不断迭代,vendor 中的依赖不断被添加与删除,使得它的 hash 会不断变化,这显然不符合我们的利益,这到底如何解决呢?

再次优化

既然 CommonsChunkPlugin 是可以按照我们的需求抽取模块,而依赖的外部模块可能是不断变化的,那么为何不将基础的依赖模块抽取出来作为一个文件,其他的依赖如插件等作为另一个文件呢?

简单说,如我们的项目中 vue 是基本的依赖,必须用到它,而 jquery 等则是后加的类库,之后可能变更。那么将 vue 独立打包一个文件,有利于浏览器缓存,因为无论此后添加更多的类库或删去 jquery 时, vue 文件的缓存依然是生效的。因而我们可以这么做,首先新建一个入口:

entry: {
    index: path.join(__dirname, 'index.js'),
    vendor: ['vue'],
},

此处主要是用于指明需要独立打包的依赖有哪些。之后在 plugins 中做如下修改:

plugins: [
    //...此前的代码
    new webpack.HashedModuleIdsPlugin(),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: Infinity,
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'common',
      minChunks: function(module) {
        return (
          module.resource &&
          /\.js$/.test(module.resource) &&
          module.resource.indexOf(
            path.join(__dirname, './node_modules')
          ) === 0
        )
      },
      chunks: ['index'],
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'manifest',
      chunks: ['vendor', 'common', 'index']
    })
]

插件 HashedModuleIdsPlugin,是用于保持模块引用的 module id 不变。而 CommonsChunkPlugin 则提取入口指定的依赖独立打包,minChunks: Infinity,的用意是让插件别管其他,就按照设置的数组提取文件就好。之后修改一下原来的 vendor,重命名为 common,指定它从入口 index.js 中抽取来自 node_modules 的依赖。最后就是抽取 webpack 运行时的函数及其模块标识组成 manifest。运行一下 webpack,构建出来如图:



可以看到 vuejquery 被分开打包成了两个文件,我们尝试添加一下新的依赖 vuex,打包后结果如下:



如此一来,我们的优化目的就达到了,不变的都提取出来,变化的可以动态配置~

小结

webpack 插件 CommonsChunkPlugin 就介绍到这里了,然而优化还是有很多的,比如开启压缩,去除注释等。而当项目体积逐渐增大时,CommonsChunkPlugin 就不一定是提取代码的最优解了。在打包速度与控制构建的精细程度来说,结合 DLLPlugin 会有更好的表现。根据不同的场景组合不同的插件以达到我们的目的,本来就是 webpack 的魅力之一。

编辑于 2017-12-18

文章被以下专栏收录