从零到一编写rollup插件

一、前言

Rollup 是一个 JavaScript 模块打包器,它专注于将现代 JavaScript 模块按需打包成更小、更高效的输出。相比于其他打包工具,Rollup 更适用于构建针对现代浏览器的库和应用程序。Rollup拥有
各种各样的优势,包括:Tree Shaking,ES6 模块支持,输出单一文件,代码分割等。Rollup通过其强大的插件系统,利用插件系统开发者可以实现任意打包自定义功能。接下来会介绍一下如何从零到一开发一个rollup插件。

二、前期准备

  • 如果你已经拥有rollup相关项目,则可以直接进入第三部分插件开发。
  • 你跟着我下面的指引,建立demo工程。

1、环境依赖

环境信息
node: “14.18.1”
rollup: “^3.29.4”
“@babel/core”: “^7.23.7”
“@babel/plugin-transform-runtime”: “^7.23.7”
“rollup-plugin-babel”: “^4.4.0”
lerna: “^6.4.1” // 用于项目管理,非必要

2、项目搭建

目录树

rollup-test
    ├─ README.md
    ├─ build
    │  └─ rollup.config.js
    ├─ dist
    │  └─ bundle.js
    ├─ main.js
    ├─ package-lock.json
    └─ package.json

rollup配置文件

  • 初始搭建先配置一个babel降级插件,方便验证项目是否搭建成功。
  • 其中产物的输出目录在 dist,后续可以在dist目录中找到对应产物。
// rollup.config.js
import babel from 'rollup-plugin-babel'

export default {
  input: 'main.js', // 入口文件
  output: {
    file: 'dist/bundle.js', // 输出文件
    format: 'esm' // 输出格式
  },
  plugins: [
    babel({
        exclude: 'node_modules/**', // 忽略 node_modules 下的文件
        runtimeHelpers: 'true',
        presets: [
          ['@babel/preset-env']
        ],
        plugins: [
          '@babel/plugin-transform-runtime' // 使用 @babel/plugin-transform-runtime
        ]
      })
  ]
};

构建测试

完成搭建后,可以执行构建命令尝试构建,建议把构建命令放入package.json,方便后续使用。

// 把构建命令放入package.json
"scripts": {
  "build": "npx rollup -c ./build/rollup.config.js"
}

执行构建命令:

npm run build

构建后可以进行产物对比:

// 构建前:
let a = {}
let b = a?.rollup

// 构建后代码已经降级,证明构建成功。
var a = {};
a === null || a === void 0 ? void 0 : a.rollup;

至此我们完成了项目搭建,接下来就正式进入插件开发正文啦!

三、rollup插件开发

在开发rollup之前,我们必须先了解rollup插件的机制,才能帮助我们更好的发挥rollup插件的作用。以下为官方对rollup插件的描述:

Rollup 插件是一个对象,具有属性、构建钩子和输出生成钩子 中的一个或多个,并遵循我们的 约定。插件应作为一个导出一个函数的包进行发布,该函数可以使用插件特定的选项进行调用并返回此类对象。

根据描述得知,rollup插件主要组成部分其实是属性和不同的钩子(构建钩子,输出生成钩子),其中属性只有两个:name和version,不同的钩子作用在不同的构建生命周期。我们通过官方例子简单了解以上三种组成部分:

// rollup-plugin-my-example.js
export default function myExample () {
  return {
    name: 'my-example', // 此名称将出现在警告和错误中
    resolveId ( source ) {
      if (source === 'virtual-module') {
        // 这表示 rollup 不应询问其他插件或
        // 从文件系统检查以找到此 ID
        return source;
      }
      return null; // 其他ID应按通常方式处理
    },
    load ( id ) {
      if (id === 'virtual-module') {
        // "virtual-module"的源代码
        return 'export default "This is virtual!"';
      }
      return null; // 其他ID应按通常方式处理
    }
  };
}

从上面例子可以看到,这个插件的name属性为:my-example。并且在两个不同钩子(resolveId和load)中实现了不同的业务逻辑。

约定

官方对插件的开发有以下建议的约定,开发时可以关注:

  • 插件应该有一个明确的名称,并以rollup-plugin-作为前缀。
  • 在package.json中包含rollup-plugin关键字。
  • 插件应该被测试,我们推荐 mocha 或 ava,它们支持 Promise。
  • 可能的话,使用异步方法,例如 fs.readFile 而不是 fs.readFileSync
  • 用英文文档描述你的插件。
  • 确保如果适当,你的插件输出正确的源映射。
  • 如果插件使用“虚拟模块”(例如用于辅助函数),请使用\0前缀模块 ID。这可以防止其他插件尝试处理它。

