微前端:从原理到实战搭建一个框架

64 阅读6分钟

本博客将带你理解微前端是什么?

以及讲述微前端的原理,

然后全实战带你手搓一个基础的微前端的框架



1.什么是微前端

微前端,你可以把他理解成 后端服务中的微服务。

他把一个大项目拆成不同的小项目

他允许不同项目中使用不同的技术栈开发 (React 、next.js、Vue 、Angular)


这样的好处是

1.减少单个项目的体积,避免了巨石项目的产生

2.提高了团队的协作效率,避免了多人协同开发的项目管理混乱和冲突

3.允许不同项目采用不同的技术栈开发,增强了项目拓展性



2.微前端的组成

微前端一般用 父应用(基底)一堆子应用组成


父应用负责

1.维护跨应用、跨域的通信

2.决定什么时候加载/卸载 子应用

3.维护整个微前端的框架


子应用负责

1.提供整个应用的渲染/卸载函数给父应用

2.加载自身的业务逻辑



3.微前端的基本原理

不妨思考一下,我们的一个微前端的项目打包之后输出的是什么?

一个HTML文件、若干CSS样式文件、若干JS脚本文件


其实也就是说,任何一个前端项目,最终都只是“一堆可以被浏览器加载的静态资源”

不管你是 Vue、React、Next.js,还是 Angular

最终放到浏览器上执行的,永远只有 HTML / CSS / JS



放一个vue项目的HTML文件, 你应该见过

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

这个HTML文件提供了什么?

在body下一个叫app的容器


然后这个Vue项目的main.js 你也一定认识

import Vue from 'vue'
import App from './App.vue'


