password
Created
Feb 19, 2023 03:59 PM
type
Post
status
Published
date
May 14, 2022
slug
summary
Rollup概念与运行原理
tags
Rollup
工程化
category
工程化
icon
一. 概念
rollup是基于ESM的模块打包器
目前业界的许多框架都是用的rollup作为打包工具
rollup的tree-shaking能力能使打包出来的产物包尽可能的小, 可以剔除掉一些无用的代码, 对于大型的项目能够起到一定的优化作用
1. 基本运行原理
- 核心依赖包:
- MagicString: MagicString是一个非常轻量的操作包, 能够让rollup实现对无用的代码移除, 替换等等工作
- acorn: 是一个轻量高性能的ast解析工具, 能够将源码进行解析以生成ast对象
- 基本流程
- 初始化变量和参数, 初始化插件
- 根据AST的import语句使用情况, 进行依赖分析, 生成moduleGraph
- 处理moduleGraph的关系, 根据scope来向上查找定义域, 通过这个方式来进行tree-shaking
- 将模块内容bundle到一起, 写入到目标文件
- scope
有如下的示例
可以看到, 对于每一个作用域, rollup会生成一个Scope, 父子Scope相连, 变成了作用域链
通过作用域链的逐层向上查找, 就能够知道变量的引用关系了
// useScope.js const Scope = require('./scope') var a = 1 function one() { var b = 2 function two() { var c = 3 console.log(a, b, c) } two() } one() // 构建scope chain const globalScope = new Scope({ name: 'global', parent: null, }) const oneScope = new Scope({ name: 'one', parent: globalScope, }) const twoScope = new Scope({ name: 'two', parent: oneScope, }) globalScope.add('a') oneScope.add('b') twoScope.add('c') console.log(twoScope.findDefiningScope('a')) console.log(oneScope.findDefiningScope('c')) console.log(globalScope.findDefiningScope('d')) // 输出结果 // Scope { name: 'global', parent: null, names: [ 'a' ] } // null // null
- module
rollup将模块之间的依赖关系进行分析之后会生成一个moduleGraph进行存储
二. 简易实现
1. 入口
对于rollup来讲, 最核心的参数则是entry和output, 一个控制了入口, 一个控制了输出
假设我们对rollup是这样调用的
const path = require('path') const rollup = require('../../src/rollup') const entry = path.resolve(__dirname, 'src/index.js') rollup(entry, 'dest/bundle.js')
在入口中, 我们如要从入口开始进行分析 摇树和打包操作, 并输出到出口
const Bundle = require('./bundle') function rollup(entry, filename) { const bundle = new Bundle({ entry, }) bundle.build(filename) } module.exports = rollup
2. Bundle
bundle是整个构建中的下一阶段, rollup处理完毕入参, 插件等信息后会通过bundle来启动整个的构建流程
const path = require('path') class Bundle { constructor(options) { this.entryPath = path.resolve(options.entry.replace(/\.js$/, '') + '.js') this.modules = {} } build(filename) { console.log(this.entryPath, filename) } } module.exports = Bundle
bundle的核心能力是:
- 获取入口文件内容, 包装成module, 生成抽象语法树
- 对入口文件抽象语法树进行依赖解析
- 生成目标代码
- 写入目标文件
在bundle对象中, 他们被实现在build函数中
const { readFileSync, writeFileSync } = require('fs') const { resolve } = require('path') const Module = require('./module') const MagicString = require('magic-string') class Bundle { constructor(options) { this.entryPath = resolve(options.entry.replace(/\.js$/, '') + '.js') this.modules = {} this.statements = [] } build(filename) { // 1. 获取入口文件的内容,包装成`module`,生成抽象语法树 const entryModule = this.fetchModule(this.entryPath) // 2. 对入口文件抽象语法树进行依赖解析 this.statements = entryModule.expandAllStatements() // 3. 生成最终代码 const { code } = this.generate() // 4. 写入目标文件 writeFileSync(filename, code) } fetchModule(importee) { let route = importee if (route) { const code = readFileSync(route, 'utf-8') const module = new Module({ code, path: importee, bundle: this, }) return module } } generate() { const ms = new MagicString.Bundle() this.statements.forEach(statement => { const source = statement._source.clone() ms.addSource({ content: source, separator: '\n', }) }) return { code: ms.toString() } } } module.exports = Bundle
对于bundle来讲, 每个文件都是一个Module, rollup会将模块使用module类来进行包装处理
3. Module
module会将源代码解析成为抽象语法树, 然后将源代码挂载到节点上, 并提供展开修改方法
const { parse } = require('acorn') const MagicString = require('magic-string') const analyse = require('./ast/analyse') class Module { constructor({ code, path, bundle, }) { this.code = new MagicString(code, { filename: path, }) this.path = path this.bundle = bundle this.ast = parse(code, { ecmaVersion: 7, sourceType: 'module', }) this.analyse() } analyse() { analyse(this.ast, this.code, this) } expandAllStatements() { const allStatements = [] this.ast.body.forEach(statement => { const statements = this.expandStatement(statement) allStatements.push(...statements) }) return allStatements } expandStatement(statement) { statement._included = true const result = [] result.push(statement) return result } } module.exports = Module
module中调用了
ast/analyse
来对模块的内容进行解析和挂载// ./src/ast/analyse.js function analyse(ast, ms) { ast.body.forEach(statement => { Object.defineProperties(statement, { _source: { value: ms.snip(statement.start, statement.end) } }) }) } module.exports = analyse
这样就能够实现整个模块的解析处理, 并最终生成代码了
4. 打包阶段总结
其实从以上的流程我们可以总结出rollup的打包阶段的运行流了
- rollup处理好入参出参等参数后, 通过bundle的build函数启动了整个的构建流程
- build函数中, 会获取到入口的文件代码, 将入口文件代码内容new了一个Module对象
- module对象中, 会首先使用acorn来将源代码转化成为ast, 然后使用
ast/analyse
来将ast中的源码与ast结构进行对应起来, 将对应位置的源码文本塞入到_source中, 这样就形成了ast与源码之前的对应关系
- 使用module对象中expandAllStatements, 遍历所有的声明语句, 并将声明语句的数组进行返回
- 调用bundle的generate方法, 通过MagicString, 将_source中的代码内容进行拼接, 最终实现打包能力
其实本质上来讲, 核心就是
参数分析 > 通过依赖寻找模块 > 模块转AST > 进行依赖分析 > 拼接输出
5. tree-shaking
在rollup中, tree-shaking的本质是通过代码的静态分析, 分析模块代码中的导入(import), 变量声明(definition), 导出(export)
并在其模块的导入导出上下文中逐层查找, 如果有依赖关系, 则移除export声明(bundle的时候), 如果没有, 则移除掉此语句块.
以此来达到tree-shaking的目的
那么相应的, 我们也需要优化丰富我们之前的代码内容
首先, 我们需要在模块中对模块的导入导出和变量声明进行收集
// 在constructor中预先定义这三个对象 this.imports = {} // 导入的变量 this.exports = {} // 导出的变量 this.definitions = {} // 变量定义的语句 // 在analyze 中通过ast的遍历进行收集 analyse() { // 收集导入和导出变量 this.ast.body.forEach(node => { if (node.type === 'ImportDeclaration') { const source = node.source.value node.specifiers.forEach(specifier => { const { name: localName} = specifier.local const { name } = specifier.imported this.imports[localName] = { source, name, localName, } }) } else if (node.type === 'ExportNamedDeclaration') { const { declaration } = node if (declaration.type === 'VariableDeclaration') { const { name } = declaration.declarations[0].id this.exports[name] = { node, localName: name, expression: declaration, } } } }) analyse(this.ast, this.code, this) // 收集所有语句定义的变量,建立变量和声明语句之间的对应关系 this.ast.body.forEach(statement => { Object.keys(statement._defines).forEach(name => { this.definitions[name] = statement }) }) }
其次, 我们需要在expandAllStatements调用的时候, 同步将其模块依赖进行递归处理, 以使其生成树状的依赖关系
其中expandAllStatement遍历调用了expandStatement, 用于处理每个ast节点
在expandStatement中遍历了_dependsOn(也就是其依赖的变量列表), 遍历变量列表
如果依赖的变量在导入变量中, 则根据变量内容来查找import语句中的导入变量, 然后根据import语句的导入源找到源模块, 通过源模块找到了其导出的变量,
否则从当前作用域中的definitions中找到这个声明, 如果声明之前没有_included(已经包含在输出语句中), 则将继续调用expandStatement
expandStatement(statement) { statement._included = true const result = [] const dependencies = Object.keys(statement._dependsOn) dependencies.forEach(name => { const definition = this.define(name) result.push(...definition) }) result.push(statement) return result } define(name) { if (hasOwn(this.imports, name)) { const importDeclaration = this.imports[name] const mod = this.bundle.fetchModule(importDeclaration.source, this.path) const exportDeclaration = mod.exports[importDeclaration.name] if (!exportDeclaration) { throw new Error(`Module ${mod.path} does not export ${importDeclaration.name} (imported by ${this.path})`) } return mod.define(exportDeclaration.localName) } else { let statement = this.definitions[name] if (statement && !statement._included) { return this.expandStatement(statement) } else { return [] } } }
这些下划线的变量其实是在analyse步骤中进行处理和分析的
analyse定义了四个内置下划线变量
_source
: 代表当前区块的源代码
_defines
: 代表当前模块定义的变量
_dependsOn
: 代表当前模块没有定义的变量(外部依赖的变量)
_included
: 是否已经包含在输出语句中, 包含在其中的才会被输出
const Scope = require('./scope') const walk = require('./walk') function analyse(ast, ms) { let scope = new Scope() // 创建作用域链、 ast.body.forEach(statement => { function addToScope(declarator) { const { name } = declarator.id scope.add(name) if (!scope.parent) { // 如果没有上层作用域,说明是模块内的定级作用域 statement._defines[name] = true } } Object.defineProperties(statement, { _source: { // 源代码 value: ms.snip(statement.start, statement.end), }, _defines: { // 当前模块定义的变量 value: {}, }, _dependsOn: { // 当前模块没有定义的变量,即外部依赖的变量 value: {}, }, _included: { // 是否已经包含在输出语句中 value: false, writable: true, }, }) // 收集每个语句上定义的变量,创建作用域链 walk(statement, { enter(node) { let newScope switch (node.type) { case 'FunctionDeclaration': const params = node.params.map(p => p.name) addToScope(node) newScope = new Scope({ parent: scope, params, }) break; case 'VariableDeclaration': node.declarations.forEach(addToScope) break; } if (newScope) { Object.defineProperty(node, '_scope', { value: newScope, }) scope = newScope } }, leave(node) { if (node._scope) { scope = scope.parent } }, }) }) ast._scope = scope // 收集外部依赖的变量 ast.body.forEach(statement => { walk(statement, { enter(node) { if (node.type === 'Identifier') { const { name } = node const definingScope = scope.findDefiningScope(name) // 作用域链中找不到 则说明为外部依赖 if (!definingScope) { statement._dependsOn[name] = true } } }, }) }) } module.exports = analyse
自此, 我们就能够成功实现:
- 对模块逐一进行分析, 获知到模块的import进来的内容, export出去的内容, 在当前模块上下文中定义了的变量, 当前模块中使用了的变量, 在当前模块中修改了的变量, 等
- 根据scope进行逐层查找, 以此来获知当前模块中使用的变量在import语句中是否已经存在, 存在则将该语句标记为_dependsOn, 也就是已经被依赖的内容
- 分析完成依赖关系之后, 会进行打包操作, 将import语句中的内容直接注入替换掉该import语句, 同时移除掉export语句
- 如果上层变量与下层变量有所冲突, 则需要在后续合并的时候进行重命名
这样就完成了rollup的基础打包工作
总结来说, rollup与webpack不同的是
- 处理维度不同
webpack的核心在于将模块塞到模块数组中用于进行调度, webpack处理的核心维度是"模块"
rollup核心处理的是"语句", rollup会将模块的上下文语句进行逐一分析处理, 将会从入口开始, 打包所有的模块, 并清除掉输入输出语句
- 流程设计不同
rollup的核心在执行流上, 只提供了有限的hooks流程来支持扩展
webpack则是完全设计在可扩展性上的架构, 以至于核心的compile和compilation都是继承自Tapable的, 但这样也会造成代码可读性极差, 阅读源码中的一大堆的回调与tap流程让人摸不着头脑
6. 参考文献
三. 写一个plugin
1. 前置知识: Rollup中的Hooks
1. rollup中的hooks类型
- sync: 默认情况下的hook
- async: 返回的是一个Promise
- first: hooks会一次执行, 直到一个hook返回了一个非null或undefined的值
- sequential: 顺序执行hooks. 当一个hook是同步hook时, 会等这个Promise resolve了之后才会向下执行
- parallel: 所有的hooks同步执行
2. 构建阶段的Hooks
整个流程梳理成文字如下:
- 入口options解析参数返回参数, 启动构建buildStart
- resolveId是构建模块的入口, 不论是启动第一个构建还是解析后续module, 还是解析异步包都会走到这里, 通过这里来定义解析行为
- 当当前模块包含在external的时候, 直接结束, 如果没有, 则通过load来进行模块加载
- 加载完成模块之后, 会判断有没有转换缓存, 有缓存则进入shouldTransformCachedModule来进行缓存判断, 缓存有效则跳过transform步骤, 否则会进行transform
- transform会进行ast的转换, 也可以给正在转换的模块增加一些参数
- 转换完成之后, 会调用moduleParsed, 代表模块转换完成了, 如果当前模块有导入, 则看是否有缓存, 有缓存则跳过resolveId步骤, 直接进入load, 否则需要进入resolveId
- 看是否有异步的导入, 有的话会调用resolveDynamicImport, 进行异步模块解析, 一样的会当做resolveId一样解析异步模块
- 如果没有导入, 则结束流程, 调用buildEnd
1. options
Type:
(options: InputOptions) => InputOptions | null
Kind: async, sequential
options hook会以入口配置为参数, 可以处理options中的参数内容, 并将处理后的参数进行返回
2. buildStart
Type:
(options: InputOptions) => void
Kind: async, parallel
每次rollup.rollup的构建的时候都会进行调用, 在这个阶段可以很好的拿到构建的最终options的结果
3. resolveId
Type:
(source: string, importer: string | undefined, options: {isEntry: boolean, custom?: {[plugin: string]: any}) => string | false | null | {id: string, external?: boolean | "relative" | "absolute", moduleSideEffects?: boolean | "no-treeshake" | null, syntheticNamedExports?: boolean | string | null, meta?: {[plugin: string]: any} | null}
Kind: async, first
如果从入口解析,或者是再次构建一个module、或者一个异步导入的包时,都会调用这里。该hook,用来自定义解析行为。
入参包括三个参数:source、importer、options:
- source:解析的模块的名称
- importer : 导入这个模块的上级模块
- options:一些参数,比如标记了是否为入口文件。如果是入口文件,则没有importer
返回值:
- 返回个字符串:一般是包名。作为id,传给load钩子函数
- 返回null:不处理
- 返回false:说明这个包配置了external参数,不进行打包
4. load
Type:
({id: string, moduleSideEffects?: boolean | 'no-treeshake' | null, syntheticNamedExports?: boolean | string | null, meta?: {[plugin: string]: any} | null, resolveDependencies?: boolean}) => Promise<ModuleInfo>
自定义的加载器。可以将代码生成AST,也可以在返回的配置中,来配置所导入的模块是有副作用等(可以控制是否tree-shaking)。
入参:
- id: 模块的绝对路径
出参:
- 返回字符串:返回的字符串将作为模块的code(可以用来在构建修改模块的代码)
- 返回null:什么都不做,延顺给下一步
- 返回对象:{code,ast,map,...} 等
5. transform
Type:
(code: string, id: string) => string | null | {code?: string, map?: string | SourceMap, ast? : ESTree.Program, moduleSideEffects?: boolean | "no-treeshake" | null, syntheticNamedExports?: boolean | string | null, meta?: {[plugin: string]: any} | null}
Kind: async, sequential
看起来和load钩子函数没什么区别,就是可以转译单个的模块。可以在这一步生成ast,也可以给正在转译的模块增加一些参数。
入参:
- code:load钩子函数返回的值,默认情况下是模块的原始文本
- id:模块的绝对路径
出参:
- 字符串 : 代替code,传递给下一个
- null : 什么都不做,给下一个函数
- 对象:{code , ast , moduleSideEffects, ...}
6. moduleParsed
Type:
(moduleInfo: ModuleInfo) => void
Kind: async, parallel
模块被rullup完全解析后会触发(源码里有个module类,就是给这儿服务的)。如果模块解析失败,则会在这个钩子中报错,例如在文件中导入了css文件。
入参
- module info : 由代码变为一个module对象(在rollup中,使用自定义的module类进行实例化的)后的module信息
7. resolveDynamicImport
Type:
(specifier: string | ESTree.Node, importer: string) => string | false | null | {id: string, external?: boolean}
Kind: async, first
处理异步的import。在上一步moduleParsed钩子中,模块变为module对象。如果这个module对象中,还有使用import 函数导入的模块,则会触发这个hook。否则将构建结束。在发现异步import后,下一个过程将再次回到 resolveID或load。
3. 输出阶段的Hooks
输出阶段钩子用来获取bundle的信息,或者修改一个构建。每次调用
bundle.generate(outputOptions)
和bundle.write(outputOptions)的时候,都会触发输出阶段钩子。使用了输出阶段钩子的插件,可以单独设置在输出参数中(output.plugins参数)。1. outputOptions
接受输出参数。对于使用API方式调用时,bundle.generate 和 bundle.write 接受的参数是一样的,都是输出参数。
入参:options ,输出参数
出参:
- null : 不做什么
- options : 对输出参数的覆盖
2. renderStart
每次bundle.generate 和 bundle.write调用时都会被触发。
入参:
- outputOptions:打包输出参数
- inputOptions : 打包入口参数
3. banner footer intro outro四个钩子
当输出的配置中,配置了这四项时,会触发的钩子。
banner 和 footer 是分别向代码的开头和结尾插入代码;
intro 和 outro 是分别向包装器的内部和外部插入代码。
4. renderDynamicImport
如果是异步的import, 则通过这里来进行render触发
入参:
- format
- moduleId
- targetModuleId
- customResolution
5. augmentChunkHash
经过renderStart +banner/footer 等阶段后,将会生成chunk。 每个chunk再接下来的步骤,都会先经历这个hook。
这个hook,用来给chunk增加hash。如果返回false类型的值,则不修改;如果是true类型的值,则用这个值,更新原来的chunk hash。
入参:chunkInfo
出参:string
6. renderChunk
转译单个的chunk时触发。rollup输出每一个chunk文件的时候都会调用。
入参:
- code:输出的代码
- chunk:chunk的信息
- options:输出的配置项
出参:
- string:写入到文件的string
- null : 什么也不做,不修改原本的代码
- 对象:{code ,map} ,code 和直接输出string 是一样的
7. generateBundle
在调用 bundle.generate 后,或者在调用 bundle.write 之前立即触发这个hook。
入参:
- options : output options
- bundle : chunk info
- isWrite : Boolean
8. writeBundle
在调用 bundle.write后,所有的chunk都写入文件后,最后会调用一次 writeBundle 。
入参:
- options : output options
- bundle : chunk info
- isWrite : Boolean