属性

rollup插件的属性值一共有2个:name和version。

  • name(string):插件的名称,用于在警告和错误消息中标识插件。
  • version(string):插件的版本,用于插件间通信场景。

构建钩子

构建钩子的使用在rollup插件中十分重要。钩子是在构建的各个阶段调用的函数。钩子可以影响构建的运行方式,提供关于构建的信息,或在构建完成后修改构建。有不同种类的钩子:

  • async:该钩子也可以返回一个解析为相同类型的值的 Promise;否则,该钩子被标记为 sync。
  • first:如果有多个插件实现此钩子,则钩子按顺序运行,直到钩子返回一个不是 null 或 undefined 的值。
  • sequential:如果有多个插件实现此钩子,则所有这些钩子将按指定的插件顺序运行。如果钩子是 async,则此类后续钩子将等待当前钩子解决后再运行。
  • parallel:如果有多个插件实现此钩子,则所有这些钩子将按指定的插件顺序运行。如果钩子是 async,则此类后续钩子将并行运行,而不是等待当前钩子。

(1)使用对象形式的钩子

除了函数之外,钩子也可以是对象。在这种情况下,实际的钩子函数(或 banner/footer/intro/outro 的值)必须指定为 handler。这允许你提供更多的可选属性,以改变钩子的执行:

  • order: “pre” | “post” | null
    • 影响执行顺序

如果有多个插件实现此钩子,则可以先运行此插件(”pre”),最后运行此插件(”post”),或在用户指定的位置运行(没有值或 null)。如果优先级重复,则最终根据用户指定的先后顺序执行。

// 官方示例
export default function resolveFirst() {
	return {
		name: 'resolve-first',
		resolveId: {
			order: 'pre',
			handler(source) {
				if (source === 'external') {
					return { id: source, external: true };
				}
				return null;
			}
		}
	};
}
  • sequential: boolean
    • 不要与其他插件的相同钩子并行运行此钩子,独立运行。
    • 仅可用于 parallel 钩子。
    • 可以将此选项与 order 结合使用进行排序。

使用此选项将使 Rollup 等待所有先前插件的结果,然后执行插件钩子,然后再次并行运行剩余的插件。例如,当你有插件 A、B、C、D、E,它们都实现了相同的并行钩子,并且中间插件 C 具有 sequential: true 时,Rollup 将首先并行运行 A + B,然后单独运行 C,然后再次并行运行 D + E。

// 官方示例
import { resolve } from 'node:path';
import { readdir } from 'node:fs/promises';

export default function getFilesOnDisk() {
	return {
		name: 'getFilesOnDisk',
		writeBundle: {
			sequential: true,
			order: 'post',
			async handler({ dir }) {
				const topLevelFiles = await readdir(resolve(dir));
				console.log(topLevelFiles);
			}
		}
	};
}

在整个生命周期中,第一个钩子为options,最后一个钩子为buildEnd。以下为官方对整个生命周期的图例说明:
从零到一编写rollup插件

常用钩子

rollup共支持12个构建钩子,15个输出生成钩子。各个钩子详细信息可以参考官方文档:cn.rollupjs.org/plugin-deve…。以下会介绍日常会用到的几个钩子。

(1)options

在创建配置选项时触发,允许修改或扩展 Rollup 配置。

类型 (options: InputOptions) => InputOptions | null
类别 async,sequential
上一个钩子 此为第一个钩子
下一个钩子 buildStart

替换或操作传递给 rollup.rollup 的选项对象。返回 null 不会替换任何内容。如果只需要读取选项,则建议使用 buildStart 钩子,因为该钩子可以访问所有 options 钩子的转换考虑后的选项。
此钩子不具有大多数 插件上下文 实用程序函数的访问权限,因为它可能在 Rollup 完全配置之前运行。唯一支持的属性是 this.meta 以及 this.error、this.warn、this.info 和 this.debug 用于记录和错误。

options(options) {
  // 修改或扩展配置选项
  return options;
}

(2)resolveId

:::info
在解析模块标识符时触发,允许修改模块的路径或返回其他模块标识符。
:::

