从零手写微前端qiankun框架【超详细万字长文】

项目创建

我们创建如图几个文件夹

  • main:主应用(采用vue3作为技术栈)
  • react:子应用1
  • vue2:子应用2
  • vue3:子应用3
  • service:服务端代码

vue2子应用:

我们在App.vue中写一点点东西

<template>
  <div class="vue2">
    <h1>vue2子应用</h1> 
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
}
</script>

<style scoped>
.vue2{
  background: #F56C6C;
  height: 90vh;
  display: flex;
  justify-content: center;
  align-items: center;
  color: #ffff;
}

</style>

然后,我们需要对做一些配置,创建vue.config.js配置文件

const path = require('path')
const { name } = require('./package.json')

function resolve (dir) {
    return path.join(__dirname)
}

const port = 9004

module.exports = {
    outputDir: 'dist',                       //打包输出目录
    assetsDir: 'static',                     //静态资源目录
    filenameHashing: true,                   //打包的文件名是否带哈希信息
    publicPath: 'http://localhost:9004',     //确保当前资源不会加载出错
    devServer: {
      contentBase: path.join(__dirname, 'dist'),    //当前服务是通过dist来拿的
      hot: true,                                    //热更新
      disableHostCheck: true,                       //热更新
      port,                                          //端口
      headers: {                                     //本地服务可以被跨域调用,主应用可以拿到子应用的数据
        'Access-Control-Allow-Origin': '*', 
      },
    },
    // 自定义webpack配置
   configureWebpack: {
    resolve: {
      alias: {
        '@': resolve('src'),                        //@代表src目录
      },
    },
    output: {
      // 把子应用打包成 umd 库格式
      libraryTarget: 'umd',                          //umd格式  支持comm.js引入  浏览器、node可以识别
      filename: 'vue2.js',                           //打包出的名称
      library: 'vue2',                               //全局可以通过window.vue2拿到的应用
      jsonpFunction: `webpackJsonp_${name}`,          
    },
  },
}

main.js改造

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

Vue.config.productionTip = false

let instance =null

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


if(!window.__MICRO_WEB__){
  render()
}

//开始加载
export const bootstrap = () => {
  console.log('开始加载');
}
//渲染成功
export const mount = () => {
  render()
  console.log('渲染成功');
}
//卸载
export const unmount = () => {
  console.log('卸载',instance);
}

主应用开发

主应用中央控制器的界面开发

界面效果

点击按钮时,路由有切换

main\src\App.vue

<template>
  <div>
     <MainHeader></MainHeader> 
  </div>
</template>
<script >
import MainHeader from './components/header.vue'
export default{
  components:{ MainHeader },
}
</script>
<style lang="less" scoped>

</style>

main\src\components\header.vue

<template>
    <header>
        <button @click="home()">首页</button>
        <button 
            v-for="(item,index) in navList" 
            :key="index"
             @click="change(item,index)" 
            :class="{'select':index === selectIndex}"
        >{
   
   { item.name}}</button>
    </header>
    <main id="micro-container">
        <div class="home" v-if="selectIndex == null">主-----------------------------页</div>
    </main>
   <footer>
      京公网安备11000002000001号京ICP证030173号©2022 
   </footer>
</template> 

<script>
import { watch ,ref} from 'vue'
//引入路由方法
import { useRoute, useRouter } from 'vue-router'
export default {
    setup(props,context){
        //router是路由的实例对象,包含全局路由信息
        const router = useRouter()
        //route是路由实例内容,包含子路由内容
        const route = useRoute()
        //当前选中按钮
        let selectIndex = ref(null)
        //导航菜单
        const navList = [
            {name:"应用1",url:"vue1"},
            {name:"应用2",url:"vue2"},
            {name:"应用3",url:"react"},
        ]
        //点击导航菜单切换路由和央样式
        function change(item,index) {
             selectIndex.value = index
             router.push(item.url)
        }
        //单击主页
        function home(){
            router.push('/')
            selectIndex.value = null
        }
        //解决刷新界面时,路由和内容不匹配的异常
        watch(route,(v)=>{
            let index = navList.findIndex(res => v.fullPath.indexOf(res.url) > -1)
            selectIndex.value = index === -1 ? null : index
        },{ deep:true})
        return {
             navList,
             change,
             selectIndex,
             router,
             home
        }
    }
}
</script>

<style>
header{
    height: 40px;
    background: #409EFF;
    display: flex;
    justify-content: center;
    align-items: center;
}
button {
    margin-left: 15px;
}
main{
    height: 850px;
}
footer{
    height: 40px;
    line-height: 40px;
    text-align: center;
    color: #fff;
    background: #67C23A;
}
.select{
    background: #67C23A;
    border: none;
    color: #fff;
}
.home{
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100%;
    font-size: 25px;
}
</style>

main\src\router\index.js

