likes
comments
collection
share

[陈同学i前端] 一起学Rollup|构建工作流与插件机制

作者站长头像
站长
· 阅读数 37

前言

大家好,我是陈同学,一枚野生前端开发者,感谢各位的点赞、收藏、评论

Rollup一个用于JavaScript的模块打包器,与webpack齐名并依靠自身差异化的体系设计赢得了许多业界工作者的青睐,同时也在Vite的架构体系中发挥着重要作用

上一节文章中我们已经掌握Rollup打包的基础操作,但随着我们的业务发展,项目会出现各种各样的构建需求,比如:全局变量构建时替换、代码压缩、路径解析与别名,像这种业务场景相关的需求逻辑如果与核心打包逻辑放在一块,会对打包器源码造成侵入性,不利于后期的维护,故Rollup作者另外设计出插件机制以将关键打包节点的流程控制权交给开发者,开发者根据自身业务需要编写对应的钩子函数最终实现目标打包效果

本文阅读成本与收益如下:

阅读耗时:10mins

全文字数:9k+

预期效益

  • 学习Rollup两大构建工作流

  • 学习Rollup插件机制

  • 了解两大构建阶段中的插件

    • Build-Hook
    • Output-Hook
  • 了解插件的类型

钩子函数

钩子函数:

  • 定义了插件的执行逻辑

  • 声明了插件的作用阶段

两大构建工作流

Rollup打包构建流程主要包括两大步骤:

  • Build:主要负责创建模块依赖图,初始化各个模块的AST以及模块之间的依赖关系(解析各模块的内容及依赖关系)

  • Output:构建打包并输出最终产物

在不同的构建阶段,Rollup插件会有不同的插件工作流程

// src/index.js

import { add } from './tool';

console.log(add(1, 2));

// src/tool.js

export const add = (a, b) => a + b;

export const multi = (a, b) => a * b;

现有如上两个JS文件

Build

下面将通过JS-API执行第一步构建逻辑:解析文件后获得各个模块的内容及依赖关系

// build.js
const rollup = require("rollup");
const inputOptions = {
  input: "./src/index.js",
  external: [],
  plugins:[]
};
async function build() {
    const bundle = await rollup.rollup(inputOptions);
    // 打印各个模块的内容及依赖关系
    console.log(bundle); // 打印_1
    console.log(bundle.cache.modules); // 打印_2
}
build();

打印_1:

{
  cache: {
    modules: [ [Object], [Object] ],
    plugins: [Object: null prototype] {}
  },
  close: [AsyncFunction: close],
  closed: false,
  generate: [AsyncFunction: generate],
  watchFiles: [
    '/Users/frontend-starter/src/index.js',
    '/Users/frontend-starter/src/tool.js'
  ],
  write: [AsyncFunction: write]
}

打印_2:

[陈同学i前端] 一起学Rollup|构建工作流与插件机制

可见经过Build阶段的bundle对象其实并没有进行模块的打包,这个对象的作用在于存储各个模块的内容及依赖关系,并提供generate(不写磁盘)、write(写入磁盘)方法以便后续output阶段输出产物

Output

通过JS-API实现构建流程中的打包生成逻辑

// build.js
const rollup = require('rollup');
const inputOptions = {
  input: './src/index.js',
  external: [],
  plugins: [],
};
const outputOptions = {
//   dir: 'dist/es', // 调用write时输出产物到该目录
  entryFileNames: `[name].[hash].js`,
  chunkFileNames: 'chunk-[hash].js',
  assetFileNames: 'assets/[name]-[hash][extname]',
  format: 'es',
  sourcemap: true,
};
async function build() {
  const bundle = await rollup.rollup(inputOptions);
  const resp = await bundle.generate(outputOptions); // 打包生成产物(generate不写入磁盘)
  console.log(resp);
}
build();

执行后结果