类型 ResolveIdHook(参见下方构造函数)
类别 async, first
上一个钩子 如果我们正在解析入口点,则为 buildStart,如果我们正在解析导入,则为 moduleParsed,否则作为 resolveDynamicImport 的后备。此外,此钩子可以通过调用 this.emitFile 来在构建阶段的插件钩子中触发以产出入口点,或随时调用 this.resolve 手动解析 id。
下一个钩子 如果尚未加载解析的 id,则为 load,否则为 buildEnd。
type ResolveIdHook = (
  source: string,
	importer: string | undefined,
	options: {
		attributes: Record<string, string>;
		custom?: { [plugin: string]: any };
		isEntry: boolean;
	}
) => ResolveIdResult;

type ResolveIdResult = string | null | false | PartialResolvedId;

interface PartialResolvedId {
	id: string;
	external?: boolean | 'absolute' | 'relative';
	attributes?: Record<string, string> | null;
	meta?: { [plugin: string]: any } | null;
	moduleSideEffects?: boolean | 'no-treeshake' | null;
	resolvedBy?: string | null;
	syntheticNamedExports?: boolean | string | null;
}

以以下代码为例:

// main.js
import {foo} from '../bar.js'
  • source:’../bar.js’
  • importer:’main.js’
    • 是导入模块的解析完全后的 id。在解析入口点时,importer 通常为 undefined。
resolveId(source, importer) {
  // 修改模块的路径或返回其他模块标识符
  return source;
}

(3)load

定义一个自定义加载器。

类型 (id: string) => LoadResult
类别 async, first
上一个钩子 已解析加载的 id 的 resolveId 或 resolveDynamicImport。此外,此钩子可以通过调用 this.load 来从插件钩子中的任何位置触发预加载与 id 对应的模块
下一个钩子 如果未使用缓存,或者没有具有相同 code 的缓存副本,则为 transform,否则为 shouldTransformCachedModule
type LoadResult = string | null | SourceDescription;

interface SourceDescription {
	code: string;
	map?: string | SourceMap;
	ast?: ESTree.Program;
	attributes?: { [key: string]: string } | null;
	meta?: { [plugin: string]: any } | null;
	moduleSideEffects?: boolean | 'no-treeshake' | null;
	syntheticNamedExports?: boolean | string | null;
}
  • moduleSideEffects
    • false:并且没有其他模块从该模块导入任何内容,则即使该模块具有副作用,该模块也不会包含在产物中。
    • true:则 Rollup 将使用其默认算法包含模块中具有副作用的所有语句(例如修改全局或导出变量)。
    • no-treeshake:关闭此模块的除屑优化,并且即使该模块为空,也将在生成的块之一中包含它。
    • null:moduleSideEffects 将由第一个解析此模块的 resolveId 钩子,treeshake.moduleSideEffects 选项或最终默认为 true 确定。
  • attributes:包括导入此模块时使用的导入属性,它们不会影响产物模块的呈现,而是用于文档目的。
    • null:则 attributes 将由第一个解析此模块的 resolveId 钩子或此模块的第一个导入中存在的断言确定。

(4)transform

可以被用来转换单个模块。

类型 (code: string, id: string) => TransformResult
类别 async, sequential
上一个钩子 load,用于加载当前处理的文件。如果使用缓存并且该模块有一个缓存副本,则为 shouldTransformCachedModule,如果插件为该钩子返回了 true
下一个钩子 moduleParsed,一旦文件已被处理和解析
type TransformResult = string | null | Partial<SourceDescription>;

interface SourceDescription {
	code: string;
	map?: string | SourceMap;
	ast?: ESTree.Program;
	attributes?: { [key: string]: string } | null;
	meta?: { [plugin: string]: any } | null;
	moduleSideEffects?: boolean | 'no-treeshake' | null;
	syntheticNamedExports?: boolean | string | null;
}

(5)buildStart

在每个 rollup.rollup 构建上调用。当你需要访问传递给 rollup.rollup() 的选项时,建议使用此钩子,因为它考虑了所有 options 钩子的转换,并且还包含未设置选项的正确默认值。

类型 (options: InputOptions) => void
类别 async, parallel
上一个钩子 options
下一个钩子 并行解析每个入口点的 resolveId

(6)buildEnd

在 Rollup 完成产物但尚未调用 generate 或 write 之前调用;也可以返回一个 Promise。如果在构建过程中发生错误,则将其传递给此钩子。

