Rollup源码学习(七)——重识Rollup生成生命周期

前言

在上一篇文章中,我们主要分析了Rollup构建阶段的生命周期,由于篇幅的关系,我们将输出阶段的生命周期推迟到这篇文章讲解。

在这篇文章中,我仍然会结合一些Vite插件的例子向大家介绍一些常见生命周期钩子的实际用途。

阅读本文之前,希望您已经阅读过Rollup源码学习(六)------重识Rollup构建生命周期,本文需要您事先知道firstsequentialparallelsyncasync这些类型的生命周期类型的实现细节。

好了,废话少说,开始进入这篇文章的正题。

输出生命周期

输出生成钩子可以提供有关生成的产物的信息并在构建完成后修改构建。它们的工作方式和类型与 构建钩子 相同,但是对于每个调用 bundle.generate(outputOptions)bundle.write(outputOptions),它们都会单独调用。仅使用输出生成钩子的插件也可以通过输出选项传递,并且因此仅针对某些输出运行。

输出生成阶段的第一个钩子是 outputOptions,最后一个钩子是 generateBundle(如果通过 bundle.generate(...) 成功生成输出),writeBundle(如果通过 bundle.write(...) 成功生成输出),或 renderError(如果在输出生成期间的任何时候发生错误)。

根据我们之前的学习,在此刻(即构建完成还没有开始输出的时机),Rollup已经把整个项目中的文件内容解析到,并存储在了对应的Module实例之上了,在输出阶段,就是要将这些Module根据分包依据组织,合并,最终输出到磁盘(虽然Rollup也可以输出到命令行,但是我们一般都是输出到磁盘)中。

outputOptions

替换或操作传递给 bundle.generate()bundle.write() 的输出选项对象。

这是一个syncsequential类型的生命周期,其实没有什么好说的。

这个插件的实际用途不多。

Rollup的源码实现如下: 每个插件都可以修改输出配置。

demo之生产模式下压缩打包文件

这个例子比较牵强,所以大家就看看就行,更多的还是需要您结合自己的业务使用。

js 复制代码
import { defineConfig } from 'rollup';
import terser from '@rollup/plugin-terser';

export default defineConfig({
  input: 'src/index.js',
  plugins: [
    // 其他插件
  ],
  output: [
    { format: 'es', file: 'dist/module.js' },
    { format: 'umd', file: 'dist/bundle.js', name: 'MyLibrary' },
  ],
  outputOptions(options) {
    if (process.env.NODE_ENV === 'production') {
      // 在生产环境中:
      // 1. 输出带 `.min.js` 的文件名
      // 2. 启用 sourcemap
      // 3. 动态添加压缩插件
      return {
        ...options,
        file: options.file.replace(/\.js$/, '.min.js'),
        sourcemap: true,
        plugins: [terser()],
      };
    }
    return options; // 保留原配置
  },
});

renderStart

每次调用 bundle.generate()bundle.write() 时最初调用。

这是一个asyncparallel类型的生命周期。

使用这个生命周期可以修改Rollup的输出配置,这个生命周期的应用也不多。

Rollup的源码实现如下: 没有什么可说的。

banner,footer,intro,outro

这几个生命周期为什么放在一起讲呢,因为它们的功能差不多,都是在生成Chunk过程中可以在Chunk上添加一些自定义代码片段。

这几个生命周期的类型都是asyncsequential类型的。

Rollup本来也是支持output.banner/output.footer这样的配置的,但是插件和配置的处理时机不一样,插件的处理要靠后一些。

在之前我们讲Rollup源码生成逻辑部分,我们有提到过可以使用这些生命周期来进行一些自定义的代码片段添加。

Rollup的源码实现如下: 上面的那个magicString,其存储的的就是当前Chunk合并之后Code内容的目标对象,只不过它目前还不是一个字符串,而是一个非常便于进行字符串增删改查操作的复杂对象。

renderChunk

可以用于转换单个块

重要程度:⭐️

这是一个asyncsequential类型的生命周期。

在Rollup的renderChunk生命周期里,我们可以对Chunk的代码进行剔除,替换,可以记录Chunk的一些信息,然后用来统计分析。