{
  output: [
    {
      exports: [],
      facadeModuleId: '/Users/frontend-starter/src/index.js',
      isDynamicEntry: false,
      isEntry: true,
      isImplicitEntry: false,
      modules: [Object: null prototype],
      name: [Getter],
      type: 'chunk',
      code: '// src/tool.js\n' +
        '\n' +
        'const add = (a, b) => a + b;\n' +
        '\n' +
        '// src/index.js\n' +
        '\n' +
        'console.log(add(1, 2));\n',
      dynamicImports: [],
      fileName: 'index.7bc8c2f6.js',
      implicitlyLoadedBefore: [],
      importedBindings: {},
      imports: [],
      map: [SourceMap],
      referencedFiles: []
    }
  ]
}

插件机制

插件HOOK类型

Rollup插件机制中有不同的Hook类型,这些类型代表了不同插件的执行特点

根据构建阶段分类

插件的各种Hook可以根据这两个构建阶段分为两类:Build-HookOutput-Hook

  • Build-Hook:在Build阶段执行的钩子函数,在这个阶段主要进行模块代码的转换、AST 解析以及模块依赖的解析,那么这个阶段的 Hook 对于代码的操作粒度一般为模块级别,也就是单文件级别

  • Ouput-Hook(Output Generation Hook):主要进行代码的打包,对于代码而言,操作粒度一般为chunk级别(一个chunk通常指很多文件打包到一起的产物)

根据执行方式分类

  1. Async & Sync

Async:异步钩子函数

Sync:同步钩子函数

最大的区别在于同步钩子里面不能有异步逻辑

  1. Parallel

并行钩子函数,如果有多个插件实现了这个钩子的逻辑,若某一个插件实现的钩子函数是异步逻辑,则并发执行钩子函数,不会等待当前钩子完成(与Sequential有区别,底层Promise.all)

说明:插件之间的操作并不是相互依赖的,也就是可以并发执行,从而提升构建性能

  1. Sequential

串行的钩子函数,适用于插件间处理结果相互依赖的情况,前一个插件Hook的返回值作为后续插件的入参

如果其中某一个插件注册的钩子是异步的,后续的这类钩子将等待,直到当前钩子被解析完成

  1. First

如果有多个插件实现了这个Hook,那么Hook将依次运行,直到返回一个非null或非undefined的值为止

补充

  • Async/Sync可以搭配后面三种类型中的任意一种

  • 钩子也可以是对象,而不是函数

return {
  name: 'resolve-first',
  resolveId: {
    order: 'pre', // "pre" | "post" | null
    handler(source) {
      if (source === 'external') {
        return { id: source, external: true };
      }
      return null;
    }
  }
};
  • 如果多个插件指定order为prepost,Rollup将按照用户提供的顺序运行它们,该选项可用于所有插件钩子(对于并行钩子,则改变运行钩子的同步部分顺序)

  • 钩子对象中的sequential属性

当插件A, B, C, D, E都实现了相同的并行钩子,并且中间的插件C具有sequential: true,那么Rollup将首先并行运行A + B,然后是C单独运行,然后是D + E并行运行

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

Build阶段工作流

[陈同学i前端] 一起学Rollup|构建工作流与插件机制

  1. 通过 options 钩子进行配置的转换,得到处理后的配置对象

钩子函数类型: (options: InputOptions) => InputOptions | null 钩子执行方式: async, sequential 前置钩子: 无,这是build阶段第一个钩子 后置钩子: buildStart

  1. 调用 buildStart 钩子,正式开始构建流程

钩子函数类型: (options: InputOptions) => void 钩子执行方式: async, parallel 前置钩子: options 后置钩子: resolveId平行解析每一个入口模块

  1. 调用 resolveId 钩子解析模块文件路径

从inputOption的input配置指定的入口文件开始,每当匹配到引入外部模块的语句(如:import a from 'a')便依次执行注册插件中的每一个 resolveId 钩子,直到某一个插件中的 resolveId 执行完后返回非 null 或非 undefined 的值,将停止执行后续插件的 resolveId 逻辑并进入下一个钩子

