Vite 微前端实践,实现一个组件化的方案

开发 前端
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

本文转载自微信公众号「前端星辰」,作者旋律 。转载本文请联系前端星辰公众号。

什么是微前端

微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

微前端借鉴了微服务的架构理念,将一个庞大的前端应用拆分为多个独立灵活的小型应用,每个应用都可以独立开发、独立运行、独立部署,再将这些小型应用联合为一个完整的应用。微前端既可以将多个项目融合为一,又可以减少项目之间的耦合,提升项目扩展性,相比一整块的前端仓库,微前端架构下的前端仓库倾向于更小更灵活。

特性

  • 技术栈无关 主框架不限制接入应用的技术栈,子应用可自主选择技术栈
  • 独立开发/部署 各个团队之间仓库独立,单独部署,互不依赖
  • 增量升级 当一个应用庞大之后,技术升级或重构相当麻烦,而微应用具备渐进式升级的特性
  • 独立运行时 微应用之间运行时互不依赖,有独立的状态管理
  • 提升效率 应用越庞大,越难以维护,协作效率越低下。微应用可以很好拆分,提升效率

目前可用的微前端方案

微前端的方案目前有以下几种类型:

基于 iframe 完全隔离的方案

作为前端开发,我们对 iframe 已经非常熟悉了,在一个应用中可以独立运行另一个应用。它具有显著的优点:

  • 非常简单,无需任何改造
  • 完美隔离,JS、CSS 都是独立的运行环境
  • 不限制使用,页面上可以放多个 iframe 来组合业务

当然,缺点也非常突出:

  • 无法保持路由状态,刷新后路由状态就丢失
  • 完全的隔离导致与子应用的交互变得极其困难
  • iframe 中的弹窗无法突破其本身

整个应用全量资源加载,加载太慢

这些显著的缺点也催生了其他方案的产生。

基于 single-spa 路由劫持方案

single-spa 通过劫持路由的方式来做子应用之间的切换,但接入方式需要融合自身的路由,有一定的局限性。

qiankun 孵化自蚂蚁金融科技基于微前端架构的云产品统一接入平台。它对 single-spa 做了一层封装。主要解决了 single-spa 的一些痛点和不足。通过 import-html-entry 包解析 HTML 获取资源路径,然后对资源进行解析、加载。

通过对执行环境的修改,它实现了 JS 沙箱、样式隔离 等特性。

京东 micro-app 方案

京东 micro-app 并没有沿袭 single-spa 的思路,而是借鉴了 WebComponent 的思想,通过 CustomElement 结合自定义的 ShadowDom,将微前端封装成一个类 webComponents 组件,从而实现微前端的组件化渲染。

在 Vite 上使用微前端

我们从 我们从 UmiJS 迁移到了 Vite 之后,微前端也成为了势在必行,当时也调研了很多方案。

为什么没用 qiankun

qiankun 是目前是社区主流微前端方案。它虽然很完善、流行,但最大的问题就是不支持 Vite。它基于 import-html-entry 解析 HTML 来获取资源,由于 qiankun 是通过 eval 来执行这些 js 的内容,而 Vite 中的 script 标签类型是 type="module",里面包含 import/export 等模块代码, 所以会报错:不允许在非 type="module" 的 script 里面使用 import。

退一步实现,我们采用了 single-spa 的方式,并使用 systemjs 的方式进行了微前端加载方案,也踩了不少的坑。single-spa 没有一个友好的教程来接入,文档虽然多,但大多都在讲概念,当时让人觉得有一种深奥的感觉。

后来看了它的源码发现,这都是些什么……里面大部分代码都是围绕路由劫持而展开的,根本没有文档上那种高大上的感觉。而我们又用不到它路由劫持的功能,那我们为什么要用它?

从组件化的层面来说 single-spa 这种方式实现得一点都不优雅。

  • 它劫持了路由,与 react-router 和组件化的思维格格不入
  • 接入方式一大堆繁杂的配置
  • 单实例的方案,即同一时刻,只有一个子应用被展示

后来琢磨着 single-spa 的缺点,我们可以自己实现一个组件化的微前端方案。

