webpack 打包JS 的运行原理
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
就是不同 loader
和 plugin
组合起来打包之后,只作为工具使用而言,算是入门了。当然,在过程中碰到数之不尽的坑,也产生了想要深入一点了解 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__
的作用,就是根据这个 module
所 require
的东西,不断递归调用 __webpack_require__
,__webpack_require__
函数返回值后供 require
使用。当然,模块是不会重复加载的,因为 installedModules
记录着 module
调用后的 exports
的值,只要命中缓存,就返回对应的值而不会再次调用 module
。webpack
打包后的文件,就是通过一个个函数隔离 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.js
与 minifest.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.js
与 index.js
。后文说到的 chunk
是打包后的文件,即 index.ad23.js
、manifest.473d.js
与 0.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;
可以看到 installedChunkData
与 installedChunks[chunkId]
被重新赋值为一个数组,存放着返回值 promise
的 resolve
与 reject
,而令人不解的是,为何将数组的第三项赋值为这个 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
返回 promise
的 resolve
!之后执行这个 resolve
。当然, webpackJsonp
方法会将下载下来文件所有的 module
存起来,当 __webpack_require__
对应 modulIde
时,返回对应的值。
让我们目光返回 __webpack_require__.e
方法。
已知对应的 js
文件下载成功后,installedChunks[chunkId]
被赋值为0。文件执行完或下载失败后都会触发 onScriptComplete
方法,在该方法中,如若 installedChunks[chunkId] !== 0
,这是下载失败的情况,那么此时 installedChunks[chunkId]
的第二项是返回 promise
的 reject
,执行这个 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
模板并自动插入依赖的 js
。 BundleAnalyzerPlugin
主要是为了生成打包后的 js
文件包含的依赖,如此时进行打包,则生成:
可以看到生成的 index.js
文件包含了 vue
与 jquery
。
一般而言,我们项目中的类库变化较少,业务代码倒是多变的。需要想办法把类库抽离出来,把业务代码单独打包。这样加伤 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
生成一个名为 vendor
的 js
文件,它抽取入口文件也就是 index.js
中来源于 node_modules
的依赖组成。此例中就是 vue
与 jquery
。打包出来画风是这样的:
此时看上去解决了我们的问题,将依赖的类库抽取抽来独立打包,加上缓存就能被浏览器缓存了。然而事情没那么简单,不行你随意改一下入口的 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
目录下多出一个名为 manifest
的 js
文件,此时你无论如何改变 index.js
的代码,打包后的 vendor.js
的 hash
都不再会改变了。
然而稍等,当你想拍拍手收工的时候,思考一下这样的场景:随着项目不断迭代,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
,构建出来如图:
可以看到 vue
与 jquery
被分开打包成了两个文件,我们尝试添加一下新的依赖 vuex
,打包后结果如下:
如此一来,我们的优化目的就达到了,不变的都提取出来,变化的可以动态配置~
webpack
插件 CommonsChunkPlugin
就介绍到这里了,然而优化还是有很多的,比如开启压缩,去除注释等。而当项目体积逐渐增大时,CommonsChunkPlugin
就不一定是提取代码的最优解了。在打包速度与控制构建的精细程度来说,结合 DLLPlugin
会有更好的表现。根据不同的场景组合不同的插件以达到我们的目的,本来就是 webpack
的魅力之一。
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK