從 rollup 初版源碼學習打包原理


前言

為了學習 rollup 打包原理,我克隆了最新版(v2.26.5)的源碼。然后發現打包器和我想像的不太一樣,代碼實在太多了,光看 d.ts 文件就看得頭疼。為了看看源碼到底有多少行,我寫了個腳本,結果發現有 19650行,崩潰...

這就能打消我學習 rollup 的決心嗎?不可能,退而求其次,我下載了 rollup 初版源碼,才 1000 行左右。

我的目的是學習 rollup 怎么打包的,怎么做 tree-shaking 的。而初版源碼已經實現了這兩個功能(半成品),所以看初版源碼已經足夠了。

好了,下面開始正文。

正文

rollup 使用了 acornmagic-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 string
s.toString(); // 'answer = 42'

s.prepend( 'var ' ).append( ';' ); // most methods are chainable
s.toString(); // 'var answer = 42;'

var map = s.generateMap({
  source: 'source.js',
  file: 'converted.js.map',
  includeContent: true
}); // generates a v3 sourcemap

require( '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.js
import { foo1, foo2 } from './foo'

foo1()

function test() {
    const a = 1
}

console.log(test())
// foo.js
export 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 實例都有一個 importsexports 對象,作用是將該模塊引入和導出的對象填進去,代碼生成時要用到。

上述例子對應的 importsexports 為:

// 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.jsexports 對象。如果匹配成功,就把 foo1() 函數對應的 AST 節點提取出來,放到 Bundle 中。如果匹配失敗,就會報錯,提示 foo.js 沒有導出這個函數。

4. 生成代碼。

由於已經引入了所有的函數。這時需要調用 Bundlegenerate() 方法生成代碼。

同時,在打包過程中,還需要對引入的函數做一些額外的操作。

移除額外代碼

例如從 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 初版源碼進行了刪減,並添加了大量注釋,讓代碼更加易讀。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM