实现扩展 external 能力的 Vite 插件

前言

大家对构建工具中的 external 的属性一定不会陌生吧。在优化构建产物体积需求中可能会引入 CDN 来取代一些基础的模块或工具包,如 ReactVuelodash 等。

最近没什么事情,闲余时间研究了下 ViteRollup 的内部实现,借此机会来探究下构建工具是如何处理 external 这一类外部链接的,并对 external 的能力做一些扩展。

如何解析 External

因为 external 的能力主要体现在构建流程,那么我们就从构建的时机开始看起吧。构建的时候 Vite 是依赖于 Rollup 的能力,也就是说 external 的属性主要是在 Rollup 构建上体现出来的。从 Vite 传递给 Rollup 的参数上也可以看出来。

const userExternal = options.rollupOptions?.external
let external = userExternal
const rollupOptions: RollupOptions = {
    context: 'globalThis',
    preserveEntrySignatures: ssr
      ? 'allow-extension'
      : libOptions
      ? 'strict'
      : false,
    ...options.rollupOptions,
    input,
    plugins,
    external,
    onwarn(warning, warn) {
      onRollupWarning(warning, warn, config)
    }
}

const { rollup } = await import('rollup')
const bundle = await rollup(rollupOptions)

那么 Rollup 是如何处理 external 的路径信息呢?

在构建准备阶段我们可以看到有一个初始化的操作:

function normalizeInputOptions(config) {
    const options = {
        // ...
        external: getIdMatcher(config.external)
    };
    return { options, unsetOptions };
}

getIdMatcher 函数中有对 config.external 进行初始化流程

const getIdMatcher = (option) => {
    if (option === true) {
        return () => true;
    }
    if (typeof option === 'function') {
        return (id, ...args) => (!id.startsWith('\0') && option(id, ...args)) || false;
    }
    if (option) {
        const ids = new Set();
        const matchers = [];
        for (const value of ensureArray(option)) {
            if (value instanceof RegExp) {
                matchers.push(value);
            }
            else {
                ids.add(value);
            }
        }
        return (id, ..._args) => ids.has(id) || matchers.some(matcher => matcher.test(id));
    }
    return () => false;
};

简单的说就是当用户在 vite.config.* 配置模块中配置了 build.rollupOptions.external 的话,那么 rollup 会收集配置的路径用来进行判断解析的路径是否为外部链接。

options.external = (id, ..._args) => ids.has(id) || matchers.some(matcher => matcher.test(id));

在此大家一定会好奇检测是在什么时机下发生的呢?

Rollup 检测时机

带着这个好奇我们继续往下看吧,Rollup 在初始化配置信息之后就会通过入口来生成 模块依赖图

async generateModuleGraph() {
    (
        { entryModules: this.entryModules, implicitEntryModules: this.implicitEntryModules } =
            await this.moduleLoader.addEntryModules(normalizeEntryModules(this.options.input), true)
    );
}
async build() {
    await this.generateModuleGraph();
    // ...
}

大家对于模块依赖图一定不会陌生吧,说简单点就是确定模块与模块之间的关系。我们就不一一照着源码来解释生成模块依赖图的流程,那么多无聊呀!

我们可以静下来好好想想,如果让我们来构建项目的模块依赖图,我们应该怎么做呢?

首先,我们肯定需要从配置的 入口(默认 index.html) 开始分析起来,我们可以分析 index.html 模块依赖哪些模块吧,可能 依赖 这个词有些同学不是很懂,那么就用更通俗的话来说 "【我们可以分析 index.html 模块 importscriptlink 了哪些模块吧】"。分析完 index.html 模块后我们不就又可以分析它依赖的模块嘛,在分析依赖模块之前我们应该要先找一下依赖模块在哪,毕竟依赖模块通常也是文件资源嘛。找到依赖模块的位置后很轻易的就能联想到肯定是要读取依赖模块信息吧,那么熟悉 node 的同学就直接 fs 开干! 那我们读取完了嘞,这还要想嘛,不就也像 index.html 模块一样进行解析然后分析依赖情况嘛。细心的同学就可以发现其实就是一个递归检索依赖模块的过程,好像也没什么难度嘛。

真棒! Rollup 还真就是这么处理的,只不过处理流程的时候会引入插件来协助分析。

那么有同学可能就会说:好像讲离题了唉,这个文章不是要分析 external,怎么讲到了依赖构建的流程呢!

别急嘛,心急吃不了热豆腐。将上面的内容是为了让大家对于 Rollup 执行流程有一个大体的认识,在后续会有关联的。

前面有提到 「找到依赖模块的位置」Rollup 在处理依赖模块路径的时候会判断当前模块路径是否是 external


this.resolveId =
async (source, importer, customOptions, isEntry, skip = null) => {
    return this.getResolvedIdWithDefaults(
      this.getNormalizedResolvedIdWithoutDefaults(
            this.options.external(source, importer, false)
                ? false
                : await resolveId(source, importer, this.options.preserveSymlinks, this.pluginDriver, this.resolveId, skip, customOptions, typeof isEntry === 'boolean' ? isEntry : !importer),
            importer,
            source
        )
    );
};

源码上可以了解到当路径被判定为外部链接的情况下是不会执行 await resolveId(...),也就意味着对于外部链接 Rollup 不会对其做处理。

external 的扩展目标与思路