import { createRouter, createWebHistory } from 'vue-router';
const routes = [
  {
    path: '/react',
    component: () => import('../App.vue'),
  },
  {
    path: '/vue2',
    component: () => import('../App.vue'),
  },
  {
    path: '/vue1',
    component: () => import('../App.vue'),
  },
];

const router = (basename = '') => createRouter({
  history: createWebHistory(basename),
  routes,
});

export default router;

main\src\main.js

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
//挂载
app.use(router()).mount('#app')

子应用注册

实现微前端,我们一定需要一个配置文件,包含所有子应用的信息,如子应用名称、访问路径、对应路由等等信息。

然后,将这些信息储存在我们的主应用内。当然这不是优选,我们应该创建一个微前端框架,将我们的这个配置信息传入到微前端框架里去,然后再主应用里调用微前端框架里的方法就行了。

子应用配置文件的创建

首先,我们需要创建一个用于储存子应用配置信息的文件,这个文件一定是定义在我们的主应用里面的。

在sr目录下创建store文件夹并创建sub.js文件

//main\src\store\sub.js
//创建子应用信息
export const navList = [
    {
      name: 'react',
      entry: '//localhost:9003/',
      container: '#micro-container',   //设定将来渲染的容器
      activeRule: '/react',
    },
    {
      name: 'vue1',
      entry: '//localhost:9004/',
      container: '#micro-container',
      activeRule: '/vue1',
    },
    {
      name: 'vue2',
      entry: '//localhost:9005/',
      container: '#micro-container',
      activeRule: '/vue2',
    },
  ];

微前端框架创建

我们首先在主应用main中创建个微前端micro文件夹(当然,这个文件夹和main才是最合理的),然后创建一个index.js入口文件。

index文件的逻辑一定是把我们封装的一些逻辑进行对外暴露的一个文件。因此,我们主逻辑应该写在其他文件里,index文件做一个引入。

那么,我们在main\micro\文件里创建 个start.js文件把,主要用来写我们框架的一些逻辑。我们先在start.js里面定义并暴露一个注册子应用的函数吧,这个函数要干什么,我们现在也不清楚。

//main\micro\start.js
//微前端框架核心逻辑------注册一个子应用
export const registerMicroApps = () => {
    
}

那么,index里面我们引入并对外暴露这个函数就行

//main\micro\index.js
export { registerMicroApps } from './start.js'

我们在主应用创建了一个配置文件,这个配置文件一定是要放入微前端框架里面的,因此,我们的 registerMicroApps函数可以先用来接受一个这样的配置列表,在合适的位置调用registerMicroApps(list)实现配置文件注入微前端框架即可。

为了实现模块化,我们的配置信息可以不放在main\micro\start.js进行储存,我们可以把所有的这种信息单独放在一个文件里进行统一管理。我们创建main\micro\const\subApps.js文件

//用于储存信息的声明
let list = []

//用于获取信息的函数
export const getList = () => list

//用于设置信息的函数
export const setList = appList => list = appList

这样,我们写入信息可以用 setList(list)

读取信息用 getList()

我们在start.js中,写下如下逻辑

import { setList } from "./const/subApps"

export const registerMicroApps = (appList) => {
    setList(appList)
}

这样,我们在外部调用registerMicroApps()函数时,就可以将配置文件储存在我们的框架里。

我们可以在main.js中引入这个函数,并把配置文件传递进去

main\src\main.js

//引入的不再是Vue构造函数了,引入的是一个名为createApp的工厂函数
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

import { registerMicroApps } from '../micro/index'
import {  navList  } from './store/sub.js'

registerMicroApps(navList)

//创建应用实例对象——app(类似于之前Vue2中的vm,但app比vm更“轻”)
const app = createApp(App)
//挂载
app.use(router()).mount('#app')

ok,现在我们的子应用配置信息已经注册到我们的微前端框架里了。

微前端框架

实现微前端,我们的大致思路应该如下:

  1. 监视页面路由的变化
  2. 根据当前页面路由匹配子应用
  3. 主应用加载子应用
  4. 特定容器渲染子应用

准备工作

在qinkun里,主应用的main.js中引入两个函数registerMicroApps, start,仿照qinkun,我们创建自己的微前端框架文件夹,命名micro-fe 我们在主应用的main.js仿照qiankun做出如下配置

//引入的不再是Vue构造函数了,引入的是一个名为createApp的工厂函数
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

import { registerMicroApps, start } from './micro-fe';

registerMicroApps([
    {
      name: 'vue1', // app name registered
      entry: 'http://localhost:9005/',
      container: '#micro-container',
      activeRule: '/vue1',
    },
    {
      name: 'vue2',
      entry: 'http://localhost:9005/',
      container: '#micro-container',
      activeRule: '/vue2',
    },
    {
        name: 'react',
        entry: 'http://localhost:9005/',
        container: '#micro-container',
        activeRule: '/react',
      },
 ]);
  