这是Rollup生成生命周期中最重要的一个生命周期钩子之一,我们先阐述一下其源码实现。

以下是renderChunk的函数调用堆栈。 这是renderChunk的参数:

ts 复制代码
type RenderChunkHook = (
	code: string,
	chunk: RenderedChunk,
	options: NormalizedOutputOptions,
	meta: { chunks: Record<string, RenderedChunk> }
) => { code: string; map?: SourceMapInput } | string | null;

interface RenderedChunk {
	dynamicImports: string[];
	exports: string[];
	facadeModuleId: string | null;
	fileName: string;
	implicitlyLoadedBefore: string[];
	importedBindings: {
		[imported: string]: string[];
	};
	imports: string[];
	isDynamicEntry: boolean;
	isEntry: boolean;
	isImplicitEntry: boolean;
	moduleIds: string[];
	modules: {
		[id: string]: RenderedModule;
	};
	name: string;
	referencedFiles: string[];
	type: 'chunk';
}

我们一般主要就是操作第一个参数code,这是当前Chunk的代码合并之后的结果,后面的一些参数信息主要就是包含了这个Chunk的元信息,比如,它是哪些源代码文件(Module)合并的。

demo1之剔除无用代码

这是Vite插件vite-plugin-chunk-split中的例子。

demo2之替换代码

这是@rollup/plugin-replace插件中的例子。

demo3之代码压缩

这是@rollup/plugin-terser插件中的例子。

generateBundle

bundle.generate() 结束时或在 bundle.write() 写入文件之前立即调用。

重要程度:⭐️

这是一个asyncsequential类型的生命周期,它与renderChunk的区别,主要体现在我们在renderChunk主要处理打包好的JS文件,在这个生命周期中,包含了全部的文件,不仅仅只是JS文件。所以,我们就可以拿到这些文件信息做一些操作。

为什么这个生命周期是sequential类型的呢,因为任何插件都可能修改bundle的信息,向后面的插件传递的内容就是修改过的bundle信息了,所以后面的插件就必须要等待之前的插件处理,所以它是sequential

这是它的参数类型定义:

ts 复制代码
interface OutputAsset {
	fileName: string;
	names: string[];
	needsCodeReference: boolean;
	originalFileNames: string[];
	source: string | Uint8Array;
	type: 'asset';
}

interface OutputChunk {
	code: string;
	dynamicImports: string[];
	exports: string[];
	facadeModuleId: string | null;
	fileName: string;
	implicitlyLoadedBefore: string[];
	imports: string[];
	importedBindings: { [imported: string]: string[] };
	isDynamicEntry: boolean;
	isEntry: boolean;
	isImplicitEntry: boolean;
	map: SourceMap | null;
	modules: {
		[id: string]: {
			renderedExports: string[];
			removedExports: string[];
			renderedLength: number;
			originalLength: number;
			code: string | null;
		};
	};
	moduleIds: string[];
	name: string;
	preliminaryFileName: string;
	referencedFiles: string[];
	sourcemapFileName: string | null;
	type: 'chunk';
}

这也是Rollup生成生命周期中最重要的一个生命周期之一,我们来阐述它的源码实现。 源码实现非常简单,没有什么值得深究的。

demo1之将CSS打包进JS

这是我们在打包UMD文件时一个最常见的需求,虽然是UMD(Universal Module Definition),其实我们更多的是希望能在浏览器中运行这个文件,那么必然最省时省力的一个点就是所有的依赖文件最终都打包成一个文件,这样在浏览器端导入的时候,就只需要引入一个JS执行就OK了。

给大家举一个例子: vite-plugin-css-injected-by-js

这个插件中,就收集了所有的css文件,然后抽取完成之后,把bundle中的内容删除掉,从而就可以把所有的内容都打包到JS中了。

本文重点旨在阐述Rollup的生成生命周期,如果大家对这个插件的实现感兴趣的话,可以自行在github查看。

demo2之自动插入link标签

