从 0 写一个迷你 wujie:用原生 Web API 实现微前端
学习于(www.bilibili.com/video/BV1Qt...) 感谢大佬的分享,对于实践我做了一些总结
wujie解决了前文微前端的什么问题:
1.原有的CSS隔离方式是在子应用的样式上增加名称,有点消耗性能(scoped)
2.原有的JS我们自己实现沙箱去隔离,但没有使用iframe这个天然的沙箱应用
wujie很好的使用了WEB component+iframe来解决这个问题
第一步:搭建框架,模拟子应用资源
先搭好基座页面骨架。页面上有一个"基座应用"的 div 和一个 #container 容器------子应用最终会被注入到这里。
然后准备两个变量,模拟子应用 fetch 请求拿到的资源:
strTemWithCss:子应用的 HTML + CSS,里面有个#inner的 div 和一个div { background-color: red }的全局样式strScript:子应用的 JS,在window上挂一个a=100,然后querySelector('#inner')查找自己的元素
js
// 模拟子应用发起 fetch 请求
const strTemWithCss = `
<!DOCTYPE html>
<html>
<body>
<div id="inner">子应用,不被基座应用影响</div>
<style>div { background-color: red; color: white; }</style>
</body>
</html>
`
const strScript = `
window.a = 100
console.log(window.a)
const ele = document.querySelector('#inner')
console.log(ele)
`
真实场景中,这两个字符串是 fetch(子应用URL) 拿到 HTML 后再解析出来的,这里先硬编码,专注讲隔离原理。
第二步:定义 <wujie-app> 自定义元素,插入到 container 中
这一步把"子应用"封装成一个自定义 HTML 元素 <wujie-app>。后面整个沙箱逻辑都放在 connectedCallback 生命周期里------当元素被插入 DOM 时自动执行。
js
function createCustomElement() {
class WujieApp extends HTMLElement {
connectedCallback() {
// 创建沙箱
const sandbox = createSandbox()
// 创建子应用
sandbox.shadowRoot = this.attachShadow({ mode: 'open' })
// 注入 html,css
injectHemplate(sandbox, strTemWithCss)
// 注入 js
injectScript(sandbox, strScript)
}
}
window.customElements.define('wujie-app', WujieApp)
container.appendChild(document.createElement('wujie-app'))
}
createCustomElement()
几个关键点:
customElements.define('wujie-app', WujieApp):注册自定义标签,之后在 HTML 里可以直接写<wujie-app></wujie-app>connectedCallback():元素插入 DOM 时触发,相当于子应用"挂载"的入口this.attachShadow({ mode: 'open' }):这是 CSS 隔离的关键------后面会讲
至此,框架搭好了,页面上有了一个 <wujie-app> 元素,但它的沙箱逻辑还没写。
第三步:构建沙箱,注入 HTML 和 CSS
3.1 为什么需要沙箱?
直接把子应用的 HTML/CSS/JS 扔到基座页面里执行,会产生两个问题:
- CSS 污染 :子应用写了
div { background: red },基座页面上所有<div>全变红 - JS 污染 :子应用写了
window.a = 100,基座也跟着多了一个window.a
所以需要一个"沙箱",让子应用的内容和基座隔离开。
3.2 iframe 提供 JS 执行环境
js
function creatrIframe() {
const iframe = document.createElement('iframe')
iframe.src = 'about:blank'
document.body.appendChild(iframe)
return iframe
}
function createSandbox() {
const sanbox = {
ifame: creatrIframe(),
shadowRoot: null
}
return sanbox
}
iframe 天生就是一个独立的执行环境------它有自己独立的 window、document,里面的变量和外层完全隔离。子应用的 JS 最终会在这个 iframe 里执行,所以 window.a = 100 不会污染基座。
3.3 Shadow DOM 提供 CSS 隔离
js
sandbox.shadowRoot = this.attachShadow({ mode: 'open' })
attachShadow 会在 <wujie-app> 内部创建一个 Shadow Root。CSS 选择器穿不透 Shadow 边界 ------子应用在 Shadow Root 里写 div { background: red },只会影响 Shadow 内部,基座页面上的 div 不受影响。
3.4 注入 HTML 和 CSS
js
function injectHemplate(sandbox, template) {
const wrapper = document.createElement('div')
wrapper.innerHTML = template
sandbox.shadowRoot.appendChild(wrapper)
}
把 strTemWithCss 通过一个临时 wrapper 解析成 DOM 节点,然后 appendChild 到 Shadow Root 里。此时子应用的 div 和 <style> 标签都在 Shadow 内部------CSS 已经隔离了。
第四步:注入 JS,重写方法实现 DOM 桥接
这是整个沙箱最精妙的一步。
4.1 问题:子应用 JS 跑在 iframe 里,DOM 却在 Shadow Root 里
子应用的 HTML/CSS 放在了基座页面的 Shadow Root 里(能看到),但 JS 要跑在 iframe 里(能隔离)。
这就出现了一个矛盾:子应用代码里写的是:
js
const ele = document.querySelector('#inner')
这个 document 是 iframe 里的 document ------空的,什么都没有。而 #inner 元素实际在基座页面的 Shadow Root 里。
4.2 解决方案:重写 iframe 的 document.querySelector
js
function injectScript(sandbox, script) {
const iframeWindow = sandbox.ifame.contentWindow
const scriptElement = iframeWindow.document.createElement('script')
const headElement = iframeWindow.document.querySelector('head')
Object.defineProperty(iframeWindow.Document.prototype, 'querySelector', {
get() {
return new Proxy(sandbox.shadowRoot['querySelector'], {
apply(target, thisArg, argumentsList) {
console.log("apply")
return thisArg.querySelector.apply(sandbox.shadowRoot, argumentsList)
}
})
}
})
scriptElement.textContent = script
headElement.appendChild(scriptElement)
}
拆开来看:
第一步 :在 iframe 里创建一个 <script> 标签,把子应用的 JS 代码塞进去,挂到 iframe 的 <head> 里------这样 JS 就在 iframe 的 window 里执行了。
第二步 (核心桥接):在注入 JS 之前 ,用 Object.defineProperty 把 iframe 的 Document.prototype.querySelector 给替换掉:
dart
iframe 的 Document.prototype.querySelector
↓ 被重写为 ↓
sandbox.shadowRoot.querySelector (基座 Shadow Root 的 querySelector)
具体做法是用 Proxy 拦截 apply------每次子应用代码调用 document.querySelector(...) 时:
- 实际执行的是
sandbox.shadowRoot.querySelector(...) - 参数原样透传(比如
'#inner') - 返回的是 Shadow Root 里的 DOM 元素
💡 为什么用
Object.defineProperty+Proxy而不是直接赋值?因为
Document.prototype.querySelector是一个访问器属性 (getter),不能直接=赋值。需要用Object.defineProperty拦截get,然后返回一个Proxy来代理apply调用。这样当子应用调用document.querySelector('#inner')时,get返回 Proxy,apply被拦截,实际执行的是 Shadow Root 上的查询。
效果 :子应用代码完全无感------它以为自己在一个普通页面里跑 document.querySelector('#inner'),实际上查询被静默转发到了基座的 Shadow Root,成功拿到了 #inner 元素。
4.3 整体数据流
xml
基座页面
├── <div>基座应用</div>
├── <div id="container">
│ └── <wujie-app>
│ └── #shadow-root ← CSS 隔离边界
│ ├── <div id="inner">...</div> ← 子应用 DOM
│ └── <style>div { background: red }</style>
│ ← iframe(隐藏,JS 执行环境)
│ └── window.a = 100 ← 不污染基座
│ └── document.querySelector ← 被重写,指向 Shadow Root
└── <iframe style="display:none" src="about:blank"></iframe>
总结:三个隔离 + 一个桥接
| 机制 | 用到的 API | 解决的问题 |
|---|---|---|
| CSS 隔离 | attachShadow({ mode: 'open' }) |
子应用样式不污染基座 |
| JS 隔离 | document.createElement('iframe') |
子应用 window 和基座完全独立 |
| DOM 桥接 | Object.defineProperty + Proxy |
子应用能正常 querySelector 到自己的元素 |
| 自定义元素 | customElements.define |
把子应用封装成 <wujie-app> 标签 |