上篇关于rollup打包的文件写的比较潦草,最近又多了解了一点关于rollup的知识,记录下来凑个续集,虽然续集也很潦草🤦♀️。
前序
在开始之前我们先回忆一下AST(Abstract Syntax Tree)抽象语法树,先前在小破站上看过尚硅谷关于AST抽象语法树的学习视频,老师讲到的指针与栈的思想还是蛮值得学习的。有兴趣的同学可以自行点击传送门了解详情。
为什么提起AST呢,因为rollup使用了acorn库;
acorn对自己的介绍是A small, fast, JavaScript-based JavaScript parser,一个用JavaScript编写的,小巧、快速的 JavaScript解析器,它可以将JavaScript字符串解析成AST。
例如如下一段简单的代码:
let a = 1
let b = 2
function add(a,b){
return a+b
}
将被解析为:
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "Literal",
"value": 1,
"raw": "1"
}
}
],
"kind": "let"
},
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "b"
},
"init": {
"type": "Literal",
"value": 2,
"raw": "2"
}
}
],
"kind": "let"
},
{
"type": "FunctionDeclaration",
"id": {
"type": "Identifier",
"name": "add"
},
"expression": false,
"generator": false,
"async": false,
"params": [
{
"type": "Identifier",
"name": "a"
},
{
"type": "Identifier",
"name": "b"
}
],
"body": {
"type": "BlockStatement",
"body": [
{
"type": "ReturnStatement",
"argument": {
"type": "BinaryExpression",
"left": {
"type": "Identifier",
"name": "a"
},
"operator": "+",
"right": {
"type": "Identifier",
"name": "b"
}
}
}
]
}
}
],
"sourceType": "module"
}
从语法树中我们可以看到,这个AST的类型是一个程序program,body则包含了这个程序下面所有语句对应的AST子节点。
type字段表示不同的节点类型,例如上面语法树中的VariableDeclaration表示变量声明,kind属性指明声明的类型(let/const/var等);Identifier表示标识符,就是js中自定义的一些变量名、函数名、参数名等;BinaryExpression表示二元运算表达式节点,left和right表示运算符左右的两个表达式,operator 表示一个二元运算符。更多详情可以自行查资料了解。
还有两个可以在线直观查看代码对应AST语法树的网站推荐给大家:AST Explorer、Esprima
rollup打包的基本流程
在rollup中,一个文件就是一个模块,每个模块都会根据文件的代码生成一个AST抽象语法树,rollup需要对每个AST节点进行分析,看看这个节点有没有调用函数或者方法,如果有就查询所调用的函数或方法是否在当前作用域,如果不在就继续往上层作用域查询,直到找到模块顶级作用域为止。如果本模块没有找到,说明这个函数、方法依赖于其他模块,需要从其他模块引入。
经历多年的迭代积累,现在的`rollup`功能越来越强大,鉴于能力和精力的限制,我找到了v0.20.0版本的源码,想抛开繁琐的配置,通过最简单的源码,了解一下`rollup`打包的时候做了什么,所涉及的内容还比较浅显,希望后面自己可以坚持循序渐进,了解到更深入的内容。
下面是一个简单的例子:
// main.js
import { test1, test2 } from './test'
test1()
function test() {
const a = 1
}
console.log(test())
// test.js
export function test1() {}
export function test2() {}
// test-rollup.js
const rollup = require('../dist/rollup')
rollup(__dirname + '/main.js').then((res) => {
res.wirte('bundle.js')
})
rollup读取入口文件main.js
rollup 打包的过程中其实有两个很重要的实例,一个是 Bundle 打包器,用于收集其他模块的代码,最后将所有收集到的代码打包到一起。另一个就是 Module 实例,每个模块都对应一个 module 实例。
上面运行test-rollup.js文件调用rollup函数进行打包时,会先生成一个Bundle实例
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
})
}
}
})
}
rollup函数的主要部分如上面函数所示,生成的Bundle实例结构如下:
// Bundle
{
entryPath: "/xxx/main.js",
base: "/xxx",
entryModule: null,
modules: {},
statements: [],
externalModules: [],
internalNamespaceModules: [],
}
- entryPath:入口文件完整路径;
- base:入口文件所在目录;
- entryModule:入口模块;
- modules:读取过的模块都缓存在此,如果重复读取则直接从缓存读取模块,提高效率;
- statements:最后真正要生成的代码的 AST 节点语句,不用生成的 AST 会被省略掉;
- externalModules:外部模块,当通过路径获取不到的模块就属于外部模块;
- internalNamespaceModules:import * as test from './test' 需要用到;
new Module()的过程
生成Bundle实例后,rollup会根据入口文件路径去读取文件,最后根据文件内容生成一个Module实例。
fs.readFile(route, 'utf-8', (err, code) => {
if (err) reject(err)
const module = new Module({
code,
path: route,
bundle: this,
})
})
Module实例的结构如下:
// Module
{
code: {},
path: "/xxx/main.js",
bundle: {},// Bundle实例
suggestedNames: {},
ast: {
_scope: { // AST节点作用域
parent: undefined,
depth: 0,
names: ["test",],
isBlockScope: false,
}
},// AST语法树
imports: {},// 对应导入对象
exports: {},// 对应导出对象
definedNames: [],// 当前模块下的顶级变量(包括函数声明)
canonicalNames: {},/ 当前语句下的变量
definitions: {},
definitionPromises: {},
modifications: {},
}
在new一个Module实例时。会调用acorn库的parse方法,将代码解析成AST;
this.ast = parse(code, {
ecmaVersion: 7, // 要解析的 JavaScript 的 ECMA 版本
sourceType: 'module', // sourceType值为 module/script,module 模式,可以使用 import/export 语法
})
树结构如下:
{
type: "Program",
body: [
{
type: "ImportDeclaration",
specifiers: [
{
type: "ImportSpecifier",
imported: {
type: "Identifier",
name: "test1",
},
local: {
type: "Identifier",
name: "test1",
},
},
{
type: "ImportSpecifier",
imported: {
type: "Identifier",
name: "test2",
},
local: {
type: "Identifier",
name: "test2",
},
},
],
source: {
type: "Literal",
value: "./test",
raw: "'./test'",
},
},
{
type: "ExpressionStatement",
expression: {
type: "CallExpression",
callee: {
type: "Identifier",
name: "test1",
},
arguments: [],
},
},
{
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "test",
},
expression: false,
generator: false,
params: [],
body: {
type: "BlockStatement",
body: [
{
type: "VariableDeclaration",
declarations: [
{
type: "VariableDeclarator",
id: {
type: "Identifier",
name: "a",
},
init: {
type: "Literal",
value: 1,
raw: "1",
},
},
],
kind: "const",
},
],
},
},
{
type: "ExpressionStatement",
expression: {
type: "CallExpression",
callee: {
type: "MemberExpression",
object: {
type: "Identifier",
name: "console",
},
property: {
type: "Identifier",
name: "log",
},
computed: false,
},
arguments: [
{
type: "CallExpression",
callee: {
type: "Identifier",
name: "test",
},
arguments: [],
},
],
},
},
],
sourceType: "module",
}
接下来就要对生成的AST进行分析:
- 首先是分析导入和导出的模块,将引入的模块和导出的模块填入对应的对象
上面例子对应的
imports和exports为:
// key 为要引入的具体对象,value 为对应的 AST 节点内容
imports = {
test1: {
source: "./test",
name: "test1",
localName: "test1",
},
test2: {
source: "./test",
name: "test2",
localName: "test2",
},
}
// 由于没有导出的对象,所以为空
exports = {}
- 分析每个AST节点的作用域,找出每个AST节点定义的变量
从上面Module实例的结构中可以看到,ast对象中包含一个_scope字段,这里表示对应ast节点的Scope实例。rollup每遍历到一个AST节点时,都会为它生成一个Scope实例,Scope实例中有一个names数组,用于保存这个AST节点内的变量,depth表示作用域层级,模块顶级作用域为0。
生成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
}
}
- 分析标识符,找出它们的依赖项
当解析到一个标识符时,
rollup会遍历它当前的作用域查找当前标识符,如果持续遍历到模块顶级作用域都没有找到,就说明该标识符对应的函数、方法等依赖于其它模块,需要从其它模块引入,这时就会把需要引入的函数、方法添加到Module的_dependsOn对象里,后面生成代码时会根据_dependsOn里的值来引入文件。
例子如下图所示:
这里也说明rollup在打包过程中其实不是说看你引入了哪些函数、方法,而是看哪些被引入的函数、方法是被调用了。只有真正被使用了的函数、方法才会被打包。如果某个函数只是被引入,并没有调用,那rollup并不会将它引入打包。从图中可以看出,虽然我们在例子里引入了test1和test2两个函数,但由于test2没有被调用,_dependsOn里就只有test1。 这就是rollup的tree-shaking基本原理。
根据依赖项,读取对应的文件
根据_dependsOn对象,rollup可以确定需要引入的函数,rollup将test1当作key值,从前面Module实例生成的imports对象中找到对应的文件,然后读取这个文件生成一个新的Module实例。
imports = {
test1: {
source: "./test",
name: "test1",
localName: "test1",
},
test2: {
source: "./test",
name: "test2",
localName: "test2",
},
}
由于./test.js文件中导出了两个函数,没有导入函数,所以由./test.js文件生成的新的Module实例中的imports和exports为:
// 由于没有引入的对象,所以为空
imports = {}
exports = {
test1: {
node: {
type: "ExportNamedDeclaration",
declaration: {
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "test1",
},
expression: false,
generator: false,
params: [],
body: {
type: "BlockStatement",
body: [],
},
},
specifiers: [],
source: null,
},
localName: "test1",
expression: {
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "test1",
},
expression: false,
generator: false,
params: [],
body: {
type: "BlockStatement",
body: [],
},
},
},
test2: {
node: {
type: "ExportNamedDeclaration",
declaration: {
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "test2",
},
expression: false,
generator: false,
params: [],
body: {
type: "BlockStatement",
body: [],
},
},
specifiers: [],
source: null,
},
localName: "test2",
expression: {
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "test2",
},
expression: false,
generator: false,
params: [],
body: {
type: "BlockStatement",
body: [],
},
},
},
}
这时就会用test1当作key去匹配test.js的exports对象,如果匹配成功就把test1()函数对应的AST节点提取出来,放到Bundle中,如果匹配失败则会抛出错误,提示test.js没有导出该函数:
使用Bundle的generate()函数生成代码
在完成引入所有需要的函数、方法后,rollup会调用Bundle 的 generate() 方法生成代码,同时还会做一些优化操作:
- 移除额外的代码:如
export function test1() {}会变成function test1() {},跳过export {//...}语句 - 重命名:解决冲突,例如两个不同的模块有一个同名函数,则需要对其中一个重命名 这里对应的源码就不放了,大家可以自行去查看
generate()函数其实还用到了一个magic-string库,这个库主要是对字符串的一些常用操作方法进行了封装,在 generate() 中,会将每个 AST 节点对应的源代码添加到 magic-string 实例中:
// add the statement itself
magicString.addSource({
content: source,
separator: newLines,
})
这个操作相当于将每个AST处理后的源码拼接到一起,最后再返回。
我们的例子打包后的代码:
'use strict'
function test1() {}
test1()
function test() {
const a = 1
}
console.log(test())
可以看到没有test2、export相关代码。
至此,暂结束。