关于vite:深入理解-Rollup-的插件机制

51次阅读

共计 10796 个字符,预计需要花费 27 分钟才能阅读完成。

上一节咱们学会了 Rollup 构建工具的应用,置信你曾经对 Rollup 的根底概念和应用有了根本的把握。同时咱们也晓得,仅仅应用 Rollup 内置的打包能力很难满足我的项目日益简单的构建需要。对于一个实在的我的项目构建场景来说,咱们还须要思考到模块打包之外的问题,比方门路别名(alias)、全局变量注入和代码压缩等等。

可要是把这些场景的解决逻辑与外围的打包逻辑都写到一起,一来打包器自身的代码会变得非常臃肿,二来也会对原有的外围代码产生肯定的侵入性,混入很多与外围流程无关的代码,不易于前期的保护。因而,Rollup 设计出了一套残缺的插件机制,将本身的外围逻辑与插件逻辑拆散,让你能按需引入插件性能,进步了 Rollup 本身的可扩展性。

那接下来,我会带你剖析 Rollup 的插件机制,相熟 Rollup 插件的残缺构建阶段和工作流程,并且联合案例深刻插件开发细节。Rollup 的打包过程中,会定义一套残缺的构建生命周期,从开始打包到产物输入,中途会经验一些标志性的阶段,并且在不同阶段会主动执行对应的插件钩子函数(Hook)。对 Rollup 插件来讲,最重要的局部是钩子函数,一方面它定义了插件的执行逻辑,也就是 ” 做什么 ”;另一方面也申明了插件的作用阶段,即 ” 什么时候做 ”,这与 Rollup 自身的构建生命周期非亲非故。

 

一、Rollup 构建阶段

在执行 rollup 命令之后,在 cli 外部的次要逻辑简化如下:

// Build 阶段
const bundle = await rollup.rollup(inputOptions);


// Output 阶段
await Promise.all(outputOptions.map(bundle.write));


// 构建完结
await bundle.close();

而 Rollup 外部次要经验了 Build 和 Output 两大阶段,如下图所示。

首先,Build 阶段次要负责创立模块依赖图,初始化各个模块的 AST 以及模块之间的依赖关系。上面咱们用一个简略的例子来感受一下:

// src/index.js
import {a} from './module-a';
console.log(a);


// src/module-a.js
export const a = 1;

而后,执行如下的构建脚本:

const rollup = require('rollup');
const util = require('util');
async function build() {
  const bundle = await rollup.rollup({input: ['./src/index.js'],
  });
  console.log(util.inspect(bundle));
}
build();

执行下面的代码,能够看到如下的 bundle 对象信息。

{
  cache: {
    modules: [
      {
        ast: 'AST 节点信息,具体内容省略',
        code: 'export const a = 1;',
        dependencies: [],
        id: '/Users/code/rollup-demo/src/data.js',
        // 其它属性省略
      },
      {
        ast: 'AST 节点信息,具体内容省略',
        code: "import {a} from'./data';\n\nconsole.log(a);",
        dependencies: ['/Users/code/rollup-demo/src/data.js'],
        id: '/Users/code/rollup-demo/src/index.js',
        // 其它属性省略
      }
    ],
    plugins: {}},
  closed: false,
  // 挂载后续阶段会执行的办法
  close: [AsyncFunction: close],
  generate: [AsyncFunction: generate],
  write: [AsyncFunction: write]
}

从下面的信息中能够看出,目前通过 Build 阶段的 bundle 对象其实并没有进行模块的打包,这个对象的作用在于存储各个模块的内容及依赖关系,同时裸露 generate 和 write 办法,以进入到后续的 Output 阶段。

所以,真正进行打包的过程会在 Output 阶段进行,即在 bundle 对象的 generate 或者 write 办法中进行。还是以下面的 demo 为例,咱们稍稍改变一下构建逻辑:

const rollup = require('rollup');
async function build() {
  const bundle = await rollup.rollup({input: ['./src/index.js'],
  });
  const result = await bundle.generate({format: 'es',});
  console.log('result:', result);
}


build();

从新执行我的项目后能够失去如下的输入:

{
  output: [
    {exports: [],
      facadeModuleId: '/Users/code/rollup-demo/src/index.js',
      isEntry: true,
      isImplicitEntry: false,
      type: 'chunk',
      code: 'const a = 1;\n\nconsole.log(a);\n',
      dynamicImports: [],
      fileName: 'index.js',
      // 其余属性省略
    }
  ]
}

这里能够看到所有的输入信息,生成的 output 数组即为打包实现的后果。当然,如果应用 bundle.write 会依据配置将最初的产物写入到指定的磁盘目录中。