start();

createApp(App).use(router()).mount('#app')

我们的主应用src\micro-fe文件夹内应该有一个入口index.js文件,按照主应用的引入逻辑,我们应该暴露两个函数。

mainApp\src\micro-fe\index.js

 //注册微前端
export const registerMicroApps = (apps) =>{
  
}
//启动微前端
export const start = () =>{} 

主应用main.js中registerMicroApps函数在执行时,传入了各个子应用的配置文件apps,因此,在registerMicroApps函数里,我们可以将获取的apps配置列表进行储存。同时,我们可以定义一个获取配置列表的函数,方便以后拿到我们传入的apps配置列表。

//创建_apps变量储存主应用传递进来的子应用配置
 let _apps = []
//子应用配置列表获取函数
 export const getApps = () => _apps
 //微前端框架核心逻辑-----注册函数
 export const registerMicroApps = (apps) =>{
    _apps = apps
 }
 
 //微前端核心逻辑------微前端的运行原理
 export const start = () =>{} 

监视路由变化

我们的satrt函数里,应该是我们微前端框架的核心逻辑,需要实现监视页面路由的变化、根据当前页面路由匹配子应用、主应用加载子应用、特定容器渲染子应用四个核心功能。

首先,我们实现路由的监听,路由分为hash路由和history路由。

hash路由

对于hash路由的变化,我们可以使用window.onhashchange来监听,比较容易。本实例中,我们暂时不考虑。

history路由

方法 说明
back() 参照当前页面,返回历史记录中的上一条记录(即返回上一页),您也可以通过点击浏览器工具栏中的←按钮来实现同样的效果。
forward() 参照当前页面,前往历史记录中的下一条记录(即前进到下一页),您也可以通过点击浏览器工具栏中的→按钮来实现同样的效果。
go() 参照当前页面,根据给定参数,打开指定的历史记录,例如 -1 表示返回上一页,1 表示返回下一页。
pushState() 向浏览器的历史记录中插入一条新的历史记录。
replaceState() 使用指定的数据、名称和 URL 来替换当前历史记录。

对于history路由, 其history.go、history.back、history.forword 等方法我们可以使用popstate事件进行监听。 (浏览器的前进后退会触发history.go、history.back方法)。

因此,我们的路由变化后,核心处理函数应该是

window.addEventListener('popstate',()=>{
   console.log("1.路由history.go等方法被触发");
   //做一些事情
})

但是,浏览器历史记录的pushState、及repalceState方法不会被popstate事件监听到,因此,我们需要重写pushState、及repalceState的原生方法。

const rawPushState = window.history.pushState
window.history.pushState = (...args) =>{
  rawPushState.apply(window.history,args)
  console.log("1.触发popstate事件");
}
const rawReplaceState = window.history.replaceState
window.history.repalceState = (...args) =>{
  rawReplaceState .apply(window.history,args)
  console.log("1.触发replaceState事件");
}

OK,要实现路由的监听,我们的start函数里应该首先执行这三个方法。为了逻辑更清晰,我们将这些函数封装到一个js文件里,在satrt中进行引入执行。

在src\micro-fe\文件夹下创建rewrite-router.js

export const rewriteRouter = () =>{

    //1.监视路由的变化
    //  hash路由 window.onhashchange
    //  history路由
    //      history.go、history.back、history.forword 使用popstate事件:window.onpopstate 
    window.addEventListener('popstate',()=>{
        console.log("1.路由history.go等方法被触发");
    })

    //pushState、及repalceState  popstate事件监听不到,我们需要重写pushState、及repalceState的原生方法
    const rawPushState = window.history.pushState
    window.history.pushState = (...args) =>{
        rawPushState.apply(window.history,args)
        console.log("1.触发popstate事件");
    }
    const rawReplaceState = window.history.replaceState
    window.history.repalceState = (...args) =>{
        rawReplaceState .apply(window.history,args)
        console.log("1.触发replaceState事件");
    }

}

然后,在start函数里(mainApp\src\micro-fe\index.js)进行引入使用

import { rewriteRouter } from './rewrite-router'
//创建_apps变量储存主应用传递进来的子应用配置
 let _apps = []
//子应用配置列表获取函数
 export const getApps = () => _apps

 //微前端框架核心逻辑-----注册函数
 export const registerMicroApps = (apps) =>{
    _apps = apps
 }
 
 
 //微前端核心逻辑------微前端的运行原理
 export const start = () =>{
    //1.监视路由的变化
    rewriteRouter()

    //2.匹配子应用

    //3.加载子应用

    //4.渲染子应用

} 

根据路由变化,匹配子应用

创建路由匹配处理函数

在监听到路由的变化后,我们应该做一些处理

window.addEventListener('popstate',()=>{
    // 路由处理相关逻辑
})

window.history.pushState = (...args) =>{
    // 路由处理相关逻辑
})
   
