2019 年的前端技术栈,无论你是用 Vue 还是用 React ,应该都离不开一样工具 – webpack。webpack 极大的简化了前端开发的构建过程,只需提供一个入口文件,webpack 就能自动帮我们分析出相关依赖,构建出 bundle 包。
webpack 很强大,但是大家知道 webpack 到底是怎么样打包的吗?本文将从一个很简单的例子,一步步带领大家探寻 webpack 打包的基本原理。
一、准备阶段
本文的代码详见webpack-mini step1。
在本文中,我们不考虑任何优化操作,尽可能的保持代码最简单。
假设我们的项目目录及文件列表如下,我们将使用命令 node webpack-mini/index.js
把 src
下的代码打包成一个 bundle.js
。
1 2 3 4 5 6 7 8
| ├── dist // 打包出来 bundle 文件所在目录 │ ├── bundle.js ├── src // 业务代码目录 │ ├── bar.js │ ├── foo.js │ └── index.js // 入口文件 └── webpack-mini // mini 打包器代码目录 └── index.js
|
1 2 3 4 5 6
| import foo from './foo.js'; import { say } from './bar.js';
foo(); say();
|
1 2 3 4
| export default function() { console.log('foo: Hello I am foo!'); }
|
1 2 3 4
| export const say = function() { console.log('bar.say: Hello I am bar!'); }
|
二、分析结构
先来看入口文件, src/index.js
通过 ES6 的 import 语法引入了 foo 的 default 方法和 bar 的 say 方法。
如何解析 index.js 、foo.js、bar.js 之间的依赖关系呢?字符串查找?不行,这样要考虑的边界情况太多。正则?也不行,同样很复杂。那么正统的处理方式是什么?语法解析。
在这个 DEMO 中,我们使用 babel
来解析 JS 文件,生成抽象语法树。先看下 src/index.js 中的 import foo from './foo';
通过在线语法解析网站 axtexplorer.net 生成的 json 结构(省略了部分无关节点信息)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| { "type": "File", "start": 0, "end": 24, "loc": {}, "program": { "type": "Program", "start": 0, "end": 24, "loc": {}, "sourceType": "module", "body": [ { "type": "ImportDeclaration", // 划重点 "start": 0, "end": 24, "loc": {}, "specifiers": [ { "type": "ImportDefaultSpecifier", // 划重点 "start": 7, "end": 10, "loc": {}, "local": { "type": "Identifier", "start": 7, "end": 10, "loc": {}, "name": "foo" } } ], "importKind": "value", "source": { "type": "Literal", "start": 16, "end": 23, "loc": {}, "value": "./foo", // 划重点 "rawValue": "./foo", "raw": "'./foo'" } } ] }, "comments": [], "tokens": [] }
|
我们的解析工具识别出了这是一个 import 声明(ImportDeclaration),并且是一个默认的声明(ImportDefaultSpecifier),声明标识符(Identifier)为 foo,引用的资源路径为 ./foo。解析工具帮我们把字符串代码转换为了结构化的对象,有了结构化的对象,我们就能进行下一步了。
三、生成依赖图
想要打包 JS 模块,先要拿到 JS 的依赖图,上图就是生成依赖图的流程图。下面我们看下具体代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| const path = require('path'); const { transformFileSync } = require('@babel/core');
function createGraph(filePath) { if (!path.isAbsolute(filePath)) filePath = path.resolve(__dirname, filePath); const dirPath = path.dirname(filePath);
const dependencies = [];
const visitor = { ImportDeclaration({ node }) { node.source.value = path.resolve(dirPath, node.source.value); dependencies.push(createGraph(node.source.value)) } };
const { code } = transformFileSync(filePath, { presets: ['@babel/env'], plugins: [ { visitor } ] });
return { filePath, code, dependencies } }
|
这里需要说明的一点是,由于 ES6 import 的语法支持程度还很低,并且需要特殊的加载方式( <script type="module">
),另外,我们后面还要兼容 commonjs 的 module.exports/exports 语法,所以我们需要用 @babel/env
转换一下代码。分别看下 src/index.js
和 src/foo.js
转换后的样子,下一步我们将使用转换后的代码,在 exports 上做文章。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| "use strict";
var _foo = _interopRequireDefault(require("/Users/david/project/webpack-mini/src/foo.js"));
var _bar = require("/Users/david/project/webpack-mini/src/bar.js");
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
(0, _foo["default"])();
(0, _bar.say)();
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| "use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports["default"] = _default;
function _default() { console.log('foo: Hello I am foo!'); }
|
四、生成 bundle 文件
拿到依赖图后,我们就要开始组装 bundle 包了。
先遍历一遍依赖图,将依赖图转为数组,方便下一步生成入口文件的时候使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
function flattenGraph(graph, modules) { if (!modules) modules = []; modules.push({ id: graph.filePath, code: graph.code }) if (graph.dependencies.length) { graph.dependencies.forEach(o => { flattenGraph(o, modules) }) } return modules }
|
拿到模块数组后,开始拼接代码块。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
function createBundle(modules) { return `(function (modules) { function require(moduleId) { let exports = {}; modules[moduleId](exports, require); return exports; } require("${modules[0].id}") })({${modules.map(module => (`"${module.id}":${generateModuleTemplate(module.code)}`) )}})` }
|
上述代码我们可以分两块看下,最外层实际上就是一个立即执行函数表达式,将 modulesTemplateObject 对象传给了 modules。
1 2 3 4
| ((function (modules) { })(modulesTemplateObject)}
|
再看下 generateModuleTemplate
做了什么,
1 2 3 4 5 6 7
| function generateModuleTemplate(code) { return `function (exports, require) { ${code} }` }
|
接着重头戏来了,我们本篇文章的核心代码,
1 2 3 4 5 6 7 8
| function require(moduleId) { let exports = {}; modules[moduleId](exports, require); return exports; }
require("${modules[0].id}")
|
接着我们将整个过程串起来,
1 2 3 4 5 6 7 8 9 10 11 12
| function webpackMini (fileEntry) { const graph = createGraph(fileEntry) const modules = flattenGraph(graph) const bundle = createBundle(modules) generateFile(bundle) }
webpackMini('../src/index.js')
|
执行程序,看看在 dist 目录下是不是得到了 bundle.js。运行 bundle.js ,得到输出:
1 2
| foo: Hello I am foo! bar.say: Hello I am bar!
|
至此,一个最简单的 JS 模块打包器就完成了!
总结
回顾一下,我们先是通过 babel 将 JS 字符串转换成了可供分析的语法结构树,然后遍历得到模块间的依赖关系,最后将依赖关系通过我们自己实现的 require 方法加载进来,这样就实现了一个最简单的 JS 模块打包器。
后续我们将继续完善代码,诸如打包 commonjs 代码、组件加载缓存、处理组件循环调用等功能。本文的代码保存在 webpack-mini,欢迎 star 关注。