手把手教你写一个简易的微前端框架(三)

简介: 手把手教你写一个简易的微前端框架(三)

隔离子应用元素作用域

我们在使用 document.querySelector() 或者其他查询 DOM 的 API 时,都会在整个页面的 document 对象上查询。如果在子应用上也这样查询,很有可能会查询到子应用范围外的 DOM 元素。为了解决这个问题,我们需要重写一下查询类的 DOM API:

// 将所有查询 dom 的范围限制在子应用挂载的 dom 容器上
Document.prototype.querySelector = function querySelector(this: Document, selector: string) {
    const app = getCurrentApp()
    if (!app || !selector || isUniqueElement(selector)) {
        return originalQuerySelector.call(this, selector)
    }
    // 将查询范围限定在子应用挂载容器的 DOM 下
    return app.container.querySelector(selector)
}
Document.prototype.getElementById = function getElementById(id: string) {
    // ...
}

将查询范围限定在子应用挂载容器的 DOM 下。另外,子应用卸载时也需要恢复重写的 API:

Document.prototype.querySelector = originalQuerySelector
Document.prototype.querySelectorAll = originalQuerySelectorAll
// ...

除了查询 DOM 要限制子应用的范围,样式也要限制范围。假设在 vue 应用上有这样一个样式:

body {
    color: red;
}

当它作为一个子应用被加载时,这个样式需要被修改为:

/* body 被替换为子应用挂载 DOM 的 id 选择符 */
#app {
    color: red;
}

实现代码也比较简单,需要遍历每一条 css 规则,然后替换里面的 bodyhtml 字符串:

const re = /^(\s|,)?(body|html)\b/g
// 将 body html 标签替换为子应用挂载容器的 id
cssText.replace(re, `#${app.container.id}`)

V4 版本

V3 版本实现了 window 作用域隔离、元素隔离,在 V4 版本上我们将实现子应用样式隔离。

第一版

我们都知道创建 DOM 元素时使用的是 document.createElement() API,所以我们可以在创建 DOM 元素时,把当前子应用的名称当成属性写到 DOM 上:

Document.prototype.createElement = function createElement(
    tagName: string,
    options?: ElementCreationOptions,
): HTMLElement {
    const appName = getCurrentAppName()
    const element = originalCreateElement.call(this, tagName, options)
    appName && element.setAttribute('single-spa-name', appName)
    return element
}

这样所有的 style 标签在创建时都会有当前子应用的名称属性。我们可以在子应用卸载时将当前子应用所有的 style 标签进行移除,再次挂载时将这些标签重新添加到 document.head 下。这样就实现了不同子应用之间的样式隔离。

移除子应用所有 style 标签的代码:

export function removeStyles(name: string) {
    const styles = document.querySelectorAll(`style[single-spa-name=${name}]`)
    styles.forEach(style => {
        removeNode(style)
    })
    return styles as unknown as HTMLStyleElement[]
}

第一版的样式作用域隔离完成后,它只能对每次只加载一个子应用的场景有效。例如先加载 a 子应用,卸载后再加载 b 子应用这种场景。在卸载 a 子应用时会把它的样式也卸载。如果同时加载多个子应用,第一版的样式隔离就不起作用了。

第二版

由于每个子应用下的 DOM 元素都有以自己名称作为值的 single-spa-name 属性(如果不知道这个名称是哪来的,请往上翻一下第一版的描述)。

所以我们可以给子应用的每个样式加上子应用名称,也就是将这样的样式:

div {
    color: red;
}

改成:

div[single-spa-name=vue] {
    color: red;
}

这样一来,就把样式作用域范围限制在对应的子应用所挂载的 DOM 下。

给样式添加作用域范围

现在我们来看看具体要怎么添加作用域:

/**
 * 给每一条 css 选择符添加对应的子应用作用域
 * 1. a {} -> a[single-spa-name=${app.name}] {}
 * 2. a b c {} -> a[single-spa-name=${app.name}] b c {}
 * 3. a, b {} -> a[single-spa-name=${app.name}], b[single-spa-name=${app.name}] {}
 * 4. body {} -> #${子应用挂载容器的 id}[single-spa-name=${app.name}] {}
 * 5. @media @supports 特殊处理,其他规则直接返回 cssText
 */

主要有以上五种情况。

通常情况下,每一条 css 选择符都是一个 css 规则,这可以通过 style.sheet.cssRules 获取:

拿到了每一条 css 规则之后,我们就可以对它们进行重写,然后再把它们重写挂载到 document.head 下:

