前端模块化成为了主流的今天,离不开各类打包工具的贡献。社区里面对于webpack,rollup以及后起之秀parcel的介绍层出不穷,对于它们各自的使用配置分析也是汗牛充栋。为了不成为一位“配置工程师”,咱们须要来了解一下打包工具的运行原理,只有把核心原理搞明白了,在工具的使用上才能更加驾轻就熟。php
本文基于parcel核心开发者@ronami的开源项目minipack而来,在其很是详尽的注释之上加入更多的理解和说明,方便读者更好地理解。css
一、打包工具核心原理
顾名思义,打包工具就是负责把一些分散的小模块,按照必定的规则整合成一个大模块的工具。与此同时,打包工具也会处理好模块之间的依赖关系,最终这个大模块将能够被运行在合适的平台中。前端
打包工具会从一个入口文件开始,分析它里面的依赖,而且再进一步地分析依赖中的依赖,不断重复这个过程,直到把这些依赖关系理清挑明为止。java
从上面的描述能够看到,打包工具最核心的部分,其实就是处理好模块之间的依赖关系,而minipack以及本文所要讨论的,也是集中在模块依赖关系的知识点当中。node
为了简单起见,minipack项目直接使用ES modules规范,接下来咱们新建三个文件,而且为它们之间创建依赖:webpack
/* name.js */export const name = 'World'
web
swift
/* message.js */import { name } from './name.js'export default `Hello ${name}!`
数组
浏览器
/* entry.js */import message from './message.js'console.log(message)
它们的依赖关系很是简单: entry.js
→ message.js
→ name.js
,其中 entry.js
将会成为打包工具的入口文件。
可是,这里面的依赖关系只是咱们人类所理解的,若是要让机器也可以理解当中的依赖关系,就须要借助必定的手段了。
二、依赖关系解析
新建一个js文件,命名为 minipack.js
,首先引入必要的工具。
/* minipack.js */const fs = require('fs')const path = require('path')const babylon = require('babylon')const traverse = require('babel-traverse').defaultconst { transformFromAst } = require('babel-core')
接下来,咱们会撰写一个函数,这个函数接收一个文件做为模块,而后读取它里面的内容,分析出其全部的依赖项。固然,咱们能够经过正则匹配模块文件里面的 import
关键字,但这样作很是不优雅,因此咱们可使用 babylon
这个js解析器把文件内容转化成抽象语法树(AST),直接从AST里面获取咱们须要的信息。
获得了AST以后,就可使用 babel-traverse
去遍历这棵AST,获取当中关键的“依赖声明”,而后把这些依赖都保存在一个数组当中。
最后使用 babel-core
的 transformFromAst
方法搭配 babel-preset-env
插件,把ES6语法转化成浏览器能够识别的ES5语法,而且为该js模块分配一个ID。
let ID = 0function createAsset (filename) {// 读取文件内容const content = fs.readFileSync(filename, 'utf-8')// 转化成ASTconst ast = babylon.parse(content, {sourceType: 'module',});// 该文件的全部依赖const dependencies = []// 获取依赖声明traverse(ast, {ImportDeclaration: ({ node }) => {dependencies.push(node.source.value);}})// 转化ES6语法到ES5const {code} = transformFromAst(ast, null, {presets: ['env'],})// 分配IDconst id = ID++// 返回这个模块return {id,filename,dependencies,code,}}
运行 createAsset('./example/entry.js')
,输出以下:
{ id: 0,filename: './example/entry.js',dependencies: [ './message.js' ],code: '"use strict";\n\nvar _message = require("./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);' }
可见 entry.js
文件已经变成了一个典型的模块,且依赖已经被分析出来了。接下来咱们就要递归这个过程,把“依赖中的依赖”也都分析出来,也就是下一节要讨论的创建依赖关系图集。
三、创建依赖关系图集
新建一个名为 createGragh()
的函数,传入一个入口文件的路径做为参数,而后经过 createAsset()
解析这个文件使之定义成一个模块。
接下来,为了可以挨个挨个地对模块进行依赖分析,因此咱们维护一个数组,首先把第一个模块传进去并进行分析。当这个模块被分析出还有其余依赖模块的时候,就把这些依赖模块也放进数组中,而后继续分析这些新加进去的模块,直到把全部的依赖以及“依赖中的依赖”都彻底分析出来。
与此同时,咱们有必要为模块新建一个 mapping
属性,用来储存模块、依赖、依赖ID之间的依赖关系,例如“ID为0的A模块依赖于ID为2的B模块和ID为3的C模块”就能够表示成下面这个样子:
{0: [function A () {}, { 'B.js': 2, 'C.js': 3 }]}
搞清楚了个中道理,就能够开始编写函数了。
function createGragh (entry) {// 解析传入的文件为模块const mainAsset = createAsset(entry)// 维护一个数组,传入第一个模块const queue = [mainAsset]// 遍历数组,分析每个模块是否还有其它依赖,如有则把依赖模块推动数组for (const asset of queue) {asset.mapping = {}// 因为依赖的路径是相对于当前模块,因此要把相对路径都处理为绝对路径const dirname = path.dirname(asset.filename)// 遍历当前模块的依赖项并继续分析asset.dependencies.forEach(relativePath => {// 构造绝对路径const absolutePath = path.join(dirname, relativePath)// 生成依赖模块const child = createAsset(absolutePath)// 把依赖关系写入模块的mapping当中asset.mapping[relativePath] = child.id// 把这个依赖模块也推入到queue数组中,以便继续对其进行以来分析queue.push(child)})}// 最后返回这个queue,也就是依赖关系图集return queue}
可能有读者对其中的 for...of ...
循环当中的 queue.push
有点迷,可是只要尝试过下面这段代码就能搞明白了:
var numArr = ['1', '2', '3']for (num of numArr) {console.log(num)if (num === '3') {arr.push('Done!')}}
尝试运行一下 createGraph('./example/entry.js')
,就可以看到以下的输出:
[ { id: 0,filename: './example/entry.js',dependencies: [ './message.js' ],code: '"use strict";\n\nvar _message = require("./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);',mapping: { './message.js': 1 } },{ id: 1,filename: 'example/message.js',dependencies: [ './name.js' ],code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\n\nvar _name = require("./name.js");\n\nexports.default = "Hello " + _name.name + "!";',mapping: { './name.js': 2 } },{ id: 2,filename: 'example/name.js',dependencies: [],code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nvar name = exports.name = \'world\';',mapping: {} } ]
如今依赖关系图集已经构建完成了,接下来就是把它们打包成一个单独的,可直接运行的文件啦!
四、进行打包
上一步生成的依赖关系图集,接下来将经过 CommomJS
规范来实现加载。因为篇幅关系,本文不对 CommomJS
规范进行扩展,有兴趣的读者能够参考@阮一峰 老师的一篇文章《浏览器加载 CommonJS 模块的原理与实现》,说得很是清晰。简单来讲,就是经过构造一个当即执行函数 (function () {})()
,手动定义 module
, exports
和 require
变量,最后实现代码在浏览器运行的目的。
接下来就是依据这个规范,经过字符串拼接去构建代码块。
function bundle (graph) {let modules = ''graph.forEach(mod => {modules += `${mod.id}: [function (require, module, exports) { ${mod.code} },${JSON.stringify(mod.mapping)},],`})const result = `(function(modules) {function require(id) {const [fn, mapping] = modules[id];function localRequire(name) {return require(mapping[name]);}const module = { exports : {} };fn(localRequire, module, module.exports);return module.exports;}require(0);})({${modules}})`return result}
最后运行 bundle(createGraph('./example/entry.js'))
,输出以下:
(function (modules) {function require(id) {const [fn, mapping] = modules[id];function localRequire(name) {return require(mapping[name]);}const module = { exports: {} };fn(localRequire, module, module.exports);return module.exports;}require(0);})({0: [function (require, module, exports) {"use strict";var _message = require("./message.js");var _message2 = _interopRequireDefault(_message);function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }console.log(_message2.default);},{ "./message.js": 1 },], 1: [function (require, module, exports) {"use strict";Object.defineProperty(exports, "__esModule", {value: true});var _name = require("./name.js");exports.default = "Hello " + _name.name + "!";},{ "./name.js": 2 },], 2: [function (require, module, exports) {"use strict";Object.defineProperty(exports, "__esModule", {value: true});var name = exports.name = 'world';},{},],})
这段代码将可以直接在浏览器运行,输出“Hello world!”。
至此,整一个打包工具已经完成。
五、概括总结
通过上面几个步骤,咱们能够知道一个模块打包工具,第一步会从入口文件开始,对其进行依赖分析,第二步对其全部依赖再次递归进行依赖分析,第三步构建出模块的依赖图集,最后一步根据依赖图集使用 CommonJS
规范构建出最终的代码。明白了当中每一步的目的,便可以明白一个打包工具的运行原理。
本文同步分享在 博客“grain先森”(JianShu)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。