因而,对于一次残缺的构建过程而言,Rollup 会先进入到 Build 阶段,解析各模块的内容及依赖关系,而后进入 Output 阶段,实现打包及输入的过程。对于不同的阶段,Rollup 插件会有不同的插件工作流程,接下来咱们就来拆解一下 Rollup 插件在 Build 和 Output 两个阶段的具体工作流程。

 

二、拆解插件工作流

2.1 插件 Hook 类型

在具体讲述 Rollup 插件工作流之前,我想先给大家介绍一下不同插件 Hook 的类型,这些类型代表了不同插件的执行特点,是咱们了解 Rollup 插件工作流的根底,因而有必要跟大家好好拆解一下。

通过上文的例子,置信你能够直观地了解 Rollup 两大构建阶段(Build 和 Output)各自的原理。可能你会有疑难,这两个阶段到底跟插件机制有什么关系呢?实际上,插件的各种 Hook 能够依据这两个构建阶段分为两类: Build Hook 与 Output Hook。

  • Build Hook:在 Build 阶段执行的钩子函数,在这个阶段次要进行模块代码的转换、AST 解析以及模块依赖的解析,那么这个阶段的 Hook 对于代码的操作粒度个别为模块级别,也就是单文件级别。
  • Ouput Hook:(官网称为 Output Generation Hook),则次要进行代码的打包,对于代码而言,操作粒度个别为 chunk 级别,而一个 chunk 通常指很多文件打包到一起的产物。

除了依据构建阶段能够将 Rollup 插件进行分类,依据不同的 Hook 执行形式也会有不同的分类,次要包含 Async、Sync、Parallel、Squential、First 这五种。在理论的开发过程中,咱们将接触各种各样的插件 Hook,但无论哪个 Hook 都离不开这五种执行形式。接下来,让咱们具体来意识下这五个函数钩子。
 

Async & Sync

首先是 Async 和 Sync 钩子函数,两者其实是绝对的,别离代表异步和同步的钩子函数,两者最大的区别在于同步钩子外面不能有异步逻辑,而异步钩子能够有。

Parallel

Parallel 用来代表并行钩子函数。如果有多个插件实现了这个钩子的逻辑,一旦有钩子函数是异步逻辑,则并发执行钩子函数,不会期待以后钩子实现(底层应用 Promise.all)。

比方对于 Build 阶段的 buildStart 钩子,它的执行机会其实是在构建刚开始的时候,各个插件能够在这个钩子当中做一些状态的初始化操作,但其实插件之间的操作并不是相互依赖的,也就是能够并发执行,从而晋升构建性能。反之,对于须要依赖其余插件处理结果的状况就不适宜用 Parallel 钩子了,比方 transform。
 

Sequential

Sequential 指串行的钩子函数。这种 Hook 往往实用于插件间处理结果相互依赖的状况,前一个插件 Hook 的返回值作为后续插件的入参,这种状况就须要期待前一个插件执行完 Hook,取得其执行后果,而后能力进行下一个插件相应 Hook 的调用,如 transform。
 

First

如果有多个插件实现了这个 Hook,那么 Hook 将顺次运行,直到返回一个非 null 或非 undefined 的值为止。比拟典型的 Hook 是 resolveId,一旦有插件的 resolveId 返回了一个门路,将进行执行后续插件的 resolveId 逻辑。

刚刚咱们介绍了 Rollup 当中不同插件 Hook 的类型,实际上不同的类型是能够叠加的,Async/Sync 能够搭配前面三种类型中的任意一种,比方一个 Hook 既能够是 Async 也能够是 First 类型,接着咱们来具体分析一下 Rollup 当中的插件工作流程。

2.2 Build 阶段工作流

首先,咱们来剖析 Build 阶段的插件工作流程。对于 Build 阶段,插件 Hook 的调用流程如下图所示。

流程图的最下面申明了不同 Hook 的类型,也就是咱们在下面总结的 5 种 Hook 分类,每个方块代表了一个 Hook,边框的色彩能够示意 Async 和 Sync 类型,方块的填充色彩能够示意 Parallel、Sequential 和 First 类型。