function handleCSSRules(cssRules: CSSRuleList, app: Application) {
    let result = ''
    Array.from(cssRules).forEach(cssRule => {
        const cssText = cssRule.cssText
        const selectorText = (cssRule as CSSStyleRule).selectorText
        result += cssRule.cssText.replace(
            selectorText, 
            getNewSelectorText(selectorText, app),
        )
    })
    return result
}
let count = 0
const re = /^(\s|,)?(body|html)\b/g
function getNewSelectorText(selectorText: string, app: Application) {
    const arr = selectorText.split(',').map(text => {
        const items = text.trim().split(' ')
        items[0] = `${items[0]}[single-spa-name=${app.name}]`
        return items.join(' ')
    })
    // 如果子应用挂载的容器没有 id,则随机生成一个 id
    let id = app.container.id
    if (!id) {
        id = 'single-spa-id-' + count++
        app.container.id = id
    }
    // 将 body html 标签替换为子应用挂载容器的 id
    return arr.join(',').replace(re, `#${id}`)
}

核心代码在 getNewSelectorText() 上,这个函数给每一个 css 规则都加上了 [single-spa-name=${app.name}]。这样就把样式作用域限制在了对应的子应用内了。

效果演示

大家可以对比一下下面的两张图,这个示例同时加载了 vue、react 两个子应用。第一张图里的 vue 子应用部分字体被 react 子应用的样式影响了。第二张图是添加了样式作用域隔离的效果图,可以看到 vue 子应用的样式是正常的,没有被影响。

V5 版本

V5 版本主要添加了一个全局数据通信的功能,设计思路如下:

  1. 所有应用共享一个全局对象 window.spaGlobalState,所有应用都可以对这个全局对象进行监听,每当有应用对它进行修改时,会触发 change 事件。
  2. 可以使用这个全局对象进行事件订阅/发布,各应用之间可以自由的收发事件。

下面是实现了第一点要求的部分关键代码:

export default class GlobalState extends EventBus {
    private state: AnyObject = {}
    private stateChangeCallbacksMap: Map<string, Array<Callback>> = new Map()
    set(key: string, value: any) {
        this.state[key] = value
        this.emitChange('set', key)
    }
    get(key: string) {
        return this.state[key]
    }
    onChange(callback: Callback) {
        const appName = getCurrentAppName()
        if (!appName) return
        const { stateChangeCallbacksMap } = this
        if (!stateChangeCallbacksMap.get(appName)) {
            stateChangeCallbacksMap.set(appName, [])
        }
        stateChangeCallbacksMap.get(appName)?.push(callback)
    }
    emitChange(operator: string, key?: string) {
        this.stateChangeCallbacksMap.forEach((callbacks, appName) => {
            /**
             * 如果是点击其他子应用或父应用触发全局数据变更,则当前打开的子应用获取到的 app 为 null
             * 所以需要改成用 activeRule 来判断当前子应用是否运行
             */
            const app = getApp(appName) as Application
            if (!(isActive(app) && app.status === AppStatus.MOUNTED)) return
            callbacks.forEach(callback => callback(this.state, operator, key))
        })
    }
}

下面是实现了第二点要求的部分关键代码:

export default class EventBus {
    private eventsMap: Map<string, Record<string, Array<Callback>>> = new Map()
    on(event: string, callback: Callback) {
        if (!isFunction(callback)) {
            throw Error(`The second param ${typeof callback} is not a function`)
        }
        const appName = getCurrentAppName() || 'parent'
        const { eventsMap } = this
        if (!eventsMap.get(appName)) {
            eventsMap.set(appName, {})
        }
        const events = eventsMap.get(appName)!
        if (!events[event]) {
            events[event] = [] 
        }
        events[event].push(callback)
    }
    emit(event: string, ...args: any) {
        this.eventsMap.forEach((events, appName) => {
            /**
             * 如果是点击其他子应用或父应用触发全局数据变更,则当前打开的子应用获取到的 app 为 null
             * 所以需要改成用 activeRule 来判断当前子应用是否运行
             */
            const app = getApp(appName) as Application
            if (appName === 'parent' || (isActive(app) && app.status === AppStatus.MOUNTED)) {
                if (events[event]?.length) {
                    for (const callback of events[event]) {
                        callback.call(this, ...args)
                    }
                }
            }
        })
    }
}

以上两段代码都有一个相同的地方,就是在保存监听回调函数的时候需要和对应的子应用关联起来。当某个子应用卸载时,需要把它关联的回调函数也清除掉。

全局数据修改示例代码

// 父应用
window.spaGlobalState.set('msg', '父应用在 spa 全局状态上新增了一个 msg 属性')
// 子应用
window.spaGlobalState.onChange((state, operator, key) => {
    alert(`vue 子应用监听到 spa 全局状态发生了变化: ${JSON.stringify(state)},操作: ${operator},变化的属性: ${key}`)
})

全局事件示例代码

// 父应用
window.spaGlobalState.emit('testEvent', '父应用发送了一个全局事件: testEvent')
// 子应用
window.spaGlobalState.on('testEvent', () => alert('vue 子应用监听到父应用发送了一个全局事件: testEvent'))

