本篇文章将告诉你如何实现子项目在基底下运行。
官方文档地址
https://github.com/micro-zoe/micro-app/issues/17
自己实现地址
https://github.com/wangyunteng/create-micro-app.git
让你的头发我的头发转一转,转一个同心圆。
让我们摸一摸永不凋谢的头发,开始出发
微前端是什么,要做什么,它是怎么运行的,你总要先有个了解。
实现原理:简单来说,它就是通过接口,将子项目的代码全部请求过来,在基座下面执行。
此时就会引出几个问题
1. 父子中的变量和监听函数在同一个window下,所以大家就会相互影响,此时应该如何分离,那就是代理,沙箱隔离
2. 父子的css 元素也会被相互影响,此时你也要进行隔离处理。
3. 父子如何进行通信
这些问题,都需要一一解决,会在后续陆续解决。
写的时候遇见问题
程序员不是在创建bug,就是在修改bug的路上,最后蓦然回首bug原因,如此简单:
1.发现项目一直在刷新
请求到子项目,再次请求的时候使用的基座的地址,因为子项目的是相对路径,此时要改为绝对路径,或者使用src去拼接
解决:
- 可以在基座应用上把publicPath改成 你的子应用的地址
- 在获取资源的时候,手动拼接url,将app.url和你的资源url拼接到一起再进行获取
url = app.url.endsWith('/') ? app.url.substring(0,app.url.length - 1): app.url{url};
2.子项目直接把父项目给冲掉
兄弟或者父子相同框架下,他们的模板代码相同,此时他们就会使用同一个元素开始项目启动。这样就会造成子项目将父亲项目给全盘替换的问题。
解决:
此时就需要微前端框架进行内部进行处理了,将相同框架造成的影响剔除掉。
搭建基本框架,实现的基本功能点
创建容器
微前端的渲染是将子应用的js、css等静态资源加载到基座应用中执行,所以基座应用和子应用本质是同一个页面。这不同于iframe,iframe则是创建一个新的窗口,由于每次加载都要初始化整个窗口信息,所以iframe的性能不高。
如同每个前端框架在渲染时都要指定一个根元素,微前端渲染时也需要指定一个根元素作为容器,这个根元素可以是一个div或其它元素。
这里我们使用的是通过customElements创建的自定义元素,因为它不仅提供一个元素容器,还自带了生命周期函数,我们可以在这些钩子函数中进行加载渲染等操作,从而简化步骤。
// /src/element.js
// 自定义元素
class MyElement extends HTMLElement {
// 声明需要监听的属性名,只有这些属性变化时才会触发attributeChangedCallback
static get observedAttributes () {
return ['name', 'url']
}
constructor() {
super();
}
connectedCallback() {
// 元素被插入到DOM时执行,此时去加载子应用的静态资源并渲染
console.log('micro-app is connected')
}
disconnectedCallback () {
// 元素从DOM中删除时执行,此时进行一些卸载操作
console.log('micro-app has disconnected')
}
attributeChangedCallback (attr, oldVal, newVal) {
// 元素属性发生变化时执行,可以获取name、url等属性的值
console.log(`attribute ${attrName}: ${newVal}`)
}
}
/**
* 注册元素
* 注册后,就可以像普通元素一样使用micro-app,当micro-app元素被插入或删除DOM时即可触发相应的生命周期函数。
*/
window.customElements.define('micro-app', MyElement)
micro-app
元素可能存在重复定义的情况,所以我们加一层判断,并放入函数中。
// /src/element.js
export function defineElement () {
// 如果已经定义过,则忽略
if (!window.customElements.get('micro-app')) {
window.customElements.define('micro-app', MyElement)
}
}
在/src/index.js
中定义默认对象SimpleMicroApp
,引入并执行defineElement
函数。
// /src/index.js
import { defineElement } from './element'
const SimpleMicroApp = {
start () {
defineElement()
}
}
export default SimpleMicroApp
引入simple-micro-app
在vue2项目的main.js中引入simple-micro-app,执行start函数进行初始化。
// vue2/src/main.js
import SimpleMicroApp from 'simple-micro-app'
SimpleMicroApp.start()
然后就可以在vue2项目中的任何位置使用micro-app标签。
<!-- page1.vue -->
<template>
<div>
<micro-app name='app' url='http://localhost:3001/'></micro-app>
</div>
</template>
插入micro-app标签后,就可以看到控制台打印的钩子信息。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uqUCgZk5-1667875578230)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c89029ba97244332940483c4783eb0b1~tplv-k3u1fbpfcp-zoom-1.image)]
以上我们就完成了容器元素的初始化,子应用的所有元素都会放入到这个容器中。接下来我们就需要完成子应用的静态资源加载及渲染。
创建微应用实例
很显然,初始化的操作要放在connectedCallback
中执行。我们声明一个类,它的每一个实例都对应一个微应用,用于控制微应用的资源加载、渲染、卸载等。
// /src/app.js
// 创建微应用
export default class CreateApp {
constructor () {}
status = 'created' // 组件状态,包括 created/loading/mount/unmount
// 存放应用的静态资源
source = {
links: new Map(), // link元素对应的静态资源
scripts: new Map(), // script元素对应的静态资源
}
// 资源加载完时执行
onLoad () {}
/**
* 资源加载完成后进行渲染
*/
mount () {}
/**
* 卸载应用
* 执行关闭沙箱,清空缓存等操作
*/
unmount () {}
}
我们在connectedCallback
函数中初始化实例,将name、url及元素自身作为参数传入,在CreateApp
的constructor中记录这些值,并根据url地址请求html。
// /src/element.js
import CreateApp, { appInstanceMap } from './app'
...
connectedCallback () {
// 创建微应用实例
const app = new CreateApp({
name: this.name,
url: this.url,
container: this,
})
// 记入缓存,用于后续功能
appInstanceMap.set(this.name, app)
}
attributeChangedCallback (attrName, oldVal, newVal) {
// 分别记录name及url的值
if (attrName === 'name' && !this.name && newVal) {
this.name = newVal
} else if (attrName === 'url' && !this.url && newVal) {
this.url = newVal
}
}
...
在初始化实例时,根据传入的参数请求静态资源。
// /src/app.js
import loadHtml from './source'
// 创建微应用
export default class CreateApp {
constructor ({ name, url, container }) {
this.name = name // 应用名称
this.url = url // url地址
this.container = container // micro-app元素
this.status = 'loading'
loadHtml(this)
}
...
}
请求html
我们使用fetch请求静态资源,好处是浏览器自带且支持promise,但这也要求子应用的静态资源支持跨域访问。
// src/source.js
export default function loadHtml (app) {
fetch(app.url).then((res) => {
return res.text()
}).then((html) => {
console.log('html:', html)
}).catch((e) => {
console.error('加载html出错', e)
})
}
因为请求js、css等都需要使用到fetch,所以我们将它提取出来作为公共方法。
// /src/utils.js
/**
* 获取静态资源
* @param {string} url 静态资源地址
*/
export function fetchSource (url) {
return fetch(url).then((res) => {
return res.text()
})
}
重新使用封装后的方法,并对获取到到html进行处理。
// src/source.js
import { fetchSource } from './utils'
export default function loadHtml (app) {
fetchSource(app.url).then((html) => {
html = html
.replace(/<head[^>]*>[\s\S]*?</head>/i, (match) => {
// 将head标签替换为micro-app-head,因为web页面只允许有一个head标签
return match
.replace(/<head/i, '<micro-app-head')
.replace(/</head>/i, '</micro-app-head>')
})
.replace(/<body[^>]*>[\s\S]*?</body>/i, (match) => {
// 将body标签替换为micro-app-body,防止与基座应用的body标签重复导致的问题。
return match
.replace(/<body/i, '<micro-app-body')
.replace(/</body>/i, '</micro-app-body>')
})
// 将html字符串转化为DOM结构
const htmlDom = document.createElement('div')
htmlDom.innerHTML = html
console.log('html:', htmlDom)
// 进一步提取和处理js、css等静态资源
extractSourceDom(htmlDom, app)
}).catch((e) => {
console.error('加载html出错', e)
})
}
html格式化后,我们就可以得到一个DOM结构。从下图可以看到,这个DOM结构包含link、style、script等标签,接下来就需要对这个DOM做进一步处理。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RhSt9MHQ-1667875578232)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1fe4cefd9ec244778590b3cfb4cb1ed7~tplv-k3u1fbpfcp-zoom-1.image)]
提取js、css等静态资源地址
我们在extractSourceDom
方法中循环递归处理每一个DOM节点,查询到所有link、style、script标签,提取静态资源地址并格式化标签。
// src/source.js
/**
* 递归处理每一个子元素
* @param parent 父元素
* @param app 应用实例
*/
function extractSourceDom(parent, app) {
const children = Array.from(parent.children)
// 递归每一个子元素
children.length && children.forEach((child) => {
extractSourceDom(child, app)
})
for (const dom of children) {
if (dom instanceof HTMLLinkElement) {
// 提取css地址
const href = dom.getAttribute('href')
if (dom.getAttribute('rel') === 'stylesheet' && href) {
// 计入source缓存中
app.source.links.set(href, {
code: '', // 代码内容
})
}
// 删除原有元素
parent.removeChild(dom)
} else if (dom instanceof HTMLScriptElement) {
// 并提取js地址
const src = dom.getAttribute('src')
if (src) { // 远程script
app.source.scripts.set(src, {
code: '', // 代码内容
isExternal: true, // 是否远程script
})
} else if (dom.textContent) { // 内联script
const nonceStr = Math.random().toString(36).substr(2, 15)
app.source.scripts.set(nonceStr, {
code: dom.textContent, // 代码内容
isExternal: false, // 是否远程script
})
}
parent.removeChild(dom)
} else if (dom instanceof HTMLStyleElement) {
// 进行样式隔离
}
}
}
请求静态资源
上面已经拿到了html中的css、js等静态资源的地址,接下来就是请求这些地址,拿到资源的内容。
接着完善loadHtml
,在extractSourceDom
下面添加请求资源的方法。
// src/source.js
...
export default function loadHtml (app) {
...
// 进一步提取和处理js、css等静态资源
extractSourceDom(htmlDom, app)
// 获取micro-app-head元素
const microAppHead = htmlDom.querySelector('micro-app-head')
// 如果有远程css资源,则通过fetch请求
if (app.source.links.size) {
fetchLinksFromHtml(app, microAppHead, htmlDom)
} else {
app.onLoad(htmlDom)
}
// 如果有远程js资源,则通过fetch请求
if (app.source.scripts.size) {
fetchScriptsFromHtml(app, htmlDom)
} else {
app.onLoad(htmlDom)
}
}
fetchLinksFromHtml
和fetchScriptsFromHtml
分别请求css和js资源,请求资源后的处理方式不同,css资源会转化为style标签插入DOM中,而js不会立即执行,我们会在应用的mount方法中执行js。
两个方法的具体实现方式如下:
// src/source.js
/**
* 获取link远程资源
* @param app 应用实例
* @param microAppHead micro-app-head
* @param htmlDom html DOM结构
*/
export function fetchLinksFromHtml (app, microAppHead, htmlDom) {
const linkEntries = Array.from(app.source.links.entries())
// 通过fetch请求所有css资源
const fetchLinkPromise = []
for (const [url] of linkEntries) {
fetchLinkPromise.push(fetchSource(url))
}
Promise.all(fetchLinkPromise).then((res) => {
for (let i = 0; i < res.length; i++) {
const code = res[i]
// 拿到css资源后放入style元素并插入到micro-app-head中
const link2Style = document.createElement('style')
link2Style.textContent = code
microAppHead.appendChild(link2Style)
// 将代码放入缓存,再次渲染时可以从缓存中获取
linkEntries[i][1].code = code
}
// 处理完成后执行onLoad方法
app.onLoad(htmlDom)
}).catch((e) => {
console.error('加载css出错', e)
})
}
/**
* 获取js远程资源
* @param app 应用实例
* @param htmlDom html DOM结构
*/
export function fetchScriptsFromHtml (app, htmlDom) {
const scriptEntries = Array.from(app.source.scripts.entries())
// 通过fetch请求所有js资源
const fetchScriptPromise = []
for (const [url, info] of scriptEntries) {
// 如果是内联script,则不需要请求资源
fetchScriptPromise.push(info.code ? Promise.resolve(info.code) : fetchSource(url))
}
Promise.all(fetchScriptPromise).then((res) => {
for (let i = 0; i < res.length; i++) {
const code = res[i]
// 将代码放入缓存,再次渲染时可以从缓存中获取
scriptEntries[i][1].code = code
}
// 处理完成后执行onLoad方法
app.onLoad(htmlDom)
}).catch((e) => {
console.error('加载js出错', e)
})
}
上面可以看到,css和js加载完成后都执行了onLoad
方法,所以onLoad
方法被执行了两次,接下来我们就要完善onLoad
方法并渲染微应用。
渲染
因为onLoad
被执行了两次,所以我们进行标记,当第二次执行时说明所有资源都加载完成,然后进行渲染操作。
// /src/app.js
// 创建微应用
export default class CreateApp {
...
// 资源加载完时执行
onLoad (htmlDom) {
this.loadCount = this.loadCount ? this.loadCount + 1 : 1
// 第二次执行且组件未卸载时执行渲染
if (this.loadCount === 2 && this.status !== 'unmount') {
// 记录DOM结构用于后续操作
this.source.html = htmlDom
// 执行mount方法
this.mount()
}
}
...
}
在mount
方法中将DOM结构插入文档中,然后执行js文件进行渲染操作,此时微应用即可完成基本的渲染。
// /src/app.js
// 创建微应用
export default class CreateApp {
...
/**
* 资源加载完成后进行渲染
*/
mount () {
// 克隆DOM节点
const cloneHtml = this.source.html.cloneNode(true)
// 创建一个fragment节点作为模版,这样不会产生冗余的元素
const fragment = document.createDocumentFragment()
Array.from(cloneHtml.childNodes).forEach((node) => {
fragment.appendChild(node)
})
// 将格式化后的DOM结构插入到容器中
this.container.appendChild(fragment)
// 执行js
this.source.scripts.forEach((info) => {
(0, eval)(info.code)
})
// 标记应用为已渲染
this.status = 'mounted'
}
...
}
以上步骤完成了微前端的基本渲染操作,我们看一下效果。
卸载
当micro-app元素被删除时会自动执行生命周期函数disconnectedCallback
,我们在此处执行卸载相关操作。
// /src/element.js
class MyElement extends HTMLElement {
...
disconnectedCallback () {
// 获取应用实例
const app = appInstanceMap.get(this.name)
// 如果有属性destory,则完全卸载应用包括缓存的文件
app.unmount(this.hasAttribute('destory'))
}
}
接下来完善应用的unmount
方法:
// /src/app.js
export default class CreateApp {
...
/**
* 卸载应用
* @param destory 是否完全销毁,删除缓存资源
*/
unmount (destory) {
// 更新状态
this.status = 'unmount'
// 清空容器
this.container = null
// destory为true,则删除应用
if (destory) {
appInstanceMap.delete(this.name)
}
}
}
当destory为true时,删除应用的实例,此时所有静态资源失去了引用,自动被浏览器回收。