接下来,咱们一步步来剖析 Build Hooks 的工作流程,你能够对照着图一起看。

  1. 首先经验 options 钩子进行配置的转换,失去解决后的配置对象。
  2. 随之 Rollup 会调用 buildStart 钩子,正式开始构建流程。
  3. Rollup 先进入到 resolveId 钩子中解析文件门路。(从 input 配置指定的入口文件开始)。
  4. Rollup 通过调用 load 钩子加载模块内容。
  5. 紧接着 Rollup 执行所有的 transform 钩子来对模块内容进行进行自定义的转换,比方 babel 转译。
  6. 当初 Rollup 拿到了模块内容,接下来就是进行 AST 剖析,失去所有的 import 内容,调用 moduleParsed 钩子。如果是一般的 import,则执行 resolveId 钩子,持续回到步骤 3;如果是动静 import,则执行 resolveDynamicImport 钩子解析门路,如果解析胜利,则回到步骤 4 加载模块,否则回到步骤 3 通过 resolveId 解析门路。
  7. 直到所有的 import 都解析结束,Rollup 执行 buildEnd 钩子,Build 阶段完结。

当然,在 Rollup 解析门路的时候,即执行 resolveId 或者 resolveDynamicImport 的时候,有些门路可能会被标记为 external(翻译为排除),也就是说不加入 Rollup 打包过程,这个时候就不会进行 load、transform 等等后续的解决了。

在流程图最下面,不晓得大家有没有留神到 watchChange 和 closeWatcher 这两个 Hook,这里其实是对应了 rollup 的 watch 模式。当你应用 rollup –watch 指令或者在配置文件配有 watch: true 的属性时,代表开启了 Rollup 的 watch 打包模式,这个时候 Rollup 外部会初始化一个 watcher 对象,当文件内容发生变化时,watcher 对象会主动触发 watchChange 钩子执行并对我的项目进行从新构建。在以后打包过程完结时,Rollup 会主动革除 watcher 对象调用 closeWacher 钩子。
 

2.3 Output 阶段工作流

好,接着咱们来看看 Output 阶段的插件到底是如何来进行工作的。这个阶段的 Hook 相比于 Build 阶段略微多一些,流程上也更加简单。须要留神的是,其中会波及的 Hook 函数比拟多,可能会给你了解整个流程带来一些困扰,因而我会在 Hook 执行的阶段解释其大抵的作用和意义,对于具体的应用大家能够去 Rollup 的官网自行查阅,毕竟这里的主线还是剖析插件的执行流程,掺杂太多的应用细节反而不易于了解。上面我联合一张残缺的插件流程图和你具体分析一下。

以下是对于 Output 阶段工作流的阐明:

  1. 执行所有插件的 outputOptions 钩子函数,对 output 配置进行转换。
  2. 执行 renderStart,并发执行 renderStart 钩子,正式开始打包。
  3. 并发执行所有插件的 banner、footer、intro、outro 钩子 (底层用 Promise.all 包裹所有的这四种钩子函数),这四个钩子性能很简略,就是往打包产物的固定地位(比方头部和尾部) 插入一些自定义的内容,比方协定申明内容、我的项目介绍等等。
  4. 从入口模块开始扫描,针对动静 import 语句执行 renderDynamicImport 钩子,来自定义动静 import 的内容。
  5. 对每个行将生成的 chunk,执行 augmentChunkHash 钩子,来决定是否更改 chunk 的哈希值,在 watch 模式下即可能会屡次打包的场景下,这个钩子会比拟实用。
  6. 如果没有遇到 import.meta 语句,则进入下一步,否则执行如下的 Case。对于 import.meta.url 语句调用 resolveFileUrl 来自定义 url 解析逻辑;对于其余 import.meta 属性,则调用 resolveImportMeta 来进行自定义的解析。
  7. 接着 Rollup 会生成所有 chunk 的内容,针对每个 chunk 会顺次调用插件的 renderChunk 办法进行自定义操作,也就是说,在这里时候能够间接操作打包产物了。
  8. 随后会调用 generateBundle 钩子,这个钩子的入参外面会蕴含所有的打包产物信息,包含 chunk (打包后的代码)、asset(最终的动态资源文件)。能够在这里删除一些 chunk 或者 asset,最终这些内容将不会作为产物输入。
  9. 因为 rollup.rollup 办法会返回一个 bundle 对象,这个对象是蕴含 generate 和 write 两个办法,两个办法惟一的区别在于后者会将代码写入到磁盘中,同时会触发 writeBundle 钩子,传入所有的打包产物信息,包含 chunk 和 asset,和 generateBundle 钩子十分类似。不过值得注意的是,这个钩子执行的时候,产物曾经输入了,而 generateBundle 执行的时候产物还并没有输入。
  1. 当上述的 bundle 的 close 办法被调用时,会触发 closeBundle 钩子,到这里 Output 阶段正式完结。