类型 (error?: Error) => void
类别 async, parallel
上一个钩子 moduleParsed、resolveId 或 resolveDynamicImport
下一个钩子 输出生成阶段的 outputOptions,因为这是构建阶段的最后一个钩子

第三节的内容大部分是rollup官网文档,如果有兴趣详细了解的同学可以直接查看:cn.rollupjs.org/plugin-deve…

四、实践出真知

理论知识我们学了个懵懵懂懂,接下来就需要在实际的开发中实践这些知识。

1、需求背景

开始之前我们先预设一个场景:在多端的构建中我们会经常遇到,需要根据不同的环境进行打包,不同环境可能逻辑不一样,但是实际所有的代码都在一个文件中。因此需要一种特殊的DSL(Domain-Specific Language),在源码中把不同环境的代码块标识出来,然后构建时再根据环境渠道,删减其余环境对应的代码,保留当前环境的代码,最后删除所有DSL标识。大概效果如下:

// 源码
//node-env-start
  // 一些node环境逻辑
  let a = 'node'
//node-env-end
  
// web-env-start
  // 一些web环境逻辑
  let a = 'web'
//web-env-end

//mp-env-start
  // 一些mp环境逻辑
  let a = 'mp'
//mp-env-end

// 构建产物:渠道为node时
let a = 'node'

其中 << xxxx-env-start >> 这类标签就是用来区分不同环境的DSL,当我们使用node环境进行打包的时候,其余环境的代码就都会被舍弃,只留下node标签下的代码内容。
我们尝试利用rollup的插件来解决这个问题。

2、rollup插件开发

首先由于这里是需要对源码进行一次转换,因此选择使用transform这个钩子来处理:

(1)在build目录下新建 rollup.plugin.js

从零到一编写rollup插件

(2)在rollup配置中引入并使用此插件

后续插件的开发过程需要一定的调试,因此先把当前插件对象抛出,并且在rollup中使用,能比较方便的进行后续调试。

// rollup.plugin.js
console.log('进入测试逻辑')

function dslTransform() {
    return {
        name: 'dslTransform',
        transform: {
            handler() {
                // todo
            }
        }
    }
} 

export default dslTransform
// rollup.config.js 中配置变更
// rollup.config.js
import babel from 'rollup-plugin-babel'
import dslTransform from './rollup.plugin.js';

export default {
  input: 'main.js', // 入口文件
  output: {
    file: 'dist/bundle.js', // 输出文件
    format: 'esm' // 输出格式
  },
  plugins: [
    dslTransform(),
    babel({
        exclude: 'node_modules/**', // 忽略 node_modules 下的文件
        runtimeHelpers: 'true',
        presets: [
          ['@babel/preset-env']
        ],
        plugins: [
          '@babel/plugin-transform-runtime' // 使用 @babel/plugin-transform-runtime
        ]
      })
  ]
};

顺利的话,控制台会输出打印的内容:
从零到一编写rollup插件

(3)获取构建命令中的渠道参数

接下来我们需要知道打包时的环境渠道参数,假设我们渠道参数输入格式为:

npx rollup -c ./build/rollup.config.js --env=node

// 可以把上述命令放到package.json的自定义命令中,与之前的构建命令统一
"scripts": {
  "build:node": "npx rollup -c ./build/rollup.config.js --env=node",
  "build": "npx rollup -c ./build/rollup.config.js"
}

通过node提供的 process.argv 我们就可以获取到具体的构建参数。并且我们可以利用minimist模块更好的帮助我们解析命令行入参:

import minimist from 'minimist'
console.log('进入测试逻辑')

let cmdArgs = minimist(process.argv.slice(2))
let env = cmdArgs.env

function dslTransform() {
    return {
        name: 'dslTransform',
        transform: {
            handler(code, id) {
                // todo
                console.log(env)
            }
        }
    }
} 

export default dslTransform

从零到一编写rollup插件

(4)执行相关转换逻辑

通过transform钩子,我们把需要执行的代码转换一下,删除其余环境的代码,保留当前环境的代码。
以下为插件代码:

import minimist from 'minimist'
console.log('进入测试逻辑')

let cmdArgs = minimist(process.argv.slice(2))
let env = cmdArgs.env

