• 为了验证某些事,我实现了一个toy微前端框架【万字长文,请君一览】


    众所周知微前端是企业中前端发展的必经之路。

    我司搭建了自己的微前端脚手架工具,它将项目变成了“多页应用”,跳转方式由 location.href 这类api进行。所以笔者之前在想:这种方式跳转能不能有动画效果呢?
    在“上层建筑”中进行“反直觉”的操作,结果当然是失败的。但是笔者又有了一个新想法:自己实现一个微前端框架,由通过劫持路由和 history 实现一些小操作!

    本文将我实现第一版微前端架子的步骤呈现给各位,希望对大家能有一些帮助。


    结构

    toy微前端结构

    这是toy微前端的项目基本结构,简单介绍下主要目录文件:

    • 控制启动的build目录
    • 项目所需后端service目录(由node实现)
    • 主应用main目录
    • 自应用vue2目录(存放vue2项目代码)
    • 自应用vue3目录(存放vue3项目代码)
    • 框架启动和版本控制文件package.json

    vue2、vue3同级,你当然还可以创建react相关目录结构或者使用其他技术栈实现相关项目。这是不限制的,也是微前端的优势。

    拿vue来说,你必然知道的是:项目启动是和 package.json 息息相关的,我们来看本框架的此文件:

    {
      "name": "toy-micro-web",
      "version": "1.0.0",
      "description": "create micro project for myself",
      "main": "index.js",
      "scripts": {
        "start": "node ./build/run.js"
      },
      "author": "yancy",
      "license": "ISC",
      "devDependencies": {},
      "dependencies": {}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    没有多余代码,也不必有。其中关键在于:scripts 字段。这是我们命令行的执行命令。
    它的意思是:当我们按下 npm start(或 npm run start)时,会通过node去执行 ./build/run.js 文件。

    启动你的微前端

    这就是上面我们说的启动目录下的唯一一个文件。它要做的也很简单 —— 进入项目对应目录,执行他们各自的启动命令。

    const childProcess = require('child_process')
    const path = require('path')
    
    const filePath = {
        vue2: path.join(__dirname, '../vue2'),
        vue3: path.join(__dirname, '../vue3'),
        service: path.join(__dirname, '../service'), //启动后端应用node
        // react15: path.join(__dirname, '../react15'),
        // react16: path.join(__dirname, '../react16'),
        main: path.join(__dirname, '../main')
    }
    
    // cd 子应用的目录 npm start 启动项目
    function runChild () {
        Object.values(filePath).forEach(item => {
          childProcess.spawn(`cd ${item} && npm start`, { stdio: "inherit", shell: true })
        })
    }
    
    runChild()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    我们借助了 child_process 模块的帮助。

    这里是qiankun的思想,一次将所有子应用全部启动。本来想按照qiankun的思路去实现,但是写的时候突然想到这就是一个为了我研究其他东西的小玩具,那么精细干啥。后面第二版第三版也有修改但是代码量就多了不放出来了先。
    各位在用新技术尝试的时候也可以把这里按照自己想法改进下。

    子应用:vue2

    执行完这个文件后,命令行中你会发现依次进入到主应用以及各个子应用中了。我们先来看子应用:以vue2为例

    vue2子应用结构

    这就是一个普通的vue2项目目录,将其置身于“微前端”场景下时,我们需要着重关注 vue.config.js 以及 src 下的 main.js 文件。

    在 vue.config.js 中笔者进行了端口号的重置、指定打包路径、热更新的开启、本地服务的跨域内容等等。最重要的还得是“自定义webpack配置项”了:

    // 自定义webpack配置
    configureWebpack: {
      resolve: {
        alias: {
          '@': resolve('src'),
        },
      },
      output: {
        // 把子应用打包成 umd 库格式 commonjs 浏览器,node环境
        libraryTarget: 'umd',
        library: `${packageName}`, //在全局环境下获取到打包的内容 ——umd,把这一行去掉与显示在浏览器控制台打印 window.此vue项目文件夹名
        // filename: 'vue2.js', //打包的名字
        // jsonpFunction: `webpackJsonp_${packageName}`
      },
    },
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    我们通过 libraryTarget 字段指定打包格式为 umd!并且通过 library 字段指定了打包出来的名字。

    这里是关键,也是微前端中很多操作的根本!其原因随后陈述。

    先来看 main.js 文件。都知道这里面主要干了一件事:new Vue

    new Vue({
      router,
      render: h => h(App)
    }).$mount('#app-vue');
    
    • 1
    • 2
    • 3
    • 4

    这是一般的写法。但是现在是微前端!在子应用加载前会不会要处理必要的参数?在加载时是不是可能要进行一些额外的处理?切换其他应用后要不要取消监听?…

    子应用微前端场景改造

    所以我们应当区分情况,并暴露出三个函数:

    let instance = null;
    
    const render = () => {
      // 这个函数就可以在微前端框架中应用,我们也可以通过window.vue2获取到内容
      instance = new Vue({
        router,
        render: h => h(App)
      }).$mount('#app-vue');
    }
    
    // 通过render触发,但是有一个前提:如果当前是微前端环境则不自己触发,而是需要根据我们的微前端生命周期去对应的触发当前的render函数
    if (!window.__MICRO_WEB__) {
      render()
    }
    
    // 微前端下
    export const bootstrap = () => {
      // 开始加载结构 - 比如一些在加载之前必要的参数处理
      console.log('开始加载')
    }
    export const mount = () => {
      render();
      // 然后得到vue实例
      console.log('渲染成功')
    }
    export const unmount = () => {
      // 比如撤销监听事件,或者处理当前容器的显示内容,,,
      console.log('卸载', instance)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    现在,让我们执行 npm start,看一看 umd 的“魅力”:
    window.vue2

    而若是将 libraryTarget: 'umd', 这一行去掉,则会打印undefined。说白了就是把子应用打包好作为节点挂载道window上供全局调用。这也就是其原因所在了。

    很明显就是依赖webpack的打包特性和 umd 模块的特殊性。
    其实微前端并不是一个新的技术,只是新的概念罢了。早期的“服务端组合SSI技术”、以及后来重新被大家认识的“iframe”、还有新兴的“web components技术”,不同于微前端发展初期概念里的“页面级别组合”,他们甚至可以进行组件级别的组合。

    这里和另一个技术也“异曲同工” —— webpack5 联邦模块!

    const { ModuleFederationPlugin } = require("webpack").container;
    
    //...
      plugins: [
        new ModuleFederationPlugin({
          // MF 应用名称
          name: "app1",
          library: { type: "var", name: "app_1" },
          // MF 模块入口,可以理解为该应用的资源清单
          filename: `remoteEntry.js`,
          // 定义应用导出哪些模块
          exposes: {
            "./utils": "./src/utils",
            "./foo": "./src/foo",
          },
        }),
      ],
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    这段代码就是联邦模块导出的配置。其中libraryname字段即是 umd 导出的name!

    主应用:main

    OK,让我们说回toy微前端。
    子应用部分已经结束,接下来该“重中之重”,主应用的介绍。主应用担负起了调配子应用、路由拦截、全局通信、应用启动等诸多功能。
    我们来看下主应用的结构:
    主应用结构

    主应用是以vue3实现的,其中micro目录是重点 —— 微前端控制器。
    让我来一一介绍。

    首先依然是 main.js文件,这里稍有不同:主应用是微前端主体,它当然也要有生命周期,用来和子应用生命周期“遥相呼应”,但正因为是主体所以自己调用自己不太合适,故而主应用的生命周期不能以export的方式写在这里!
    上面提到主应用应该肩负起调配子应用的职责,故而我们应当在主应用初始化前注册子应用:

    import { createApp } from 'vue'
    import App from './App.vue'
    import router from './router'
    import { subNavList } from "./store/sub"
    import { registerApp } from "./util"
    
    // 在整个实例初始化之前先将子应用注册好
    registerApp(subNavList)
    
    createApp(App).use(router()).mount('#micro_web_main_app')
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    subNavList很简单,就是子应用的一些信息:路径、名字、需要挂载的节点等,后面我们要根据这些信息处理子应用

    export const subNavList = [
        {
          name: 'vue2',
          activeRule: '/vue2',
          container: '#micro-container',
          entry: '//localhost:9004/',
        },
        {
          name: 'vue3',
          activeRule: '/vue3',
          container: '#micro-container',
          entry: '//localhost:9005/',
        },
    ]
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    然后registerApp用来将这些子应用注册到微前端中,并且在这个时候注册主应用的生命周期!

    import { registerMicroApps, start } from "../../micro"
    
    import { loading } from "../store"
    
    export const registerApp = (list) => {
        // 将子应用注册到主应用里是没有任何效果的,所以我们应该注册到微前端框架里
        // 主应用的生命周期也需要在此时注册好!
        registerMicroApps(list, {
            beforeLoad: [
                () => {
                    // 在生命周期中控制loading
                    loading.changeLoading(true)
                    console.log('开始加载')
                }
            ],
            mounted: [
                () => {
                    loading.changeLoading(false)
                    console.log('渲染完成')
                }
            ],
            destoryed: [
                () => {
                    console.log('卸载完成')
                }
            ]
        })
    
        start()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    我们通过参数的方式将生命周期传到处理函数中,这里需要注意的是,主应用的生命周期函数可能不止一个(要干的事情和时机不一样),所以我们用数组形式处理。

    先来看registerMicroApps函数:

    export const registerMicroApps = (appList, lifeCycle) => {
        setList(appList)
    
        setMainLifeCycle(lifeCycle)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    很简答,首先接收子应用列表,然后处理子应用和对应生命周期。setList函数其实就是一个赋值函数,对应的还有一个getList取值函数,这是为了避免随意往window上挂载东西。
    setMainLifeCycle也是如此,先将生命周期“存起来”。

    什么时候用呢?上一段代码最后还有一个start函数,我们来看下:

    export const start = () => {
        // 首先验证当前子应用列表是否为空
        let apps = getList();
        if(!apps.length) {
            console.error('当前没有子应用注册')
            return;
        }
        // 查找到符合当前url的子应用
        let app = currentApp()
    
        if(app) {
            const { pathname, hash } = window.location
            const url = pathname + hash
            window.history.pushState('', '', url)
        }
    
        // 这时候我们会发现路由被触发了不止一次,我们可以加一个限制
        window.__CURRENT_SUB_APP__ = app.activeRule
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    首先判断当前有没有子应用注册,若有,取到子应用并将路由赋值,而后将当前行为“告知”全局,我们用“公共变量”__CURRENT_SUB_APP__去接收,这一点是为了方便后面区分“上一个子应用”和“在一个子应用”。

    currentApp中即是先通过window.location.pathname获取当前路由,然后在getList中查找:

    const filterApp = (key, value) => {
        const currentApp = getList().filter(item => item[key] === value)
    
        return currentApp && currentApp.length ? currentApp[0] : {}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    路由监听

    我们会发现上面的操作大多和“router”相关,这里就引出了微前端中另一个重点概念:路由监听。以及后期的“路由劫持”!

    其实就是“重写路由跳转函数”,重写的原因也很简单:我们需要做一些符合自己需求的额外处理。

    export const rewriteRouter = () => {
        window.history.pushState = patchRouter(window.history.pushState, 'micro_push')
        window.history.replaceState = patchRouter(window.history.replaceState, 'micro_repalce')
    
        // 给新事件加一个事件绑定
        window.addEventListener('micro_push', turnApp);
        window.addEventListener('micro_replace', turnApp);
    
        // 给返回事件也添加绑定
        window.onpopstate = function () {
            turnApp()
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    我们通过重写pushStatereplaceState绑定了两个新事件,在路由切换后去执行生命周期~

    export const turnApp = async () => {
        if(!isTurnChild()) {
            return
        }
        console.log('路由切换')
        // 执行微前端生命周期
        await lifeCycle()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    其中判断子应用是否切换的函数isTurnChild就是利用了我们刚刚说到的挂载到window上的“公共变量”:

    export const isTurnChild = () => {
        // 需要在判断之前先拿到全局变量,用两个全局变量获取上一个子应用和下一个子应用
        window.__ORIGIN_APP__ = window.__CURRENT_SUB_APP__
        if(window.__CURRENT_SUB_APP__ === window.location.pathname) {
            return false
        }
        let currentApp = window.location.pathname.match(/(\/\w+)/)
        if(!currentApp) {
            return
        }
        window.__CURRENT_SUB_APP__ = currentApp[0]
        return true
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    其中currentApp的判断是为了处理路由字符串,方便后面加载资源。

    然后我们来看如何执行微前端生命周期lifeCycle

    export const lifeCycle = async () => {
        // 获取到上一个子应用:执行他的unload。我们需要先将上一个子应用卸载掉
        const prevApp = findAppByRoute(window.__ORIGIN_APP__)
    
        // 获取到要跳转的子应用:执行他的生命周期
        const nextApp = findAppByRoute(window.__CURRENT_SUB_APP__)
    
        console.log(prevApp, nextApp)
    
        if(!nextApp) {
            return
        }
    
        if(prevApp && prevApp.destoryed) {
            await destoryed(prevApp)
        }
    
        const app = await beforeLoad(nextApp)
    
        await mounted(app)
    }
    
    // 作为微前端控制器,也需要有自己的生命周期函数
    export const beforeLoad = async (app) => {
        await runMainLifeCycle('beforeLoad')
        app && app.beforeLoad && app.beforeLoad()
    
        // 获取到子应用所有的显示内容 —— 获取真正的子应用
        const appContext = await loadHtml(app)
        appContext && appContext.beforeLoad && appContext.beforeLoad()
    
        return appContext
    }
    
    export const mounted = async (app) => {
        app && app.mount && app.mount()
        await runMainLifeCycle('mounted')
    }
    
    export const destoryed = async (app) => {
        app && app.unmount && app.unmount()
        // 对应的执行一下主应用的生命周期
        await runMainLifeCycle('destoryed')
    }
    
    export const runMainLifeCycle = async (type)=> {
        const mainlife = getMainLifeCycle(); //get函数,获取到主应用生命周期数组
    
        // 等到所有的生命周期执行完毕才能下一步
        await Promise.all(mainlife[type].map(async item => await item()))
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51

    简单来讲就是一句话:先获取到上一个(也就是要跳出的)子应用,去执行它的卸载生命周期,然后获取到下一个(也就是即将跳转的)子应用,去执行它的“预挂载”生命周期,获取到子应用的内容!然后执行子应用的“挂载”生命周期。因为子应用的节点实际由主应用控制,所以在每次操作时都要去执行主应用对应的生命周期!

    findAppByRoute和上面的currentApp作用一样,就是根据 router 获取到子应用信息。

    这段代码里为什么充斥着async-await?除了在runMainLifeCycle中要等待主应用生命周期执行外还有一点:在mounted中加载子应用资源!
    这也就到了第三个重点:资源的获取和处理。我们来看下。

    dom获取、js代码获取&执行

    export const loadHtml = async (app) => {
        // 子应用需要显示在哪里
        let container = app.container
        // 子应用的入口是啥
        let entry = app.entry
    
        const [dom, scripts] = await parseHtml(entry)
    
        console.log(scripts)
    
        const ct = document.querySelector(container)
    
        if(!ct) {
            console.error('容器不存在')
            return
        }
        console.log(ct)
        ct.innerHTML = dom
    
        return app
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    在此函数中,我们的主要内容就是“下载”html、和链接的css、js资源。app参数就是获取到的子应用信息,前面提到的结构这里就派上用场了。
    下载资源并不只是单纯的下载html就行了,headbody中的linkscripts标签也是我们需要关注的地方:我们需要将 js 内容也下载下来方便后面运行:

    export const parseHtml = async (entry) => {
        // 资源加载其实是一个get请求,我们去模拟这个过程
        const html = await fetchResource(entry)
    
        let allScripts = []
    
        const div = document.createElement('div')
        div.innerHTML = html
    
        // 标签、link、script(src、代码)
        const [dom, scriptUrl, script] = await getResources(div, entry)
    
        // 获取所有的js资源
        const fetchedScripts = await Promise.all(scriptUrl.map(async item => await fetchResource(item)))
        allScripts = script.concat(fetchedScripts)
        return [dom, allScripts]
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    fetchResource就是fetch请求函数。不必细说。

    笔者在getResources函数中进行了上述的处理。可以看到我将结构分为三类:domscript代码和script链接(然后根据连接请求资源得到代码合并进script中),为了归类我利用了递归扫描html代码。这里只放核心代码 —— 判断标签中有没有srchref属性,如果有,则说明是链接文件(被scriptUrl接收),这时还需要判断链接有没有前缀,因为有的链接是本地连接,如果直接请求资源则会“找不到资源路径”,需要处理下:

    // 处理script
    if(element.nodeName.toLowerCase() === 'script') {
        const src = element.getAttribute('src')
        if(!src) {
            // 说明是直接在script标签中写代码
            script.push(element.outerHTML)
        } else {
            if(src.startsWith('http')) {
                scriptUrl.push(src)
            } else {
                scriptUrl.push(`http:${entry}/${src}`)
            }
        }
    
        if(parent) {
            parent.replaceChild(document.createComment('此js文件已经被微前端替换'), element)
        }
    }
    
    // link中也会有js内容
    if(element.nodeName.toLowerCase() === 'link') {
        const href = element.getAttribute('href')
    
        if(href.endsWith('.js')) {
            if(href.startsWith('http')) {
                scriptUrl.push(href)
            } else {
                scriptUrl.push(`http:${entry}/${href}`)
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    然后让我们回到loadHtml函数中。此时我们已经拿到 dom 和 script代码了,除了将 dom 挂载到容器中,我们还需要执行 js :

    scripts.forEach(item => {
         // 不仅执行js代码,还要挂载生命周期
         sandBox(app, item)
         // performScriptForFunction(item)
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5

    微前端场景改造

    这里当然不止要进行js代码的执行操作 —— 拿 vue2 子项目来说,在上面的描述中我们可以发现除了在实现的时候加入了“微前端场景的判断”和“生命周期函数”以外,我们似乎还没有真正将这些函数用到微前端场景中。
    所以,我们在这里 —— 在子应用真正挂载的时候,必须将子应用的生命周期变为“可控态”。

    const isCheckLiftCycle = lifecycle => lifecycle && lifecycle.bootstrap && lifecycle.mount && lifecycle.unmount
    
    // 对子应用生命周期的处理和环境变量的设置
    export const sandBox = (app, script) => {
        // 设置环境变量
        window.__MICRO_WEB__ = true
    
        // 运行js代码
        const lifecycle = performScriptForFunction(script, app.name)
    
        // 将生命周期挂载到app上
        if(isCheckLiftCycle(lifecycle)) {
            app.bootstrap = lifecycle.bootstrap
            app.mount = lifecycle.mount
            app.unmount = lifecycle.unmount
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    话说,这里的参数 app 是什么?“下一个子应用”啊!那为什么我们要这么麻烦,把子应用打包的节点下暴露的函数挂载到子应用上?

    其实这么做是为了和前面提到的“生命周期”控制器lifeCycle函数结合起来。在那里我们获取到子应用并执行它的生命周期,这些函数如果不挂载到子应用下,我们其实是没有办法找到他们的。
    也就是说,如果我们不用sandBox函数而是直接走performScriptForFunction函数,我们的参数 app 中则只会有一个内容 —— 注册的第一个子应用。
    子应用一览

    而对于我们之前写的对于生命周期的切换和调用都是没有执行到的。
    换句话说,我们其实一直走的是这里的逻辑:

    if (!window.__MICRO_WEB__) {
      render()
    }
    
    • 1
    • 2
    • 3

    (:不知道各位还是否记得子应用 main.js 文件中的这个判断

    performScriptForFunction函数是用来“执行 js 代码”的,这里有两种方案:

    • new Function
    • eval

    笔者采用的是第一种,为了展现这两种方式的不同,这里将两种写法都列出:

    export const performScriptForFunction = (script, appName) => {
        const scriptText = `
            ${script}
            return window['${appName}']
        `
        return new Function(scriptText).call(window, window)
    }
    
    export const performScriptForEval = (script, appName) => {
        const scriptText = `
            () => {
                ${script}
                return window['${appName}']
            }`
        return eval(scriptText).call(window, window)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    ok。在进入到下一环节之前,还有一个问题“让人疑惑”:为什么要执行 js 代码?在 loadHtml函数中不是已经ct.innerHTML = dom了么?

    这里需要注意:你往容器中插入的 dom 是什么 dom?这里又涉及到vue的编译。但这里我们不需关心,如下图便是上文中的“dom”:
    控制台打印-请求资源

    运行环境沙箱隔离

    接下来是微前端中第四个重点:沙箱隔离(也叫“沙箱快照”)。
    运行环境沙箱隔离是必要的,它能为“非公共全局变量”提供保护,它和微前端应该算是相辅相成吧。

    想比于Map/object,笔者采用了性能更高的Proxy

    
    let defaultValue = {} //子应用沙箱容器
    
    // 更流批的快照沙箱
    export class ProxySandbox {
        constructor() {
            this.proxy = null
    
            this.active()
        }
    
        active() {
            this.proxy = new Proxy(window, {
                get(target, key) {
                    // 解决路由劫持后window指向问题:TypeError Illegal invocation报错
                    if(typeof target[key] === 'function') {
                        return target[key].bind(target)
                    }
                    return defaultValue[key] || target[key]
                },
                set(target, key, value) {
                    defaultValue[key] = value
                    return true
                }
            })
        }
    
        inactive() {
            defaultValue = {}
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    简单来说就是把“顶层对象”和window区分开,window并不能拿到当前子应用顶层对象中新加入的值。

    沙箱建成后,所有的操作都不再经过window。所以我们应该修改下 js 执行函数performScriptForFunction —— 将快照作为参数传入:

    export const performScriptForFunction = (script, appName, global) => {
        window.proxy = global
        const scriptText = `
            return ((window) => {
                ${script}
                return window['${appName}']
            })(window.proxy)
        ` //模仿jquery写法
        return new Function(scriptText)()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    怎么用沙箱?
    在子应用生命周期处理函数sandBox中,我们在“环境变量设置”前实例化沙箱并挂载到子应用上:

    const proxy = new ProxySandbox()
    if(!app.proxy) {
        app.proxy = proxy
    }
    
    • 1
    • 2
    • 3
    • 4

    并新增传参:

    // 运行js代码
    const lifecycle = performScriptForFunction(script, app.name, app.proxy.proxy)
    
    • 1
    • 2

    然后在生命周期控制器lifeCycle中,卸载子应用前,进行沙箱的销毁:

    if(prevApp && prevApp.unmount) {
        if(prevApp.proxy) {
            prevApp.proxy.inactive() //将沙箱销毁
        }
        await destoryed(prevApp)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    js 已经达到了“隔离”的效果。那如何让一个应用的样式不会影响其他应用呢?
    这便是“css样式隔离”。
    样式隔离有几种方案:

    • css modules:webpack打包时提供的一种类似命名空间的方案
    • shadow dom:新的js api,将html、样式写到一个虚拟 dom 中,和其他内容没有冲突
    • minicss:将css打包成单独的css文件

    第一种方案使用时只需要在 webpack.config.js 中新增 rules 配置即可:

    {
      test: /\.(cs|scs)s$/,
      use: [MiniCssExtractPlugin.loader, {
        loader: 'css-loader',
        options: {
          module: true
        }
      }, 'sass-loader']
    },
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    这个就…看各位自己怎么选择吧。但是在一个项目甚至框架中是不可能只有一种处理方式的。

    应用间通信

    和同一个应用中“组件间通信”类似,微前端中应用间通信也有两种方案:

    • props
    • customEvent

    哪种都是我们熟悉的。

    对于props来说,因为微前端的特殊性,比如主应用向子应用传参,我们必须通过“子应用处理lifeCycle”函数中的mounted生命周期调用去实现传参。方法如下:
    假如某个元素的控制变量是headerStatus

    //store.js文件
    export const headerStatus = ref(true)
    
    export const changeHeader = type => headerStatus.value = type;
    
    • 1
    • 2
    • 3
    • 4

    在子应用信息列表sub中引入:

    import * as appInfo from "../store"
    
    export const subNavList = [
        {
          name: 'vue2',
          activeRule: '/vue2',
          container: '#micro-container',
          entry: '//localhost:9004/',
          appInfo,
        },
        //...
    ]
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    然后在 mount 生命周期被执行时传入:

    // 修改上面写到过的mounted函数
    export const mounted = async (app) => {
        // mount中的参数是props方式主子应用传参时的参数,与store中的index和sub文件有关
        app && app.mount && app.mount({
            appInfo: app.appInfo,
            entry: app.entry
        })
        await runMainLifeCycle('mounted')
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    然后在对应子应用(我们想让谁接收参数)的 mount 生命周期中就可以获取到:

    export const mount = (app) => {
      app.appInfo.header.changeHeader(false) //通过函数修改主应用变量
      render();
      // 然后得到vue实例
      console.log('渲染成功')
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    可以发现,这种方式的弊端和效率之低如此明显,以至于笔者不能忍受。
    所以我们采取另一种方案:custom

    首先创建一个类 —— 他用来在 window 上创建一个事件:

    export class Custom {
        // 监听一定要在触发之前操作
        // 事件监听
        on(name, cb) {
            window.addEventListener(name, (e)=> {
                cb(e.detail)
            })
        }
        // 事件触发
        emit(name, data) {
            const event = new CustomEvent(name, {
                detail: data
            })
            window.dispatchEvent(event)
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    还是拿主应用向子应用传参来说,我们可以在“路由监听”操作前实例化,并挂载到 window 对象上,供全局调用:

    const custom = new Custom()
    custom.on('test', (data) => {
        console.log(data)
    })
    
    window.custom = custom
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    然后在子应用到 mount 生命周期中执行:

    window.custom.emit('test', {
      a: 1
    })
    
    • 1
    • 2
    • 3

    切记:监听一定要在触发之前!
    此言何解?
    我们再来看“子应用之间如何传参”:我们先在 vue3 子应用的 mount 生命周期中监听一个事件:

    window.custom.on('test1', (data) => {
      console.log(data)
    })
    
    • 1
    • 2
    • 3

    然后在 vue2 子应用的 mount 生命周期中触发这个事件:

    window.custom.emit('test1', {
      a: '1-子vue2'
    })
    
    • 1
    • 2
    • 3

    现在是没有问题的。
    然后我们再在 vue2 子应用的 mount 生命周期中监听一个事件:

    window.custom.on('test2', (data) => {
      console.log(data)
    })
    // 这是刚刚触发的事件
    window.custom.emit('test1', {
      a: '1-子vue2'
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    然后到 vue3 子应用的 mount 生命周期中触发这个事件:

    // 这是刚刚监听的事件
    window.custom.on('test1', (data) => {
      console.log(data)
    })
    window.custom.emit('test2', {
      a: "1-子vue3"
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    这时我们会发现浏览器中切换相应应用时并没有打印{a: "1-子vue3"}。这是因为“触发和监听时机错误”导致的。我们可以这么改写 vue3 的 mount 生命周期:

    window.custom.on('test1', () => {
      window.custom.emit('test2', {
        a: "1-子vue3"
      })
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5

    应该说在只要有不止一个 emiton 的子应用中都应该采用这种写法保证顺序!这也是 custom 这种方式的瑕疵之处。

    全局状态管理

    从某种角度来说,全局状态管理 store 和通信是一个作用。我们的写法也类似。
    创建 store,笔者选择了“自执行函数”方式:

    export const createStore = (initData={}) => (() => {
        let store = initData
        const observers = [] //管理所有的订阅者
    
        // 获取store
        const getStore = () => store
    
        // 更新store
        const update = (value) => {
            if(value !== store) {
                // 执行store操作
                const oldValue = store
                store = value
                observers.forEach(async item => await item(store, oldValue))
            }
        }
    
        // 添加订阅者
        const subscribe = (fn) => {
            observers.push(fn)
        }
    
        return {
            getStore,
            update,
            subscribe
        }
    })()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    然后在应用执行的一开始将其初始化并挂载到 window 上。就可以在全局使用了~

    子应用预加载

    最后,我们可以给“非当前展示的子应用”的子应用一个“预加载”,提高加载性能:
    在“子应用启动 start 函数”中:

    // 获取到所有子应用的列表,但不包括当前正在显示的
    const list = getList().filter(item => !window.location.pathname.startsWith(item.activeRule))
    
    console.log(list, '==-')
    
    // 预加载剩下的所有子应用
    await Promise.all(list.map(async item => await parseHtml(item.entry, item.name)))
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    parseHtml 就是上面说过的“资源加载”函数。再配合上 cache 缓存的话,就可以保证切换到加载过的子应用时迅速展现而不用去再次加载资源!


    虎头蛇尾的结尾~

    因为当前是第一版,还不确定有没有什么隐藏问题,故而本文没有放出所有代码(太多了!)。当前正在多场景测试 & 邀请了几位好友加入仓库一起以新技术构建第二版,到时可能会将文章拆分细说并放出更多代码,搞成一个专栏,方便各位交流。

  • 相关阅读:
    基于移动设备的OCR识别工作进展(2)
    Nvidia Jetson Nano学习笔记--使用C语言实现GPIO控制
    HJ20 密码验证合格程序
    go语言学习
    abp(net core)+easyui+efcore实现仓储管理系统——ABP升级7.3上(五十八)
    Vue-生命周期勾子函数
    跨境电商收款账号一样会关联吗?谁能告诉?
    前端不安装Nginx情况下部署
    入栏需看——学习记忆
    Flutter ー Authentication 认证
  • 原文地址:https://blog.csdn.net/qq_43624878/article/details/127769758