到这里,咱们终于梳理完了 Rollup 当中残缺的插件工作流程,从一开始在构建生命周期中对两大构建阶段的感性认识,到当初插件工作流的具体分析,不禁感叹 Rollup 看似简略,实则外部细节繁冗。

三、罕用 Hook 

实际上开发 Rollup 插件就是在编写一个个 Hook 函数,你能够了解为一个 Rollup 插件根本就是各种 Hook 函数的组合。

1,门路解析: resolveId

resolveId 钩子个别用来解析模块门路,为 Async + First 类型即异步优先的钩子。这里咱们拿官网的 alias 插件 来阐明,这个插件用法演示如下:

// rollup.config.js
import alias from '@rollup/plugin-alias';
module.exports = {
  input: 'src/index.js',
  output: {
    dir: 'output',
    format: 'cjs'
  },
  plugins: [
    alias({
      entries: [
        // 将把 import xxx from 'module-a' 
        // 转换为 import xxx from './module-a'
        {find: 'module-a', replacement: './module-a.js'},
      ]
    })
  ]
};

插件的代码简化后如下:

export default alias(options) {
  // 获取 entries 配置
  const entries = getEntries(options);
  return {
    // 传入三个参数,以后模块门路、援用以后模块的模块门路、其余参数
    resolveId(importee, importer, resolveOptions) {
      // 先查看能不能匹配别名规定
      const matchedEntry = entries.find((entry) => matches(entry.find, importee));
      // 如果不能匹配替换规定,或者以后模块是入口模块,则不会持续前面的别名替换流程
      if (!matchedEntry || !importerId) {
        // return null 后,以后的模块门路会交给下一个插件解决
        return null;
      }
      // 正式替换门路
      const updatedId = normalizeId(importee.replace(matchedEntry.find, matchedEntry.replacement)
      );
      // 每个插件执行时都会绑定一个上下文对象作为 this
      // 这里的 this.resolve 会执行所有插件 (除以后插件外) 的 resolveId 钩子
      return this.resolve(
        updatedId,
        importer,
        Object.assign({skipSelf: true}, resolveOptions)
      ).then((resolved) => {
        // 替换后的门路即 updateId 会通过别的插件进行解决
        let finalResult: PartialResolvedId | null = resolved;
        if (!finalResult) {
          // 如果其它插件没有解决这个门路,则间接返回 updateId
          finalResult = {id: updatedId};
        }
        return finalResult;
      });
    }
  }
}

从这里你能够看到 resolveId 钩子函数的一些罕用应用形式,它的入参别离是以后模块门路、援用以后模块的模块门路、解析参数,返回值能够是 null、string 或者一个对象,上面咱们分状况讨论一下。

  • 返回值为 null 时,会默认交给下一个插件的 resolveId 钩子解决。
  • 返回值为 string 时,则进行后续插件的解决。这里为了让替换后的门路能被其余插件解决,特意调用了 this.resolve 来交给其它插件解决,否则将不会进入到其它插件的解决。
  • 返回值为一个对象,也会进行后续插件的解决,不过这个对象就能够蕴含更多的信息了,包含解析后的门路、是否被 enternal、是否须要 tree-shaking 等等,不过大部分状况下返回一个 string 就够用了。

2,load

load 为 Async + First 类型,即异步优先的钩子,和 resolveId 相似。它的作用是通过 resolveId 解析后的门路来加载模块内容。这里,咱们以官网的 image 插件 为例来介绍一下 load 钩子的应用。源码简化后如下所示:

const mimeTypes = {
  '.jpg': 'image/jpeg',
  // 前面图片类型省略
};


export default function image(opts = {}) {const options = Object.assign({}, defaults, opts);
  return {
    name: 'image',
    load(id) {const mime = mimeTypes[extname(id)];
      if (!mime) {
        // 如果不是图片类型,返回 null,交给下一个插件解决
        return null;
      }
      // 加载图片具体内容
      const isSvg = mime === mimeTypes['.svg'];
      const format = isSvg ? 'utf-8' : 'base64';
      const source = readFileSync(id, format).replace(/[\r\n]+/gm, '');
      const dataUri = getDataUri({format, isSvg, mime, source});
      const code = options.dom ? domTemplate({dataUri}) : constTemplate({dataUri});


      return code.trim();}
  };
}

从中能够看到,load 钩子的入参是模块 id,返回值个别是 null、string 或者一个对象:

  • 如果返回值为 null,则交给下一个插件解决;
  • 如果返回值为 string 或者对象,则终止后续插件的解决,如果是对象能够蕴含 SourceMap、AST 等。

3,代码转换: transform