在之前的文章中,我写过一个插件用于在Vite打包过程中把一些比较大的资源文件自动生成<link rel="preload" />这样的标签,当时对Rollup的生命周期的理解不足,我之前的实现实在是比较丑陋,哈哈哈,在精读了Rollup的源码之后,发现有优雅的实现方式。

ts 复制代码
import { extname } from "path";
import type { Plugin, UserConfig } from "vite";
import type { OutputAsset, OutputChunk, OutputOptions } from "rollup";

interface ConfigOptions {
  identifier?: string;
}

export function createAutoOptimizeAssetsPlugin(options: ConfigOptions = {}): Plugin {
  const { identifier = "__link" } = options;

  function matcher(fileName: string) {
    return new RegExp(identifier).test(fileName);
  }

  const autoInsertLinkTagAssetsList: Set<string> = new Set();
  let base = "";
  return {
    name: "vite-plugin-auto-optimize-assets",
    apply: "build",
    config: {
      order: "post",
      handler(options: UserConfig) {
        base = options.base || "";
      },
    },
    generateBundle(output: OutputOptions, bundle: { [fileName: string]: OutputAsset | OutputChunk }) {
      for (const fileName in bundle) {
        if (matcher(fileName)) {
          autoInsertLinkTagAssetsList.add(fileName);
        }
      }
    },
    transformIndexHtml: {
      order: "post",
      handler(html) {
        return {
          html,
          tags: [...autoInsertLinkTagAssetsList].map((src) => {
            const ext = extname(src).replace(/^\./, "");
            return {
              tag: "link",
              attrs: {
                as: "image",
                rel: "preload",
                href: base + src,
                type: "image/" + ext,
              },
              injectTo: "head",
            };
          }),
        };
      },
    },
  };
}

我在generateBundle中搜集到了资源文件,检测它的文件名中是否包含__link这样的标记(这是我们的团队规范),然后在transformIndexHtml把这些内容生成标签插入到index.html中,这个生命周期是Vite独有的生命周期钩子,我们在后面的Vite源码系列解读文章中再做详细的解读。

writeBundle

仅在 bundle.write() 结束时调用,一旦所有文件都已写入。与 generateBundle 钩子类似,bundle 提供正在写入的所有文件的完整列表以及它们的详细信息。

这是一个asyncparallel类型的生命周期钩子,它跟generateBundle生命周期的参数完全一致,但是它们的最大的区别就是writeBundle是已经完成bundle写入磁盘了,所以我们再改bundle已经没有任何影响了。

这也是为什么它可以是一个parallel类型生命周期钩子的原因,因为大家都是只读的处理,互不影响,就可以是并行处理了。

以下是Rollup的源码处理: 代码逻辑也非常简单,没有什么可以深究的。

demo之文件自动上传至CDN

ts 复制代码
import { OutputAsset, OutputChunk, OutputOptions } from "rollup";
import { basename, extname, resolve } from "path";
import type { Plugin, ResolvedConfig, UserConfig } from "vite";
import { glob } from "glob";
import dayjs from "dayjs";
import mime from "mime";
import { readFileSync, rmdirSync, unlinkSync } from "fs-extra";
import pako from "pako";
import { logger } from "@changba/cli-share";
import { trimEnd } from "lodash";
import { OssAuthConfig } from "./oss";
import { useUpload as useAliYunUpload } from "./providers/aliyun";
import { useUpload as useTencentCloudUpload } from "./providers/tencent";

interface OssConfig {
  /**
   * OSS提供商
   */
  provider?: "AliYun" | "TencentCloud";
  /**
   * 授权信息
   */
  auth: OssAuthConfig;
}

/**
 * 获取资源的划分路径依据
 * @param filePath 资源路径
 * @returns
 */
function getExtPathStrategy(filePath: string) {
  const ext = extname(filePath);
  const isDefaultExt = [".css", ".js"].includes(ext);
  // sourcemap文件需要被当做js处理
  return isSourceMap(filePath) ? "js" : !isDefaultExt ? "assets" : ext.replace(/^\./, "");
}

/**
 * 判断当前资源是否是sourcemap文件
 * @param filePath 资源文件路径
 * @returns
 */
