MicroApp 实现
微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将 Web 应用由单一的单体应用转变为多个小型前端应用聚合为一的应用。各个前端应用还可以独立运行、独立开发、独立部署。微前端架构与框架无关,每个微应用都可以使用不同的框架。
MicroApp借鉴了WebComponent的思想,通过CustomElement结合自定义的ShadowDom,将微前端封装成一个类WebComponent组件,从而实现微前端的组件化渲染。并且由于自定义ShadowDom的隔离特性,micro-app不需要像single-spa和qiankun一样要求子应用修改渲染逻辑并暴露出方法,也不需要修改webpack配置,是目前市面上接入微前端成本最低的方案。 -- 《MicroApp》
MicroApp实践深入探索
源码实践:Github
前置知识
实现思路
微前端分为主应用和子应用,主应用作为子应用的容器。MicroApp通过 类WebComponent + HTML Entry 进行微前端的组件化实现。
-
类WebComponent
WebComponent 是 web原生组件,它有两个核心组成部分:CustomElement和ShadowDom。
CustomElement用于创建自定义标签,ShadowDom用于创建阴影DOM,阴影DOM具有天然的样式隔离和元素隔离属性。由于WebComponent是原生组件,它可以在任何框架中使用,理论上是实现微前端最优的方案。但WebComponent有一个无法解决的问题:ShadowDom的兼容性非常不好,一些前端框架在ShadowDom环境下无法正常运行,尤其是react框架。
由于ShadowDom存在的问题,我们采用自定义的样式隔离和元素隔离实现ShadowDom类似的功能,然后将微前端应用封装在一个CustomElement中,从而模拟实现一个类WebComponent组件,它的使用方式和兼容性与WebComponent一致,同时也避开了ShadowDom的问题。
-
HTML Entry
指设置html作为资源入口,通过加载远程html,解析其DOM结构从而获取js、css等静态资源来实现微前端的渲染。
实现准备
-
准备项目文件&添加源码目录文件
src
&初始化 package.jsonyarn init -y
-
配置打包环境
-
安装相关依赖
yarn add rollup fs-extra --dev
-
添加配置文件
rollup.config.js
import path from 'path'
import fs from 'fs-extra'
// 清空目标目录
fs.emptyDirSync(path.join(process.cwd(), 'lib'))
export default {
input: path.join(__dirname, 'src/index.js'),
output: [
{
file: path.join(__dirname, 'lib/index.js'),
format: 'es',
sourcemap: true,
},
],
} -
package.json
配置相关命令"build": "rollup -c"
-
-
准备测试案例
- 创建主应用项目-vue2:
vue create vue2
- 创建子应用项目-react17:
npx create-react-app react17
(如果安装到react18及以上版本,修改package.json中相应版本重新安装即可【附:react版本降级修改】) - 按照官方教程配置好相应的应用配置:渲染
- 搭建好项目路由环境
- 创建主应用项目-vue2:
-
准备好测试案例后我们添加一个小工具
npm-run-all
帮助我们快捷安装和启动测试项目
核心原理
将通过CustomElement创建的自定义标签作为容器,加载子应用所有的元素及样式等资源。
渲染实现
通过自定义元素的生命周期函数connectedCallback
监听元素被渲染,加载子应用的html并转换为DOM结构,递归查询所有js和css等静态资源并加载,设置元素隔离,拦截所有动态创建的script、link等标签,提取标签内容。 将加载的js经过插件系统处理后放入沙箱中运行,对css资源进行样式隔离,最后将格式化后的元素放入micro-app
中,最终将micro-app
元素渲染为一个微前端的子应用。在渲染的过程中,会执行开发者绑定的生命周期函数,用于进一步操作。
流程图
实现
参考官方教程:渲染 进行实现即可
常见问题:
- 步骤-创建容器:基座应用中抛出警告,micro-app未定义
-
报错信息:
vue2: [Vue warn]: Unknown custom element: <micro-app>
-
解决方式:
在基座应用中添加如下配置
// 在入口文件main.js中设置ignoredElements
import Vue from 'vue'
Vue.config.ignoredElements = [
'micro-app',
]
-
资源地址补全
微前端中经常出现资源丢失的现象,原因是基座应用将子应用的资源加载到自己的页面渲染,如果子应用的静态资源地址是相对地址,浏览器会以基座应用所在域名地址补全静态资源,从而导致资源丢失。
资源地址补全就是将子应用静态资源的相对地址补全为绝对地址,保证地址指向正确的资源路径,这种操作类似于webpack在运行时设置publicPath。
元素隔离
元素隔离源于ShadowDom的概念,即ShadowDom中的元素可以和外部的元素重复但不会冲突,ShadowDom只能对自己内部的元素进行操作。
Micro App模拟实现了类似的功能,拦截了底层原型链上元素的方法,保证子应用只能对自己内部的元素进行操作,每个子应用都有自己的元素作用域。
元素隔离可以有效的防止子应用对基座应用和其它子应用元素的误操作,常见的场景是多个应用的根元素都使用相同的id,元素隔离可以保证子应用的渲染框架能够正确找到自己的根元素。
实际效果
如上图所示,micro-app
元素内部渲染的就是一个子应用,它还有两个自定义元素 micro-app-head
、micro-app-body
,这两个元素的作用分别对应html中的head和body元素。子应用在原head元素中的内容和一些动态创建并插入head的link、script元素都会移动到micro-app-head
中,在原body元素中的内容和一些动态创建并插入body的元素都会移动到micro-app-body
中。这样可以防止子应用的元素泄漏到全局,在进行元素查询、删除等操作时,只需要在micro-app
内部进行处理,是实现元素隔离的重要基础 。
可以将micro-app
理解为一个内嵌的html页面,它的结构和功能都和html页面类似。
实现
参考官方教程:渲染 部分 创建微应用实例 进行实现即可
js沙箱和样式隔离
js沙箱通过Proxy代理子应用的全局对象,防止应用之间全局变量的冲突,记录或清空子应用的全局副作用函数,也可以向子应用注入全局变量用于定制化处理。
样式隔离是指对子应用的link和style元素的css内容进行格式化处理,确保子应用的样式只作用域自身,无法影响外部。
实现
JS 沙箱
参考官方教程:沙箱 部分进行实现即可
样式隔离
参考官方教程:样式隔离 部分进行实现即可
数据通信
数据通信是微前端中非常重要的功能,实现数据通信的技术方案很多,优秀的方案可以提升开发效率,减少试错成本。我们尝试直接通过元素属性传递复杂数据的形式实现数据通信。
对于前端研发人员最熟悉的是组件化的数据交互的方式,而自定义元素micro-app作为类WebComponent,通过组件属性进行数据交互必然是最优的方式。但Micro App在数据通信中遇到的最大的问题是自定义元素无法支持设置对象类型属性,例如<micro-app data={x: 1}></micro-app>
会转换为 <micro-app data='[object Object]'></micro-app>
,想要以组件化形式进行数据通信必须让元素支持对象属性。
为了解决这个问题,我们重写了micro-app
元素原型链上属性设置的方法,在micro-app
元素设置对象属性时将传递的值保存到数据中心,通过数据中心将值分发给子应用。
Micro App中数据是绑定通信的,即每个micro-app
元素只能与自己指向的子应用进行通信,这样每个应用都有着清晰的数据链,可以避免数据的混乱,同时Micro App也支持全局通信,以便跨应用传递数据。
概念图
实现
参考官方教程:数据通信 部分进行实现即可
插件系统
微前端的使用场景非常复杂,即便有沙箱机制也无法避免所有的 问题,所以我们提供了一套插件系统用于解决一些无法预知的问题。
插件可以理解为符合特定规则的对象,对象中提供一个函数用于对资源进行处理,插件通常由开发者自定义。
插件系统的作用是对传入的静态资源进行初步处理,并依次调用符合条件的插件,将初步处理后的静态资源作为参数传入插件,由插件对资源内容进一步的修改,并将修改后的内容返回。插件系统赋予开发者灵活处理静态资源的能力,对有问题的资源文件进行修改。
插件系统本身是纯净的,不会对资源内容造成影响,它的作用是统筹各个插件如何执行,当开发者没有设置插件时,则传入和传出的内容是一致的。
实现
实现 processHtml html 处理函数
-
存储插件
// index.js
const MicroApp = {
start(options) {
if (isPlainObject(options)) {
isPlainObject(options.plugins) && (this.plugins = options.plugins)
}
...
}
} -
processHtml 处理html
export default function loadHtml(app) {
fetchSource(app.url)
.then((html) => {
html = processHtml(app.url, html, app.name)
...
})
.catch((e) => {
console.error('加载html出错', e)
})
}
function processHtml(url, code, appName) {
// 插件处理
const plugins = microApp.plugins
if (!plugins) return code
const mergedPlugins = getAssetsPlugins(appName)
if (mergedPlugins.length > 0) {
return mergedPlugins.reduce((preCode, plugin) => {
if (isPlainObject(plugin) && isFunction(plugin.processHtml)) {
return plugin.processHtml(preCode, url)
}
return preCode
}, code)
}
return code
}
生命周期
在微应用渲染时,micro-app
在不同渲染阶段会发送不同的生命周期事件,基座应用可以通过监听事件来进行相应的操作。
生命周期列表:
•created:当micro-app标签被创建后,加载资源之前执行。
•beforemount:资源加载完成,正式渲染之前执行。
•mounted:子应用已经渲染完成后执行
•unmount:子应用卸载时执行。
•error:当出现破坏性错误,无法继续渲染时执行。
在卸载时,子应用也会接收到一个卸载的事件,用于执行卸载相关操作。
实现
生命周期通过 customEvent 事件实现
因为发送生命周期事件是一个通用的的事件,所以我们参考源码部分将其抽离到单独的 lifecycles_event
文件中
-
定义发送生命周期事件
export default function dispatchLifecyclesEvent(element, appName, lifecycleName) {
const detail = Object.assign({
name: appName,
container: element,
})
const event = new CustomEvent(lifecycleName, {
detail,
})
element.dispatchEvent(event)
} -
在指定时机触发并发送生命周期事件
-
created
class MicroAppElement extends HTMLElement {
...
connectedCallback() {
// 元素被插入到DOM时执行,此时去加载子应用的静态资源并渲染
console.log('micro-app is connected')
dispatchLifecyclesEvent(this, this.name, 'created')
...
}
...
}
-
预加载
Micro App 提供了预加载子应用的功能,它是基于requestIdleCallback
实现的,预加载不会对基座应用和其它子应用的渲染速度造成影响,它会在浏览器空闲时间加载应用的静态资源,在应用真正被渲染时直接从缓存中获取资源并渲染。
虚拟路由系统
通过自定义location和history 实现虚拟路由系统