【总结】1259- Vite 插件开发实践:微前端的资源处理

共 5799字,需浏览 12分钟

 ·

2022-03-16 02:23

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

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

通过文章你可以学到:

1. 写一个 Vite 插件
2. 通过 rollup 编译生成一个单独的资源文件
3. import 资源路径处理
4. rollup 一些配置含义
5. 一些解决问题的思路

问题点

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

  1. Vite 打包后的资源默认是以 HTML 为入口,我们的微前端方案需要以 JS 为入口
  2. JS 为入口方案打包导出代码被移除掉了
  3. import.meta 语句打包被转译成 {} 空对象了
  4. chunk 分离后的 CSS 文件,Vite 默认以 document.head.appendChild 处理
  5. 打包后的 CSS 文件默认在 main.js 中没有引用
  6. 资源路径手动写 new URL(image, import.meta.url) 太繁琐

通过配置解决问题

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

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

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

问题三,看了 ViteIssue,很多人遇到了这个问题,最初以为是 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 提供的钩子
},
],
});

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

插件一般不直接写在 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 处理

  1. 使用 transform 钩子,替换 Vite 默认的 document.head.appendChild 为自定义节点
  2. cssCodeSplit 打包为一个 CSS 文件

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

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

  1. 使用 HTML 打包模式,抽取 index.html 中的 JSCSS 文件再单独处理
  2. 不添加样式文件名 hash ,通过约定固定该样式名称
  3. 通过钩子提取文件名处理

权衡之下,最终采用 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 提取可以获取到带 hashJSCSS 入口文件了。现在需要写入一个新的文件 main.jsrollup 中有个 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 之后会生成一个不带 hashmain.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

浏览 67
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报