现在我们站在打包工具的角度来理解下打包原理,假设我们自己要去做一个打包工具,想象一下都应该做什么,怎么做。
打包工具都应该做什么
打包工具负责把源码打包成目标代码。
这个打包过程就涉及很多问题了,先看下源码都长什么样:
- 源码中JS可能是由各种语法来写的,ES6, ES7, TS等;
- 源码不止包含JS, 还有静态资源,例如CSS, 图片等;
- 源码的代码组织方式上,出于模块化的编程思想,项目代码会被自己拆解为多个文件,文件之间通过一定的依赖关系组织在一起;
- 每个文件(模块)可能遵循不同的模块规范(CommonJS, ES6 module, AMD etc);
而目标代码是可能运行在不同环境上的,例如浏览器,NodeJS等。
因此,打包工具要做的就是把这样的源码打包成能运行在目标环境中的代码。
由此,一款打包工具应该做哪些工作从上述分析源码特点这里就可以知道了,它需要解决的问题至少有下面几个:
-
转换
- 例如语法转换(使用了ES6,TS等语法要转换为目标环境可以运行的语法)
-
非JS的静态资源的处理
- 例如CSS图片资源的处理
-
识别各类模块规范并处理,并解析模块依赖关系
- 例如CommonJS 中的require,浏览器并不认识,如何处理成为它可以运行的代码
- 捋清楚模块的关系,打包到一起
-
创建bundle代码
- 针对一个入口,最终代码还是要化多模块为一份代码文件
-
生成bundle文件
- 输出代码文件,写入磁盘
三个打包工具如何实现的
那么,Webpack, Rollup, Parcel是现有的较为流行的打包工具,他们都是如何处理这些问题的呢?
(以下内容都假设目标环境是web)版本分别为:rollup: 1.28.0 webpack: 4.41.5 parcel-bundler:1.12.4
| 功能 | Webpack | Rollup | Parcel |
|---|---|---|---|
| 转换 | js语法:需要配置babel-loader等完成转换 | js语法:借助插件rollup-plugin-babel转换 | js语法:无需配置,默认使用@babel/preset-env转换 开箱即用 |
| 非JS静态资源的处理 | 利用loader处理为模块(webpack主打"一切皆模块"的思想) | 借助插件: - rollup-plugin-postcss - rollup-plugin-copy-assets 非js资源实际上支持的有限,与rollup定位有关系,适合库打包 | 无需配置,CSS/Less默认支持(会抽CSS为单独文件) Postcss需要.postcssrc文件 图片自动打包 |
| 解析模块依赖关系 | 使用acorn解析AST收集依赖关系 | 使用acorn解析AST收集依赖关系 | 使用babel解析AST收集依赖关系 |
| 创建bundle代码 | 使用template, __webpack_require__等辅助函数替换等,拼接成bundle代码,bundle形式:自执行函数,详见下述分析 | 利用magicString,代码拼接。bundle形式:自执行函数,代码粘贴,无额外辅助函数,详见下述分析 | 构建资源树,根据资源树构建Bundle树 |
| 生成bundle文件 | 使用文件系统如fs.writeFile写入磁盘 | fs.writeFile写入磁盘 | bundle形式:自执行函数,详见下述分析 |
这里我们来比较下在创建bundle代码上三者的不同:
Bundle形式/模块机制
webpack
webpack的模块我们看下打包后的代码形式:
js
// src/index.js:
const { b } = require('./b.js')
console.log(b)
js
// src/b.js
module.exports = {
b: 'b'
}
// webpack.config.js mode:development
js
const path = require('path');
module.exports = {
entry: {
'index': './src/index.js',
},
output: {
path: path.join(__dirname, 'dist'),
filename: '[name].js'
},
devtool: 'source-map',
mode: 'development',
};
Webpack打包结果, mode:development, src代码使用commonjs模块规范
js
(function(modules) {
function __webpack_require__(moduleId) {...}
// ...
return __webpack_require__(入口文件) // 假设入口文件是src/index.js
})(obj);
其中,参数modules传入的obj的 key, value是下列形式:key是模块文件路径,value是个function, function内部是:转换后的模块代码,也就是,把模块用function包裹了一层:
(mode:production 使用es6模块规范时,key 是id, mode:development 使用es6模块规范时,key是文件名称)
obj:
js
{
"./src/index.js": function() { 转换后当前模块的代码 }, // function 的三个参数:module, __webpack_exports__, __webpack_require__
"./src/b.js": function () { 转换后当前模块的代码 }
}
整个obj作为参数传递给自执行函数,自执行的结果应该是__webpack_require__(入口文件)的执行结果。
(可谓是运行时获取,即层层依赖的模块是在运行__webpack_require__时才拿到模块代码执行的。)
Rollup
再来看rollup rollup.config.js:
js
export default {
input: 'src/index.js',
output: {
file: 'dist/bundle.js',
format: 'iife', // iife, cjs, amd, esm, umd
}
}
src/index.js rollup默认使用ES6模块作为标准:
js
import { b } from './b'
console.log(b)
src/b.js:
js
export const b = 'b'
rollup打包结果:
js
(function () {
'use strict';
const b = 'b';
console.log(b);
}());
可以看到,rollup所采取的方式更直接一些,output format为iife时,外层是自执行函数,内部代码在导入模块时的处理像是将代码"粘贴"过去了一样,没有额外的require的实现。模块代码搬运到一起实现最终的输出。
可能会有疑问,变量名冲突了怎么办?rollup会自动为冲突的变量重新起名字。例如变量b出现了两次,那么就是var b, var b$1,...
Parcel
再看下parcel
parcel主打0配置和极速编译,所以没有配置文件
src/index.js:
js
const { b } = require('./b.js')
console.log(b)
src/b.js:
js
module.exports = {
b: 'b'
}
parcel区分dev product是靠运行指令 parcel xxx.html/js 还是 parcel build xxx.html/js 来区分的。
这里,我们执行parcel index.html:
js
parcelRequire = (function(modules, cache, entry, globalName) { // entry数组
var nodeRequire = typeof require === 'function' && require;
function newRequire(name, jumped) {
if (!cache[name]) {
// ...
// 没有模块缓存则取出模块执行
modules[name][0].call(module.exports, localRequire, module, module.exports, this);
}
// 有模块缓存则返回模块
return cache[name].exports;
}
newRequire.isParcelRequire = true;
newRequire.isParcelRequire = true;
newRequire.Module = Module;
newRequire.modules = modules;
newRequire.cache = cache;
newRequire.parent = previousRequire;
newRequire.register = function (id, exports) {
modules[id] = [function (require, module) {
module.exports = exports;
}, {}];
};
for (var i = 0; i < entry.length; i++) {
try {
newRequire(entry[i]); // 遍历entry,开始执行
} catch (e) {
}
}
if (entry.length) {
// Expose entry point to Node, AMD or browser globals
// Based on https://github.com/ForbesLindesay/umd/blob/master/template.js
var mainExports = newRequire(entry[entry.length - 1]);
// CommonJS
if (typeof exports === "object" && typeof module !== "undefined") {
module.exports = mainExports;
// RequireJS
} else if (typeof define === "function" && define.amd) {
define(function () {
return mainExports;
});
// <script>
} else if (globalName) {
this[globalName] = mainExports;
}
}
// Override the current require with this new one
parcelRequire = newRequire;
return newRequire;
})(obj, {}, ["../node_modules/parcel-bundler/src/builtins/hmr-runtime.js","index.js"], null)
其中参数modules传入的obj为:
js
{
"b.js":[ function(){
// ...b.js 的代码
}, {}],// 空对象,说明b没有依赖其他模块
"index.js": [ function(){
// ...index.js的代码
}, {
"./b.js":"b.js" // index.js依赖b.js
}],
"../node_modules/parcel-bundler/src/builtins/hmr-runtime.js": [ // parcel dev模式需要的runtime代码
function() {},
],
}
key value, key为模块文件名称(有时也会带路径),value是个数组,数组第一项为模块代码,第二项为该模块依赖其他模块的关系, 最后一项固定为parcel hmr 的runtime 文件(dev模式下)
parcel打包结果是一个自执行函数的同时,还将parcelRequire挂在了window上,不同于webpack的使用webpack_require来替换require或import,parcel将模块平铺展开,并将模块的依赖通过模块名指定出来。
备注
- magicstring: 快速轻量的工具,用来操作字符串,生成sourcemap等(例如replace, wrap some code, 生成sourcemap等)
- webpack target除了web还有,web-worker, electron等
小结
从打包结果来看,rollup是最直接和容易理解的,webpack 和 parcel 都各自用了自己的方法支持模块关系的解析。
代码比rollup打包结果大,由此,也容易看出,rollup适合类库类打包,webpack parcel适用于应用类项目的打包
Reference
- parcel官网:parceljs.org/
- Parcel 源码解读
- magicstring: github.com/Rich-Harris...