微前端的核心理念是将前端应用程序看作是一个整体,由多个独立的部分组成。每个部分被视为一个微前端应用,它们可以具有自己的技术栈、开发流程和团队组织。这种方式使得团队可以独立开发和部署各个子应用,减少了协调和合并的复杂性。
IFrame 在传统的前端开发中是一种常见的技术,用于在页面中嵌入其他网页或应用程序。然而,在微前端架构中,IFrame 并不是一个理想的选择,主要是因为以下几个方面的限制:
鉴于以上限制,微前端架构通常采用其他技术手段来实现子应用的拆分和集成,例如使用 Web Components、JavaScript 模块加载器等。这些技术能够提供更好的隔离性、通信机制和性能优化,使得微前端架构更具可行性和灵活性。
history.go、history.back、history.forward 使用 popstate 事件 window.onpopstate
监听的方式
js复制代码window.addEventListener('popstate', () => {})
重写: pushState、replaceState 需要通过函数重写的方式进行 劫持
js复制代码const rawPushState = window.history.pushState
window.history.pushState = function(...args) {
rawPushState.apply(window.history, args)
// 其他逻辑
}
const rawReplaceState = window.history.replaceState
window.history.replaceState = function(...args) {
rawReplaceState.apply(window.history, args)
// 其他逻辑
}
在 Vue 项目中,我们通过 this.router.push会触发‘history.pushState‘事件,this.router.push 会触发 `history.pushState` 事件,this.router.push会触发‘history.pushState‘事件,this.router.replace 会触发 history.replaceState 事件。
监听路由的变化后,拿到当前路由的路径 window.location.pathname,然后根据 registerMicroApps 的参数 apps 查找子应用。因为子应用都配置了 activeRule。
js复制代码// 如果当前的 pathname 以 activeRule 开头,表明匹配到了子应用
const currentApp = apps.find(app => window.location.pathname.startWith(app.activeRule))
当我们找到了与当前路由匹配的子应用,接着就去加载这个子应用的资源。
js复制代码function handleRouter = async () => {
// 匹配子应用
// 加载资源
const html = await fetch(currentApp.entry.then(res => res.text())
// 将 html 渲染到指定的容器内
const container = document.querySelector(currentApp.container)
}
这个时候,我们就拿到了子应用的 html 文本。
但是我们不能给直接通过 container.innerHTML = html 将文本放到容器内,这样是无法显示的。
注意 浏览器处于安全考虑,放到页面上的 html 如果包含了 js 脚本,它是不会去执行 js 的。我们需要手动处理 script 脚本。
我们来封装一个函数 importHTML,专门来处理 html 文本。(qiankun内部引用的 import-html-entry 就是做这个事的。)
我们可以把加载子应用资源的 fetch 请求放到 importHTML 函数中,它还有如下几个功能:
js复制代码export const importHTML = url => {
const html = await fetch(currentApp.entry).then(res => res.text()
const template = document.createElement('div')
template.innerHTML = html
const scripts = template.querySelectAll('script')
const getExternalScripts = () => {
console.log('解析所有脚本: ', scripts)
}
const execScripts = () => {}
return {
template, // html 文本
getExternalScripts, // 获取 Script 脚本
execScripts, // 执行 Sript 脚本
}
}
script 脚本分为 内联 脚本和外链脚本,这里需要分开处理,拿到内联脚本后,获取内容可以通过 eval 直接处理。如果是含有 scr 的 script 脚本,还需要拿到 src 的值,通过 fetch 去加载脚本。
我们在 getExternalScripts 方法中来处理
js复制代码const getExternalScripts = async () => {
return Promise.all(Array.from(scripts).map(script => {
// 获取 scr 属性
const src = script.getAttribute('src')
if (!src) {
return Promise.resolve(script.innerHTML)
} else {
return fetch(src.startWith('http') ? src : `${url}${src}`).then(res => res.text())
}
}))
}
然后我们就可以通过 execScripts 方法去调用 getExternalScripts,拿到所有的脚本内容后,执行!
js复制代码const execScripts = async () => {
const scripts = await getExternalScripts()
scripts.forEach(code => {
eval(code)
})
}
http://qiankun.umijs.org/zh
我们看 qiankun 的依赖,可以发现 qiankun 是基于 single-spa 实现的,通过 import-html-entry 包处理 html / css
json复制代码"dependencies": {
"import-html-entry": "^1.14.0",
"single-spa": "^5.9.2"
// ...
},
js复制代码registerMicroApps(
[
{
name: 'react16',
entry: '//localhost:7100',
container: '#subapp-viewport',
loader,
activeRule: '/react16',
},
{
name: 'react15',
entry: '//localhost:7102',
container: '#subapp-viewport',
loader,
activeRule: '/react15',
},
]
)
js复制代码// vue.config.js
module.exports = {
devServer: {
// ...
headers: {
'Access-Control-Allow-Origin': '*',
},
},
// 自定义webpack配置
configureWebpack: {
output: {
// 把子应用打包成库文件、格式是 umd
library: `${name}-[name]`,
libraryTarget: 'umd',
jsonpFunction: `webpackJsonp_${name}`,
},
},
}
umd全称是UniversalModuleDefinition,是一种通用模块定义格式,通常用于前端模块化开发中。
由于不同的模块化规范定义不同,为了让各种规范的模块可以通用,在不同的环境下都可以正常运行,就出现了umd这个通用格式。
umd 格式是一种既可以在浏览器环境下使用,也可以在 node 环境下使用的格式。它将 CommonJS、AMD以及普通的全局定义模块三种模块模式进行了整合。
js复制代码(function (global, factory) {
// CommonJS
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
// AMD
typeof define === 'function' && define.amd ? define(['exports'], factory) :
// Window
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.qiankun = {}));
}(this, (function (exports) {
// 应用代码
})));
主要是为了主应用能够拿到子应用在 入口文件 导出的 生命钩子函数,这也是主应用和子应用之间通信的关键。
HTML Entry + Sandbox 是 qiankun 区别于 single-spa 的主要两个特性。
single-spa和qiankun最大的不同,大概就是qiankun实现了html entry,而single-spa只能是js entry
通过 import-html-entry,我就能像 iframe 一样加载一个子应用,只需要知道其 html 的 url 就能加载到主应用中。
importHTML 几个核心方法:
首先importHTML的参数为需要加载的页面url,拿到后会先通过 fetch方法 读取页面内容。
js复制代码import importHTML from 'import-html-entry';
importHTML('./subApp/index.html')
.then(res => {
console.log(res.template);
res.execScripts().then(exports => {
const mobx = exports;
const { observable } = mobx;
observable({
name: 'kuitos'
})
})
});
它会解析html的内容并且删除注释,获取style样式及script代码。通过大量的正则 + replace,每一个步骤都做了很多适配,比如获取script脚本,需要区分该script是不是entry script,type是JavaScript还是module,是行内script还是外链script,是相对路径还是绝对路径,是否需要处理协议等等。
processTpl的返回值有 template,script,style,entry。
JavaScript 沙箱是一种安全机制,用于隔离和限制 JavaScript 代码的执行环境,以防止恶意代码或意外行为对系统造成损害。沙箱提供了一种受控的环境,限制了代码的访问权限和执行能力,确保代码只能在受限制的范围内操作。
JavaScript 沙箱通常用于以下情况:
JavaScript 沙箱通过限制代码的访问权限、提供隔离的执行环境、使用安全策略和沙箱沙盒技术等手段来实现。常见的 JavaScript 沙箱技术包括沙盒环境、Web Worker、iframe、JavaScript 虚拟机等。这些技术通过限制代码的执行权限、提供独立的运行环境、隔离全局上下文等方式来确保代码的安全执行。
缺点:
优点:
可以支持不兼容Proxy的浏览器。
先了解 SnapshotSandbox 的功能
我们来举个例子
js复制代码// 激活前
window.city = 'Beijing'
// 激活
sanbox.active()
window.city = '上海'
// 失活
sanbox.inactive()
console.log(window.city) // 打印 'Beijing'
// 再激活
console.log(window.city) // 打印 '上海'
接下来,实现一个简易版的 SnapshotSandbox
js复制代码1. windowSnapshot 用来存储沙箱激活前的 window
2. modifyPropsMap 用来存储沙箱激活期间,在 window 上修改过的属性
js复制代码1. sanbox.active() // 激活沙箱
- 保存 window 的快照
- 再次激活时,将 window 还原到上次 active 的状态
2. sanbox.inactive() // 失活沙箱
- 记录当前在 window 上修改了的 prop
- 还原 window 到 active 之前的状态
我们先来实现沙箱内部细节:
js复制代码class SnapshotSandbox {
constructor() {
this.windowSnapshot = {}
this.modifyPropsMap = {}
}
active() {
// 1. 保存 window 的快照
for (let prop in window) {
if (window.hasOwnProperty(prop)) {
this.windowSnapshot[prop] = window[prop]
}
}
// 2. 再次激活时,将 window 还原到上次 active 的状态,modifyPropsMap 存储了上次 active 时在 widow 上修改了哪些属性
Object.keys(modifyPropsMap).forEach(prop => {
window[prop] = this.modifyPropsMap[prop]
})
}
inactive() {
for(let prop in window) {
if (window.hasOwnProperty(prop)) {
// 两者不相同,表示修改了某个 prop 记录当前在 window 上修改了的 prop
if (window[prop] !== this.windowSnapshot[prop]) {
this.modifyPropsMap[prop] = window[prop]
}
// 还原 window
window[prop] = this.windowSnapshot[prop]
}
}
}
}
我们来验证一下,首先设置 window.city 一个初始值 beijing,然后初始化 沙箱,在第一次激活后,修改了 window.city 为 上海,那么应该在失活后,打印 beijing,再次激活时,window.city 是 上海
js复制代码window.city = 'beijing'
const ss = new SnapshotSandbox()
console.log('window.city0 ', window.city)
ss.active() // 激活
window.city = '上海'
console.log('window.city1 ', window.city) // 上海
ss.inactive()
console.log('window.city2 ', window.city) // beijing
ss.active()
console.log('window.city3 ', window.city) // 上海
ss.inactive()
console.log('window.city4 ', window.city) // beijing
ss.active()
console.log('window.city5 ', window.city) // 上海
不支持多个应用同时运行,因为污染了全局 window
js复制代码window.city = 'beijing'
const ss = new SnapshotSandbox()
ss.active() // 激活
window.city = '上海'
const ss1 = new SnapshotSandbox()
ss1.active() // 激活
window.city = '广州'
console.log(window.city) // 广州
功能和 快照沙箱 一样,但内部实现是通过 proxy 实现的。
主要实现在 constructor 中,创建一个 fakeWindow 对象,通过 Proxy 代理这个对象,全程没有改变 window
只是在获取属性值的时候,如果在代理对象上没有找到想要的属性,才回去 window 中查找。
js复制代码class ProxySandbox {
constructor() {
// 沙箱是否是激活状态
this.isRunning = false
const fakeWindow = Object.create(null)
const _this = this
this.proxyWindow = new Proxy(fakeWindow, {
set(target, prop, value) {
// 只有激活状态下,才做处理
if (_this.isRunning) {
target[prop] = value
return true
}
},
get(target, prop, reciver) {
// 如果fakeWindow里面有,就从fakeWindow里面取,否则,就从外部的window里面取
return prop in target ? target[prop] : window[prop]
}
})
}
active() {
this.isRunning = true
}
inactive() {
this.isRunning = false
}
}
window.city = '北京'
const p1 = new ProxySandbox()
const p2 = new ProxySandbox()
// 激活
p1.active()
p2.active()
p1.proxyWindow.city = '上海'
p2.proxyWindow.city = '杭州'
console.log(p1.proxyWindow.city) // '上海'
console.log(p2.proxyWindow.city) // '杭州'
console.log(window.city) // 北京
// 失活
p1.inactive()
p2.inactive()
console.log(p1.proxyWindow.city) // '上海'
console.log(p2.proxyWindow.city) // '杭州'
console.log(window.city) // '北京'
如果不启动样式隔离,主应用、子应用所有的样式都是全局环境下,意味着,如果我在主应用里面设置了高权重的 css 样式,是会直接影响到子应用的。
css复制代码// 主应用 main.css
h1 {
color: red !important;
}
button {
background-color: red !important;
}
主应用、子应用所有的 h1 和 button 都会应用以上颜色。
当然我们不能这样做,我们的应用间样式应该独立,不能互相影响。可以通过 BEM 解决,不过在大型项目下,约定是一件很不靠谱的事情,最好是在框架中解决此问题,一劳永逸。
在 start 方法中,配置 sanbox 属性,即可开启 css 隔离。
js复制代码// sanbox: boolean | { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean }
start({
sanbox: true
})
strictStyleIsolation 模式下 qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,所有的子应用都被 #shadow-root 所包裹,从而确保微应用的样式不会对全局造成影响。
当我们开启了 strictStyleIsolation 模式后,主应用设置的高权重 css 确实没有影响子应用了。但是,但是,咱们去看看 Vue dialog 的样式(别看 React 的,因为React事件在 Shadow DOM 中根本不起作用 )
注意:
shadow dom 并不是一个无脑的解决方案,特别是在 React 中,事件的处理可能不那么奏效了 !
React 官方关于 web component 的解释
乍一看是不是没问题?
我们摁下电脑的 ESC 键,会触发 是否取消弹窗 的二次确认,你再看看有没有问题?
样式完全丢失了,这是为什么呢?因为二次确认的 Dialog 是挂在 body 下,而我们整个子应用都被 shadow dom 所包裹,内部的样式对外部的样式起不到任何作用,所以这个弹窗失去了漂亮的外衣了 !
不过,为啥弹窗要挂在 body 下?
这个是为了避免被父元素的样式影响,比如父元素设置了 display:none,那么这个弹窗也是无法展示的。
experimentalStyleIsolation 被设置为 true 时,qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围,因此改写后的代码会表达类似为如下结构:
css复制代码// 假设应用名是 react16 中的样式是这样
.app-main {
font-size: 14px;
}
// ===== 处理后 ======>
div[data-qiankun-react16] .app-main {
font-size: 14px;
}
有点类似 Vue 中的 css scoped 作用,给每个子应用加了一个 ”唯一“ 的属性选择器。
这个时候,React 的事件处理没问题了(真好啊 ),我们来到页面上看看效果:
事件是生效了,但是弹窗样式丢失了
这个弹窗是挂在 body 下,而加了 experimentalStyleIsolation 之后,所有的样式都加了 div[data-qiankun="react16"] 前缀,唯独 body 下的 dialog 没有加前缀,导致无法应用到正确的样式了。(Vue子应用 也有这样的问题!!)
还有就是,在主应用设置的高权重样式依然影响到了子应用。
在 Vue 的单文件组件中使用