return {
    name: 'pluginName',
    resolveId(importee, importer, resolveOptions) {
      // 每个插件执行时都会绑定一个上下文对象作为 this
      // this.resolve 会执行所有插件(除当前插件外)的 resolveId 钩子
      return this.resolve(
        importee,
        importer,
        Object.assign({ skipSelf: true }, resolveOptions)
      ).then((resolved) => {
        // 替换后的路径会经过别的插件进行处理
        let finalResult: PartialResolvedId | null = resolved;
        if (!finalResult) {
          // 如果其它插件没有处理这个路径,则直接返回
          finalResult = { id: importee };
        }
        return finalResult;
      });
    }
}
  1. 调用load钩子加载模块内容

前提:经过resolveId处理后的模块路径不在external列表当中

return {
    name: 'pluginName',
    load(id) {
      // code
      return code;
    }
}
  1. 接着判断当前解析的模块是否存在缓存,若不存在则执行所有的 transform 钩子来对模块内容进行进行自定义的转换;若存在则判断shouldTransformCachedModule属性,true则执行所有的 transform 钩子,false则进入moduleParsed钩子逻辑
return {
    name: 'pluginName',
    transform(code, id) {
      // 执行代码转化的逻辑,并生成最后的代码和SourceMap
      return codeTransform(code, id);
    }
}
  1. 这一步拿到最后的模块内容,进行 AST 分析,得到所有的 import 内容,调用 moduleParsed 钩子:
  • 如果是普通的 import,则执行resolveId 钩子,继续回到步骤3
  • 如果是动态 import,则执行resolveDynamicImport 钩子解析路径,如果解析成功,则回到步骤4加载模块,否则回到步骤3通过 resolveId 解析路径

钩子函数类型: (moduleInfo: ModuleInfo) => void 钩子执行方式: async,parallel 前置钩子: transform 后置钩子: resolveId,resolveDynamicImport 平行地解析所有静态或动态引入的模块

  1. 直到所有的 import 都解析完毕,Rollup 执行buildEnd钩子,Build阶段结束

钩子函数类型: (error?: Error) => void 钩子执行方式: async,parallel 前置钩子: moduleParsed,resolveId,resolveDynamicImport 后置钩子: outputOptions为build阶段的最后一个钩子逻辑

Output阶段工作流

[陈同学i前端] 一起学Rollup|构建工作流与插件机制

  1. 执行所有插件的 outputOptions 钩子函数,对 output 配置进行转换

钩子函数类型: (outputOptions: OutputOptions) => OutputOptions | null 钩子执行方式: sync, sequential 前置钩子: buildEnd(第一次输入生成),generateBundle, writeBundle, renderError(其它情况) 后置钩子: renderStart

  1. 执行 renderStart,并发执行 renderStart 钩子,正式开始打包

该钩子可以读取所有outputOptions钩子的转换之后,可以访问的输出选项

钩子函数类型: (outputOptions: OutputOptions, inputOptions: InputOptions) => void 钩子执行方式: async, parallel 前置钩子: outputOptions 后置钩子: renderDynamicImport(chunk中存在未处理的动态引入表达式), resolveFileUrl(chunk中存在未处理的import.meta.url), resolveImportMeta(chunk中存在未处理的import.meta.*)

  1. 并发执行所有插件的 banner、footer、intro、outro 钩子(底层用Promise.all包裹所有的这四种钩子函数),这四个钩子功能很简单,就是往打包产物的固定位置(比如头部和尾部)插入一些自定义的内容,比如协议声明内容、项目介绍等等

  2. 从入口模块开始扫描,扫描 动态import 语句执行 renderDynamicImport 钩子,让开发者能自定义动态import的内容与行为

钩子函数类型: ({format: string, moduleId: string, targetModuleId: string | null, customResolution: string | null}) => {left: string, right: string} | null 钩子执行方式: sync, first 前置钩子: renderStart (若为第一个chunk), banner, footer, intro, outro(其它情况) 后置钩子: resolveFileUrl(处理每一个import.meta.ROLLUP_FILE_URL_referenceId表达式) and resolveImportMeta(处理每一个import.meta).

  1. 对每个即将生成的 chunk,执行 augmentChunkHash 钩子,来决定是否更改 chunk 的哈希值

