前言
为了学习 rollup 打包原理,我克隆了最新版(v2.26.5)的源码。而后发现打包器和我想像的不太一样,代码切实太多了,光看 d.ts 文件就看得头疼。为了看看源码到底有多少行,我写了个脚本,后果发现有 19650行,解体...
这就能打消我学习 rollup 的信心吗?不可能,退而求其次,我下载了 rollup 初版源码,才 1000 行左右。
我的目标是学习 rollup 怎么打包的,怎么做 tree-shaking 的。而初版源码曾经实现了这两个性能(半成品),所以看初版源码曾经足够了。
好了,上面开始注释。
注释
rollup 应用了 acorn
和 magic-string
两个库。为了更好的浏览 rollup 源码,必须对它们有所理解。
上面我将简略的介绍一下这两个库的作用。
acorn
acorn
是一个 JavaScript 语法解析器,它将 JavaScript 字符串解析成语法形象树 AST。
例如以下代码:
export default function add(a, b) { return a + b }
将被解析为:
{ "type": "Program", "start": 0, "end": 50, "body": [ { "type": "ExportDefaultDeclaration", "start": 0, "end": 50, "declaration": { "type": "FunctionDeclaration", "start": 15, "end": 50, "id": { "type": "Identifier", "start": 24, "end": 27, "name": "add" }, "expression": false, "generator": false, "params": [ { "type": "Identifier", "start": 28, "end": 29, "name": "a" }, { "type": "Identifier", "start": 31, "end": 32, "name": "b" } ], "body": { "type": "BlockStatement", "start": 34, "end": 50, "body": [ { "type": "ReturnStatement", "start": 36, "end": 48, "argument": { "type": "BinaryExpression", "start": 43, "end": 48, "left": { "type": "Identifier", "start": 43, "end": 44, "name": "a" }, "operator": "+", "right": { "type": "Identifier", "start": 47, "end": 48, "name": "b" } } } ] } } } ], "sourceType": "module"}
能够看到这个 AST 的类型为 program
,表明这是一个程序。body
则蕴含了这个程序上面所有语句对应的 AST 子节点。
每个节点都有一个 type
类型,例如 Identifier
,阐明这个节点是一个标识符;BlockStatement
则表明节点是块语句;ReturnStatement
则是 return 语句。
如果想理解更多详情 AST 节点的信息能够看一下这篇文章《应用 Acorn 来解析 JavaScript》。
magic-string
magic-string
也是 rollup 作者写的一个对于字符串操作的库。上面是 github 上的示例:
var MagicString = require( 'magic-string' );var s = new MagicString( 'problems = 99' );s.overwrite( 0, 8, 'answer' );s.toString(); // 'answer = 99's.overwrite( 11, 13, '42' ); // character indices always refer to the original strings.toString(); // 'answer = 42's.prepend( 'var ' ).append( ';' ); // most methods are chainables.toString(); // 'var answer = 42;'var map = s.generateMap({ source: 'source.js', file: 'converted.js.map', includeContent: true}); // generates a v3 sourcemaprequire( 'fs' ).writeFile( 'converted.js', s.toString() );require( 'fs' ).writeFile( 'converted.js.map', map.toString() );
从示例中能够看进去,这个库次要是对字符串一些罕用办法进行了封装。这里就不多做介绍了。
rollup 源码构造
│ bundle.js // Bundle 打包器,在打包过程中会生成一个 bundle 实例,用于收集其余模块的代码,最初再将收集的代码打包到一起。│ external-module.js // ExternalModule 内部模块,例如引入了 'path' 模块,就会生成一个 ExternalModule 实例。│ module.js // Module 模块,开发者本人写的代码文件,都是 module 实例。例如有 'foo.js' 文件,它就对应了一个 module 实例。│ rollup.js // rollup 函数,所有的开始,调用它进行打包。│├─ast // ast 目录,蕴含了和 AST 相干的类和函数│ analyse.js // 次要用于剖析 AST 节点的作用域和依赖项。│ Scope.js // 在剖析 AST 节点时为每一个节点生成对应的 Scope 实例,次要是记录每个 AST 节点对应的作用域。│ walk.js // walk 就是递归调用 AST 节点进行剖析。│├─finalisers│ cjs.js // 打包模式,目前只反对将代码打包成 common.js 格局│ index.js│└─utils // 一些帮忙函数 map-helpers.js object.js promise.js replaceIdentifiers.js
下面是初版源码的目录构造,在持续深刻前,请仔细阅读下面的正文,理解一下每个文件的作用。
rollup 如何打包的?
在 rollup 中,一个文件就是一个模块。每一个模块都会依据文件的代码生成一个 AST 语法形象树,rollup 须要对每一个 AST 节点进行剖析。
剖析 AST 节点,就是看看这个节点有没有调用函数或办法。如果有,就查看所调用的函数或办法是否在以后作用域,如果不在就往上找,直到找到模块顶级作用域为止。
如果本模块都没找到,阐明这个函数、办法依赖于其余模块,须要从其余模块引入。
例如 import foo from './foo.js'
,其中 foo()
就得从 ./foo.js
文件找。
在引入 foo()
函数的过程中,如果发现 foo()
函数依赖其余模块,就会递归读取其余模块,如此循环直到没有依赖的模块为止。
最初将所有引入的代码打包在一起。
下面例子的示例图:
接下来咱们从一个具体的示例开始,一步步剖析 rollup 是如何打包的。
以下两个文件是代码文件。
// main.jsimport { foo1, foo2 } from './foo'foo1()function test() { const a = 1}console.log(test())
// foo.jsexport function foo1() {}export function foo2() {}
上面是测试代码:
const rollup = require('../dist/rollup')rollup(__dirname + '/main.js').then(res => { res.wirte('bundle.js')})
1. rollup 读取 main.js
入口文件。
rollup()
首先生成一个 Bundle
实例,也就是打包器。而后依据入口文件门路去读取文件,最初依据文件内容生成一个 Module
实例。
fs.readFile(path, 'utf-8', (err, code) => { if (err) reject(err) const module = new Module({ code, path, bundle: this, // bundle 实例 })})
2. new Moudle() 过程
在 new 一个 Module
实例时,会调用 acorn
库的 parse()
办法将代码解析成 AST。
this.ast = parse(code, { ecmaVersion: 6, // 要解析的 JavaScript 的 ECMA 版本,这里按 ES6 解析 sourceType: 'module', // sourceType值为 module 和 script。module 模式,能够应用 import/export 语法})
接下来须要对生成的 AST 进行剖析。
第一步,剖析导入和导出的模块,将引入的模块和导出的模块填入对应的对象。
每个 Module
实例都有一个 imports
和 exports
对象,作用是将该模块引入和导出的对象填进去,代码生成时要用到。
上述例子对应的 imports
和 exports
为:
// key 为要引入的具体对象,value 为对应的 AST 节点内容。imports = { foo1: { source: './foo', name: 'foo1', localName: 'foo1' }, foo2: { source: './foo', name: 'foo2', localName: 'foo2' }}// 因为没有导出的对象,所以为空exports = {}
第二步,剖析每个 AST 节点间的作用域,找出每个 AST 节点定义的变量。
每遍历到一个 AST 节点,都会为它生成一个 Scope
实例。
// 作用域class Scope { constructor(options = {}) { this.parent = options.parent // 父作用域 this.depth = this.parent ? this.parent.depth + 1 : 0 // 作用域层级 this.names = options.params || [] // 作用域内的变量 this.isBlockScope = !!options.block // 是否块作用域 } add(name, isBlockDeclaration) { if (!isBlockDeclaration && this.isBlockScope) { // it's a `var` or function declaration, and this // is a block scope, so we need to go up this.parent.add(name, isBlockDeclaration) } else { this.names.push(name) } } contains(name) { return !!this.findDefiningScope(name) } findDefiningScope(name) { if (this.names.includes(name)) { return this } if (this.parent) { return this.parent.findDefiningScope(name) } return null }}
Scope
的作用很简略,它有一个 names
属性数组,用于保留这个 AST 节点内的变量。
例如上面这段代码:
function test() { const a = 1}
打断点能够看进去,它生成的作用域对象,names
属性就会蕴含 a
。并且因为它是模块下的一个函数,所以作用域层级为 1(模块顶级作用域为 0)。
第三步,剖析标识符,并找出它们的依赖项。
什么是标识符?如变量名,函数名,属性名,都归为标识符。当解析到一个标识符时,rollup 会遍历它以后的作用域,看看有没这个标识符。如果没有找到,就往它的父级作用域找。如果始终找到模块顶级作用域都没找到,就阐明这个函数、办法依赖于其它模块,须要从其余模块引入。如果一个函数、办法须要被引入,就将它增加到 Module
的 _dependsOn
对象里。
例如 test()
函数中的变量 a
,能在以后作用域找到,它就不是一个依赖项。foo1()
在以后模块作用域找不到,它就是一个依赖项。
打断点也能发现 Module
的 _dependsOn
属性里就有 foo1
。
这就是 rollup 的 tree-shaking 原理。
rollup 不看你引入了什么函数,而是看你调用了什么函数。如果调用的函数不在此模块中,就从其它模块引入。
换句话说,如果你手动在模块顶部引入函数,但又没调用。rollup 是不会引入的。从咱们的示例中能够看出,一共引入了 foo1()
foo2()
两个函数,_dependsOn
里却只有 foo1()
,因为引入的 foo2()
没有调用。
_dependsOn
有什么用呢?前面生成代码时会依据 _dependsOn
里的值来引入文件。
3. 依据依赖项,读取对应的文件。
从 _dependsOn
的值能够发现,咱们须要引入 foo1()
函数。
这时第一步生成的 imports
就起作用了:
imports = { foo1: { source: './foo', name: 'foo1', localName: 'foo1' }, foo2: { source: './foo', name: 'foo2', localName: 'foo2' }}
rollup 将 foo1
当成 key,找到它对应的文件。而后读取这个文件生成一个新的 Module
实例。因为 foo.js
文件导出了两个函数,所以这个新 Module
实例的 exports
属性是这样的:
exports = { foo1: { node: Node { type: 'ExportNamedDeclaration', start: 0, end: 25, declaration: [Node], specifiers: [], source: null }, localName: 'foo1', expression: Node { type: 'FunctionDeclaration', start: 7, end: 25, id: [Node], expression: false, generator: false, params: [], body: [Node] } }, foo2: { node: Node { type: 'ExportNamedDeclaration', start: 27, end: 52, declaration: [Node], specifiers: [], source: null }, localName: 'foo2', expression: Node { type: 'FunctionDeclaration', start: 34, end: 52, id: [Node], expression: false, generator: false, params: [], body: [Node] } }}
这时,就会用 main.js
要导入的 foo1
当成 key 去匹配 foo.js
的 exports
对象。如果匹配胜利,就把 foo1()
函数对应的 AST 节点提取进去,放到 Bundle
中。如果匹配失败,就会报错,提醒 foo.js
没有导出这个函数。
4. 生成代码。
因为曾经引入了所有的函数。这时须要调用 Bundle
的 generate()
办法生成代码。
同时,在打包过程中,还须要对引入的函数做一些额定的操作。
移除额定代码
例如从 foo.js
中引入的 foo1()
函数代码是这样的:export function foo1() {}
。rollup 会移除掉 export
,变成 function foo1() {}
。因为它们就要打包在一起了,所以就不须要 export
了。
重命名
例如两个模块中都有一个同名函数 foo()
,打包到一起时,会对其中一个函数重命名,变成 _foo()
,以防止抵触。
好了,回到注释。
还记得文章一开始提到的 magic-string
库吗?在 generate()
中,会将每个 AST 节点对应的源代码增加到 magic-string
实例中:
magicString.addSource({ content: source, separator: newLines})
这个操作实质上相当于拼字符串:
str += '这个操作相当于将每个 AST 的源代码当成字符串拼在一起,就像当初这样'
最初将拼在一起的代码返回。
return { code: magicString.toString() }
到这就曾经完结了,如果你想把代码生成文件,能够调用 write()
办法生成文件:
rollup(__dirname + '/main.js').then(res => { res.wirte('dist.js')})
这个办法是写在 rollup()
函数里的。
function rollup(entry, options = {}) { const bundle = new Bundle({ entry, ...options }) return bundle.build().then(() => { return { generate: options => bundle.generate(options), wirte(dest, options = {}) { const { code } = bundle.generate({ dest, format: options.format, }) return fs.writeFile(dest, code, err => { if (err) throw err }) } } })}
结尾
本文对源码进行了形象,所以很多实现细节都没说进去。如果对实现细节有趣味,能够看一下源码。代码放在我的 github 上。
我曾经对 rollup 初版源码进行了删减,并增加了大量正文,让代码更加易读。