window.history.repalceState = (...args) =>{
    // 路由处理相关逻辑  
})

为了结构更加清晰,我们将这些逻辑封装成一个函数,在rewrite-router.js文件里进行引入

在src\micro-fe\文件夹下创建handle-router.js文件

//处理路由变化
//获取子应用的配置列表,用于路由匹配
import { getApps } from "./index";

export const handleRouter = async () => {
    console.log('2.路由已经发生变化,现在进行处理');
}
//处理路由相关变化逻辑
import { handleRouter } from "./handle-router";
export const rewriteRouter = () =>{
    history.go、history.back、history.forword 使用popstate事件:window.onpopstate 
    window.addEventListener('popstate',()=>{
        console.log("1.路由history.go等方法被触发");
        handleRouter()
    })
    const rawPushState = window.history.pushState
    window.history.pushState = (...args) =>{
        rawPushState.apply(window.history,args)
        handleRouter()
    }
    const rawReplaceState = window.history.replaceState
    window.history.repalceState = (...args) =>{
        rawReplaceState .apply(window.history,args)
        handleRouter()
    }
}

根据当前路由匹配子应用

匹配子应用的逻辑大致如下:

  • 获取当前的路由路径
  • 去 apps 里面查找
//处理路由变化的
import { getApps } from "./index";
import { importHTML } from './import-html'
export const handleRouter = async () => {
    console.log('2.路由已经发生变化,现在进行处理');
    //2.匹配子应用
    //  2.1获取当前的路由路径
    console.log('2.1获取当前路由路径',window.location.pathname);
    //  2.2去 apps 里面查找
    const apps = getApps()
    const app = apps.find(item => window.location.pathname.startsWith(item.activeRule))
    if(!app){
        return
    }
}

加载子应用

匹配到子应用后,我们可以获取到子应用的配置对象,像这样

   {
      name: 'vue1', // app name registered
      entry: 'http://localhost:9005/',
      container: '#micro-container',
      activeRule: '/vue1',
    },

现在,我们需要加载子应用的所有资源,如HTML、CSS及javasrcipt。

获取并渲染html内容

  • 使用ajax获取子应用对应域名下的html
  • 获取要渲染html的dom元素
  • 将获取的html挂载在获取的dom元素上
//mainApp\src\micro-fe\handle-router.js
import { getApps } from "./index";
import { importHTML } from './import-html'
export const handleRouter = async () => {
   
    //2.匹配子应用
    const apps = getApps()
    const app = apps.find(item => window.location.pathname.startsWith(item.activeRule))
    if(!app){
        return
    }
  
    //3.加载子应用
    // 使用 fetch 获取子应用对应域名下的html
    html = await fetch(app.entry).then(res => res.text())
    //获取要渲染html的dom元素
    const container = document.querySelector(app.container)
    //将获取的html挂载在获取的dom元素上
    container.innerHTML = html
}

观察控制台,我们发现html已经被成功挂载在主应用的容器里,但是,页面并没有如我们预期的那样显示出来


原因在于,页面的渲染需要通过执行 js代码来生成内容,浏览器出于安全考虑,innerHTML中生成的script不会被加载。因此,script标签的解析及处理是微前端框架的第一步核心操作。

资源请求函数封装

在乾坤框架里,对于html的资源请求,阿里做了单独的插件。这个插件包含三个核心对象

  • template:用于获取html模板
  • getExternalScripts()函数:获取所有的script标签代码
  • execScripts()函数:获取并执行所有的js脚本

类似的,我们也可以封装一个这样的函数。由于html和js内容都是通过fetch进行请求的,我们可以先将fetch函数进行封装。

  1. 在src\micro-fe\文件夹下创建fetch-resource.js文件
export const fetchResource = url => fetch(url).then(res => res.text())
  1. 我们在src\micro-fe\文件夹下创建import-html.js文件。
import { fetchResource } from "./fetch-resource"

export const importHTML = async (url) =>{
    //获取html模板
    const html = await fetch(url).then(res => res.text())
    const template = document.createElement('div')
    template.innerHTML = html

    //获取所有的script标签代码
    function getExternalScripts () {
        
    }
    //执行所有的js脚本
    function execScripts () {

    }

    return {
        template,
        getExternalScripts,
        execScripts
    }
}
  1. 最后,我们将封装好的 importHT ML函数导出在src\micro-fe\handle-router.js中引入使用
//处理路由变化的

import { getApps } from "./index";
import { importHTML } from './import-html'
export const handleRouter = async () => {
    //2.匹配子应用
    const apps = getApps()
    const app = apps.find(item => window.location.pathname.startsWith(item.activeRule))
    if(!app){
        return
    }
  
    //3.加载子应用
    // 请求子应用的资源:HTML、CSS、js
    const {  template,getExternalScripts,execScripts } = await importHTML(app.entry)
    const container = document.querySelector(app.container)
    container.appendChild(template)
}