transform 钩子也是十分常见的一个钩子函数,为 Async + Sequential 类型,也就是异步串行钩子,作用是对加载后的模块内容进行自定义的转换。咱们以官网的 replace 插件为例,这个插件的应用形式如下:

// rollup.config.js
import replace from '@rollup/plugin-replace';


module.exports = {
  input: 'src/index.js',
  output: {
    dir: 'output',
    format: 'cjs'
  },
  plugins: [
    // 将会把代码中所有的 __TEST__ 替换为 1
    replace({__TEST__: 1})
  ]
};

事实上,transform 的外部实现也并不是很简单,次要通过字符串替换来实现,外围逻辑简化如下:

import MagicString from 'magic-string';


export default function replace(options = {}) {
  return {
    name: 'replace',
    transform(code, id) {
      // 省略一些边界状况的解决
      // 执行代码替换的逻辑,并生成最初的代码和 SourceMap
      return executeReplacement(code, id);
    }
  }
}


function executeReplacement(code, id) {const magicString = new MagicString(code);
  // 通过 magicString.overwrite 办法实现字符串替换
  if (!codeHasReplacements(code, id, magicString)) {return null;}


  const result = {code: magicString.toString() };


  if (isSourceMapEnabled()) {result.map = magicString.generateMap({ hires: true});
  }


  // 返回一个带有 code 和 map 属性的对象
  return result;
}

transform 钩子的入参别离为模块代码、模块 ID,返回一个蕴含 code(代码内容) 和 map(SourceMap 内容) 属性的对象,当然也能够返回 null 来跳过以后插件的 transform 解决。须要留神的是,以后插件返回的代码会作为下一个插件 transform 钩子的第一个入参,实现相似于瀑布流的解决。

 

4,Chunk 级代码批改: renderChunk

这里咱们持续以 replace 插件举例,在这个插件中,也同样实现了 renderChunk 钩子函数:

export default function replace(options = {}) {
  return {
    name: 'replace',
    transform(code, id) {// transform 代码省略},
    renderChunk(code, chunk) {
      const id = chunk.fileName;
      // 省略一些边界状况的解决
      // 拿到 chunk 的代码及文件名,执行替换逻辑
      return executeReplacement(code, id);
    },
  }
}

能够看到这里 replace 插件为了替换后果更加精确,在 renderChunk 钩子中又进行了一次替换,因为后续的插件依然可能在 transform 中进行模块内容转换,进而可能呈现合乎替换规定的字符串。

 

这里咱们把关注点放到 renderChunk 函数自身,能够看到有两个入参,别离为 chunk 代码内容、chunk 元信息,返回值跟 transform 钩子相似,既能够返回蕴含 code 和 map 属性的对象,也能够通过返回 null 来跳过以后钩子的解决。

 

5,generateBundle

generateBundle 也是异步串行的钩子,你能够在这个钩子外面自定义删除一些无用的 chunk 或者动态资源,或者本人增加一些文件。这里咱们以 Rollup 官网的 html 插件来具体阐明,这个插件的作用是通过拿到 Rollup 打包后的资源来生成蕴含这些资源的 HTML 文件,源码简化后如下所示:

export default function html(opts: RollupHtmlOptions = {}): Plugin {
  // 初始化配置
  return {
    name: 'html',
    async generateBundle(output: NormalizedOutputOptions, bundle: OutputBundle) {
      // 省略一些边界状况的解决
      // 1. 获取打包后的文件
      const files = getFiles(bundle);
      // 2. 组装 HTML,插入相应 meta、link 和 script 标签
      const source = await template({attributes, bundle, files, meta, publicPath, title});
      // 3. 通过上下文对象的 emitFile 办法,输入 html 文件
      const htmlFile: EmittedAsset = {
        type: 'asset',
        source,
        name: 'Rollup HTML Asset',
        fileName
      };
      this.emitFile(htmlFile);
    }
  }
}

置信从插件的具体实现中,你也能感触到这个钩子的弱小作用了。入参别离为 output 配置、所有打包产物的元信息对象,通过操作元信息对象你能够删除一些不须要的 chunk 或者动态资源,也能够通过 插件上下文对象的 emitFile 办法输入自定义文件。

好,罕用的 Rollup 钩子咱们就先介绍到这里,置信这些知识点曾经足够你应酬大多数的构建场景了。顺便说一句,大家在前面的章节能够理解到,Vite 的插件机制也是基于 Rollup 来实现的,像下面介绍的这些罕用钩子在 Vite 当中也随处可见,因而,把握了这些罕用钩子,也相当于给 Vite 插件的学习做下了很好的铺垫。

正文完
 0