Rollup续集

278 阅读9分钟

上篇关于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的类型是一个程序programbody则包含了这个程序下面所有语句对应的AST子节点。

type字段表示不同的节点类型,例如上面语法树中的VariableDeclaration表示变量声明,kind属性指明声明的类型(let/const/var等);Identifier表示标识符,就是js中自定义的一些变量名、函数名、参数名等;BinaryExpression表示二元运算表达式节点,leftright表示运算符左右的两个表达式,operator 表示一个二元运算符。更多详情可以自行查资料了解。

还有两个可以在线直观查看代码对应AST语法树的网站推荐给大家:AST ExplorerEsprima

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进行分析:

  1. 首先是分析导入和导出的模块,将引入的模块和导出的模块填入对应的对象 上面例子对应的importsexports为:
// key 为要引入的具体对象,value 为对应的 AST 节点内容
imports = {
  test1: {
    source: "./test",
    name: "test1",
    localName: "test1",
  },
  test2: {
    source: "./test",
    name: "test2",
    localName: "test2",
  },
}

// 由于没有导出的对象,所以为空
exports = {}
  1. 分析每个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
    }
}
  1. 分析标识符,找出它们的依赖项 当解析到一个标识符时,rollup会遍历它当前的作用域查找当前标识符,如果持续遍历到模块顶级作用域都没有找到,就说明该标识符对应的函数、方法等依赖于其它模块,需要从其它模块引入,这时就会把需要引入的函数、方法添加到Module_dependsOn 对象里,后面生成代码时会根据 _dependsOn 里的值来引入文件。

例子如下图所示:

image.png

这里也说明rollup在打包过程中其实不是说看你引入了哪些函数、方法,而是看哪些被引入的函数、方法是被调用了。只有真正被使用了的函数、方法才会被打包。如果某个函数只是被引入,并没有调用,那rollup并不会将它引入打包。从图中可以看出,虽然我们在例子里引入了test1test2两个函数,但由于test2没有被调用,_dependsOn里就只有test1。 这就是rolluptree-shaking基本原理。

根据依赖项,读取对应的文件

根据_dependsOn对象,rollup可以确定需要引入的函数,rolluptest1当作key值,从前面Module实例生成的imports对象中找到对应的文件,然后读取这个文件生成一个新的Module实例。

imports = {
  test1: {
    source: "./test",
    name: "test1",
    localName: "test1",
  },
  test2: {
    source: "./test",
    name: "test2",
    localName: "test2",
  },
}

由于./test.js文件中导出了两个函数,没有导入函数,所以由./test.js文件生成的新的Module实例中的importsexports为:

// 由于没有引入的对象,所以为空
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.jsexports对象,如果匹配成功就把test1()函数对应的AST节点提取出来,放到Bundle中,如果匹配失败则会抛出错误,提示test.js没有导出该函数:

image.png

使用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())

可以看到没有test2export相关代码。

至此,暂结束。