和之前一样,这时的html可以被挂载到html页面上,但js不能被正确引入处理。

获取所有的script标签及js代码

  1. 获取所有的script标签:
 constscripts = template.querySelectorAll('script')

注:获取到的constscripts是一个类数组,我们可以使用ES6的Array.from()方法将其转换成真数组

  1. 解析scripts报签内的js内容

对于scripts报签,有两种形式

//行内标签
<script>console.log('行内标签');</script>

//外链标签
<script src="./a.js"></script>
  • 行内标签解析:直接使用innerHTML获取内容
  • 外链标签解析:使用fecth函数请求内容

import-html.js文件内添加相应逻辑:

import { fetchResource } from "./fetch-resource"

export const importHTML = async (url) =>{
    //--------------------------------------获取子应用html内容
    const html = await fetchResource(url)
    const template = document.createElement('div')
    template.innerHTML = html

    //--------------------------------------获取子应用所有js内容
    const scripts = template.querySelectorAll('script')
    
    //获取所有的script标签代码:返回一个[代码,代码]的数组
    function getExternalScripts () {
        return Promise.all(Array.from(scripts).map(script => {
            //获取标签的src属性
            const src  = script.getAttribute('src')
            //加载script标签的js代码
            if(!src){
                return Promise.resolve(script.innerHTML)
            }else{
                return fetchResource(
                    //有的标签没有域名,我们需要添加域名
                    src.startsWith('http') ? src : `${url}${src}`
                )
            }
        }))
    }
  
    //打印获取的scrip标签内容
    getExternalScripts().then((scripts) =>{
        console.log('3.2获取所有scripts代码',scripts);
    })
  
  
    //获取并执行所有的js脚本
    function execScripts () { }
    return { template, getExternalScripts, execScripts }
}

观察浏览器控制台,可以看到,所有的js内容已经获取到了,虽然目前只是字符串形式

渲染子应用

现在,我们已经知道,执行完获取的子应用js代码后,页面上就应该能够渲染出我们的子应用界面。

执行获取的js内容

我们使用eval函数来执行这些脚本内容。

//mainApp\src\micro-fe\import-html.js
import { fetchResource } from "./fetch-resource"

export const importHTML = async (url) =>{
    const html = await fetchResource(url)
    const template = document.createElement('div')
    template.innerHTML = html

    const scripts = template.querySelectorAll('script')
    function getExternalScripts () {
        return Promise.all(Array.from(scripts).map(script => {
            const src  = script.getAttribute('src')
            if(!src){
                return Promise.resolve(script.innerHTML)
            }else{
                return fetchResource(
                    src.startsWith('http') ? src : `${url}${src}`
                )
            }
        }))
    }

    //----------------------------------执行所有的js脚本
    async function execScripts () {
        const script = await getExternalScripts()
        script.forEach(code => {
            eval(code)
        })
    }

    return {
        template,
        getExternalScripts,
        execScripts
    }
}

execScripts函数我们需要在handle-router.js内手动执行

//处理路由变化
import { getApps } from "./index";
import { importHTML } from './import-html'
export const handleRouter = async () => {
    const apps = getApps()
    const app = apps.find(item => window.location.pathname.startsWith(item.activeRule))
    if(!app){
        return
    }
    // 加载子应用
    const {  template,getExternalScripts,execScripts } = await importHTML(app.entry)
    const container = document.querySelector(app.container)
    container.appendChild(template)
    // 渲染子应用
    execScripts()
}

切换路由,我们会发现,我们的页面被成功渲染出来!!!但是,页面整体被替换成了vue2的内容,而不是渲染在我们指定的容器里。

这是为什么呢?其实很简单,当前我们的全局没有微前端的全局变量,vue2子应用在渲染时没有走微前端框架,直接渲染在了id = app这个容器里,而这个容器也是主应用的容器名称。

因此,解决办法也很简单,要么直接更换主应用的容器,或者我们使用我们的微前端框架来手动调用子应用的render函数。

使用微前端框架执行render函数

要使子应用运行在微前端框架里,我们只需要在handle-router.js中添加变量window.MICRO_WEB = true即可

//处理路由变化
import { getApps } from "./index";
import { importHTML } from './import-html'
export const handleRouter = async () => {

    const apps = getApps()
    const app = apps.find(item => window.location.pathname.startsWith(item.activeRule))
    if(!app){
        return
    }

    const {  template,getExternalScripts,execScripts } = await importHTML(app.entry)
    const container = document.querySelector(app.container)
    container.appendChild(template)

    //配置全局变量
    window.__MICRO_WEB__ = true

    // 3.2执行获取的js内容
    execScripts()
}

此时,切换路由就不会出现app根节点被替换的问题,但由于vue2子应用的render函数没有执行,所以页面依旧不会有内容。我们需要做的就是拿到子应用的生命周期函数,手动执行render函数。

