从零开始的Webpack原理剖析(一)——模块加载
前言
脚手架用多了,webpack的一些配置基本上忘得透透的,用了这么久都没有去研究过,到底是如何进行文件打包的,回想起刚刚自学前端的时候,都是手动创建xxx.html文件,xxx.js文件,xxx.css文件,模仿着某米手机官网的页面,搞出来了个静态页面,然而工作之后,一切全变了,直接就过度到了脚手架生成的单页面应用,看着满屏的.vue文件和import xxx各种语法,一脸懵,感觉之前学的都是假的一样,都是从来没见过的写法,之后的半年才逐渐适应了项目的写法与结构,但却感觉跨越了好多步,仿佛处于一个黑箱之外,很多原理性的东西并没有掌握,只知道怎样写,就能输出怎样的结果,中间做了什么事情,完全不清楚,相信很多初入门的前端同胞们,会有一样的体会,身处舒适区,完全不想离开,直至今年的互联网寒冬,各种公司都在裁员,猛然发现,自己对好多东西都是一知半解,而且也没有输出和记录,决定痛定思痛,从现在开始,跳出舒适区,记录下我的学习之路。那么第一步,打算先啃下打包工具这块硬骨头,我会以一个只会简单配置webpack,却完全不懂原理的小白视角,从头开始分析webpack,rollup,vite
等打包工具的原理,记录下学习成长的每一步,也希望对刚入门和渴望提升的初中级前端程序员有所帮助~
阅读此文章的前提是用过webpack,能看懂一些最基本的配置,只需要知道import和require导入导出模块的写法(不需要具体了解其区别)。因为是个人的理解,所以有些观点可能不正确,还请各位大佬们及时指正。
模块的加载
webpack对CommonJs模块加载的处理:
首先我们需要思考一个最基础的问题,模块是怎样被加载的呢?只说没概念,还是要借助简单的实例来进行分析,我们创建几个文件,并在其中写下如下代码(关于Commonjs和ES module的具体区别,我们这里只需要简单的理解他们导入导出的方式写法不同,就好了,其他的区别以后随着webpack原理的学习,会再去细讲,不然上来就讲有啥区别,却没有看到实际的例子,和直接背答案没啥区别,并不能理解):
// index.js文件
const testName = require('./testName.js')
console.log(testName)
// testName.js文件
module.exports = 'testName: xiaoMing'
// index.html文件,引入index.js文件
......
<script src="./index.js"></script>
......
然后在浏览器中打开index.html文件,打开控制台,看下是什么结果呢?没错,如下图所示,因为浏览器不识别require方法,所以就会报错。
那么为什么同样的代码,我们在配置完webpack之后,就可以正常在浏览器中运行了呢?先说结论:没错,正是webpack对require方法进行了重写,转化成了浏览器能识别的方法,从而能够正确的运行;为了证实我们的猜想,就按照下边的方法试验一下:
step 1:
npm init -y生成package.json文件,并在scripts中配置"build": "webpack" 命令
step 2:
npm i webpack webpack-cli html-webpack-plugin -D 来安装基本的依赖(本文章中用的都是webpack5的版本)
step 3:
在package.json同级创建webpack.config.js,其中内容为:
const path = require("path")
const HtmlWebpackPlugin = require("html-webpack-plugin")
module.exports = {
mode: "development", // 模式写成development,方便查看打包结果
devtool: "source-map",
entry: "./index.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "main.js"
},
plugins: [
new HtmlWebpackPlugin({ template: "./index.html", filename: "index.html"})
]
}
step 4:
使用 npm run build命令进行打包,查看打包后的结果dist/main.js
step 5:
在浏览器中打开dist/index.html文件,发现并没有报错,而且控制台也成功的打印出了testName: xiaoMing
这是为什么呢?webpack是如何做到处理用require来引入js文件的呢?经过对注释的删除和整理,可以得到如下的代码,我们可以看下webpack是如何处理require(CommonJs模块)的:
(() => {
var __webpack_modules__ = ({
"./testName.js": ((module) => {
module.exports = 'testName: xiaoMing';
})
});
var __webpack_module_cache__ = {};
function __webpack_require__(moduleId) {
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
var module = __webpack_module_cache__[moduleId] = {
exports: {}
};
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
return module.exports;
}
(() => {
var testName = __webpack_require__("./testName.js");
console.log(testName);
})();
})()
看到这里可能就烦来了,名字这么长,看起来好麻烦啊,别急,让我们一块一块的来分析代码的意思,坚持过第一步,后边的都是小case:
// step 1:首先声明了一个modules模块对象,key是文件的路径,value是一个函数,执行后是文件中的代码
var __webpack_modules__ = ({
"./testName.js": ((module) => {
module.exports = 'testName: xiaoMing';
})
})
// step2: 定义一个缓存对象,在下边的require方法中首先判断的时候会用到
var __webpack_module_cache__ = {};
// step3: 重新定义require方法,进行解析
function __webpack_require__(moduleId) {
// 如果能在缓存对象中找到,那么就直接返回
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// 重点:定义一个module对象,里边包含exports对象(没有为啥,就是这么定义的,为了符合commonJS规范)
var module = __webpack_module_cache__[moduleId] = {
exports: {}
};
// 执行step1模块对象中,key为[moduleId]所对应的方法(相当于执行了代码)
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
// 返回执行完的结果module.exports
return module.exports;
}
// step4: 开始执行代码啦~
(() => {
var testName = __webpack_require__("./testName.js");
console.log(testName);
})();
综上,完整的执行流程为:当传入了'./testName.js'后,在缓存对象中没有找到对应的Id,需要在modules模块对象中找到对应的key;
找到后,执行了其对应的方法,然后给module.exports赋值'testName: xiaoMing';
最后require方法返回的module.exports给testName赋值,所以console.log打印出来的结果为'testName: xiaoMing'。
好了,此时此刻,如何使用require加载(CommonJS模块)的原理,我们已经清楚了,那么还有其他3中情况还需要我们进行一一分析,即:ES module的加载,ES module中加载CommonJs模块,CommonJs模块中加载 ES module。
webpack对ES6 modules加载的处理:
先说结论:webpack将ES module转化为上述CommonJs模块所示的代码,并且进行特殊标记。什么意思呢?同样还是以代码为例,我们需要将之前的文件,用ES6标准的导入导出进行改写:
// index.js文件,删除原有代码,用下边代码覆盖
import testName, { age } from './testName.js'
console.log(testName, age)
// testName.js文件,删除原有代码,用下边代码覆盖
export default 'test_name_xiaoMing'
export const age = 'test_age_22'
// index.html文件,保持原样
同样,我们执行npm run build命令,在打包出来目录的dist/main.js中观察结果
打包出来一大坨代码,看的头痛,经过删除注释和代码格式化,我们得到了如下所示的代码:
(() => {
"use strict";
var __webpack_modules__ = {
"./testName.js": (__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, {
"age": () => age,
"default": () => __WEBPACK_DEFAULT_EXPORT__
});
const __WEBPACK_DEFAULT_EXPORT__ = 'test_name_xiaoMing';
var age = 'test_age_22';
}
};
var __webpack_module_cache__ = {};
function __webpack_require__(moduleId) {
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
var module = __webpack_module_cache__[moduleId] = {
exports: {}
};
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
return module.exports;
}
(() => {
__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]
});
}
}
};
})();
(() => {
__webpack_require__.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop);
})();
(() => {
__webpack_require__.r = exports => {
if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, {
value: 'Module'
});
}
Object.defineProperty(exports, '__esModule', {
value: true
});
};
})();
var __webpack_exports__ = {};
(() => {
__webpack_require__.r(__webpack_exports__);
var _testName_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./testName.js");
console.log(_testName_js__WEBPACK_IMPORTED_MODULE_0__["default"], _testName_js__WEBPACK_IMPORTED_MODULE_0__.age);
})();
})();
相信不少同学看到上边的代码已经准备关闭页面,或点击小星星,去收藏夹里吃灰吧,其实很正常,我们看源码的时候都会产生这种想法,介于这种心理,我再进行几层优化,把这些代码删减成我们看的舒服的代码,首先,把自执行函数全给拿掉,我们只看其中的逻辑,一堆括号太碍眼了,最后,我们简化变量名,看起来很复杂,一大部分是因为变量名起的实在是太长了,又都是大写,可读性边的很差,所以我们做些简写名称替换:
__webpack_modules__ -> modules;__webpack_require__ -> require;__webpack_exports__ -> exports;__unused_webpack_module -> module;__WEBPACK_DEFAULT_EXPORT__ -> _DEFAULT_EXPORT__;__WEBPACK_IMPORTED_MODULE_ -> ''
,之后再调整下代码顺序,删除一些无关紧要的代码逻辑,方便阅读;
经过简化,最终结果如下,是不是变得舒服多了呢?我们同样,来一步一步分析做了那些事情:(首先请自行了解Object.definproperty和Symbol.toStringTag的作用)
(() => {
"use strict";
// step1:和之前的CommonJS模块转化相同,定义模块对象,然后定义一个require方法,均是由其演变过来的
var modules = {
"./testName.js": (module, exports, require) => {
// 标识打包前的模块是ES module(testName.js)
require.r(exports);
// 给exports赋值
require.d(exports, {
"age": () => age,
"default": () => _DEFAULT_EXPORT__
});
const _DEFAULT_EXPORT__ = 'test_name_xiaoMing';
var age = 'test_age_22';
}
};
var cache = {};
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;
}
var exports = {};
/* step2:给require定义了一个r方法,那么这个r方法的作用,就是为了给ES module做一个标识,因为无论
CommonJs模块还是ES module,最后打包出来的结果都是要借助webpack重写的require方法的,那如何区分呢?
那就是给ES module一个标识,通过这个标识来区分*/
require.r = exports => {
// 写法其实就是相当于 exports[Symbol.toStringTag] = 'Module'
Object.defineProperty(exports, Symbol.toStringTag, {
value: 'Module'
});
// 写法其实相当于 exports.__esModule = true
Object.defineProperty(exports, '__esModule', {
value: true
});
};
/* step3:给require定义了一个d方法,这个d方法的作用就是给exports对象进行赋值,遍历definition对象
将其中的value作为exports对象中的getter */
require.d = (exports, definition) => {
for (var key in definition) {
Object.defineProperty(exports, key, {
enumerable: true,
get: definition[key]
});
}
};
/* step4:开始执行代码,传入了文件路径,经过require方法处理后,
取default则可获得export default默认导出的结果,取age则可获取export const age导出的值*/
(() => {
// 只要打包前的模块(index.js)是一个ES module,就要调用require.r方法进行标识
require.r(exports);
var _testName_js_0__ = require("./testName.js");
console.log(_testName_js_0__["default"], _testName_js_0__.age);
})();
})();
综上,完整的执行流程其实和转换CommonJS模块相同,就是增加了给module.exports对象打上ES Module的标识
和赋值操作,只要打包前的模块是ES module,那么就先执行一次require.r方法进行标识;核心就是赋值,导出。
有些细心的小伙伴可能会有疑问,在step1中,为啥一定要使用Object.defineProperty的get访问器属性,来给exports对象赋值呢,直接写成exports.age = xxx不是一样的么?这里其实涉及到了一个Commonjs模块和ES module的重大区别,:ES module导出的是一个引用,在内部修改后,任何地方都可以获取到它模块内最新的值,但CommonJs导出的仅仅是一个值,内部修改了这个值,就不会再同步到外部。我们单独拿出这段代码来讲解一下:
/* 使用Object.defineProperty来进行赋值,取值时要经过get访问器的拦截,那么如果改变了age的值,再去访
问exports.age的话,是能够访问到最新的exports.age的值,也就是test_age_33*/
var modules = {
"./testName.js": (module, exports, require) => {
require.r(exports);
// 给exports赋值
require.d(exports, {
"age": () => age,
"default": () => _DEFAULT_EXPORT__
});
const _DEFAULT_EXPORT__ = 'test_name_xiaoMing';
var age = 'test_age_22';
// 新增定时器代码便于理解
setTimeout(() => {age = 'test_age_33'}, 1000)
}
};
/* 直接使用exports.age = xxx来进行赋值,那么如果改变了age的值,再去访问exports.age的话,age虽然变了,
但是却和exports.age是没关系的,所以此时获取到的exports.age的值,依旧是原来的'test_age_22',这里理
解了,就能理解上边所说的Commonjs和ES module之一的区别了*/
var modules = {
"./testName.js": (module, exports, require) => {
require.r(exports);
// 给exports赋值
//require.d(exports, {
//"age": () => age,
//"default": () => _DEFAULT_EXPORT__
//});
// 直接进行赋值
var age = 'test_age_22';
exports.age = age
// const _DEFAULT_EXPORT__ = 'test_name_xiaoMing';
// 新增定时器代码便于理解
setTimeout(() => {age = 'test_age_33'}, 1000)
}
};
还没理解?ok,那我们用更简单的例子来说明一下:
// 提问,这段代码的输出结果是什么呢?相信只要你明白js中值和引用的区别,马上就能回答出来
let age = 1
let obj = { age: age }
console.log(obj.age) // 1
age = 222
console.log(obj.age) // 1
/* 变化一种形式,那么结果输出的是什么呢?没错,当你访问obj.age的时候,访问的其实是age这个变量的值,所
以,当age变量的值发生改变了,就一直能获取到最新的值 */
let age = 1
let obj = {}
Object.defineProperty(obj, 'age', {
enumerable: true,
get: () => age
})
console.log(obj.age) // 1
age = 222
console.log(obj.age) // 222
有时候简简单单的原理单独拿出来,大部分人都能看懂,但是当把这些运用到真实的场景中,很多人看的就一脸懵,所以,还是需要理论+实践,多去看优秀的源码和项目,来对我们学习的原理,进行融会贯通。
CommonJS加载ES module:
// index.js文件,删除原有代码,用下边代码覆盖
const testName = require('./testName.js')
console.log(testName.default, testName.age)
// testName.js文件,保持原样
// index.html文件,保持原样
我们执行npm run build命令,在打包出来目录的dist/main.js中观察结果
同样,经过注释的删减,变量名称的优化,可以得到和上边几乎一样的结果,之后最后开始执行的地方,有些许区别,可以自行分析一下,就当是复习
(() => {
var modules = {
"./testName.js": (module, exports, require) => {
"use strict";
require.r(exports);
require.d(exports, {
"age": () => age,
"default": () => _DEFAULT_EXPORT__
});
const _DEFAULT_EXPORT__ = 'test_name_xiaoMing';
var age = 'test_age_22';
}
};
var cache = {};
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.d = (exports, definition) => {
for (var key in definition) {
Object.defineProperty(exports, key, {
enumerable: true,
get: definition[key]
});
}
};
require.r = exports => {
Object.defineProperty(exports, Symbol.toStringTag, {
value: 'Module'
});
Object.defineProperty(exports, '__esModule', {
value: true
});
};
(() => {
var testName = require("./testName.js");
// 只有这里是不同的,如果想要拿到export default默认导出的值,那么需要手动去取值testName['default']
console.log(testName["default"], testName.age);
})();
})();
ES module加载CommonJS
我们继续对文件中代码进行如下修改:
// index.js文件,删除原有代码,用下边代码覆盖
import testName, { age } from './testName.js'
console.log(testName, age)
// testName.js文件,删除原有代码,用下边代码覆盖
module.exports = {
age: 'age_11',
testName: 'test_xiaoMing'
}
// index.html文件,保持原样
我们执行npm run build命令,在打包出来目录的dist/main.js中观察结果。
有了之前的学习和分析,再来看最后一种情况,便会发现好理解了很多,还是删除了无用的注释,无关紧要的逻辑和简化了变量名称,纵观全部代码,发现只是增加了一个require.n方法,开始执行的时候也略有不同:
(() => {
var modules = {
"./testName.js": module => {
module.exports = {
age: 'age_11',
testName: 'test_xiaoMing'
};
}
};
var cache = {};
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.d = (exports, definition) => {
for (var key in definition) {
Object.defineProperty(exports, key, {
enumerable: true,
get: definition[key]
});
}
};
require.r = exports => {
Object.defineProperty(exports, Symbol.toStringTag, {
value: 'Module'
});
Object.defineProperty(exports, '__esModule', {
value: true
});
};
// require.n方法其实就是,根据模块是否有__esModule标识,来获取默认导出的对象
require.n = module => {
var getter = module && module.__esModule ? () => module['default'] : () => module;
// 就是给当前的getter添加了个a属性,指向自己,作用其实就是在获取默认导出对象的时候,不需要加()执
// 行只需要getter.a便可以获取到默认导出对象
require.d(getter, {
a: getter
});
return getter;
};
var exports = {};
(() => {
"use strict";
// 开始执行,只要打包前的模块是ES module(index.js),那么就要调用require.r方法进行标识
require.r(exports);
// 获取导出对象
var _testName_js_0__ = require("./testName.js");
// 获取用require.n方法处理完,导出对象的默认对象
var _testName_js_0___default = require.n(_testName_js_0__);
console.log(_testName_js_0___default(), _testName_js_0__.age);
})();
})();
综上,整体的打包流程几乎相同,因为index.js为ES module,所以在开始执行时,要先调用require.r方法进行
标识,之后调用require方法,传入.testName.js来获取导出对象module.exports,然后利用require.n方法
对导出对象进行判断,从而再获取默认导出对象getter
总结
通过简单的几个例子,和webpack打包的结果,我们大致上知道,webpack是如何处理CommonJs与ES module之间相互引用的原理了,迈出第一步虽然艰难,但是更艰难的是坚持走下去,花了2个晚上,终于把第一篇文章完成了,希望能对大家有所帮助,我也会抽空继续更新,文章中有哪些不对的地方,或理解不同的地方,欢迎大家一起来讨论~
转载自:https://juejin.cn/post/7145434593708277796