插件 Hooks
插件通过 lifecycle hooks 处理构建期副作用与底层 bundler 定制。在 setup() 中定义共享
状态,并返回需要该状态的 hooks。如果行为应以声明方式记录在 framework IR 中,则改用
generated contributions。
生命周期
通过 definePlugin() 创建插件时,类型安全的值在这些阶段保持扁平:configure 与 setup
使用 ctx.options;emitIR() 使用 ctx.options 和
ctx.pages[].options;emitPageIR() 使用 ctx.options 与
ctx.pageOptions。
| Hook | 用途 |
|---|---|
configureBundler(config, ctx) | 修改当前 bundler 配置 |
beforeBuild(ctx) | fresh bundler facts 就绪后、evjs 链接或发射 canonical output 前执行 |
transformOutput(output, ctx) | 调整已链接的 AssetGroup 内容或添加 deployment metadata |
transformHtml(doc, ctx) | 逐个 HTML 文档修改输出;接收当前 manifest result 字段 |
afterBuild({ output, isRebuild }) | 构建后输出最终产物 |
dispose(ctx) | 清理资源 |
先于这些 hooks 运行的 configure() 与 setup() 合同见
插件开发。
Rebuild 与 Watch 行为
每个 afterBuild() hook 都会收到 canonical build result 的一份隔离快照。修改只在当前
hook 内可见,不会改变后续 hook 或 deployment adapter 收到的输入。
在 dev 中,初次输出的两个 hook 都收到 isRebuild: false;之后每次 evjs 可观测的
output cycle 都收到 isRebuild: true,并按 beforeBuild() → afterBuild() 成对执行。
beforeBuild() 表示 fresh bundler facts 已就绪、evjs 即将链接并发布 canonical output,
并不是底层 bundler 的 compile-start 回调。
如果 bundler 在产生 fresh facts 前失败,两者都不会执行。如果 beforeBuild()、链接、
output transform、HTML 发射或发布失败,afterBuild() 不会执行。prepare 与 inspect
只暂存 framework state、不发布 output,因此也不会触发这两个 hook。
afterBuild() 明确定义在发布之后。若它失败,evjs 会报告构建失败或停止 dev session,
但不会回滚 canonical output,也不会删除更早的 afterBuild() hook 已输出的产物。
每个 setup snapshot 的 dispose() 最多执行一次,并按 plugin 逆序运行。触发场景包括
production build 结束、dev server 关闭、config reload 替换旧 snapshot,以及
setup/初始化失败后的回滚;普通 dev rebuild 之后不会执行它。
setup()、emitIR() 和 configureBundler() context 提供 addWatchFile() 来注册
analysis/config 依赖;BeforeBuildContext 明确不提供它,晚期 output、HTML 与 dispose
context 也不提供。文件变化时,框架复用已提交的
config、Application options 与 setup hooks,再重新执行 contributions 和 graph analysis。
需要读取变化数据时,应在 emitIR() 中读取,不要在 setup() 中缓存。
configureBundler() context 的 addWatchFile() 注册实际 bundler config 依赖。文件变化时,
框架会先暂存一份完整的 config 与 plugin 快照,再应用对应的 plan update。如果所选
adapter 无法安全地原地替换配置,更新会 fail-closed 并明确提示重启,不会继续使用混合
或过期状态。
Build Output 所有权
transformOutput() 只能调整已链接的 AssetGroup 内容和 deployment metadata。
deployment 必须是可无损 JSON 序列化的普通对象。函数、访问器、非有限数值、负零、
不安全 key、稀疏数组和循环引用会在引入它们的 hook 执行后立即被拒绝,后续 output
hook 与发布阶段都不会继续执行。
其他 BuildOutput 字段仍由 framework 持有,包括:
- build id、输出路径和 public path;
- runtime endpoint 与 transport;
- server entry、renderer、function 与 route;
- Application、Page、RSC 与 PPR 语义。
Hook 不能新增、删除或重排 framework record 或数组。具体来说,hook 不能新增、删除或 重命名 Application、Page、Route 或 Document,不能调整 Route 顺序、修改 Page path 或 Route ownership,也不能修改 Document file name 和 static alias。这些值必须在 graph linking 前完成配置。
HTML Transform Context
transformHtml() 会为每个实际发射的 static HTML 文件,以及每个在构建期编译的
Page-specific request-time document shell,分别接收一个已解析 HTML 文档。应通过
ctx.owner.kind 判断当前文档归属,不要从文件名猜。
transformHtml(doc, ctx) {
doc.head?.appendChild(doc.createComment(` build ${ctx.buildId} `));
if (ctx.owner.kind === "application") {
doc.documentElement?.setAttribute("data-app", ctx.applicationId);
}
if (ctx.owner.kind === "page") {
doc.documentElement?.setAttribute("data-page", ctx.owner.pageId);
}
}
Context 字段包括:
ctx.documentId与ctx.applicationId;ctx.owner:{ kind: "application" }、{ kind: "page", pageId }或{ kind: "plugin", pluginId };ctx.fileName和ctx.template;对于 request-time shell,fileName是逻辑 Document filename,不会作为 static file 发射;ctx.assets;ctx.output,即当前 build output;ctx.buildId和ctx.publicPath。
文档类型是 HtmlDocument,它是标准 DOM API 的 bundler 无关子集:
import type { HtmlDocument } from "@evjs/ev/plugin";
最终 Build Result
afterBuild() 接收最终构建输出、framework runtime 与 canonical deployment metadata:
setup() {
return {
afterBuild({
output,
frameworkRuntime,
deploymentMetadata,
isRebuild,
}) {
console.log("Apps:", Object.keys(output.apps));
console.log("Pages:", Object.keys(output.pages));
console.log("Runtime routing:", frameworkRuntime?.routing.kind);
console.log("Server entry:", deploymentMetadata.server.entry);
console.log("Deploy routes:", deploymentMetadata.routes.length);
console.log("Rebuild:", isRebuild);
},
};
}
部署插件应优先从 deploymentMetadata 读取 routes、documents、assets 和 server entry。
需要完整内部 build graph 的插件仍可在内存中检查 output;需要 runtime 信息的插件可
读取 frameworkRuntime。部署规划应直接使用 deploymentMetadata,不要再派生拆分的
client/server manifest。HTML hook 会收到同一组结果字段,并额外包含 ctx.owner、
ctx.fileName、ctx.assets 等文档字段。
Bundler Config
definePlugin() 默认创建 bundler 无关的插件,同一个 factory 可安装到
Utoopack 或 webpack 应用。底层 bundler 修改应使用类型安全的 adapter
helper;每个 helper 只会在对应 adapter 下调用回调,并提供该 adapter 的具体
config 类型。
最终 BuildPlan 始终是 framework runtime endpoint 与 output ownership 的事实源。
configureBundler() hook 可以定制受支持的 loader、resolution、optimization 等底层
setting,但不能覆盖 framework client/server 输出路径。即使关闭 recursive clean,
adapter 也会在 hook 运行后按 BuildPlan 校验这些路径。Plugin 持有的 clean output
同样必须位于 framework 持有的 distDir 内,且不能与 client/server output 重叠。
Framework 持有的 client/server config 还必须在每个 hook 后保留完全一致的 entry
集合,以及每个 entry 对应的 BuildPlan import。需要改变 framework 启动组合时,
应使用 generated contributions。仅面向 webpack 的插件可以为独立产物增加一个
单独命名的 config,但必须配置明确且可移植地不重叠的 output.path;仅大小写不同
仍视为冲突。Utoopack 的单一 framework config 不允许增加额外 entry。
Utoopack 示例:
import { merge, utoopack } from "@evjs/bundler-utoopack";
import { definePlugin } from "@evjs/ev/plugin";
export const yamlPlugin = definePlugin({
id: "yaml-support",
setup() {
return {
configureBundler: utoopack((cfg) => {
merge(cfg, {
module: {
rules: {
".yaml": { type: "json" },
},
},
});
}),
};
},
});
切换到 webpack 的项目,选择 webpack adapter 并使用它的类型安全 helper
即可。defineConfig() 会从 adapter 自动推断 bundler config 类型,helper
回调会收到完整的 Configuration[] 配置集合:
import { defineConfig } from "@evjs/ev";
import { webpack, webpackAdapter } from "@evjs/bundler-webpack";
import { definePlugin } from "@evjs/ev/plugin";
const webpackAlias = definePlugin({
id: "webpack-alias",
setup() {
return {
configureBundler: webpack((configs) => {
for (const config of configs) {
config.resolve ??= {};
config.resolve.alias ??= {};
config.resolve.alias["@app"] = "./src";
}
}),
};
},
});
export default defineConfig({
bundler: webpackAdapter,
plugins: [webpackAlias()],
});
完整示例见插件配方。