那现在核心的问题应该是我们如何拿到子应用的生命周期函数。

umd

子打包的时候,我们做了如下配置

    output: {
      // 把子应用打包成 umd 库格式
      libraryTarget: 'umd',                          //umd格式  支持comm.js引入  浏览器、node可以识别
      filename: 'vue2.js',                           //打包出的文件名称
      library: 'vue2',                               //全局可以通过window.vue2拿到的应用
      jsonpFunction: `webpackJsonp_${name}`,          
    },

我们这里将文件打包成了一个umd模块化的js文件

什么是UMD

UMD (Universal Module Definition),就是一种javascript通用模块定义规范,让你的模块能在javascript所有运行环境中发挥作用。

我们来学习一下umd模块化后的代码,我们在vue2的子应用配置文件vue.config.js中配置mode:‘development’

module.exports = {
  configureWebpack: {
    mode:'development',//这样打包可以看到未压缩的代码
    ...
  },
    ...
}

然后,执行 npm run build 进行打包

观察打包后的vue2.js的代码,它的简化逻辑如下

vue2.js打包后的代码,这里实际是一个自执行函数,主要功能是兼容不同的模块规范

//umd的模块格式
(function webpackUniversalModuleDefinition(root, factory) {
  //兼容Commojs模块规范   node环境
	if(typeof exports === 'object' && typeof module === 'object')
		module.exports = factory();
  //兼容amd规范
	else if(typeof define === 'function' && define.amd)
		define([], factory);
  //兼容ES6
	else if(typeof exports === 'object')
		exports["vue2"] = factory();
	else
    //挂载到去局Window对象上
		root["vue2"] = factory();
})(window, function() {

  //这里是自定义的内部代码
  //最后会返回结果

});

简化逻辑

//umd的模块格式
function webpackUniversalModuleDefinition(root, factory) {
	
})

webpackUniversalModuleDefinition(window, factory)

//factory是框架内部的代码

根据以上知识,通过umd模块化打包后的js文件,我们可以通过window.拿到子应用的内容

获取子应用的生命周期函数

通过umd,我们在微前端框架里可以使用window对象来获取子应用的生命周期函数。

    //3.2----------------------------------执行所有的js脚本
    async function execScripts () {
        const script = await getExternalScripts()
        console.log('3.2获取所有scripts代码',script);
        script.forEach(code => {
            eval(code)
        })
        //子应用的所有信息
        console.log(window['vue2']);
    }

由于每个子应用打包后的名称不一样,这样获取子应用的属性需要知道打包后的子应用名称,因此,我们通过别的方式来获取这个子应用的对象。

通过umd的代码,我们可以知道子应用的信息也可以通过CommnJs的方式拿到,因此,我们手动创建个CommnJs环境。

    //3.2----------------------------------执行所有的js脚本
    async function execScripts () {
        const script = await getExternalScripts()
        console.log('3.2获取所有scripts代码',script);

        //手动构建一个commonJs环境
        const module = { exports:{}}
        const exports = module.exports
        
        script.forEach(code => {
            //eval执行的代码可以访问外部变量
            eval(code)
        })
    }

结合umd的封装函数

if(typeof exports === 'object' && typeof module === 'object')
		module.exports = factory();

我们可以知道,通过 module.exports 就可以拿到所有的子应用属性。为了代码的简洁性,我们通过execScripts函数把这个对象暴露出去,在handle-router.js中获取这个值

import { fetchResource } from "./fetch-resource"

export const importHTML = async (url) =>{
    const html = await fetchResource(url)
    const template = document.createElement('div')
    template.innerHTML = html

    //--------------------------------------获取子应用所有js内容
    const scripts = template.querySelectorAll('script')
    //获取所有的script标签代码:返回一个[代码,代码]的数组
    function getExternalScripts () {
        return Promise.all(Array.from(scripts).map(script => {
            const src  = script.getAttribute('src')
            if(!src){
                return Promise.resolve(script.innerHTML)
            }else{
                return fetchResource(
                    src.startsWith('http') ? src : `${url}${src}`
                )
            }
        }))
    }

    //3.2----------------------------------执行所有的js脚本
    async function execScripts () {
        const script = await getExternalScripts()
        //手动构建一个commonJs环境
        const module = { exports:{}}
        const exports = module.exports
        script.forEach(code => {
            eval(code)
        })
        return module.exports
    }
    return {
        template,
        getExternalScripts,
        execScripts
    }
}

mainApp\src\micro-fe\handle-router.js

//处理路由变化
import { getApps } from "./index";
import { importHTML } from './import-html'
export const handleRouter = async () => {
    //2.匹配子应用
    const apps = getApps()
    const app = apps.find(item => window.location.pathname.startsWith(item.activeRule))
    if(!app){
        return
    }
    //3.加载子应用
    const {  template,getExternalScripts,execScripts } = await importHTML(app.entry)
    const container = document.querySelector(app.container)
    container.appendChild(template)
    //配置全局变量
    window.__MICRO_WEB__ = true
    // 4.渲染子应用
    const appExPports = await execScripts()   
}