总结

至此,一个简易微前端框架的技术要点已经讲解完毕。强烈建议大家在看文档的同时,把 demo 运行起来跑一跑,这样能帮助你更好的理解代码。

如果你觉得我的文章写得不错,也可以看看我的其他一些技术文章或项目:

目录
相关文章
|
1月前
|
前端开发 JavaScript 开发者
颠覆传统:React框架如何引领前端开发的革命性变革
【10月更文挑战第32天】本文以问答形式探讨了React框架的特性和应用。React是一款由Facebook推出的JavaScript库,以其虚拟DOM机制和组件化设计,成为构建高性能单页面应用的理想选择。文章介绍了如何开始一个React项目、组件化思想的体现、性能优化方法、表单处理及路由实现等内容,帮助开发者更好地理解和使用React。
77 9
|
2月前
|
人工智能 前端开发 JavaScript
前端架构思考 :专注于多框架的并存可能并不是唯一的方向 — 探讨大模型时代前端的分层式微前端架构
随着前端技术的发展,微前端架构成为应对复杂大型应用的流行方案,允许多个团队使用不同技术栈并将其模块化集成。然而,这种设计在高交互性需求的应用中存在局限,如音视频处理、AI集成等。本文探讨了传统微前端架构的不足,并提出了一种新的分层式微前端架构,通过展示层与业务层的分离及基于功能的横向拆分,以更好地适应现代前端需求。
|
2月前
|
JavaScript 前端开发 API
Vue.js:现代前端开发的强大框架
【10月更文挑战第11天】Vue.js:现代前端开发的强大框架
81 41
|
1月前
|
前端开发 JavaScript API
前端界的秘密武器:掌握这些框架,让你轻松秒杀99%的同行!
前端开发日新月异,掌握几个明星框架如React、Vue.js和Angular,不仅能让工作更得心应手,还能轻松超越同行。React以高效的虚拟DOM和组件化著称;Vue.js简洁易懂,灵活性高;Angular提供全面的解决方案,适合大型应用。此外,轻量级的Svelte也值得关注,其编译时处理设计提升了应用性能。掌握这些框架,结合深刻理解和灵活运用,助你在前端领域脱颖而出。
31 9
|
2月前
|
JavaScript 前端开发 测试技术
前端全栈之路Deno篇(五):如何快速创建 WebSocket 服务端应用 + 客户端应用 - 可能是2025最佳的Websocket全栈实时应用框架
本文介绍了如何使用Deno 2.0快速构建WebSocket全栈应用,包括服务端和客户端的创建。通过一个简单的代码示例,展示了Deno在WebSocket实现中的便捷与强大,无需额外依赖,即可轻松搭建具备基本功能的WebSocket应用。Deno 2.0被认为是最佳的WebSocket全栈应用JS运行时,适合全栈开发者学习和使用。
135 7
|
2月前
|
缓存 前端开发 JavaScript
前端serverless探索之组件单独部署时,利用rxjs实现业务状态与vue-react-angular等框架的响应式状态映射
本文深入探讨了如何将RxJS与Vue、React、Angular三大前端框架进行集成,通过抽象出辅助方法`useRx`和`pushPipe`,实现跨框架的状态管理。具体介绍了各框架的响应式机制,展示了如何将RxJS的Observable对象转化为框架的响应式数据,并通过示例代码演示了使用方法。此外,还讨论了全局状态源与WebComponent的部署优化,以及一些实践中的改进点。这些方法不仅简化了异步编程,还提升了代码的可读性和可维护性。
|
2月前
|
前端开发 JavaScript 中间件
前端全栈之路Deno篇(四):Deno2.0如何快速创建http一个 restfulapi/静态文件托管应用及oak框架介绍
Deno 是由 Node.js 创始人 Ryan Dahl 开发的新一代 JavaScript 和 TypeScript 运行时,旨在解决 Node.js 的设计缺陷,具备更强的安全性和内置的 TypeScript 支持。本文介绍了如何使用 Deno 内置的 `Deno.serve` 快速创建 HTTP 服务,并详细讲解了 Oak 框架的安装和使用方法,包括中间件、路由和静态文件服务等功能。Deno 和 Oak 的结合使得创建 RESTful API 变得高效且简便,非常适合快速开发和部署现代 Web 应用程序。
115 2
|
2月前
|
前端开发 JavaScript 开发者
qiankun(乾坤)微前端框架简介
qiankun(乾坤)微前端框架简介
221 1
|
2月前
|
前端开发 JavaScript 开发者
探索现代Web前端技术:React框架入门
【10月更文挑战第9天】 探索现代Web前端技术:React框架入门
|
2月前
|
存储 前端开发 JavaScript
前端技术深度探索:从基础到现代框架的实践之旅
前端技术深度探索:从基础到现代框架的实践之旅
47 3