如何实现一个简单、透明、组件化的方案

通过组件化思维实现一个微应用非常简单:子应用导出一个方法,主应用加载子应用并调用该方法,并传入一个 Element 节点参数,子应用得到该 Element 节点,将本身的组件 appendChild 到 Element 节点上。

类型约定

在此之前我们需要约定一个主应用与子应用之间的一个交互方式。主要通过三个钩子来保证应用的正确执行、更新、和卸载。

类型定义:

  1. export interface AppConfig { 
  2.   // 挂载 
  3.   mount?: (props: unknown) => void; 
  4.   // 更新 
  5.   render?: (props: unknown) => ReactNode | void; 
  6.   // 卸载 
  7.   unmount?: () => void; 

子应用导出

通过类型的约定,我们可以将子应用导出:mount、render、unmount 为主要钩子。

React 子应用实现:

  1. export default (container: HTMLElement) => { 
  2.   let handleRender: (props: AppProps) => void; 
  3.  
  4.   // 包裹一个新的组件,用作更新处理 
  5.   function Main(props: AppProps) { 
  6.     const [state, setState] = React.useState(props); 
  7.     // 将 setState 方法提取给 render 函数调用,保持父子应用触发更新 
  8.     handleRender = setState; 
  9.     return <App {...state} />; 
  10.   } 
  11.  
  12.   return { 
  13.     mount(props: AppProps) { 
  14.       ReactDOM.render(<Main {...props} />, container); 
  15.     }, 
  16.     render(props: AppProps) { 
  17.       handleRender?.(props); 
  18.     }, 
  19.     unmount() { 
  20.       ReactDOM.unmountComponentAtNode(container); 
  21.     }, 
  22.   }; 
  23. }; 

 

Vue 子应用实现:

  1. import { createApp } from 'vue'
  2. import App from './App.vue'
  3.  
  4. export default (container: HTMLElement) => { 
  5.   // 创建 
  6.   const app = createApp(App); 
  7.   return { 
  8.     mount() { 
  9.       // 装载 
  10.       app.mount(container); 
  11.     }, 
  12.     unmount() { 
  13.       // 卸载 
  14.       app.unmount(); 
  15.     }, 
  16.   }; 
  17. }; 

主应用实现

React 实现

其核心代码仅十余行,主要处理与子应用交互 (为了易读性,隐藏了错误处理代码):

  1. export function MicroApp({ entry, ...props }: MicroAppProps) { 
  2.   // 传递给子应用的节点 
  3.   const containerRef = useRef<HTMLDivElement>(null); 
  4.   // 子应用配置 
  5.   const configRef = useRef<AppConfig>(); 
  6.  
  7.   useLayoutEffect(() => { 
  8.     import(/* @vite-ignore */ entry).then((res) => { 
  9.       // 将 div 传给子应用渲染 
  10.       const config = res.default(containerRef.current); 
  11.       // 调用子应用的装载方法 
  12.       config.mount?.(props); 
  13.       configRef.current = config; 
  14.     }); 
  15.     return () => { 
  16.       // 调用子应用的卸载方法 
  17.       configRef.current?.unmount?.(); 
  18.       configRef.current = undefined; 
  19.     }; 
  20.   }, [entry]); 
  21.  
  22.   return <div ref={containerRef}>{configRef.current?.render?.(props)}</div>; 

完成,现在已经实现了主应用与子应用的装载、更新、卸载的操作。现在,它是一个组件,可以同时渲染出多个不同的子应用,这点就比 single-spa 优雅很多。

entry 子应用地址,当然真实情况会根据 dev 和 prod 模式给出不同的地址:

  1. <MicroApp className="micro-app" entry="//localhost:3002/src/main.tsx" /> 

Vue 实现

  1. <script setup lang="ts"
  2. import { onMounted, onUnmounted, ref } from 'vue'
  3.  
  4. const { entry, ...props } = defineProps<{ entry: string }>(); 
  5. const container = ref<HTMLDivElement | null>(null); 
  6. const config = ref(); 
  7.  
  8. onMounted(() => { 
  9.   const element = container.value; 
  10.   import(/* @vite-ignore */ entry).then((res) => { 
  11.     // 将 div 传给子应用渲染 
  12.     const config = res.default(element); 
  13.     // 调用子应用的装载方法 
  14.     config.mount?.(props); 
  15.     config.value = config; 
  16.   }); 
  17. }); 
  18.  
  19. onUnmounted(() => { 
  20.   // 调用子应用的卸载方法 
  21.   config.value?.unmount?.(); 
  22. }); 
  23. </script> 
  24.  
  25. <template> 
  26.   <div ref="container"></div> 
  27. </template>

如何让子应用也能独立运行

single-spa 等众多方案,都是将一个变量挂载到 window 上,通过判断该变量是否处于微前端环境,这样很不优雅。在 ESM 中,我们可以通过 import.meta.url 传入参数来判断:

  1. if (!import.meta.url.includes('microAppEnv')) { 
  2.   ReactDOM.render( 
  3.     <React.StrictMode> 
  4.       <App /> 
  5.     </React.StrictMode>, 
  6.     document.getElementById('root'), 
  7.   ); 

入口导入修改:

  1. // 添加环境参数和当前时间避免被缓存 
  2. import(/* @vite-ignore */ `${entry}?microAppEnv&t=${Date.now()}`); 

浏览器兼容性

IE 浏览器已经逐步退出我们的视野,基于 Vite,我们只需要支持 import 的特性浏览器就够了。当然,如果考虑 IE 浏览器的话也不是不可以,很简单:将上面代码的 import 替换为 System.import 即 systemjs,也是 single-spa 的所推崇的用法。

浏览器 Chrome Edge Firefox Internet Explorer Safari
import 61 16 60 No 10.1
Dynamic import 63 79 67 No 11.1
import.meta 64 79 62 No 11.1

模块公用

我们的子组件必须要使用 mount 、unount 模式吗?答案是不一定,如果我们的技术栈都是 React 的话。我们的子应用只导出一个 render 就够了。这样用的就是同一个 React 来渲染,好处是子应用可以消费父应用的 Provider。但有个前提是两个应用之间的 React 必须为同一个实例,否则就会报错。

我们可以将 react、react-dom 、styled-componets 等常用模块提前打包成 ESM 模块,然后放到文件服务中使用。

更改 Vite 配置添加 alias:

  1. defineConfig({ 
  2.   resolve: { 
  3.     alias: { 
  4.       react: '//localhost:8000/react@17.js'
  5.       'react-dom''//localhost:8000/react-dom@17.js'
  6.     }, 
  7.   }, 
  8. }); 

这样就能愉快地使用同一份 React 代码了。还能抽离出主应用和子应用之间的公用模块,让应用总体积更小。当然如果没上 http2 的话,就需要考虑颗粒度的问题了。

在线 CDN 方案:https://esm.sh

还有个 importmap 方案,兼容性不太好,但未来是趋势:

  1. <script type="importmap"
  2.   { 
  3.     "imports": { 
  4.       "react""//localhost:8000/react@17.js" 
  5.     } 
  6.   } 
  7. </script> 

 

父子通信

组件式微应用,可以传递参数而通信,完全就是 React 组件通信的模型。

资源路径

  1. import logo from './images/logo.svg'
  2.  
  3. <img src={logo} />; 

在 Vite 的 dev 模式中,子应用里面静态资源一般会这样引入:

  1. import logo from './images/logo.svg'
  2.  
  3. <img src={logo} />; 

图片的路径:/basename/src/logo.svg,在主应用显示就会 404。因为该路径只是存在于子应用。我们需要配合 URL 模块使用,这样路径前面会带上 origin 前缀:

  1. const logoURL = new URL(logo, import.meta.url); 
  2.  
  3. <img src={logoURL.href} />; 

当然这样使用比较繁琐,我们可以将其封装为一个 Vite 插件自动处理该场景。

路由同步

项目使用 react-router,那么它可能会存在路由不同步的问题,因为不是同一个 react-router 实例。即路由之间出现不联动的现象。

在 react-router 支持自定义 history 库,我们可以创建:

  1. import { createBrowserHistory } from 'history'
  2.  
  3. export const history = createBrowserHistory(); 
  4.  
  5. // 主应用:路由入口 
  6. <HistoryRouter history={history}>{children}</HistoryRouter>; 
  7.  
  8. // 主应用:传递给子应用 
  9. <Route 
  10.   path="/child-app/*" 
  11.   element={<MicroApp entry="//localhost:3002/src/main.tsx" history={history} />} 
  12. />; 
  13.  
  14. // 子应用:路由入口 
  15. <HistoryRouter basename="/child-app" history={history}> 
  16.   {children} 
  17. </HistoryRouter>; 

最终子应用使用同一份 history 模块。当然这不是唯一的实现,也不是优雅的方式,我们可以将路由实例 navigate 传递给子应用,这样也能实现路由的交互。

注意:子应用的 basename 必须与主应用的 path 名称保持一致。这里还需要修改 Vite 的配置 base 字段:

  1. export default defineConfig({ 
  2.   base: '/child-app/'
  3.   server: { 
  4.     port: 3002, 
  5.   }, 
  6.   plugins: [react()], 
  7. }); 

JS 沙箱

因为沙箱在 ESM 下不支持,因为无法动态改变执行环境中模块 window 对象,也无法注入新的全局对象。

一般 React、Vue 项目也很少修改全局变量,做好代码规范检查才是最主要的。

CSS 样式隔离

自动 CSS 样式隔离是有代价的,一般我们建议子应用使用不同的 CSS 前缀,再配合 CSS Modules 基本上能实现需求。

打包部署

部署可以根据子应用的 base 放置在不同的目录,并将名称对应。配置好 nginx 转发规则就可以了。我们可以将子应用统一路由前缀,便于 nginx 将主应用区分开并配置通用规则。

比如将主应用放置在 system 目录,子应用放置在 app- 开头的目录:

  1. location ~ ^\/app-.*(\..+)$ { 
  2.     root /usr/share/nginx/html; 
  3.  
  4. location / { 
  5.     try_files $uri $uri/ /index.html; 
  6.     root /usr/share/nginx/html/system; 
  7.     index  index.html index.htm; 

优点

1. 简单 核心不足 100 行代码,无需多余的文档

2. 灵活 通过约定的方式接入,也可以渐进增强

3. 透明 无任何劫持方案,更多逻辑透明性

4. 组件化 组件化的渲染及参数通信

5. 基于 ESM 支持 Vite,面向未来

6. 向下兼容 可选 SystemJS 方案,兼容低版本浏览器

有示例吗

示例代码在 Github,感兴趣的朋友可以 clone 下来学习。由于我们的技术栈是 React,所以这里示例的主应用的实现用的是 React 。

微前端组件(React):https://github.com/MinJieLiu/micro-app

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

结语

微前端的方案适合团队场景的最好,打造一个团队能掌控的方案尤为重要。

参考资料:

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/import.meta

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/import

 

 

责任编辑:武晓燕 来源: 前端星辰
相关推荐

2022-01-24 12:38:58

Vite插件开发

2020-05-19 10:45:31

沙箱前端原生对象

2017-07-24 13:58:49

Android组件化插件化

2022-08-10 10:32:47

编程实践

2022-05-09 09:28:04

Vite前端开发

2022-07-27 22:56:45

前端应用缓存qiankun

2021-01-01 09:01:05

前端组件化设计

2021-01-28 06:11:40

导航组件Sidenav Javascript

2024-03-06 11:14:13

ViteReact微前端

2021-06-21 15:49:39

React动效组件

2020-05-06 09:25:10

微前端qiankun架构

2021-01-26 10:33:45

前端开发技术

2020-04-02 09:31:49

微前端架构系统

2021-04-11 09:00:13

Fes.js前端

2023-09-07 20:04:06

前后端趋势Node.js

2020-11-30 06:20:13

javascript

2014-05-26 16:52:29

移动前端web组件

2023-04-28 09:30:40

vuereact

2015-01-08 10:08:03

前后端分离

2022-08-29 07:48:27

文件数据参数类型
点赞
收藏

51CTO技术栈公众号