Vite 插件开发实践:微前端的资源处理

最近实现的插件处理简单、透明、实践组件化微前端方案总体感觉不错,微前也收到了很多人的资源反馈,很具有学习参考价值。插件处理

但有不少朋友使用该方案打包配置出现了一些问题,实践做事应有始有终,微前挖的资源坑总得完善一下。今天分享一下 Vite 针对微应用方案插件开发历程。插件处理

通过文章你可以学到:

问题点

总结下来,实践在 Vite 中使用该微前端方案会遇到如下问题:

Vite 打包后的微前资源默认是以 HTML 为入口,我们的资源微前端方案需要以 JS 为入口

JS 为入口方案打包导出代码被移除掉了

import.meta 语句打包被转译成 { } 空对象了

chunk 分离后的 CSS 文件,Vite 默认以document.head.appendChild 处理

打包后的插件处理 CSS 文件默认在 main.js 中没有引用

资源路径手动写 new URL(image, import.meta.url) 太繁琐

通过配置解决问题

首先前三个问题可以通过 Vite 解决。Vite 兼容了 rollup 的实践配置

问题一,修改 JS 入口则需要修改 Vite 配置,微前设置 build.rollupOptions.input 为 src/main.tsx,这样 Vite 会默认以自定义配置的main.tsx 为入口文件做打包处理,不再生成 index.html。

问题二,rollup 的一个特性默认会清理掉入口文件的导出模块,可以配置 preserveEntrySignatures: allow-extension 来保证打包之后 export 的模块不被移除掉。免费信息发布网

问题三,看了 Vite 的 Issue,很多人遇到了这个问题,最初以为是 Vite 默认对它做了处理,后面看了 Vite 源码也没有发现处理的逻辑所在,应该是被 esbuild 做了转译。因此将 build.target 设置为 esnext 即可解决问题,即import.meta 属于 es2020,设置为具体的 es2020 也行。

配置:

export default defineConfig({

build: {

// es2020 支持 import.meta 语法

target: es2020,

rollupOptions: {

// 用于控制 Rollup 尝试确保入口块与基础入口模块具有相同的导出

preserveEntrySignatures: allow-extension,

// 入口文件

input: src/main.tsx,

},

},

});

写 Vite 插件

我们可以写一个插件将上面的配置封装。

一个普通的 Vite 插件很简单

defineConfig({

plugins: [

{

// 可以使用 Vite 和 rollup 提供的钩子

},

],

});

插件可以做很多事情,通过 Vite 和 rollup提供的钩子对代码解析、编译、打包、输出的整体流程进行自定义处理。

插件一般不直接写在 vite.config.ts 中,可以定义一个方法导出这个插件,这里可以用config 这个钩子来提供默认的 Vite 配置,将自定义的配置进行封装:

export function microWebPlugin(): Plugin {

// 插件钩子

return {

name: vite-plugin-micro-web,

config() {

return {

build: {

target: es2020,

rollupOptions: {

preserveEntrySignatures: allow-extension,

input: src/main.tsx,

},

},

};

},

};

}

这样一个简单的插件就完成了。企商汇

Vite 独有钩子

config - 在解析 Vite 配置前调用,它可以返回一个将被深度合并到现有配置中的部分配置对象,或者直接改变配置

configResolved - 在解析 Vite 配置后调用,使用这个钩子读取和存储最终解析的配置

configureServer - 是用于配置开发服务器的钩子

transformIndexHtml - 转换 index.html 的专用钩子。钩子接收当前的 HTML 字符串和转换上下文

handleHotUpdate - 执行自定义 HMR 更新处理。

rollup 钩子

rollup 钩子非常多,一共分两个阶段

编译阶段:

输出阶段:

这里我们会用到的钩子有:

transform - 用于转换已加载的模块内容generateBundle - 已经编译过的代码块生成阶段

样式插入节点处理

问题四,document.head.appendChild 处理

使用 transform 钩子,替换 Vite 默认的document.head.appendChild 为自定义节点

cssCodeSplit 打包为一个 CSS 文件

我们默认采用 cssCodeSplit 打包为一个 CSS 文件,免去了用插件 transform 修改 Vite 的逻辑。

问题五,即打包后的 CSS 没有引用的问题,获取这个带 hash 的 CSS 我们可以有多种解决方案

使用 HTML 打包模式,抽取 index.html 中的 JS 、CSS 文件再单独处理

不添加样式文件名 hash ,通过约定固定该样式名称

通过钩子提取文件名处理

权衡之下,最终采用 generateBundle 阶段提取 Vite 编译生成的 CSS 文件名,云服务器提供商通过修改入口代码将其插入。但 generateBundle 已经在输出阶段,不会再走 transform 钩子。

