153

webpack 打包JS 的运行原理

 6 years ago
source link: https://zhuanlan.zhihu.com/p/32093510?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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,打包后结果如下:

v2-45e86488f25c71611cb5a783562925b6_720w.jpg

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK