Vite学习笔记(二)Vite的插件API与MPA插件开发

这篇博客我们介绍一下Vite的插件API,并且开发一个简单的插件来支持MPA项目。

首先我们在上一篇博客中提到过,Vite 自身不包含 loader 的概念。

Vite 通过原生 ES 模块导入提供了开箱即用的支持,能够直接导入 .js.jsx.ts.tsx.css.json 等文件,不需要额外的 loader 配置。

对于其他类型的资源文件,Vite 提供了两种处理方式:

  1. 使用专门的插件,比如 @vitejs/plugin-vue 来处理 .vue 文件,unplugin-auto-import 来自动导入 API。这些插件会在内部使用 loader 做转换和加载。

  2. 使用 vite.transform 钩子对某些文件类型提供自定义的转换链。这相当于 loader 的功能,但需要自行实现。

另外,Vite 也支持在 vite.config.js 中使用 Rollup 的 load 钩子来加载自定义文件类型,但是这种方式对于开发模式不起作用,因为开发模式下vite使用的是esbuild进行打包。

插件API介绍

Vite 插件扩展了设计出色的 Rollup 接口,带有一些 Vite 独有的配置项。因此,你只需要编写一个 Vite 插件,就可以同时为开发环境和生产环境工作。

Rollup插件

Vite的插件API及其功能基本和Rollup的差不多,我们只需要了解Rollup的插件API,然后再看看Vite与其之间的一点小区别就好。

这是Rollup的插件文档:https://cn.rollupjs.org/plugin-development/

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

插件允许你通过例如在打包之前进行转译代码或在node_modules文件夹中查找第三方模块来自定义 Rollup 的行为。

我们这里简单介绍几个常用的插件API

属性

name
类型: string
插件的名称,用于在警告和错误消息中标识插件。

version
类型: string
插件的版本,用于插件间通信场景。

构建类型

为了与构建过程交互,你的插件对象包括“钩子”。钩子是在构建的各个阶段调用的函数。钩子可以影响构建的运行方式,提供关于构建的信息,或在构建完成后修改构建。有不同种类的钩子:

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

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

构建钩子

构建钩子在构建阶段运行,该阶段由 rollup.rollup(inputOptions) 触发。它们主要涉及在 Rollup 处理输入文件之前定位、提供和转换输入文件。构建阶段的第一个钩子是 options,最后一个钩子始终是 buildEnd。如果有构建错误,则在此之后将调用 closeBundle。

此外,在监视模式下,watchChange 钩子可以在任何时候触发,以通知当前运行生成输出后将触发新的运行。另外,当监视器关闭时,closeWatcher 钩子将被触发。

buildEnd

类型: (error?: Error) => void

类别: async, parallel

上一个钩子: moduleParsed、resolveId 或 resolveDynamicImport

下一个钩子: 输出生成阶段的 outputOptions,因为这是构建阶段的最后一个钩子

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

buildStart

类型: (options: InputOptions) => void

类别: async, parallel

上一个钩子: options

下一个钩子: 并行解析每个入口点的 resolveId

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

options

类型: (options: InputOptions) => InputOptions | null

类别: async,sequential

上一个钩子: 这是构建阶段的第一个钩子

下一个钩子: buildStart

替换或操作传递给 rollup.rollup 的选项对象。返回 null 不会替换任何内容。如果只需要读取选项,则建议使用 buildStart 钩子,因为该钩子可以访问所有 options 钩子的转换考虑后的选项。

transform

类型: (code: string, id: string) => TransformResult

类别: async, sequential

上一个钩子: load,用于加载当前处理的文件。如果使用缓存并且该模块有一个缓存副本,则为 shouldTransformCachedModule,如果插件为该钩子返回了 true

下一个钩子: moduleParsed,一旦文件已被处理和解析

1
2
3
4
5
6
7
8
9
10
11
type TransformResult = string | null | Partial<SourceDescription>;

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

可用于转换单个模块。为了避免额外的解析开销,例如此钩子已经使用 this.parse 生成了 AST,此钩子可以选择性地返回一个 { code, ast, map } 对象。ast 必须是一个标准的 ESTree AST,每个节点都有 start 和 end 属性。如果转换不移动代码,则可以通过将 map 设置为 null 来保留现有的源映射。否则,你可能需要生成源映射。请参见 源代码转换 一节。

请注意,在观察模式下或明确使用缓存时,当重新构建时,此钩子的结果会被缓存,仅当模块的 code 发生更改或上次触发此钩子时添加了通过 this.addWatchFile 添加的文件时,才会再次触发该模块的钩子。

在所有其他情况下,将触发 shouldTransformCachedModule 钩子,该钩子可以访问缓存的模块。从 shouldTransformCachedModule 返回 true 将从缓存中删除该模块,并再次调用 transform。

你还可以使用返回值的对象形式来配置模块的其他属性。请注意,可能只返回属性而没有代码转换。

如果对于 moduleSideEffects 返回 false,并且没有其他模块从该模块导入任何内容,则即使该模块具有副作用,该模块也不会被包含。

如果返回 true,则 Rollup 将使用其默认算法,包括模块中具有副作用的所有语句(例如修改全局或导出变量)。

如果返回 “no-treeshake”,则将关闭此模块的除屑优化,并且即使为空,它也将包含在生成的块之一中。

如果返回 null 或省略标志,则 moduleSideEffects 将由加载此模块的 load 钩子、解析此模块的第一个 resolveId 钩子、treeshake.moduleSideEffects 选项或最终默认为 true 确定。

输出钩子

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

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

Vite插件

通用钩子

在开发中,Vite 开发服务器会创建一个插件容器来调用 Rollup 构建钩子,与 Rollup 如出一辙。

以下钩子在服务器启动时被调用:

options
buildStart

以下钩子会在每个传入模块请求时被调用:

resolveId
load
transform

它们还有一个扩展的 options 参数,包含其他特定于 Vite 的属性。你可以在 SSR 文档 中查阅更多内容。

一些 resolveId 调用的 importer 值可能是根目录下的通用 index.html 的绝对路径,这是由于 Vite 非打包的开发服务器模式无法始终推断出实际的导入者。对于在 Vite 的解析管道中处理的导入,可以在导入分析阶段跟踪导入者,提供正确的 importer 值。

以下钩子在服务器关闭时被调用:

buildEnd
closeBundle

请注意 moduleParsed 钩子在开发中是 不会 被调用的,因为 Vite 为了性能会避免完整的 AST 解析。

Output Generation Hooks(除了 closeBundle) 在开发中是 不会 被调用的。你可以认为 Vite 的开发服务器只调用了 rollup.rollup() 而没有调用 bundle.generate()。

Vite独有钩子

config

类型: (config: UserConfig, env: { mode: string, command: string }) => UserConfig | null | void

种类: async, sequential

在解析 Vite 配置前调用。钩子接收原始用户配置(命令行选项指定的会与配置文件合并)和一个描述配置环境的变量,包含正在使用的 mode 和 command。它可以返回一个将被深度合并到现有配置中的部分配置对象,或者直接改变配置(如果默认的合并不能达到预期的结果)

configResolved

类型: (config: ResolvedConfig) => void | Promise

种类: async, parallel

在解析 Vite 配置后调用。使用这个钩子读取和存储最终解析的配置。当插件需要根据运行的命令做一些不同的事情时,它也很有用。
configureServer
类型: (server: ViteDevServer) => (() => void) | void | Promise<(() => void) | void>

种类: async, sequential

是用于配置开发服务器的钩子。最常见的用例是在内部 connect 应用程序中添加自定义中间件:

configureServer 钩子将在内部中间件被安装前调用,所以自定义的中间件将会默认会比内部中间件早运行。如果你想注入一个在内部中间件 之后 运行的中间件,你可以从 configureServer 返回一个函数,将会在内部中间件安装后被调用:

此外还有:configurePreviewServer,transformIndexHtml,handleHotUpdate

插件顺序

一个 Vite 插件可以额外指定一个 enforce 属性(类似于 webpack 加载器)来调整它的应用顺序。enforce 的值可以是pre 或 post。解析后的插件将按照以下顺序排列:

Alias
带有 enforce: ‘pre’ 的用户插件
Vite 核心插件
没有 enforce 值的用户插件
Vite 构建用的插件
带有 enforce: ‘post’ 的用户插件
Vite 后置构建插件(最小化,manifest,报告)

MPA项目插件实战代码

这个插件主要通过使用开发服务器的server.middleware来进行rewrite来实现多入口项目的启动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
import type { ResolvedConfig, PluginOption } from 'vite';
import { render } from 'ejs';
import { normalizePath } from 'vite';
import { parse } from 'node-html-parser';
import path from 'path';
import history from 'connect-history-api-fallback';