发现一个两全其美的办法:创建极小的入口文件main.js,还可以配合 hash 和主应用时间戳缓存处理。

async generateBundle(options, bundle) {

// 主入口文件

let entry: string | undefined;

// 所有的 CSS 模块

const cssChunks: string[] = [];

// 找出入口文件和 CSS 文件

for (const chunkName of Object.keys(bundle)) {

if (chunkName.includes(main) && chunkName.endsWith(.js)) {

entry = chunkName;

}

if (chunkName.endsWith(.css)) {

// 使用相对路径,避免后续 ESM 无法解析模块

cssChunks.push(`./${ chunkName}`);

}

}

// 接下面代码

},

生成新的入口文件

通过 bundle 提取可以获取到带 hash 的JS、CSS 入口文件了。现在需要写入一个新的文件 main.js。rollup 中有个 API emitFile可以触发创建一个资源文件。

接下来对它进行处理:

// 接上面代码

if (entry) {

const cssChunksStr = JSON.stringify(cssChunks);

// 创建极小的入口文件,配合 hash 和主应用时间戳缓存处理

this.emitFile({

fileName: main.js,

type: asset,

source: `

// 带上 microAppEnv 参数,使用相对路径避免报错

import defineApp from ./${ entry}?microAppEnv;

// 创建 link 标签

function createLink(href) {

const link = document.createElement(link);

link.rel = stylesheet;

link.href = href;

return link;

}

// 入口文件导出一个方法,将打包的 CSS 文件通过 link 的方式插入到对应的节点中

defineApp.styleInject = (parentNode) => {

${ cssChunksStr}.forEach((css) => {

// import.meta.url 让路径保持正确,中括号取值避免被 rollup 转换掉

const link = createLink(new URL(css, import.meta[url]));

parentNode.prepend(link);

});

};

export default defineApp;

`,

});

}

插件需要应用入口配合导出一个 styleInject方法提供样式插入,我们通过封装入口方法得以解决。

封装一个方法给应用入口调用:

export function defineMicroApp(callback) {

const defineApp = (container) => {

const appConfig = callback(container);

// 处理样式局部插入

const mountFn = appConfig.mount;

// 获取到插件中的方法

const inject = defineApp.styleInject;

if (mountFn && inject) {

appConfig.mount = (props) => {

mountFn(props);

// 装载完毕后,插入样式

inject(container);

};

}

return appConfig;

};

return defineApp;

}

现在 build 之后会生成一个不带 hash 的 main.js 文件,主应用可以正常加载打包后的资源了。

进一步优化,main.js 的压缩混淆,可以用 Vite 导出 transformWithEsbuild 进行编译:

const result = await transformWithEsbuild(customCode, main.js, {

minify: true,

});

this.emitFile({

fileName: main.js,

type: asset,

source: result.code,

});

子应用路径问题

之前我们需要手动添加 new URL(image, import.meta.url) 来修复子应用路径问题。通过 transform 钩子自动处理该逻辑。

在这个插件之前,Vite 会将所有的资源文件转换为路径

import logo from ./logo.svg;

// 转换为:

export default /src/logo.svg;

因此,我们只需要将 export default "资源路径" 替换为 export default new URL("资源路径", import.meta[url]).href 就可以了。

const imagesRE = new RegExp(`\\.(png|webp|jpg|gif|jpeg|tiff|svg|bmp)($|\\?)`);

transform(code, id) {

// 修正图片资源使用绝对地址

if (imagesRE.test(id)) {

return {

code: code.replace(

/(export\s+default)\s+(".+")/,

`$1 new URL($2, import.meta[url]).href`

),

map: null,

};

}

return undefined;

},

完成,一个比较完善的 Vite 微应用方案由此而生。

看看效果:

更多

有了插件,可以发挥出意想不到的事情。本微前端方案没有实现以下的隔离方式,不保证后续会实现,大家可以发挥更多的想象力。

CSS 样式隔离

通过插件将主应用节点中的 id 添加并修改CSS

.name {

color: red;

}

/* 转换为 */

#id .name {

color: red;

}

但前提是需要为每个 设置一个唯一的 id。并且样式性能会受到影响,CSSModules 方案会更好。

JS 沙箱

虽然在 ESM 中做运行时沙箱目前没有现成的方案,但运行时沙箱性能非常差。换个思路,可以从编译时沙箱入手。用 transform 钩子将应用所有的 window 转译为沙箱fakeWindow,从而达到隔离效果。

代码示例

大家可以 clone 下来学习

插件仓库:https://github.com/MinJieLiu/micro-app/tree/main/packages/micro-vite-plugin

微前端示例:https://github.com/MinJieLiu/micro-app-demo

滇ICP备2023000592号-31