let tagConfig = {
    node: {
        start: '//node-env-start',
        end: '//node-env-end'
    },
    web: {
        start: '//web-env-start',
        end: '//web-env-end'
    },
    mp: {
        start: '//mp-env-start',
        end: '//mp-env-end'
    }
}

function dslTransform() {
    return {
        name: 'dslTransform',
        order: 'pre',
        transform: {
            handler(code) {
                if(!env) {
                    code: code
                }

                let newCode = code

                // 删除不是当前环境的tag信息
                let temTag = Object.assign({}, tagConfig) // 复制一份避免操作源对象
                !!temTag[env] && delete temTag[env]

                for(let i in temTag) {
                    let regexPattern = new RegExp(`${temTag[i].start}([\s\S]*?)${temTag[i].end}`, 'gm')
                    newCode = newCode.replace(regexPattern, '')
                }

                // 删除当前环境标识 demo不实现容错,默认全支持
                let envRegStart = new RegExp(`${tagConfig[env].start}`, 'gm')
                let envRegEnd = new RegExp(`${tagConfig[env].end}`, 'gm')

                newCode = newCode.replace(envRegStart, '').replace(envRegEnd, '')

                return {
                    code: newCode
                }
            }
        }
    }
}

export default dslTransform

以下为需要转换的业务代码(main.js):

//node-env-start
  // 一些node环境逻辑
  let a = 'node'
//node-env-end
  
//web-env-start
  // 一些web环境逻辑
  let a = 'web'
//web-env-end

//mp-env-start
  // 一些mp环境逻辑
  let a = 'mp'
//mp-env-end
console.log(a)
console.log('产物输出')

完美,现在可以执行一下构建命令:npx rollup -c ./build/rollup.config.js –env=node。
从零到一编写rollup插件
:::info
obbs!不对劲,我transform逻辑中明明已经删除了对应的代码了,为什么编译的时候还是会出现重复变量定义的问题,感觉我的插件逻辑没生效呀。
:::

(5)选择正确的钩子

上面问题其实很简单,就是我们钩子使用错。

Rollup在打包过程中,会先做一个预处理,将所有的ES模块进行预编译。在预编译阶段,它会检查所有代码的语法正确性,在这个阶段,因为代码的预处理还没有发生,所以它会认为你重复定义了变量。

因此我们使用更改使用load钩子

import minimist from 'minimist'
import fs from 'fs'
console.log('进入测试逻辑')

let cmdArgs = minimist(process.argv.slice(2))
let env = cmdArgs.env

let tagConfig = {
    node: {
        start: '//node-env-start',
        end: '//node-env-end'
    },
    web: {
        start: '//web-env-start',
        end: '//web-env-end'
    },
    mp: {
        start: '//mp-env-start',
        end: '//mp-env-end'
    }
}

function dslTransform() {
    return {
        name: 'dslTransform',
        order: 'pre',
        load: {
            handler(id) {
                let newCode =  fs.readFileSync(id, 'utf-8')

                if(!env) {
                    return newCode
                }

                // 删除不是当前环境的tag信息
                let temTag = Object.assign({}, tagConfig) // 复制一份避免操作源对象
                !!temTag[env] && delete temTag[env]
                
                for(let i in temTag) {
                    let regexPattern = new RegExp(`${temTag[i].start}([\s\S]*?)${temTag[i].end}`, 'gm')
                    newCode = newCode.replace(regexPattern, '')
                }
                
                // 删除当前环境标识 demo不实现容错,默认全支持
                let envRegStart = new RegExp(`${tagConfig[env].start}`, 'gm')
                let envRegEnd = new RegExp(`${tagConfig[env].end}`, 'gm')
                
                newCode = newCode.replace(envRegStart, '').replace(envRegEnd, '')

                return {
                    code: newCode
                }
            }
        }
    }
}

export default dslTransform

执行一下构建命令:npx rollup -c ./build/rollup.config.js –env=node。从零到一编写rollup插件
检查一下bundle.js:

// 一些node环境逻辑
var a = 'node';
console.log(a);
console.log('产物输出');

也没问题,完成解决。

五、结语

当然上面只是很简单的一个插件的实践,希望能帮助到各位熟悉rollup的插件开发。如果有任何问题,欢迎在评论区一起讨论。

原文链接:https://juejin.cn/post/7328309942770909203 作者:Vincevii

(0)
上一篇 2024年1月27日 下午4:11
下一篇 2024年1月27日 下午4:21

相关推荐

发表回复

登录后才能评论