好嘞,前文先介绍到这里。我们接下来先确定下对 external 扩展的目标吧

  1. ESMUMD 产物链接做支持且自动引入 CDN 链接。

    • 解释:

      伴随着高版本浏览器支持 ESM 规范。各大包开发者也意识到了 ESM 的重要性,也提供了ESM 规范的产物且同时也有类似 [skypack](Skypack: search millions of open source JavaScript packages) 的 CDN 厂商对ESM 产物的链接做支持。因此插件对于 ESM 外链的支持也是势在必行。

  2. 支持 Vite 2.0及其以上版本。

    • 解释:

      可能大家有疑惑为什么不支持 2.0 之前的版本呢,原因在于 2.0 其实是 Vite 的第一个稳定版本。大家可以在 NPM Version 上看出 2.0 之前的版本基本没什么受众,耗费时间去兼容意义并不是很大。同时在 awesome-vite 库中在申请优秀插件的时候官方也是要求: The plugin/tool is working with Vite 2.x and onward 即对于 Vite 2.x 版本及以上的支持。

好嘞,现在我们已经确认好目标。那么接下来我们就要分析下如何需要如何进行实现。

对 ESM 和 UMD 产物链接做支持且自动引入 CDN 链接 目标的实现

  1. ESM规范产物链接的支持:

    由于高版本浏览器支持对 ESM 链接的支持,那么我们只需要将原先的包模块名称换成 CDN 链接就可以了。就好比将 import react from 'react' 替换为 import react from 'https://cdn.skypack.dev/react'。按照我们先前说的,当我们引入 react 模块,那么在构建阶段 Rollup 就会去找 react 模块究竟在哪里,也就是说寻找 react 模块的具体路径。那么我们只需要告诉 Rollupreact 的具体路径为 https://cdn.skypack.dev/react 不就好了嘛。的确就是这么处理的,不过这里需要提一嘴的是 Rollup 在获取路径后还会通过 external 配置项来确定下获取的链接是否是外链,如果不是外链的话会进一步进行解析,继续解析按照我们先前说的就是去加载资源,我们引入 CDN,不就是想让包体积变小嘛,如果让 Rollup 加载资源,那打包体积不久没优化嘛,我们目标就想要替换 reacthttps://cdn.skypack.dev/react。因此我们需要告诉 Rolluphttps://cdn.skypack.dev/react是一个外链,你不要继续解析了。

  1. UMD规范产物链接的支持:

    在早期 JQuery 盛行的年代,我们通常会使用 CDN 的方式来引入 JQuery,然后 CDN 引入的产物执行后会在全局(浏览器环境在 window 下)注入特定的属性,开发者就可以通过这个特定属性来获取 JQuery 的能力。大家可能会有想法,浏览器环境中我们只需要帮忙获取一下 JQuerywindow 下注入的属性然后将导入语句改一下就好了嘛,就也是将 import jQuery from 'jquery' 替换成 const jQuery = window['jQuery']。这样做也可以,不过需要对不同的导入方式做特定的处理。在这里,我们可以使用更为简单的方式来进行实现,借助 虚拟模块 来进行实现。可能有部分同学对于 虚拟模块 的定义有点陌生,其实顾名思义就是虚假的模块,是一个不存在磁盘空间上的模块。那么我们可以在 虚拟模块 中将我们定义的内容导出就好了,即将 import jQuery from 'jquery' 转换如下,也就是 Rollup 加载 jquery 模块的内容。

  // virtual: jquery

  const jQuery = window['jQuery'];
  export default jQuery;
  1. 自动引入 CDN 链接:

按照以往流程的话,最后一步我们肯定会手动创建 scriptlink 标签插入到 index.html 模块的 head 标签内。由于这个流程过于简单但有可能存在开发人员不用 CDN 但却没将 CDN 链接移除,那就存在一定的开销了,因此我们把这个能力一并做掉吧! 其实这个流程就是对于 html 模块所做操作,按照正常想法实现的话大概就是先找到一个能解析 html 模块的工具,将 html 文本转换为 ast 结构对象,然后递归分析 ast,针对 head 节点做插入处理。这里 也实现了相类似的能力,感兴趣的同学可以看看。
熟悉 Vite 源码或仔细看官方文档的同学或许会很轻易的想到可以借助 Vite 内置 html 插件 的能力,我们可以用更简单的方式来做到。感兴趣的同学可以 点击 了解一下具体的实现,这里我就简单的说明一下作用吧。html 插件 会调用 transformIndexHtml,返回值若携带特定的标识符会将信息注入到 html 特定的地方,使用方式可见 官方文章。那么我们只需要在 transformIndexHtml 钩子中返回信息,Vite 的内置 插件 就会帮我们处理。

支持 Vite 2.0及其以上版本 目标的实现:

在上文 【ESM规范产物链接的支持】 中有提到我们需要告诉 Rollup 对于 ESM 规范的 CDN 链接不需要继续做解析,因此需要在 Rollup 配置上新增 external 属性。由于 ESM 外链支持异步化(配置外链的流程很大程度会接入平台),而对于在 2.02.2 版本之间 Vite 并不支持异步配置。只能在 vite.config.* 模块中手动添加 external

结束语

好嘞,到此实现思路就已经聊完了。相信大部分的同学已经可以写出 external 这一类的插件了,感觉还是蛮有意思的。我已经将实现的思路代码化了,感兴趣的同学可以将 项目 clone 下来。在此我也很期待大家能给 仓库 带来好的 想法贡献,当然能留下您的 Star 再好不过了,感谢大家的阅读!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容