可以看到,这里的值我们是正常拿到的。

这时,只要执行mount周期函数里的render函数,我们就可以实现子应用的挂载渲染。

执行子应用的生命周期函数

对获取到的生命周期函数信息,我们可以将其绑定在我们的配置列表app里,然后使用app.mount 对这个生命周期函数进行调用即可。为了便于函数逻辑的添加,我们可以把生命周期函数的调用都封装成函数。

  • 获取子应用生命周期函数
const appExPports = await execScripts()
  • 将函数绑定在app上
app.bootstrap = appExPports.bootstrap
app.mount = appExPports.mount
app.unmount = appExPports.unmount
  • 封装相应生命周期执行函数
async function bootstrap (app) {
    app.bootstrap && (await app.bootstrap())
}
async function mount(app){
    app.mount && (await app.mount({
        container:document.querySelector(app.container)
    }))
}
async function unmount(app){
    app/unmount && (await app.unmount())
}
  • 执行封装的函数
await bootstrap(app)
await mount(app)

handle-router.js完成代码

//处理路由变化
import { getApps } from "./index";
import { importHTML } from './import-html'
export const handleRouter = async () => {
    console.log('2.路由已经发生变化,现在进行处理');
    //2.匹配子应用
    //  2.1获取当前的路由路径
    console.log('2.1获取当前路由路径',window.location.pathname);
    //  2.2去 apps 里面查找
    const apps = getApps()
    const app = apps.find(item => window.location.pathname.startsWith(item.activeRule))
    if(!app){
        return
    }
    //3.加载子应用
    // 3.1将获取的HTML插入容器里
    const {  template,getExternalScripts,execScripts } = await importHTML(app.entry)
    const container = document.querySelector(app.container)
    container.appendChild(template)

    //配置全局变量
    window.__MICRO_WEB__ = true

    // 3.2执行获取的js内容;返回子应用的生命周期函数
    const appExPports = await execScripts()
   
    //3.3将获取的生命周期函数储存在app上
    app.bootstrap = appExPports.bootstrap
    app.mount = appExPports.mount
    app.unmount = appExPports.unmount

    //3.4执行生命周期函数
    await bootstrap(app)
    await mount(app)
    
}

async function bootstrap (app) {
    app.bootstrap && (await app.bootstrap())
}
async function mount(app){
    app.mount && (await app.mount({
        container:document.querySelector(app.container)
    }))
}
async function unmount(app){
    app/unmount && (await app.unmount())
}

切换路由,发现我们的基本功能已经实现了!!

卸载前一个子应用

当我们切换路由时,会发现页面内容不断变长,原因在于原先的子应用并没有被卸载。因此,我们需要实现应用切换时,卸载上一个子应用,实现这个功能,我们就需要得到浏览器的历史记录。

浏览器由于安全问题,没有提供路由的历史记录获取API,我们需要自己在路由监视的代码中自己维护一个路由记录。

  1. 创建两个变量,分别用于储存路由变化前的值及变化后的值
let prevRoute = ''                        //上一个路由
let nextRoute = window.location.pathname  //下一个路由
  1. 变量赋值
  • 对于pushState和replaceState的路由变化

在路由的真正历史记录前后分别对两个值进行赋值

    const rawPushState = window.history.pushState
    window.history.pushState = (...args) =>{
        prevRoute = window.location.pathname
        rawPushState.apply(window.history,args)   //真正的改变历史记录
        nextRoute = window.location.pathname   
        handleRouter()
    }
    const rawReplaceState = window.history.replaceState
    window.history.repalceState = (...args) =>{
        prevRoute = window.location.pathname
        rawReplaceState .apply(window.history,args)
        nextRoute = window.location.pathname   
        handleRouter()
    }
  • 对于popstate事件,我们需要将nextRoute的值赋给prevRoute,nextRoute = window.location.pathname
prevRoute = nextRoute
nextRoute = window.location.pathname
  1. 暴露出这两个变量,供其他地方使用
export const getPrevRoute = () => prevRoute
export const getNextRoute = () => nextRoute
  1. 根据获取的上一次子应用路由,匹配子应用信息,卸载子应用
const apps = getApps()
//------------------卸载上一个路由
const prevApp = apps.find(item => {
  return getPrevRoute().startsWith(item.activeRule)
})

mainApp\src\micro-fe\rewrite-router.js

//处理路由相关变化逻辑
import { handleRouter } from "./handle-router";

let prevRoute = ''                        //上一个路由
let nextRoute = window.location.pathname  //下一个路由

export const getPrevRoute = () => prevRoute
export const getNextRoute = () => nextRoute