import type { Options as EJSOptions } from 'ejs';
import type { HtmlTagDescriptor } from 'vite';


interface InjectOptions {
/**
* @description Data injected into the html template
*/
data?: Record<string, any>

tags?: HtmlTagDescriptor[]

/**
* @description esj options configuration
*/
ejsOptions?: EJSOptions
}

interface PageOption {
url: string;
template: string
entry?: string
injectOptions?: InjectOptions
}

type Pages = PageOption[]

interface UserOptions {
/**
* @description Page options
*/
pages: Pages
}


const DEFAULT_TEMPLATE = 'index.html';

const bodyInjectRE = /<\/body>/;

export function createMultiPagePlugin(userOptions: UserOptions): PluginOption {
const {
pages = [],
} = userOptions;

let viteConfig: ResolvedConfig;
const env: Record<string, any> = {};

return {
name: 'vite-plugin-multi-page',
enforce: 'pre',

configResolved(resolvedConfig) {
viteConfig = resolvedConfig;
},

configureServer(server) {
const rewrites: { from: RegExp; to: any }[] = [];
const proxy = viteConfig.server?.proxy ?? {};
const baseUrl = viteConfig.base ?? '/';
const keys = Object.keys(proxy);

for (const page of pages) {
rewrites.push(createRewire(page.url, page, baseUrl, keys));
}

server.middlewares.use(
history({
disableDotRule: undefined,
htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'],
rewrites: rewrites,
}),
);
},

transformIndexHtml: {
enforce: 'pre',
async transform(html, ctx) {
const url = ctx.filename;
const base = viteConfig.base;
const excludeBaseUrl = url.replace(base, '/');
const htmlName = path.relative(process.cwd(), excludeBaseUrl);

const page = getPageConfig(htmlName, userOptions.pages, DEFAULT_TEMPLATE);
const { injectOptions = {} } = page;
const _html = await renderHtml(html, {
injectOptions,
viteConfig,
env,
entry: page.entry,
});
const { tags = [] } = injectOptions;
return {
html: _html,
tags: tags,
};
},
},
};
}

async function renderHtml(
html: string,
config: {
injectOptions: InjectOptions
viteConfig: ResolvedConfig
env: Record<string, any>
entry?: string
verbose?: boolean
},
) {
const { injectOptions, viteConfig, env, entry, verbose } = config;
const { data, ejsOptions } = injectOptions;

const ejsData: Record<string, any> = {
...(viteConfig?.env ?? {}),
...(viteConfig?.define ?? {}),
...(env || {}),
...data,
};
let result = await render(html, ejsData, ejsOptions);

if (entry) {
result = removeEntryScript(result, verbose);
result = result.replace(
bodyInjectRE,
`<script type="module" src="${normalizePath(
`${entry}`,
)}"></script>\n</body>`,
);
}
return result;
}

function removeEntryScript(html: string, verbose = false) {
if (!html) {
return html;
}

const root = parse(html);
const scriptNodes = root.querySelectorAll('script[type=module]') || [];
const removedNode: string[] = [];
scriptNodes.forEach((item) => {
removedNode.push(item.toString());
item.parentNode.removeChild(item);
});
verbose &&
removedNode.length &&
console.warn(`vite-plugin-html: Since you have already configured entry, ${removedNode.toString()} is deleted. You may also delete it from the index.html.`);
return root.toString();
}

function getPageConfig(
htmlName: string,
pages: Pages,
defaultPage: string,
): PageOption {
const defaultPageOption: PageOption = {
url: defaultPage,
template: `./${defaultPage}`,
};

const page = pages.filter((page) => {
return path.resolve('/' + page.template) === path.resolve('/' + htmlName);
})?.[0];
console.log(htmlName, page);
return page ?? defaultPageOption ?? undefined;
}

function createRewire(
reg: string,
page: any,
baseUrl: string,
proxyUrlKeys: string[],
) {
return {
from: new RegExp(`^/${reg}*`),
to({ parsedUrl }: any) {
const pathname: string = parsedUrl.pathname;

const excludeBaseUrl = pathname.replace(baseUrl, '/');

const template = path.resolve(baseUrl, page.template);

if (excludeBaseUrl === '/') {
return template;
}
const isApiUrl = proxyUrlKeys.some((item) =>
pathname.startsWith(path.resolve(baseUrl, item)),
);
return isApiUrl ? excludeBaseUrl : template;
},
};
}