Rollup概念与运行原理
2022-5-14
| 2023-2-19
0  |  0 分钟
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. 基本运行原理
  1. 核心依赖包:
  • MagicString: MagicString是一个非常轻量的操作包, 能够让rollup实现对无用的代码移除, 替换等等工作
  • acorn: 是一个轻量高性能的ast解析工具, 能够将源码进行解析以生成ast对象
  1. 基本流程
  • 初始化变量和参数, 初始化插件
  • 根据AST的import语句使用情况, 进行依赖分析, 生成moduleGraph
  • 处理moduleGraph的关系, 根据scope来向上查找定义域, 通过这个方式来进行tree-shaking
  • 将模块内容bundle到一起, 写入到目标文件
  1. 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
  1. 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的核心能力是:
  1. 获取入口文件内容, 包装成module, 生成抽象语法树
  1. 对入口文件抽象语法树进行依赖解析
  1. 生成目标代码
  1. 写入目标文件
在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的打包阶段的运行流了
  1. rollup处理好入参出参等参数后, 通过bundle的build函数启动了整个的构建流程
  1. build函数中, 会获取到入口的文件代码, 将入口文件代码内容new了一个Module对象
  1. module对象中, 会首先使用acorn来将源代码转化成为ast, 然后使用ast/analyse来将ast中的源码与ast结构进行对应起来, 将对应位置的源码文本塞入到_source中, 这样就形成了ast与源码之前的对应关系
  1. 使用module对象中expandAllStatements, 遍历所有的声明语句, 并将声明语句的数组进行返回
  1. 调用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
自此, 我们就能够成功实现:
  1. 对模块逐一进行分析, 获知到模块的import进来的内容, export出去的内容, 在当前模块上下文中定义了的变量, 当前模块中使用了的变量, 在当前模块中修改了的变量, 等
  1. 根据scope进行逐层查找, 以此来获知当前模块中使用的变量在import语句中是否已经存在, 存在则将该语句标记为_dependsOn, 也就是已经被依赖的内容
  1. 分析完成依赖关系之后, 会进行打包操作, 将import语句中的内容直接注入替换掉该import语句, 同时移除掉export语句
  1. 如果上层变量与下层变量有所冲突, 则需要在后续合并的时候进行重命名
这样就完成了rollup的基础打包工作
总结来说, rollup与webpack不同的是
  1. 处理维度不同
webpack的核心在于将模块塞到模块数组中用于进行调度, webpack处理的核心维度是"模块"
rollup核心处理的是"语句", rollup会将模块的上下文语句进行逐一分析处理, 将会从入口开始, 打包所有的模块, 并清除掉输入输出语句
  1. 流程设计不同
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

notion image
整个流程梳理成文字如下:
  1. 入口options解析参数返回参数, 启动构建buildStart
  1. resolveId是构建模块的入口, 不论是启动第一个构建还是解析后续module, 还是解析异步包都会走到这里, 通过这里来定义解析行为
  1. 当当前模块包含在external的时候, 直接结束, 如果没有, 则通过load来进行模块加载
  1. 加载完成模块之后, 会判断有没有转换缓存, 有缓存则进入shouldTransformCachedModule来进行缓存判断, 缓存有效则跳过transform步骤, 否则会进行transform
  1. transform会进行ast的转换, 也可以给正在转换的模块增加一些参数
  1. 转换完成之后, 会调用moduleParsed, 代表模块转换完成了, 如果当前模块有导入, 则看是否有缓存, 有缓存则跳过resolveId步骤, 直接进入load, 否则需要进入resolveId
  1. 看是否有异步的导入, 有的话会调用resolveDynamicImport, 进行异步模块解析, 一样的会当做resolveId一样解析异步模块
  1. 如果没有导入, 则结束流程, 调用buildEnd

1. options

Type: (options: InputOptions) => InputOptions | nullKind: async, sequential
options hook会以入口配置为参数, 可以处理options中的参数内容, 并将处理后的参数进行返回

2. buildStart

Type: (options: InputOptions) => voidKind: 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) => voidKind: 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

notion image
输出阶段钩子用来获取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
工程化
  • Rollup
  • 工程化
  • Vuejs设计与实现读书笔记-响应式Esbuild概念,使用与自定义插件实现
    目录