export const rewriteRouter = () =>{

    //1.监视路由的变化
    //  hash路由 window.onhashchange
    //  history路由
    //      history.go、history.back、history.forword 使用popstate事件:window.onpopstate 
    window.addEventListener('popstate',()=>{
        prevRoute = nextRoute
        nextRoute = window.location.pathname
        handleRouter()
    })

    //pushState、及repalceState  popstate事件监听不到,我们需要重写pushState、及repalceState的原生方法
    const rawPushState = window.history.pushState
    window.history.pushState = (...args) =>{
        //导航前
        prevRoute = window.location.pathname
        rawPushState.apply(window.history,args)   //真正的改变历史记录
        //导航后
        nextRoute = window.location.pathname   
        handleRouter()
    }
    const rawReplaceState = window.history.replaceState
    window.history.repalceState = (...args) =>{
        //导航前
        prevRoute = window.location.pathname
        rawReplaceState .apply(window.history,args)
        //导航后
        nextRoute = window.location.pathname   
        handleRouter()
    }

}//处理路由变化
import { getApps } from "./index";
import { importHTML } from './import-html'
import { getNextRoute, getPrevRoute } from "./rewrite-router";
export const handleRouter = async () => {
    //1.获取子路由配置信息
    const apps = getApps()
    //2.匹配子应用
    //------------------卸载上一个路由
    const prevApp = apps.find(item => {
        return getPrevRoute().startsWith(item.activeRule)
    })
    if(prevApp) await unmount(prevApp)
    //------------------加载下一个路由
    const app = apps.find(item => getNextRoute().startsWith(item.activeRule))
    
    if(!app) return
    //3.加载子应用
    const {  template,getExternalScripts,execScripts } = await importHTML(app.entry)
    const container = document.querySelector(app.container)
    container.appendChild(template)

    //配置全局变量
    window.__MICRO_WEB__ = true

    // 3.2获取子应用的生命周期函数
    const appExPports = await execScripts()
   
    //4.渲染子应用
    //将获取的生命周期函数储存在app上
    app.bootstrap = appExPports.bootstrap
    app.mount = appExPports.mount
    app.unmount = appExPports.unmount
    //执行生命周期函数
    await bootstrap(app)
    await mount(app)
}

async function bootstrap (app) {
    app.bootstrap && (await app.bootstrap())
}
async function mount(app){
    app.mount && (await app.mount({
        container:document.querySelector(app.container)
    }))
}
async function unmount(app){
    app.unmount && (await app.unmount({
        container:document.querySelector(app.container)
    }))
}

mainApp\src\micro-fe\handle-router.js

//处理路由变化
import { getApps } from "./index";
import { importHTML } from './import-html'
import { getNextRoute, getPrevRoute } from "./rewrite-router";
export const handleRouter = async () => {
    //1.获取子路由配置信息
    const apps = getApps()
    //2.匹配子应用
    //------------------卸载上一个路由
    const prevApp = apps.find(item => {
        return getPrevRoute().startsWith(item.activeRule)
    })
    if(prevApp) await unmount(prevApp)
    //------------------加载下一个路由
    const app = apps.find(item => getNextRoute().startsWith(item.activeRule))
    
    if(!app) return
    //3.加载子应用
    const {  template,getExternalScripts,execScripts } = await importHTML(app.entry)
    const container = document.querySelector(app.container)
    container.appendChild(template)

    //配置全局变量
    window.__MICRO_WEB__ = true

    // 3.2获取子应用的生命周期函数
    const appExPports = await execScripts()
   
    //4.渲染子应用
    //将获取的生命周期函数储存在app上
    app.bootstrap = appExPports.bootstrap
    app.mount = appExPports.mount
    app.unmount = appExPports.unmount
    //执行生命周期函数
    await bootstrap(app)
    await mount(app)
}

async function bootstrap (app) {
    app.bootstrap && (await app.bootstrap())
}
async function mount(app){
    app.mount && (await app.mount({
        container:document.querySelector(app.container)
    }))
}
async function unmount(app){
    app.unmount && (await app.unmount({
        container:document.querySelector(app.container)
    }))
}

样式资源文件的加载

在微前端框架里的子应用图片资源是加载不到的。

因为图片的路径是子应用ip + url (9004 + url)

在主应用请求后就变成了主应用ip+ url (8080 + url)

要解决此问题,我们可以在子应用里把路径写死,像这样

module.exports = {
    publicPath:'http://localhost:9004',
}

这当然不是个好办法。

webpack支持运行时的publicPath

import './public-path.js'
module.exports = {
    publicPath:webpack_public_path,
}
//webpack在运行时生成的路径会自动拼接上这个全局变量,如果有的话
webpack_public_path = http://localhost:9004

我们使用微前端框架来提供

if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
    //配置全局变量
    window.__MICRO_WEB__ = true
    window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = app.entry + "/"

猜你喜欢

转载自blog.csdn.net/weixin_46769087/article/details/131406750