function isSourceMap(filePath: string) {
  return /\.js\.map$/.test(filePath);
}

/**
 * 删除指定目录下的所有文件(但保留 index.html),并清理空文件夹
 * @param dirname 指定目录路径
 */
function deleteFilesAndCleanEmptyFolders(dirname: string) {
  const pattern = `${dirname}/**`;
  glob(pattern, {}, (err, files) => {
    if (err) {
      console.log(err);
      return;
    }
    // 删除非 index.html 的文件
    files.forEach((file) => {
      if (basename(file) === "index.html") {
        return;
      }
      try {
        unlinkSync(file);
      } catch (err) {}
    });
    // 清理空文件夹
    removeEmptyFolder(dirname);
  });
}

/**
 * 递归删除空文件夹
 * @param dirPath 文件夹路径
 */
function removeEmptyFolder(dirPath: string) {
  try {
    // 获取文件夹下的所有内容
    const files = glob.sync(`${dirPath}/*`);
    if (files.length === 0) {
      // 如果文件夹为空,删除文件夹
      rmdirSync(dirPath);
    } else {
      // 如果有子文件夹,递归检查子文件夹
      files.forEach((file) => {
        if (!file.includes(".")) {
          // 只针对文件夹
          removeEmptyFolder(file);
        }
      });
    }
  } catch (err) {}
}

export function createOssUploaderPlugin(ossConfig: OssConfig): Plugin {
  let resolvedConfig: ResolvedConfig;

  const { provider = "AliYun" } = ossConfig;

  const providerFn = provider === "AliYun" ? useAliYunUpload : useTencentCloudUpload;

  const { uploadFileToOss: upload } = providerFn(ossConfig.auth);

  const standardUrlPrefix = trimEnd(ossConfig.auth.urlPrefix, "/");

  async function uploadFileToOss(filePath: string, useExtPath = true) {
    const charsetMimes: Record<string, string> = {
      ".js": "utf-8",
      ".css": "utf-8",
      ".html": "utf-8",
      ".htm": "utf-8",
      ".svg": "utf-8",
    };
    const gzipMimes: Record<string, number> = {
      ".plist": 6,
      ".html": 6,
      ".htm": 6,
      ".js": 6,
      ".css": 6,
      ".svg": 6,
    };
    let content = readFileSync(filePath);
    const ext = extname(filePath);
    let contentType = mime.getType(ext) || "application/octet-stream";
    if (charsetMimes[ext]) {
      contentType += "; charset=" + charsetMimes[ext];
    }
    const withoutCdnHostBaseUrl = resolvedConfig.base.replace(standardUrlPrefix, "");
    const fileName = basename(filePath);
    let key = trimEnd(withoutCdnHostBaseUrl, "/");
    if (!key.startsWith("/")) {
      key = "/" + key;
    }
    // 根据rollup配置的策略读取上传的文件前缀
    if (useExtPath) {
      const extPath = getExtPathStrategy(filePath);
      key += `/${extPath}`;
    }
    key += `/${fileName}`;
    const headers: Record<string, unknown> = {
      "Access-Control-Allow-Origin": "*",
      "Content-Type": contentType,
      "Cache-Control": "max-age=315360000",
      Expires: dayjs().add(10, "years").toDate().toUTCString(),
    };
    if (gzipMimes[ext]) {
      headers["Content-Encoding"] = "gzip";
      content = Buffer.from(
        pako.gzip(content, {
          level: gzipMimes[ext] as any,
        })
      );
    }
    const resp = await upload({
      key,
      content,
      headers,
    });
    if (resp) {
      logger.success(`文件${fileName}上传完成!`);
    }
  }

  return {
    name: "vite-plugin-oss-uploader",
    apply: "build",
    config: {
      order: "pre",
      handler(config: UserConfig) {
        // 增加上CDN的前缀
        if (!config.base) {
          return;
        }
        config.base = standardUrlPrefix + "/" + trimEnd(config.base, "/");
      },
    },
    configResolved(config: ResolvedConfig) {
      resolvedConfig = config;
    },
    async writeBundle(outputOptions: OutputOptions, bundle: { [fileName: string]: OutputAsset | OutputChunk }) {
      for (const fileName in bundle) {
        // 不处理html文件
        if (fileName.endsWith(".html")) {
          continue;
        }
        const absPath = resolve(resolvedConfig.build.outDir, fileName);
        await uploadFileToOss(absPath, true);
      }
      // TODO: upload public dir files
      deleteFilesAndCleanEmptyFolders(resolvedConfig.build.outDir);
    },
  };
}

