最近在整理webpack的内容,在此小记一笔。
模块
作为一个菜鸟,以我对webpack浅薄的认识,大概知道webpack的定位是一个bundler,解决的是如何将多个模块打包到一起去的问题。这里的模块定义十分广泛,可以是一个JS文件,可以是一张图片,也可以是一个样式文件……当然,因为我们是前端开发,对模块的概念理解的最多的,应该是JS文件。
在平时的开发过程中,我们一般把一个JS文件当做一个模块,对接到ES Module的模块规范,大概可以给出这样的例子:
// import 引入
import message from './message.js';
import {word} from './word.js'
import a from './a.js'
// export 导出
export const a = 1;
export default ...
export function ...
我觉得这里需要明白的是:
- 我们可以通过
import
或者export
引入或导出东西。 - 我们将
import
进来的东西,赋给了当前模块内部的一个变量。 - 我们
export
出去的东西,可以作为其他模块文件的导入,我们可以把这个导出去的东西叫exports
。
大概还是能画出一张图的:
由此我们可以知道,import
语句的作用就是得到其他模块文件导出的exports
对象。我们同样知道,对于浏览器来说,当我们不给<script>
标签上加module
属性的话,浏览器是无法识别import
语法的。
那为什么webpack由入口文件对我们的代码进行打包,打包出的JS文件挂在HTML文件上就能运行了呢?
根据上面的行为基本可以想到:
- 对于
import
的行为,我们可以抽象出一个函数require(path)
,去获取path下那个JS文件导出的exports
对象。 - 对于
export
的行为,我们简单理解为它在为某个叫exports
的对象赋值,至于这个exports
对象哪里来,暂时先放一放。
概念先理到这里,这时我找出了以前不知道哪里抄的手写bundler的代码,略做整理。
手写一个bundler
代码在这里。请务必忽略我长草的狗窝。以下内容请参考代码阅读。
首先我们看下整体流程。
首先,对所有文件,我们都应该将其的语法做一个转换,至少不应该再出现import
和export
的语法,而应该转为require
函数获取另一个模块的exports
这种语法,这样我们可以给每个文件生成转换后的code。
然后,问题来了,我们如何从入口文件出发,找到这次打包所有相关的文件呢?
模块分析
还是先从入口文件的分析开始吧。
我们用fs
模块根据路径读取到了入口文件的内容,使用@babel/parser
将其转换成了抽象语法树AST。
我们只需要知道AST是一个树状结构,而每一个import
语句对应着这颗树中的一个type为ImportDeclaration
节点,其中包含了对应import
语句的一些元信息,比如from
后面的依赖模块的路径。
我们通过@babel/traverse
遍历了这颗AST,对于每个ImportDeclaration
节点,我们都将其保存的相对于入口文件的路径,和入口文件的路径放到一起做些路径上的映射处理,并将映射放到一个dependencies
对象里。
最后,我们通过@babel/core
结合@babel/preset-env
预设,将这颗AST转换成了我们之前说的我们需要的语法格式,也就是不存在import
和export
语法的格式。
我们可以看一下当前入口文件index.js
:
import message from './message.js';
console.log(message);
export const a = 1;
我们可以得到这样的东西:
const result = {
filename: './src/index.js',
dependencies: { './message.js': 'src/message.js' },
code:
'"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\n exports.a = void 0;\n\nvar _message = _interopRequireDefault(require("./message.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\nconsole.log(_message["default"]);\nvar a = 1;\nexports.a = a;',
};
-
import
语法已经变成一个require
函数了,export
语法,也变成了在给一个exports
变量赋值。 -
得到了一个
denpendencies
对象,里面key是相对于当前文件(此处为入口文件)的路径,value为相对于我们的bundler的路径。
依赖图谱生成
我们拿到了入口文件的dependencies
映射,我们自然可以拿其中的依赖路径再一次做模块分析,其实就是广度优先遍历,可以很轻松得到这次打包所有需要的模块的分析结果。
const graph = {
'./src/index.js': {
dependencies: { './message.js': 'src/message.js' },
code:'...'
},
'src/message.js': {
dependencies: { './word.js': 'src/word.js' },
code:'...'
},
'src/word.js': {
dependencies: {},
code:'...'
},
};
生成代码
我们需要开始生成最终可运行的代码了。
为了不污染全局作用域,我们使用立即执行函数来包装我们的代码,将依赖图谱graph
作为参数传入:
(function(graph){
// todo
})(graph)
我们需要开始运行入口文件的代码,因此我们必须在graph
中找到入口文件对应的code
,并运行它:
(function(graph){
function require(module){
eval(graph[module].code)
}
require('./src/index.js')
})(graph)
然而在code
中,我们同样需要调用require
去获取其他模块导出的对象exports
,所以require
函数必须有导出对象。还要支持内部调用require
函数,但是注意!!此require
并非现在声明的require
函数,因为我们观察之前编译出的代码,可以知道在code
中,require
函数传的参数是相对于当前module的路径。这时候,我们之前给每个module存的dependencies
映射再次派上了用场。
(function(graph){
function require(module){
// 定义code内部使用的require函数 -> localRequire
function localRequire(relativePath){
return require(graph[module].dependencies[relativePath])
}
var exports = {};
eval(graph[module].code)
return exports;
}
require('./src/index.js')
})(graph)
为为了覆盖当前作用域链中的require
变量,我们在eval
外面包一层立即执行函数,将localRequire
、exports
和code
作为参数传入,这样也保证了eval中代码相关函数名字的对应。
(function(graph){
function require(module){
// 定义code内部使用的require函数 -> localRequire
function localRequire(relativePath){
return require(graph[module].dependencies[relativePath])
}
var exports = {};
(function(require, exports, code){
eval(code);
})(localRequire, exports, graph[module].code)
return exports;
}
require('./src/index.js')
})(graph);
由此一个bundler就写完了,最终生成的代码,也是可以直接在浏览器中运行的。
但是,我依然非常好奇,webpack打包后的结果究竟长什么样。
webpack打包结果分析
我们用同样的文件,使用webpack做一次打包,对结果进行一些分析(鉴于代码还挺长,建议自己去打包看)。
先从最外层开始分析:
(function (modules) {
// ...
})({
'./src/index.js': function (module, __webpack_exports__, __webpack_require__) {
'use strict';
eval(
// ...
);
},
'./src/message.js': function (module, __webpack_exports__, __webpack_require__) {
'use strict';
eval(
'__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _word_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./word.js */ "./src/word.js");\n\n\nconst message = `say ${_word_js__WEBPACK_IMPORTED_MODULE_0__["word"]}`;\n\n/* harmony default export */ __webpack_exports__["default"] = (message);\n\n\n//# sourceURL=webpack:///./src/message.js?'
);
},
'./src/word.js': function (module, __webpack_exports__, __webpack_require__) {
'use strict';
eval(
// ...
);
},
});
根据我们精密的观察,发现webpack同样将import
和export
做了转换,上面所说的在code
内部的require
函数和exports
对象,变成了__webpack_require__
和__webpack_exports__
。
更加智能的是,在如今的每个模块的code
中,__webpack_require__
的参数已经不再是之前的相对于该模块的路径,而是全部被转化为了相对于bundler的路径。
除此之外,graph
每个key所对应的value变成了一个函数,和我们自己写的在eval
代码外面包的那一层立即执行函数非常相似。
第二步,进入其中,分析核心逻辑:
// The module cache
var installedModules = {}
function __webpack_require__(moduleId) {
// Check if module is in cache
if (installedModules[moduleId]) {
return installedModules[moduleId].exports
}
// Create a new module (and put it into the cache)
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
}
// Execute the module function
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
// Flag the module as loaded
module.l = true
// Return the exports of the module
return module.exports
}
// ...
return __webpack_require__(__webpack_require__.s = './src/index.js')
//...
返回结果是拿webpack.config.js
里配的entry作为moduleId
,去调了声明的__webpack_require__
函数。
__webpack_require__
函数内部优先返回缓存里找对应模块的exports对象;如果不存在,则先在缓存中声明该模块的信息module
,再拿moduleId
去graph
里找对应的函数,传入刚声明的module.exports
当this环境,其他参数也一一对应,非常自然,运行完后,返回module.exports
结果。
这里有一个细节问题,虽然我们绑定了module.exports
进行了call
调用,但实际上在我们模块中最外层使用this
,在编译成graph
时,被转化为了undefined
。
感觉对webpack有个粗略的了解了。