钩子函数类型: (chunkInfo: ChunkInfo) => string 钩子执行方式: sync, sequential 前置钩子: renderChunk. 后置钩子: renderChunk(若还有其它chunk待处理), generateBundle(所有chunk处理完成)

return {
  name: 'pluginName',
  augmentChunkHash(chunkInfo) {
    if (chunkInfo.name === 'foo') {
      return Date.now().toString(); // 返回的值为false则不会改变哈希值,若不为false则传递给hash.update
    }
  }
};

TIP:在 watch 模式下即可能会多次打包的场景下,这个钩子会比较适用

  1. 若无 import.meta 语句,则进入下一步,否则:
  • 对于import.meta.url调用 resolveFileUrl 来自定义 url 解析逻辑
  • 对于import.meta调用 resolveImportMeta 来进行自定义元信息解析
  1. 接着生成所有 chunk 的内容,针对每个 chunk 会依次调用插件的renderChunk方法进行自定义操作,在这里可以直接操作打包产物

  2. 调用 generateBundle 钩子,这个钩子的入参里面会包含所有的打包产物信息,包括 chunk (打包后的代码)、asset(最终的静态资源文件)。可以在这里删除一些 chunk 或者 asset,最终被删除的内容将不会作为产物输出

  • rollup.rollup方法会返回一个bundle对象,这个对象是包含generate和write两个方法,两个方法唯一的区别在于后者会将代码写入到磁盘中,同时会触发writeBundle钩子,传入所有的打包产物信息,包括 chunkasset,与generateBundle钩子非常相似

  • writeBundle钩子执行的时候,产物已经输出了

  • generateBundle 执行的时候产物还并没有输出

顺序:generateBundle->输出并保存产物到磁盘->writeBundle

  1. 当上述的bundleclose方法被调用时,会触发closeBundle钩子(Output 阶段正式结束)

钩子函数类型: closeBundle: () => Promise<void> | void 钩子执行方式: async, parallel 前置钩子: buildEnd

讲到最后

Rollup插件机制将Rollup打包器的构建打包流程当中各个关键的节点通过钩子函数的方式提供给开发者,解决了各种各样的特殊开发场景下的构建流程自定义的问题,进一步提高了Rollup的灵活性可扩展性

本节文章我们一开始通过简单的例子初步了解Rollup构建的两个阶段,并依据这两个阶段工作流展开了对Rollup插件体系的学习,通过流程图以及对每一个节点的关键描述,我们浅浅地了解了工作流中每一步钩子函数的作用以及部分应用场景,从中我们发现:

  • 插件的编写简单易上手,只需要编写一个对象A,对象当中包含一个name属性以及各个钩子函数对象,使用时将对象A作为RollupinputOptionplugins属性数组元素即可

  • 单个插件当中可以编写各个构建阶段的Hook,使得单个插件的潜在能力变得空前强大,在webpack当中分为loaderplugin的功能,一个Rollup插件就可以实现

  • 通过插件上下文对象this,我们可以在一个插件的钩子函数当中通过this.resolve()调用其它插件的对应钩子函数,增加了插件的灵活性

  • 在特定的应用场景下找到合适的钩子函数能够快速解决业务上的问题

大家通过本篇文章能够对Rollup插件体系形成一个初步的了解,之后文章我们再通过简单的案例来巩固本节的内容

谢谢大家,我们下节再见!!!

感谢各位看到这里,如果你觉得本节内容还不错的话,欢迎各位的点赞、收藏、评论,大家的支持是我做内容的最大动力

本文为作者原创,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文链接,否则保留追究法律责任的权利

补充

Rollup文档

转载自:https://juejin.cn/post/7155702261724184589
评论
请登录