阿里云上传Provider:

ts 复制代码
import { OssAuthConfig, UploadStructure } from "../oss";
import OSS from "ali-oss";

export function useUpload(ossConfig: OssAuthConfig) {
  const ossClient = new OSS({
    accessKeyId: ossConfig.accessKeyId,
    accessKeySecret: ossConfig.accessKeySecret,
    bucket: ossConfig.bucket,
    endpoint: ossConfig.endpoint,
  });

  function uploadFileToOss(uploadInput: UploadStructure) {
    const { key, content, headers } = uploadInput;
    return ossClient.put(key, content, { headers });
  }

  return {
    uploadFileToOss,
  };
}

腾讯云上传Provider:

ts 复制代码
import { OssAuthConfig, UploadStructure } from "../oss";
import COS from "cos-nodejs-sdk-v5";

export function useUpload(ossConfig: OssAuthConfig) {
  const cosClient = new COS({
    SecretId: ossConfig.accessKeyId,
    SecretKey: ossConfig.accessKeySecret,
  });

  function uploadFileToOss(uploadInput: UploadStructure) {
    const { key, content, headers } = uploadInput;
    return cosClient.putObject({
      Bucket: ossConfig.bucket,
      Region: ossConfig.region!,
      Key: key,
      Body: content,
      Headers: headers,
    });
  }

  return {
    uploadFileToOss,
  };
}

closeBundle

可用于清理可能正在运行的任何外部服务。

这也是一个asyncparallel类型的生命周期钩子。

有的同学可能会问,writeBundlecloseBundle这两个生命周期有什么区别呢?我个人的一点浅薄的理解,closeBundle并不一定是Rollup成功完成构建之后才触发的生命周期,所以,closeBundle设计的目的是为了我们清理应用程序,而不是给我们在这个生命周期中访问打包结果的

在我之前的文章中,有向大家聊到过一个完成将资源上传到CDN的例子,以现在的视角来看,这个做法是不太科学的,这个过程应该放在writeBundle里面完成才是合理的。

Rollup的源码实现如下: 源码很简单,没有什么值得说道的,但是有一个重点不要忽略了,这个result对象是Rollup的JS API对外返回的,Rollup提供的CLI是调用了close方法,从而可以正常触发closeBundle生命周期,而如果你是使用的是JS API进行构建的话,可千万别忘了调用,否则到时候你可别怪Rollup有bug,怎么没有触发closeBundle生命周期,😂。

结语

在本文中,我们结合一些插件的例子,向大家阐述了Rollup生成阶段中较为一些重要的生命周期钩子。

在生成生命周期钩子中,我们主要可以对构建产物进行处理,对于产物的操作是多元化的,可以是还在内存中的资源,也可以是已经写入到磁盘中的资源。

我通过实际项目总结经验得出大家最需要掌握的钩子是renderChunkgenerateBundle钩子。

对于插件的阐述,就涵盖这些内容了,如果大家还有一些什么困惑,或者觉得我跳过了一些您认为较为关键的内容,可以联系我进行增加。

相关推荐
前端大卫28 分钟前
Vue3 + Element-Plus 自定义虚拟表格滚动实现方案【附源码】
前端
却尘43 分钟前
Next.js 请求最佳实践 - vercel 2026一月发布指南
前端·react.js·next.js
ccnocare1 小时前
浅浅看一下设计模式
前端
Lee川1 小时前
🎬 从标签到屏幕:揭秘现代网页构建与适配之道
前端·面试
Ticnix1 小时前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人1 小时前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl1 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅1 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人2 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼2 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端