new Vue({
    render:h=>h(App)
}).$mount(#app)


什么意思?

创建一个Vue的实例,把这个App这个虚拟dom 挂载到#app这个HTML容器上

本质是上是在这个容器执行JS脚本 和加载对应的CSS样式


微前端的原理是什么?


父项目,在运行时,去加载并运行子应用打包出来的资源

和Vue项目编译一样父应用只需要提供一个挂载点,给子应用


然后去请求子应用打包之后的JS和CSS文件

然后让他挂载在这个父应用提供的挂载点上去就行


父应用并不关心子应用是用什么技术栈写的

因为打包之后都只有JS和CSS文件


父应用只需要关心

1.什么时候加载

2.加载什么资源

3.什么时候卸载


既然已经了解了基本原理,接下来就开始我们的实战,通过具体的实例,给大伙讲解微前端的构成


4.微前端实战

4.1架构组成

其实微前端有很多种方案实现,

但是都不外乎都需要以下这个几个部分

  • 1.子模块注册模块

  • 2.loader加载子模块

  • 3.调度模块

  • 4.生命周期模块

  • 5.沙箱隔离模块


具体流程是

1.调用模块(决定调用哪些子模块)

2.注册模块查找对应模块

3.loader模块加载对应的模块

4.加载之后运行在沙箱环境

5.提供生命周期模块 (让父应用决定什么时候卸载)


但是我今天要介绍的方案,比较返璞归真


一般正常的架构的方案,都需要你去专门写 子模块注册模块调度模块

然后还需要

  • 1.重写 window.history.popstate函数,监听路由的变化

  • 2.根据URL的变化去判断是否跳转到某个子模块





我今天介绍的方案,是这样

  • 1.首先本身微前端的原理,就是父应用提供一个挂载DOM,给子应用挂载

  • 2.那么我需要在src/view下新建一个MicroView文件夹

  • 3.MicroView文件夹下创建一个Vue组件页面

  • 4.vue组件页面专门给对应的子应用提供挂载点 (寄生容器)

  • 5.至于调度模块,我把这个vue组件页面注册在Router/index.js模块下

  • 6.父应用完全可以根据自己什么时候切入寄生容器,而什么时候加载子应用

总的来说,就是我把注册模块和调度模块都交给了Vue/React自带的Router的去自动管理了

这样减少了很多心智负担


路由文件:

import  Vue from 'vue'
import VueRouter from 'vue-router'

export const  router =new VueRouter({
    mode:history,
    routes:[
    
        {
            path:'/app1'
            component:()=> import ('@/view/MicroView/app1')
        
        },
        
         {
            path:'/app2'
            component:()=> import ('@/view/MicroView/app2')
        
        },
    
    ]

})

Microview/app1


<template>
  <div id="app1">

  </div>
</template>

<script> 

<script/>

我们的寄生容器,只需要提供挂载点就行了

这样我们的调度模块、注册模块全部由父应用的路由模块来自动负责



4.2 loader模块

我们已经写好了调度模块和注册模块

那么我们现在要思考的就是,怎么加载子模块的CSS和JS,让他在<div id=app>这里执行并生效

那么我们就要开始封装loader模块了


第一步我们要干什么?

1.请求子模块打包之后的HTML文件

我们封装一个loader.js

他导出一个函数,这个函数接收一个对象


const Micro={

name:app1,                    1.子模块的名字
URL:'https://host:300',       2.子模块的HTML文件地址
container:app1               3.子项目在父应用挂载的目标挂载点

}

loadMicroApp函数


export async  funciton loadMircoApp( {name,  URL,  container}  ){

--- 我们第一步要做的就是
--- 请求子模块的入口文件 也就是HTML文件

const html = await fetch(URL).then((res)=> res.text() ) 
}

在请求到HTML文件之后,我们使用fetch封装的对象方法 text() 把HTML文件转换成字符串

1.fetch(url) → 返回一个 Response 对象

2.Response 对象本身是 流式数据,不能直接当字符串用

3..text()Response 对象的方法,用来把 HTML文件转换成字符串


然后我们需要解析这个HTML入口文件,分析他有哪些CSS样式 和JS脚本

这里需要我们对HTML文件进行解析


loader/utils.js下封装

parseHTML()

export parseHTML( MicroHTML:string )  {

    1.创建DOM解析器
    const parser=new DOMParser()


    2.把字符串解析成DOM树(对象)
    const document=parser.parseFormString( MicroHTML, 'text/html' )
    
    3.解析出js脚本和CSS样式资源元素
    
    const scripts=document.querySelectorAll('script[src]')
    const styles= document.querySelectorAll('link[rel="stylesheet"]' )
    
    4.取出JSCSS资源的URL地址
    
    const ScriptsUrl = Array.from(scripts).map((item)=>item.src )
    
    const StylesUrl = Array.from(styles).map((item)=> item.herf  )
    
    
    5.返回JS脚本 和CSS 样式的地址
    return {
    
    ScriptsUrl,
    
    StylesUrl
    
    }
}



然后在loadMicroApp()中使用


export async  funciton loadMircoApp( {name,  URL,  container}  ){

--- 我们第一步要做的就是
--- 1.请求子模块的入口文件 也就是HTML文件

const html = await fetch(URL).then((res)=> res.text() ) 

--- 2.解析JSCSS资源

const { scriptsUrl, stylesUrl } = parserHTML(html)

--- 3.根据Url资源地址去请求对应的 CSSJS资源


--- 4.请求CSS资源并加载

await loaderStyle(stylesUrl)  -------接下来就是封装这个函数


}


4.2.1 加载CSS资源

在utils.js中封装loaderStyle()

这个函数负责请求styles资源,并加载


export function loaderStyle(Urls){

    
    1.请求全部的styles资源
    const styles=await Promise.all(
                                                   全部转换成string文本           
        Urls.map((item)=> fetch.get(item) .then((res) =>  res.text()  ) )
    
    )
    
    这里得到的styles是一个数组,其中每一个元素,都是一个CSS文件转换成的长string
    
    
    2.我们把多个CSS文件 合并成一个文件CSS文件 通过换行符分割
    
    const StylesAll = styles.join('\n')
    
    
    
    3.创建一个style容器来存放全部的CSS文件
    const stylesElement = document.createElement('style')
    
    
    4.CSS文件放入容器
    stylesElement.textContent = StytlesAll
    
    5.把这个元素加入到<head>中让样式生效
    document.head.appendChild(stylesElement)

}



但是你发现问题没有?

子应用的样式如果就这样加入到全局样式

会污染全局样式


假如 ,父应用 和子应用 之间存在两个一模一样的类名? 或者说 两个子应用存在一模一样的CSS类名

那么后面的最后进来的类,会覆盖前面的类

造成全局的样式污染



所以我们需要进行样式隔离

样式隔离最简单的方式就是,子应用全部的类

都加上子应用的app挂载的目标容器的类

比如说

<template>
    <div id="app1"> </div> 
</template> 


<script> <script/>

子应用要挂载到 id=app1的div上

那么我们只需要给全部的子应用的类,在前面加上 #app1 就行了


这件事在哪里做呢?

总不能在写子应用的时候,全部的类都加上 #app1


我们只要需要在父应用加载子应用的CSS文件的时候

用正则去匹配CSS文件,给全部的类加上 #app1就行



4.2.2 CSS样式隔离

在loader/utils.js 下面封装CSS隔离函数

CSSisolation()

函数参数: CSS样式 Styles 、 子应用的挂载点container


export function CSSisolation( Styles,  container ){

    1.匹配全部 不以{}开头,但是以 } 结尾字符串, 以此捕获全部的类名
    return Styles.replace.(/([^ {}] +) \{ /g) , (match,Selector) =>{
    
    
        2.跳过全部的@开头的
        if(Selector.trim().startsWith('@') ){
            
            return match  ---- 直接return,不做修改 
        }
    
    
        3.处理并列选择器
        
        const ScopedStylesArr  =  Selector.split(',').map((item)=>{
        
            let sel = item.trim()
            
            
            4.不允许子应用的样式 影响全局样式
            
            if( sel==='body' || sel==='html' || sel=== 'app' ){
                
                return '${container}'   --- 只允许修改最大的父类样式是container挂载点
             
            }
        
            
            5.给全部的类加上container
            if(! sel.startsWith(container) ){
                return '${container} ${sel}'
                
            }
        
        
            6.都没有匹配的直接返回
            
            return sel
        
        
        }) 
    
        
        7.拼回成字符串
        const  ScopedStyles = ScopedStylesArr.join(',' )
        
        
        return '${ScopedStyles} {'
    }


}


然后使用这个函数

在loader/index.js: loadMicroApp()


export async  funciton loadMircoApp( {name,  URL,  container}  ){

--- 我们第一步要做的就是
--- 1.请求子模块的入口文件 也就是HTML文件

const html = await fetch(URL).then((res)=> res.text() ) 

--- 2.解析JSCSS资源

const { scriptsUrl, stylesUrl } = parserHTML(html)

--- 3.根据Url资源地址去请求对应的 CSSJS资源


--- 4.请求CSS资源并加载

await loaderStyle(stylesUrl, container)  ----------- 传递container目标挂载点


}

在loader/utils.js :loaderStyle()


export function loaderStyle(Urls){

    
    1.请求全部的styles资源
    const styles=await Promise.all(
                                                   全部转换成string文本           
        Urls.map((item)=> fetch.get(item) .then((res) =>  res.text()  ) )
    
    )
    
    这里得到的styles是一个数组,其中每一个元素,都是一个CSS文件转换成的长string
    
    
    2.我们把多个CSS文件 合并成一个文件CSS文件 通过换行符分割
    
    const StylesAll = styles.join('\n')
    
    
    3.然后把这个CSS文件进行隔离处理
    
    const ScopedStyles =CSSisolation(StylesALL)  --------------------------------新增部分
    
    
    
    4.创建一个style容器来存放全部的CSS文件
    const stylesElement = document.createElement('style')
    
    
    5.CSS文件放入容器
    stylesElement.textContent = ScopedStyles
    
    6.把这个元素加入到<head>中让样式生效
    document.head.appendChild(stylesElement)

}



但是现在又有了一个新的问题,

写完了加载逻辑,我们需要写卸载逻辑

什么时候卸载呢? 当子应用的生命周期结束,父应用切到其他子应用的时候

当前子应用的CSS样式应该被卸载 ,这样不会污染全局 和 其他子模块的样式



4.2.3 CSS样式卸载

卸载的逻辑其实非常简单,我们只需要在loaderStyles()函数

StylesElement加多一个标记就行了


loader/utils.js :loaderStyle()


export function loaderStyle(Urls){

    
    1.请求全部的styles资源
    const styles=await Promise.all(
                                                   全部转换成string文本           
        Urls.map((item)=> fetch.get(item) .then((res) =>  res.text()  ) )
    
    )
    
    这里得到的styles是一个数组,其中每一个元素,都是一个CSS文件转换成的长string
    
    
    2.我们把多个CSS文件 合并成一个文件CSS文件 通过换行符分割
    
    const StylesAll = styles.join('\n')
    
    
    3.然后把这个CSS文件进行隔离处理
    
    const ScopedStyles =CSSisolation(StylesALL)  
    
    
    
    4.创建一个style容器来存放全部的CSS文件
    const stylesElement = document.createElement('style')
    
    5.给这个容器加上一个标记元素
    StylesElement.setAttribute('data-container',container)  ---------------标记这个元素
    
    
    6.CSS文件放入容器
    stylesElement.textContent = ScopedStyles
    
    
    7.把这个元素加入到<head>中让样式生效
    document.head.appendChild(stylesElement)

}



接下来我们可以卸载CSS样式函数了


export clearStyles(container){


     1.找到这个目标元素
    const stylesElement=document.querySelectorAll('style[data-container=" + 'container' +  "]')

     2.把这个元素从head上移除
     if(styleElements && styleElements.length > 0){
     
        styleElements.forEach((item) => item.remove())
        
      }


}




这个函数的调用时机是,子应用被卸载的时候调用

也就是子应用的寄生容器页面被卸载的时候

<template>
    <div id="app1"> </div> 
</template> 


<script>

unmount:(()=>{

    clearStyles('#app1')

}) 


<script/>


4.2.4 JS沙箱隔离

接下来就是到我们,微前端架构最难的部分: JS沙箱隔离


这个时候你可能会疑惑,写完了CSS的加载逻辑、隔离逻辑、卸载逻辑

我们不是应该开始写JS的加载逻辑吗?


但在真正进入 JS 加载之前,我们必须先解决一个更基础、也更关键的问题:

如何保证子应用的 JS 在运行时,不会影响宿主应用和其他子应用?


JavaScript 没有天然的作用域边界,一旦多个子应用同时运行,就必然会互相污染。


我们需要隔离他们的运行环境,让每一个子应用都拥有一个window

但是不真正污染全局window


所以在真正 加载JS 和执行JS 之前, 我们需要先做的是搭建一个运行环境给他们


新建一个sandbox文件夹

下面建立一个proxy.js



export funciton createProxy(){

    1.创建虚拟的全局环境
    const FakeWindow = Object.create(null)   这里只是一个空对象


    
    2.开启代理
    
    const proxy = new Proxy(FakeWindo ,{
    
    
    
        3.读取属性
        get(target,key){
        
            4.如果是全局属性,直接返回我们的 FakeWindow
        
            if(key==="window" || key === "self" || key==="globalThis"){
                
               return Proxy
            }
            
        
            5.如果是普通的属性 优先从FakeWind上读取,如果没有才去真正的全局window上取
            
            const value =(key in target )? target[key] : window[key] 
            
            return value
        }
    
    
    
        
        
        6.写入属性
        
        set(target,key,value){
 
            7.只允许写入在FakeWindow上,不允许污染真正的全局
            
            target[key]=value
            
            return true
        }
    
    
    
      })



}

写完这个基础的沙箱隔离,搭建完成这个JS的运行环境,那么我们可以加载的JS的逻辑了



4.2.5 JS逻辑加载

然后在loadMicroApp()中使用


export async  funciton loadMircoApp( {name,  URL,  container}  ){

--- 我们第一步要做的就是
--- 1.请求子模块的入口文件 也就是HTML文件

const html = await fetch(URL).then((res)=> res.text() ) 

--- 2.解析JSCSS资源

const { scriptsUrl, stylesUrl } = parserHTML(html)

--- 3.根据Url资源地址去请求对应的 CSSJS资源


--- 4.请求CSS资源并加载

await loaderStyle(stylesUrl)  


--- 5.创建沙箱环境

const proxy = createProxy()  -------------运行环境

--- 6.请求并加载JS

await loaderScript(scriptUrl,proxy) ------------ 加载运行


}

在loader/utils.js下封装 loaderScripts()


export function fetchScripts(Urls,proxy){



    1.请求JS文件资源,并转换成string文本

    const JScode = await Promise.all(
    
        Url.map( (item)=> fetch(item).then( (res)=> res.text()  ) )
    )
    

    2.串行执行,JS脚本的加载是要保证先后顺序的

    for( const code of JScode ){
    
        await execScript( code ,proxy  )  -------封装的执行函数
    
    }
    


}




export execScript(code, proxy ){


    
    1.通过new Funciton来把String文本作为JS执行
    
    const runner= new Function ('window','self','globalThis',code)

    2.立即执行
    runner(proxy,proxy,proxy)

}





4.2.6 JS副作用清除


接下来是什么呢?

你可以应该很自然的想到,当子应用卸载的时候

我们需要清除加载JS遗留下来的副作用

比如说,全局监听/定时器/计时器


原理很简单

当子应用全创建局事件、定时器、观察者等副作用,

在创建的那一刻,最终一定会落到的 window上的

那么实际上,他们就会去访问我们封装好了的FakeWindow 这个代理对象


那么我们只需要在他们创建这些事件的时候,记录下这些事件的Id

然后当寄生页面销毁的时候 ,根据这些id去销毁这些监听事件


sandbox/proxy.js:

createProxy()



export funciton createProxy(){


    const timers=new Set() ---------------- 定时器id记录表
    const intervals= new Set() -------------计时器id记录表
    const eventlisteners= new Set() --------事件监听id记录表
    const observers=new Set()---------------观察事件id记录表
 
    
    
    
    
   接下来我们使用装饰器重写这些方法    核心就是在每次调用的时候,都记录一下id
   
   重写setTimeout
   funciton WrapSetTimeout(fn,delay,...args){
       const id=window.setTimeout(fn,delay,...args)
   
     
       timers.add(id)   记录这个id
       
       return id
       
   }
    
    
    重写intervals
    function WrapSetInterval(fn,delay,...agrs){
        
        const id= window.setInerval(fn,delay,...args)
        
        intervals.add(id)  记录
        
        return id
    }
    
    
    重写addEventListener
    function WrapAddEventListener(type,litstener,options){
    
        eventlinsteners.add({  记录事件
            target:window,
            type,
            listener,
            options
        })
        
        开启监听
        window.addEventListener(type,listener,options)
    }
    
    
    
    
    重写removeEventListener
    function WrapRemoveEventListener(type,listener,options){
        
        //遍历查找
        for( item of eventListeners){
        
        if(item.type===type && item.listener===listener ){
        
            eventlistener.delete(item)
            break
        }
        
       }
        
        window.removeEventListener(type,listener,options)
    }
    
    
    
    
    重写observe
    const RawMutationsObserver= window.MutationObserver
    
    function ProxyMuatationObeserver(callback){
    
        创建实例
        const instance new RawMutationsObserver(callback)
    
        劫持实例的 disconnect 方法
        const originDisconnect = instance.disconnect
        
        instance.disconnect = function() {
        
          observers.delete(instance);     --------------  开发者手动销毁时,从账本移除
          
          
          return originDisconnect.call(this)
        }
    
    
        记录实例
        observers.add(instance)
        
        
        return instance

    }
    
    
    
    
    创建映射表
    const EffectFunction ={
    
        'setTimeout'WrapSetTimeout,
        
        
        'setInterval':WrapSetInterval,
        
        'addEventListener':WrapAddEventListener,
    
        'removeEventListener':WrapRemoveEventListener,
        
        'MutationObserver':ProxyMuatationObeserver
    }





    1.创建虚拟的全局环境
    const FakeWindow = Object.create(null)   这里只是一个空对象


    
    2.开启代理
    
    const proxy = new Proxy(FakeWindo ,{
    
    
    
        3.读取属性
        get(target,key){
        
            4.如果是全局属性,直接返回我们的 FakeWindow
        
            if(key==="window" || key === "self" || key==="globalThis"){
                
               return proxy
            }
            
            
            5.劫持全部的副作用函数,return重写的函数
            if(key in EffectFunction){
                return EffectFunction[key]
            }
             
            
        
            6.如果是普通的属性 优先从FakeWind上读取,如果没有才去真正的全局window上取
            
            const value =(key in target )? target[key] : window[key] 
        
            return value
        }
    
    
    
        
        
        7.写入属性
        
        set(target,key,value){
 
            8.只允许写入在FakeWindow上,不允许污染真正的全局
            
            target[key]=value
            
            return true
        }
    
    
    
      })

    
    
    
    8.清除副作用函数
    function clearEffect(){
    
    
        清除定时器
        timers.forEach(id=> window.clearTimeout(id))
        timers.clear() -----清空记录表
    
    
        清除监听事件
        eventListeners.forEach(({target,type,listener,options})=>{
            target.removeEventListener(type,listener,options)
       
        })
        eventlisteners.clear() -----清空记录表
    
    
        
        清空观察者
        observers.forEach(observer => {
        if (observer.disconnect) {
        
            observer.disconnect()   
         }
        })
        observers.clear()-----------------清除观察者集合
    
    
    
        清空时间间隔
        intervals.forEach(id=>window.clearInterval(id))
        intervals.clear()-----------------------------------------清除定时器ID集合
    }
    
    
    
    return {
    
        proxy,
        clearEffects()  ------------把函数导出    
    }

}


然后在loader/index.js中

loadMicroApp()中使用


export async  funciton loadMircoApp( {name,  URL,  container}  ){

--- 我们第一步要做的就是

--- 1.请求子模块的入口文件 也就是HTML文件

const html = await fetch(URL).then((res)=> res.text() ) 

--- 2.解析JSCSS资源

const { scriptsUrl, stylesUrl } = parserHTML(html)

--- 3.根据Url资源地址去请求对应的 CSSJS资源


--- 4.请求CSS资源并加载

await loaderStyle(stylesUrl)  


--- 5.创建沙箱环境

const {proxy,clearEffect } = createProxy()  -------------得到清除函数

--- 6.请求并加载JS

await loaderScript(scriptUrl,proxy)



}


4.2.7 子应用提供mount/unmount函数

这里以Vue文件为例

告诉大家怎么让子应用为父应用提供mount函数

这样能让父项目决定什么时候挂载/卸载 子模块


loader/index.js

loadMicroApp()中使用


export async  funciton loadMircoApp( {name,  URL,  container}  ){

--- 我们第一步要做的就是

--- 1.请求子模块的入口文件 也就是HTML文件

const html = await fetch(URL).then((res)=> res.text() ) 

--- 2.解析JSCSS资源

const { scriptsUrl, stylesUrl } = parserHTML(html)

--- 3.根据Url资源地址去请求对应的 CSSJS资源


--- 4.请求CSS资源并加载

await loaderStyle(stylesUrl)  


--- 5.创建沙箱环境

const {proxy,clearEffect } = createProxy()  

--- 6.请求并加载JS

await loaderScript(scriptUrl,proxy)

--- 7.注入环境变量

proxy.__MICRO_FRONTEND__ = true ---------------------告诉子应用当前是在微前端环境中运行的



}

Vue2/main.js


import Vue from 'vue'

import App from './App.vue'


子应用实例
let appInstance =null

判断当前是什么环境
const isMicroFrontend = !!window.__MICRO_FRONTEND__


判断环境
if(!isMicroFrontend){

    如果当前不是微前端环境,直接正常启动,挂到自己项目的HTMML的根节点
    appInstance=new Vue({
    
        render:h=>h(App)
    
    })
    appInstance.$mount(#app)
}


如果是微前端环境,提供函数

export async function microMount(container){  --------container是父应用传递进来的挂载点
    创建实例
    appInstance=new Vue({
    
        render:h=>h(App)
    
    })
    appInstance.$mount(container)  ---------------挂载到目标挂载点

}


export async function  microUnmount(){

    if(appInstance){
    
        appInstance.$destroy()--------------销毁
        
        appInstance=null ---------------------消除实例
        
    }



     或者更暴力稳妥的写法
        
        这里通过parentNode节点移除组件挂载
        if (appInstance.$el && appInstance.$el.parentNode) {
        
          手动移除DOM,因为Vue2 mount会替换挂载点,导致父应用失去对DOM的引用
          
          appInstance.$el.parentNode.removeChild(appInstance.$el)
          
          
        }


}

子应用提供这两个函数,什么时候真正发生挂载,由父应用决定


然后把子模块的打包方式设置成UMD模式 (非常重要)

Webpack 会自动把这些 export 的函数 挂载到了 window[name]

这样我们父应用就能从代理window, 也就是proxy中拿到, 子应用的mount/unmount函数


至于什么是UMD打包模式,怎么设置UMD打包模式

这里就不多说了,你自己去了解就行



loader/index.js

loadMicroApp()中使用


export async  funciton loadMircoApp( {name,  URL,  container}  ){

--- 我们第一步要做的就是

--- 1.请求子模块的入口文件 也就是HTML文件

const html = await fetch(URL).then((res)=> res.text() ) 

--- 2.解析JSCSS资源

const { scriptsUrl, stylesUrl } = parserHTML(html)

--- 3.根据Url资源地址去请求对应的 CSSJS资源


--- 4.请求CSS资源并加载

await loaderStyle(stylesUrl)  


--- 5.创建沙箱环境

const {proxy,clearEffect } = createProxy()  


--- 6.请求并加载JS

await loaderScript(scriptUrl,proxy)



--- 7.注入环境变量

proxy.__MICRO_FRONTEND__ = true 


--- 8.拿到子应用挂载到proxy上的生命周期函数


const app =proxy[name] -------------如果没有开启umd打包模式,这里是拿不到的


---- 9.做一个判断

  if(!app){
    throw new Error(`子应用${name}导出的生命周期函数不存在`)
  }


---- 10.调用子应用导出的生命周期函数 挂载到指定的容器

app.mount(container)


return {

----11. 返回一个卸载清除函数函数


            MicroUnmount:()=>{

                ----12. 卸载子应用
                app.unmount()

                ----13.清除CSS样式
                clearStyles()  


                ----14.清除JS副作用
                clearEffect()


            }


}

}

最终我们在寄生页面的使用

<template>
    <div id="app1"> </div> 
</template> 


<script>

import loadMircoApp from '@/Micro/loader/index.js' --------导入加载流程函数
import { onMounted, onUnmounted } from 'vue' ------组件页面的渲染/卸载函数

let Instance =null


1.当这个页面被路由跳转进来开始加载的时候

onMounted( async() =>{

    执行挂载逻辑
    const microApp= await loadMircoApp({
        
        name:app1,
        URL:'http://test.com',
        container:"#app1"
    
    
    })


    Instance = microApp

})


2.当父应用离开这个页面 页面会自动销毁

onUnmounted(()=>{


    Instance.MicroUnmount() --------执行全部的清除函数

})



<script/>






4.2.8 生命周期函数管理

更加标准的做法是把把生命周期的管理抽离出来,不放在loader模块

大概类似这样子



import {clearStyle} from '../loader/utils'

----- 1.挂载子应用

export function mountApp(app, container) {

  传递对象类型的props,包含container属性
  
  app.mount({ container })
}


----- 2.卸载子应用

export function unmountApp(app,clearEffects,container) {

  卸载子应用时,指定容器
  
  try {
     app.unmount(container) 
  } catch (error) {
    console.warn('子应用卸载失败',error)
  }

  清除JS副作用
  
  try {
    clearEffects() 
  } catch (error) {
    console.warn('清除JS副作用失败',error)
  }

  清除CSS样式
  
  try {
    clearStyle(container) 
  } catch (error) {
    console.warn('清除CSS样式失败',error)
  }
}



5.总结

本博客的实战部分就到此结束,基本上是带你手搓了一个简单基本的微前端架构


不过实际上的微前端架构考虑的东西还要更复杂一些

我在搭建自己的博客项目的时候 ,就使用自己搭建的架构

实际的生产中还是会遇到很多问题的


比如说

1.window部分函数对象要求this必须指向真正的window

你在get的时候,没办法用proxy代理,你必须返回真正的window指针

解法方法类似于


    const value = (key in target) ? target[key] : window[key];


    if(typeof value==='function' && /^[a-z]/.test(key)){
 
      简单的判断:如果函数没有 prototype 属性(箭头函数),或者 key 是构造函数名
      
      更好的方式:只对 window 上的原生方法 bind,避免 bind 用户定义的函数
      if (value.toString().indexOf('[native code]') !== -1 && !key.startsWith('webkit')) {
         try {
           return value.bind(window) 
         } catch (e) {
         
         
           bind 失败(比如是不可 bind 的函数),直接返回原值
           return value
         }
      }
      return value


2.部分属性必须挂载到真实的window上



    set(target,key,value){
    
       允许部分子模块逃逸出沙箱
      if(key.startsWith('webpackHotUpdate')){
      
        window[key]=value   白名单:将webpack热更新函数挂载到真实windowreturn true
      }
      
      target[key]=value
      
      return true
    },

3.window的属性不单单只有 set、get、has

  • ownKeys

  • getOwnPropertyDescriptor

  • defineProperty

  • deleteProperty

这些也要专门写隔离



不过讲到这里你估计也大概明白了微前端的基本框架和原理

基本入门之后,你就可以自己深入的去探索

如果希望更好的隔离的效果,可以参考qiankun框架的快照沙箱

我觉得是比单纯的代理window强的,无论是隔离还是拓展性



另外本文并没有教你怎么搭建微前端中的跨域通信

大概常见的几个方案有

1.props 注入

2.全局状态(store)

3.发布订阅(Event Bus)

4.URL / 路由通信


重点了解 2方案和3方案 即可

我认为在入门之后只要去对这些做了解,应该是能很轻松地搭建通信机制出来的

所以本文就不多做介绍了


本博客中没有去写 调度模块注册模块

而且都把他交给了vue/React中天然的注册模块和调度模块--------Router路由机制


如果你想要一个完整的,不依赖router的路由模块的微前端框架

你可以尝试在我给出的示例上做改写,只需要增加调度模块注册模块即可


最后,如果你觉得博客写的还行,对你学习微前端有帮助的话,麻烦给个点赞🐔